diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index d068e06..385ce16 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -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) } diff --git a/server/game.ts b/server/game.ts index 6b6340c..22464ec 100644 --- a/server/game.ts +++ b/server/game.ts @@ -53,3 +53,91 @@ export function isEmptyCell(board: Cell[], x: number, y: number): boolean { export function opponent(p: Player): Player { return p === 1 ? 2 : 1 } + +/* ------------------------------------------------------------------ */ +/* 爱心连珠彩蛋:预设爱心形状模板,任一方棋子完整覆盖即触发 */ +/* 模板用 1 图案 / 0 空格表示,扫描全盘(含 4 向旋转),支持 any 尺寸 */ +/* ------------------------------------------------------------------ */ + +const HEART_TEMPLATES: Array>> = [ + // 4x4 小爱心(12 格) + [ + [0, 1, 1, 0], + [1, 1, 1, 1], + [1, 1, 1, 1], + [0, 1, 1, 0], + ], + // 5x5 大爱心(12 格,尖角形) + [ + [0, 1, 0, 1, 0], + [1, 1, 1, 1, 1], + [0, 1, 1, 1, 0], + [0, 0, 1, 0, 0], + ], +] + +/** 镜像模板(左右翻转) */ +function mirror(t: Array>): Array> { + return t.map((row) => [...row].reverse()) +} + +/** 顺时针旋转 90° */ +function rotate(t: Array>): Array> { + const h = t.length + const w = t[0]!.length + const out: Array> = [] + for (let x = 0; x < w; x++) { + const row: Array<0 | 1> = [] + for (let y = h - 1; y >= 0; y--) row.push(t[y]![x]!) + out.push(row) + } + return out +} + +/** + * 检测 player 是否在棋盘上完整摆出了某个爱心模板(含旋转与镜像)。 + * 命中返回模板格子的下标数组(用于前端撒花定位),否则 null。 + */ +export function findHeartLine(board: Cell[], player: Player): number[] | null { + const compact: Array<{ i: number; j: number }> = [] + + for (const base of HEART_TEMPLATES) { + // 变体集合:原形、旋转 90/180/270、每种的镜像 + const variants: Array>> = [base] + const seen = new Set() + let cur = base + for (let r = 0; r < 4; r++) { + cur = rotate(cur) + const key = JSON.stringify(cur) + if (!seen.has(key)) { + seen.add(key) + variants.push(cur) + variants.push(mirror(cur)) + } + } + + for (const tmpl of variants) { + const th = tmpl.length + const tw = tmpl[0]!.length + compact.length = 0 + for (let i = 0; i < th; i++) { + for (let j = 0; j < tw; j++) { + if (tmpl[i]![j]) compact.push({ i, j }) + } + } + + // 全盘扫描偏移 + for (let oy = 0; oy + th <= BOARD_SIZE; oy++) { + for (let ox = 0; ox + tw <= BOARD_SIZE; ox++) { + const all = compact.every( + ({ i, j }) => board[idx(ox + j, oy + i)] === player, + ) + if (all) { + return compact.map(({ i, j }) => idx(ox + j, oy + i)) + } + } + } + } + } + return null +} diff --git a/server/index.ts b/server/index.ts index 50952d6..4fc40bb 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,9 +8,11 @@ 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, @@ -57,9 +59,15 @@ declare module 'socket.io' { interface SocketData { roomId?: string seat?: Player + /** 表情发送冷却时间戳 */ + lastEmojiAt?: number } } +function opponentOf(p: Player): Player { + return p === 1 ? 2 : 1 +} + /* ------------------------------------------------------------------ */ /* 工具 */ /* ------------------------------------------------------------------ */ @@ -124,13 +132,14 @@ io.on('connection', (socket) => { } } - socket.on(EV.create, (_payload: unknown, ack?: unknown) => { + 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) }) @@ -138,7 +147,7 @@ io.on('connection', (socket) => { socket.on( EV.join, ( - payload: { roomId?: string; resumeToken?: string } | undefined, + payload: { roomId?: string; resumeToken?: string; nickname?: string } | undefined, ack?: unknown, ) => { const reply = asAck(ack) @@ -164,6 +173,9 @@ io.on('connection', (socket) => { 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) @@ -226,6 +238,36 @@ io.on('connection', (socket) => { }, ) + // 发送表情给对手:校验合法性(必须在 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 diff --git a/server/room.ts b/server/room.ts index a5dcdf3..736ba16 100644 --- a/server/room.ts +++ b/server/room.ts @@ -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 = { 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, } } diff --git a/shared/protocol.ts b/shared/protocol.ts index 9927bce..952df79 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -18,12 +18,26 @@ export type GameStatus = 'waiting' | 'playing' | 'over' /** 终局原因:五连 / 和棋 / 认输 / 对手离线判负 */ export type EndReason = 'five' | 'draw' | 'resign' | 'offline' -/** 座位是否有人、是否在线 */ +/** 座位是否有人、是否在线、昵称 */ export interface SeatInfo { joined: boolean online: boolean + /** 昵称,空串表示未设置(前端显示「黑棋/白棋」) */ + nickname: string } +/** 房间内累计战绩(进程内存,重启即清零) */ +export interface RoomStats { + /** 当前进行的局数编号,从 1 起 */ + round: number + blackWins: number + whiteWins: number + draws: number +} + +/** 可发送给对手的表情符号 */ +export const EMOJIS = ['❤️', '💕', '✨', '🌟', '💗', '😘', '🥰', '💌', '🍀', '💘'] as const + /** * 完整对局状态(服务端权威,每次变更后全量下发)。 * 225 个格子的全量同步只有几百字节,用全量换掉增量同步的一致性风险。 @@ -46,6 +60,13 @@ export interface RoomState { undoRequestedBy: Player | null black: SeatInfo white: SeatInfo + /** + * 本局是否已触发「爱心连珠」彩蛋及触发方。 + * null = 未触发;否则前端播放爱心雨特效并提示。 + */ + heartBy: Player | null + /** 房间累计战绩 */ + stats: RoomStats /** 接收者自己执子颜色,未入座为 null */ seat: Player | null } @@ -71,6 +92,10 @@ export const EV = { claimOffline: 'game:claim-offline', /** 再来一局,ack: ActionAck */ restart: 'game:restart', + /** 发送表情给对手(长按棋盘),ack: ActionAck */ + emojiSend: 'emoji:send', + /** 服务端 → 客户端:收到对手表情 */ + emojiRecv: 'emoji:recv', /** 服务端 → 客户端:全量状态推送 */ state: 'game:state', /** 服务端 → 客户端:一次性提示(错误/事件) */ @@ -109,6 +134,14 @@ export interface NoticePayload { message: string } +/** 发送表情载荷:emoji 必须是 EMOJIS 中的字符 */ +export interface EmojiPayload { + emoji: string +} + +/** 音效场景 */ +export type SoundKind = 'place' | 'win' | 'undo' | 'emoji' | 'heart' + /** 悔棋请求自动失效时长 */ export const UNDO_TIMEOUT_MS = 60_000 /** 对手离线多久后允许判负 */ diff --git a/vite.config.ts b/vite.config.ts index ba020d3..f669363 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ emptyOutDir: true, }, server: { + // 绑定所有网卡(0.0.0.0),便于局域网/内网其他机器(如 100.64.0.2)直接访问 + host: true, port: 5173, // 开发态把 Socket.IO 请求(含 WebSocket upgrade)转发给本地 Node 服务 proxy: { diff --git a/web/src/App.vue b/web/src/App.vue index ef9eaee..53eae3f 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -4,16 +4,19 @@ import { io } from 'socket.io-client' import type { Socket } from 'socket.io-client' import Board from './components/Board.vue' import { + EMOJIS, EV, OFFLINE_CLAIM_MS, type ActionAck, type CreateAck, + type EmojiPayload, type EndReason, type JoinAck, type NoticePayload, type Player, type RoomState, } from '../../shared/protocol' +import { THEMES, applyTheme, loadTheme, type ThemeId } from './theme' /* ------------------------------------------------------------------ */ /* 本地会话:房间号 + 重连凭证,用于刷新/掉线后自动回到原座位 */ @@ -53,6 +56,24 @@ function clearSession(): void { const initialRoomId = (new URLSearchParams(location.search).get('r') ?? '').trim().toUpperCase() +const NICKNAME_KEY = 'wuziqi.nickname' + +function loadNickname(): string { + try { + return (localStorage.getItem(NICKNAME_KEY) ?? '').slice(0, 12) + } catch { + return '' + } +} + +function saveNickname(name: string): void { + try { + localStorage.setItem(NICKNAME_KEY, name.trim().slice(0, 12)) + } catch { + /* 隐私模式忽略 */ + } +} + /* ------------------------------------------------------------------ */ /* 状态 */ /* ------------------------------------------------------------------ */ @@ -63,6 +84,9 @@ const connected = ref(false) const phase = ref<'connecting' | 'lobby' | 'room'>('connecting') const state = ref(null) const joinCode = ref(initialRoomId) +const nickname = ref(loadNickname()) +const theme = ref(loadTheme()) +const showThemePicker = ref(false) const busy = ref(false) const resultDismissed = ref(false) /** 悔棋等待弹窗是否被手动收起(对方未响应前允许先看棋盘) */ @@ -70,6 +94,16 @@ const undoWaitDismissed = ref(false) const toast = ref<{ text: string; error: boolean } | null>(null) let toastTimer: number | undefined +/** 收到的表情动画请求(id 递增以触发 watch) */ +const emojiFx = ref<{ id: number; emoji: string } | null>(null) +let emojiId = 0 +/** 爱心特效触发计数(彩蛋 heartBy 变化时 +1) */ +const heartFx = ref(0) +let lastHeartBy: Player | null | undefined = undefined + +// 应用初始主题 +applyTheme(theme.value) + /** 只有拿到服务端状态才认为真正进入了房间 */ const view = computed<'lobby' | 'loading' | 'room'>(() => { if (state.value) return 'room' @@ -82,6 +116,12 @@ function showToast(text: string, error = false): void { toastTimer = window.setTimeout(() => (toast.value = null), 2600) } +function showSweetToast(text: string): void { + toast.value = { text, error: false } + if (toastTimer) window.clearTimeout(toastTimer) + toastTimer = window.setTimeout(() => (toast.value = null), 3800) +} + /* ------------------------------------------------------------------ */ /* 连接与房间进入 */ /* ------------------------------------------------------------------ */ @@ -90,8 +130,12 @@ function doJoin(payload: { roomId: string; resumeToken?: string }, notifyOnFail: const s = socket if (!s) return if (phase.value !== 'room') phase.value = 'connecting' + // 新加入时带昵称;重连时不覆盖已有昵称(服务端只在非空时设置) + const sendPayload: Record = { roomId: payload.roomId } + if (payload.resumeToken) sendPayload.resumeToken = payload.resumeToken + if (!payload.resumeToken && nickname.value.trim()) sendPayload.nickname = nickname.value.trim() - s.emit(EV.join, payload, (res: JoinAck | ActionAck) => { + s.emit(EV.join, sendPayload, (res: JoinAck | ActionAck) => { busy.value = false if (res.ok) { const ok = res as JoinAck @@ -149,6 +193,18 @@ onMounted(() => { if (incoming.status === 'playing') resultDismissed.value = false // 悔棋请求已结束(同意/拒绝/超时/落子取消)时重置弹窗状态 if (incoming.undoRequestedBy === null) undoWaitDismissed.value = false + // 爱心连珠彩蛋触发:heartBy 从 null 变为某方 → 播放全场爱心雨 + if (incoming.heartBy !== null && incoming.heartBy !== lastHeartBy) { + lastHeartBy = incoming.heartBy + heartFx.value += 1 + showSweetToast('你们在棋盘上摆出了一颗心 💗') + } + if (incoming.heartBy === null) lastHeartBy = null + }) + // 收到对手表情 → 播放漂浮动画 + s.on(EV.emojiRecv, (p: EmojiPayload) => { + emojiId += 1 + emojiFx.value = { id: emojiId, emoji: p.emoji } }) s.on(EV.notice, (p: NoticePayload) => showToast(p.message, p.level === 'error')) @@ -185,7 +241,9 @@ function createRoom(): void { } busy.value = true // 统一约定:载荷在前、回调在后,服务端只从最后一个参数取 ack - s.emit(EV.create, {}, (res: CreateAck | ActionAck) => { + const createPayload: Record = {} + if (nickname.value.trim()) createPayload.nickname = nickname.value.trim() + s.emit(EV.create, createPayload, (res: CreateAck | ActionAck) => { busy.value = false if (!res.ok) { showToast((res as { error: string }).error, true) @@ -225,6 +283,21 @@ function leaveRoom(): void { history.replaceState(null, '', location.pathname) } +/** 长按棋盘 → 随机发一个甜蜜表情给对手,并本地立即可视化 */ +function sendEmoji(): void { + const emoji = EMOJIS[Math.floor(Math.random() * EMOJIS.length)]! + // 乐观反馈:本地先播放 + emojiId += 1 + emojiFx.value = { id: emojiId, emoji } + act(EV.emojiSend, { emoji }) +} + +function setTheme(id: ThemeId): void { + theme.value = id + applyTheme(id) + showThemePicker.value = false +} + /* ------------------------------------------------------------------ */ /* 派生状态 */ /* ------------------------------------------------------------------ */ @@ -295,6 +368,67 @@ const END_REASON_TEXT: Record = { offline: '对手离线判负', } +const myNick = computed(() => { + const s = state.value + if (!s) return '' + return s.seat === 1 ? s.black.nickname || '黑棋' : s.seat === 2 ? s.white.nickname || '白棋' : '' +}) + +const oppNick = computed(() => { + const s = state.value + if (!s) return '' + if (s.seat === 1) return s.white.nickname || '白棋' + if (s.seat === 2) return s.black.nickname || '黑棋' + return '' +}) + +/** 终局决胜子坐标文案(五连时最后落子即决胜点) */ +const endMoveText = computed(() => { + const s = state.value + if (!s || s.endReason !== 'five' || s.lastMove === null) return '' + const x = s.lastMove % 15 + const y = Math.floor(s.lastMove / 15) + return `决胜于 (${x + 1}, ${y + 1})` +}) + +const SWEET_LINES = { + win: [ + '赢的不是棋,是你的偏爱。', + '这局是你的,下局也是你的。', + '五子连珠,不及你牵我的手。', + '赢了棋,输给你,都开心。', + ], + lose: [ + '输给你,我心甘情愿。', + '让你一局,先把爱攒着。', + '这局我让的,下局还敢~', + '输棋没关系,赢你在身边。', + ], + draw: [ + '平分秋色,也是双赢。', + '平局最好——谁都不用让谁。', + '和棋了?那继续,反正还早。', + ], + resign: [ + '主动投降,钓走你这颗心。', + '不认输,只是想把温柔让给你。', + '认输最甜,因为你可以赢我。', + ], + offline: ['你掉线了,我等你回来。', '胜负无所谓,回来就好。'], +} + +const SWEET_SELECT = (arr: readonly T[]): T => arr[Math.floor(Math.random() * arr.length)]! + +const resultSweet = computed(() => { + const s = state.value + if (!s || s.status !== 'over' || s.seat === null) return '' + if (s.winner === 0) return SWEET_SELECT(SWEET_LINES.draw) + const won = s.winner === s.seat + if (s.endReason === 'resign') return SWEET_SELECT(SWEET_LINES.resign) + if (s.endReason === 'offline') return SWEET_SELECT(SWEET_LINES.offline) + return won ? SWEET_SELECT(SWEET_LINES.win) : SWEET_SELECT(SWEET_LINES.lose) +}) + const result = computed<{ title: string; sub: string } | null>(() => { const s = state.value if (!s || s.status !== 'over' || s.winner === null || s.seat === null) return null @@ -344,8 +478,45 @@ async function copyShareLink(): Promise {
-

五子棋

-

两人对弈 · 手机浏览器直接玩

+ + +
+ +

双人对弈 · 五子棋

+

只属于你们两个人的小小棋盘

+
+ + + +

{{ statusText }}

@@ -403,7 +581,11 @@ async function copyShareLink(): Promise { :win-line="state!.winLine" :seat="state!.seat" :my-turn="canPlace" + :theme="theme" + :emoji-fx="emojiFx" + :heart-fx="heartFx" @place="place" + @longpress="sendEmoji" />