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',
|
resign: 'game:resign',
|
||||||
claimOffline: 'game:claim-offline',
|
claimOffline: 'game:claim-offline',
|
||||||
restart: 'game:restart',
|
restart: 'game:restart',
|
||||||
|
emojiSend: 'emoji:send',
|
||||||
|
emojiRecv: 'emoji:recv',
|
||||||
state: 'game:state',
|
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 pass = 0
|
||||||
let fail = 0
|
let fail = 0
|
||||||
function check(name, cond, extra = '') {
|
function check(name, cond, extra = '') {
|
||||||
@@ -60,7 +77,6 @@ async function act(s, ev, payload, pred) {
|
|||||||
return { ack, st }
|
return { ack, st }
|
||||||
}
|
}
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
||||||
const at = (x, y) => y * 15 + x
|
const at = (x, y) => y * 15 + x
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -70,16 +86,20 @@ async function main() {
|
|||||||
check('两个客户端均已连接', A.connected && B.connected)
|
check('两个客户端均已连接', A.connected && B.connected)
|
||||||
|
|
||||||
console.log('\n== 1. 建房与加入 ==')
|
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))
|
check('创建房间返回 ok', createAck?.ok === true, JSON.stringify(createAck))
|
||||||
const roomId = createAck.roomId
|
const roomId = createAck.roomId
|
||||||
check('房间码为 6 位', typeof roomId === 'string' && roomId.length === 6, 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 加入成功且执白', beforeJoin?.ok === true && beforeJoin.seat === 2, JSON.stringify(beforeJoin).slice(0, 120))
|
||||||
check('B 加入后状态为 playing', beforeJoin.state.status === 'playing')
|
check('B 加入后状态为 playing', beforeJoin.state.status === 'playing')
|
||||||
check('黑棋先手', beforeJoin.state.turn === 1)
|
check('黑棋先手', beforeJoin.state.turn === 1)
|
||||||
check('B 拿到重连凭证', typeof beforeJoin.resumeToken === 'string' && beforeJoin.resumeToken.length > 10)
|
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 third = await connect('C')
|
||||||
const thirdJoin = await emit(third, EV.join, { roomId })
|
const thirdJoin = await emit(third, EV.join, { roomId })
|
||||||
@@ -141,6 +161,8 @@ async function main() {
|
|||||||
'winLine 内容正确',
|
'winLine 内容正确',
|
||||||
JSON.stringify(won.winLine) === JSON.stringify([3, 4, 5, 6, 7].map((x) => at(x, 7))),
|
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 })
|
const afterOver = await emit(B, EV.move, { x: 10, y: 10 })
|
||||||
check('终局后落子被拒', afterOver?.ok === false, JSON.stringify(afterOver))
|
check('终局后落子被拒', afterOver?.ok === false, JSON.stringify(afterOver))
|
||||||
@@ -158,6 +180,7 @@ async function main() {
|
|||||||
check('交换黑白生效:A 变白', swapAck.ok === true && sA.seat === 2)
|
check('交换黑白生效:A 变白', swapAck.ok === true && sA.seat === 2)
|
||||||
check('交换黑白生效:B 变黑', sB.seat === 1)
|
check('交换黑白生效:B 变黑', sB.seat === 1)
|
||||||
check('交换后仍是黑棋先手', sA.turn === 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 })
|
const blackNowIsB = await emit(B, EV.move, { x: 7, y: 7 })
|
||||||
check('交换后 B 可以执黑落子', blackNowIsB?.ok === true, JSON.stringify(blackNowIsB))
|
check('交换后 B 可以执黑落子', blackNowIsB?.ok === true, JSON.stringify(blackNowIsB))
|
||||||
@@ -189,6 +212,18 @@ async function main() {
|
|||||||
const notFound = await emit(A, EV.join, { roomId: 'ZZZZZZ' })
|
const notFound = await emit(A, EV.join, { roomId: 'ZZZZZZ' })
|
||||||
check('加入不存在的房间被拒', notFound?.ok === false, JSON.stringify(notFound))
|
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())
|
const health = await fetch(`${URL}/healthz`).then((r) => r.json())
|
||||||
check('健康检查可用', health.ok === true && typeof health.rooms === 'number', JSON.stringify(health))
|
check('健康检查可用', health.ok === true && typeof health.rooms === 'number', JSON.stringify(health))
|
||||||
check('房间统计可用(≥1)', typeof health.rooms === 'number' && health.rooms >= 1, String(health.rooms))
|
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('房间码不含易混字符 O/0/I/1', [...codes].every((c) => !/[O0I1]/.test(c)), [...codes].slice(0, 3).join(','))
|
||||||
check('200 次生成无重复', codes.size === 200, String(codes.size))
|
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} 项 =====`)
|
console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`)
|
||||||
process.exit(fail === 0 ? 0 : 1)
|
process.exit(fail === 0 ? 0 : 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,3 +53,91 @@ export function isEmptyCell(board: Cell[], x: number, y: number): boolean {
|
|||||||
export function opponent(p: Player): Player {
|
export function opponent(p: Player): Player {
|
||||||
return p === 1 ? 2 : 1
|
return p === 1 ? 2 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 爱心连珠彩蛋:预设爱心形状模板,任一方棋子完整覆盖即触发 */
|
||||||
|
/* 模板用 1 图案 / 0 空格表示,扫描全盘(含 4 向旋转),支持 any 尺寸 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const HEART_TEMPLATES: Array<Array<Array<0 | 1>>> = [
|
||||||
|
// 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<0 | 1>>): Array<Array<0 | 1>> {
|
||||||
|
return t.map((row) => [...row].reverse())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顺时针旋转 90° */
|
||||||
|
function rotate(t: Array<Array<0 | 1>>): Array<Array<0 | 1>> {
|
||||||
|
const h = t.length
|
||||||
|
const w = t[0]!.length
|
||||||
|
const out: Array<Array<0 | 1>> = []
|
||||||
|
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<Array<Array<0 | 1>>> = [base]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
+44
-2
@@ -8,9 +8,11 @@ import path from 'node:path'
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { Server } from 'socket.io'
|
import { Server } from 'socket.io'
|
||||||
import {
|
import {
|
||||||
|
EMOJIS,
|
||||||
EV,
|
EV,
|
||||||
type ActionAck,
|
type ActionAck,
|
||||||
type CreateAck,
|
type CreateAck,
|
||||||
|
type EmojiPayload,
|
||||||
type JoinAck,
|
type JoinAck,
|
||||||
type NoticePayload,
|
type NoticePayload,
|
||||||
type Player,
|
type Player,
|
||||||
@@ -57,9 +59,15 @@ declare module 'socket.io' {
|
|||||||
interface SocketData {
|
interface SocketData {
|
||||||
roomId?: string
|
roomId?: string
|
||||||
seat?: Player
|
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 reply = asAck(ack)
|
||||||
const room = manager.create()
|
const room = manager.create()
|
||||||
room.bind(1, socket.id)
|
room.bind(1, socket.id)
|
||||||
socket.data.roomId = room.id
|
socket.data.roomId = room.id
|
||||||
socket.data.seat = 1
|
socket.data.seat = 1
|
||||||
void socket.join(room.id)
|
void socket.join(room.id)
|
||||||
|
room.setNickname(1, payload?.nickname ?? '')
|
||||||
reply?.({ ok: true, roomId: room.id, seat: 1, resumeToken: room.tokenOf(1) } satisfies CreateAck)
|
reply?.({ ok: true, roomId: room.id, seat: 1, resumeToken: room.tokenOf(1) } satisfies CreateAck)
|
||||||
broadcast(room)
|
broadcast(room)
|
||||||
})
|
})
|
||||||
@@ -138,7 +147,7 @@ io.on('connection', (socket) => {
|
|||||||
socket.on(
|
socket.on(
|
||||||
EV.join,
|
EV.join,
|
||||||
(
|
(
|
||||||
payload: { roomId?: string; resumeToken?: string } | undefined,
|
payload: { roomId?: string; resumeToken?: string; nickname?: string } | undefined,
|
||||||
ack?: unknown,
|
ack?: unknown,
|
||||||
) => {
|
) => {
|
||||||
const reply = asAck(ack)
|
const reply = asAck(ack)
|
||||||
@@ -164,6 +173,9 @@ io.on('connection', (socket) => {
|
|||||||
socket.data.seat = seat
|
socket.data.seat = seat
|
||||||
void socket.join(room.id)
|
void socket.join(room.id)
|
||||||
|
|
||||||
|
// 昵称:重连时不覆盖已有昵称;新加入时设置
|
||||||
|
if (payload?.nickname) room.setNickname(seat, payload.nickname)
|
||||||
|
|
||||||
// 顶掉该座位的旧连接(同一玩家在另一台设备/标签页重连)
|
// 顶掉该座位的旧连接(同一玩家在另一台设备/标签页重连)
|
||||||
if (staleSocketId && staleSocketId !== socket.id) {
|
if (staleSocketId && staleSocketId !== socket.id) {
|
||||||
const stale = io.sockets.sockets.get(staleSocketId)
|
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', () => {
|
socket.on('disconnect', () => {
|
||||||
const ctx = ctxOf(socket.id, socket.data)
|
const ctx = ctxOf(socket.id, socket.data)
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
|
|||||||
+58
-21
@@ -12,9 +12,10 @@ import {
|
|||||||
type EndReason,
|
type EndReason,
|
||||||
type GameStatus,
|
type GameStatus,
|
||||||
type Player,
|
type Player,
|
||||||
|
type RoomStats,
|
||||||
type RoomState,
|
type RoomState,
|
||||||
} from '../shared/protocol.js'
|
} 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 等易混字符 */
|
/** 房间码字母表:剔除 O/0/I/1 等易混字符 */
|
||||||
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||||
@@ -33,10 +34,12 @@ interface Seat {
|
|||||||
online: boolean
|
online: boolean
|
||||||
/** 掉线起始时间,用于判定"离线超时" */
|
/** 掉线起始时间,用于判定"离线超时" */
|
||||||
offlineSince: number | null
|
offlineSince: number | null
|
||||||
|
/** 昵称,空串 = 未设置 */
|
||||||
|
nickname: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function newSeat(): Seat {
|
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 }
|
export type ActionResult = { ok: true } | { ok: false; error: string }
|
||||||
@@ -57,6 +60,11 @@ export class Room {
|
|||||||
undoRequestedAt: number | null = null
|
undoRequestedAt: number | null = null
|
||||||
lastActiveAt = Date.now()
|
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() }
|
private seats: Record<Player, Seat> = { 1: newSeat(), 2: newSeat() }
|
||||||
|
|
||||||
constructor(id = generateRoomId()) {
|
constructor(id = generateRoomId()) {
|
||||||
@@ -98,6 +106,19 @@ export class Room {
|
|||||||
return null
|
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 顶掉 */
|
/** 把 socket 绑定到座位;若该座位已有旧 socket,则把旧 socket 顶掉 */
|
||||||
bind(seat: Player, socketId: string): void {
|
bind(seat: Player, socketId: string): void {
|
||||||
const s = this.seats[seat]
|
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 {
|
place(x: number, y: number, player: Player): ActionResult {
|
||||||
if (this.status === 'waiting') return { ok: false, error: '对手还没进入房间' }
|
if (this.status === 'waiting') return { ok: false, error: '对手还没进入房间' }
|
||||||
if (this.status === 'over') return { ok: false, error: '本局已结束,请开新局' }
|
if (this.status === 'over') return { ok: false, error: '本局已结束,请开新局' }
|
||||||
@@ -159,16 +192,16 @@ export class Room {
|
|||||||
this.undoRequestedAt = null
|
this.undoRequestedAt = null
|
||||||
this.touch()
|
this.touch()
|
||||||
|
|
||||||
|
// 爱心连珠彩蛋:本局未触发过且落子后成爱心 → 记录触发方
|
||||||
|
if (this.heartBy === null && findHeartLine(this.board, player)) {
|
||||||
|
this.heartBy = player
|
||||||
|
}
|
||||||
|
|
||||||
const line = findWinLine(this.board, x, y, player)
|
const line = findWinLine(this.board, x, y, player)
|
||||||
if (line) {
|
if (line) {
|
||||||
this.status = 'over'
|
this.finish(player, 'five', line)
|
||||||
this.winner = player
|
|
||||||
this.endReason = 'five'
|
|
||||||
this.winLine = line
|
|
||||||
} else if (this.moveCount >= CELL_COUNT) {
|
} else if (this.moveCount >= CELL_COUNT) {
|
||||||
this.status = 'over'
|
this.finish(0, 'draw', null)
|
||||||
this.winner = 0
|
|
||||||
this.endReason = 'draw'
|
|
||||||
} else {
|
} else {
|
||||||
this.turn = opponent(player)
|
this.turn = opponent(player)
|
||||||
}
|
}
|
||||||
@@ -227,11 +260,7 @@ export class Room {
|
|||||||
resign(player: Player): ActionResult {
|
resign(player: Player): ActionResult {
|
||||||
if (this.status === 'over') return { ok: false, error: '本局已结束' }
|
if (this.status === 'over') return { ok: false, error: '本局已结束' }
|
||||||
if (this.status === 'waiting') return { ok: false, error: '对手还没进入房间' }
|
if (this.status === 'waiting') return { ok: false, error: '对手还没进入房间' }
|
||||||
this.status = 'over'
|
this.finish(opponent(player), 'resign', null)
|
||||||
this.winner = opponent(player)
|
|
||||||
this.endReason = 'resign'
|
|
||||||
this.winLine = null
|
|
||||||
this.touch()
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,11 +272,7 @@ export class Room {
|
|||||||
if (this.offlineFor(foe, now) < OFFLINE_CLAIM_MS) {
|
if (this.offlineFor(foe, now) < OFFLINE_CLAIM_MS) {
|
||||||
return { ok: false, error: `对手掉线未超过 ${Math.round(OFFLINE_CLAIM_MS / 1000)} 秒,请再等一下` }
|
return { ok: false, error: `对手掉线未超过 ${Math.round(OFFLINE_CLAIM_MS / 1000)} 秒,请再等一下` }
|
||||||
}
|
}
|
||||||
this.status = 'over'
|
this.finish(player, 'offline', null)
|
||||||
this.winner = player
|
|
||||||
this.endReason = 'offline'
|
|
||||||
this.winLine = null
|
|
||||||
this.touch()
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +292,8 @@ export class Room {
|
|||||||
this.moveCount = 0
|
this.moveCount = 0
|
||||||
this.undoRequestedBy = null
|
this.undoRequestedBy = null
|
||||||
this.undoRequestedAt = null
|
this.undoRequestedAt = null
|
||||||
|
this.heartBy = null
|
||||||
|
this.stats.round += 1
|
||||||
this.touch()
|
this.touch()
|
||||||
|
|
||||||
if (!swap) return { swapped: false, mapping: [] }
|
if (!swap) return { swapped: false, mapping: [] }
|
||||||
@@ -298,8 +325,18 @@ export class Room {
|
|||||||
lastMove: this.lastMove,
|
lastMove: this.lastMove,
|
||||||
moveCount: this.moveCount,
|
moveCount: this.moveCount,
|
||||||
undoRequestedBy: this.undoRequestedBy,
|
undoRequestedBy: this.undoRequestedBy,
|
||||||
black: { joined: this.seats[1].resumeToken !== null, online: this.seats[1].online },
|
black: {
|
||||||
white: { joined: this.seats[2].resumeToken !== null, online: this.seats[2].online },
|
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,
|
seat,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-1
@@ -18,12 +18,26 @@ export type GameStatus = 'waiting' | 'playing' | 'over'
|
|||||||
/** 终局原因:五连 / 和棋 / 认输 / 对手离线判负 */
|
/** 终局原因:五连 / 和棋 / 认输 / 对手离线判负 */
|
||||||
export type EndReason = 'five' | 'draw' | 'resign' | 'offline'
|
export type EndReason = 'five' | 'draw' | 'resign' | 'offline'
|
||||||
|
|
||||||
/** 座位是否有人、是否在线 */
|
/** 座位是否有人、是否在线、昵称 */
|
||||||
export interface SeatInfo {
|
export interface SeatInfo {
|
||||||
joined: boolean
|
joined: boolean
|
||||||
online: boolean
|
online: boolean
|
||||||
|
/** 昵称,空串表示未设置(前端显示「黑棋/白棋」) */
|
||||||
|
nickname: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 房间内累计战绩(进程内存,重启即清零) */
|
||||||
|
export interface RoomStats {
|
||||||
|
/** 当前进行的局数编号,从 1 起 */
|
||||||
|
round: number
|
||||||
|
blackWins: number
|
||||||
|
whiteWins: number
|
||||||
|
draws: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 可发送给对手的表情符号 */
|
||||||
|
export const EMOJIS = ['❤️', '💕', '✨', '🌟', '💗', '😘', '🥰', '💌', '🍀', '💘'] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 完整对局状态(服务端权威,每次变更后全量下发)。
|
* 完整对局状态(服务端权威,每次变更后全量下发)。
|
||||||
* 225 个格子的全量同步只有几百字节,用全量换掉增量同步的一致性风险。
|
* 225 个格子的全量同步只有几百字节,用全量换掉增量同步的一致性风险。
|
||||||
@@ -46,6 +60,13 @@ export interface RoomState {
|
|||||||
undoRequestedBy: Player | null
|
undoRequestedBy: Player | null
|
||||||
black: SeatInfo
|
black: SeatInfo
|
||||||
white: SeatInfo
|
white: SeatInfo
|
||||||
|
/**
|
||||||
|
* 本局是否已触发「爱心连珠」彩蛋及触发方。
|
||||||
|
* null = 未触发;否则前端播放爱心雨特效并提示。
|
||||||
|
*/
|
||||||
|
heartBy: Player | null
|
||||||
|
/** 房间累计战绩 */
|
||||||
|
stats: RoomStats
|
||||||
/** 接收者自己执子颜色,未入座为 null */
|
/** 接收者自己执子颜色,未入座为 null */
|
||||||
seat: Player | null
|
seat: Player | null
|
||||||
}
|
}
|
||||||
@@ -71,6 +92,10 @@ export const EV = {
|
|||||||
claimOffline: 'game:claim-offline',
|
claimOffline: 'game:claim-offline',
|
||||||
/** 再来一局,ack: ActionAck */
|
/** 再来一局,ack: ActionAck */
|
||||||
restart: 'game:restart',
|
restart: 'game:restart',
|
||||||
|
/** 发送表情给对手(长按棋盘),ack: ActionAck */
|
||||||
|
emojiSend: 'emoji:send',
|
||||||
|
/** 服务端 → 客户端:收到对手表情 */
|
||||||
|
emojiRecv: 'emoji:recv',
|
||||||
/** 服务端 → 客户端:全量状态推送 */
|
/** 服务端 → 客户端:全量状态推送 */
|
||||||
state: 'game:state',
|
state: 'game:state',
|
||||||
/** 服务端 → 客户端:一次性提示(错误/事件) */
|
/** 服务端 → 客户端:一次性提示(错误/事件) */
|
||||||
@@ -109,6 +134,14 @@ export interface NoticePayload {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 发送表情载荷:emoji 必须是 EMOJIS 中的字符 */
|
||||||
|
export interface EmojiPayload {
|
||||||
|
emoji: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 音效场景 */
|
||||||
|
export type SoundKind = 'place' | 'win' | 'undo' | 'emoji' | 'heart'
|
||||||
|
|
||||||
/** 悔棋请求自动失效时长 */
|
/** 悔棋请求自动失效时长 */
|
||||||
export const UNDO_TIMEOUT_MS = 60_000
|
export const UNDO_TIMEOUT_MS = 60_000
|
||||||
/** 对手离线多久后允许判负 */
|
/** 对手离线多久后允许判负 */
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export default defineConfig({
|
|||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
// 绑定所有网卡(0.0.0.0),便于局域网/内网其他机器(如 100.64.0.2)直接访问
|
||||||
|
host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
// 开发态把 Socket.IO 请求(含 WebSocket upgrade)转发给本地 Node 服务
|
// 开发态把 Socket.IO 请求(含 WebSocket upgrade)转发给本地 Node 服务
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
+372
-10
@@ -4,16 +4,19 @@ import { io } from 'socket.io-client'
|
|||||||
import type { Socket } from 'socket.io-client'
|
import type { Socket } from 'socket.io-client'
|
||||||
import Board from './components/Board.vue'
|
import Board from './components/Board.vue'
|
||||||
import {
|
import {
|
||||||
|
EMOJIS,
|
||||||
EV,
|
EV,
|
||||||
OFFLINE_CLAIM_MS,
|
OFFLINE_CLAIM_MS,
|
||||||
type ActionAck,
|
type ActionAck,
|
||||||
type CreateAck,
|
type CreateAck,
|
||||||
|
type EmojiPayload,
|
||||||
type EndReason,
|
type EndReason,
|
||||||
type JoinAck,
|
type JoinAck,
|
||||||
type NoticePayload,
|
type NoticePayload,
|
||||||
type Player,
|
type Player,
|
||||||
type RoomState,
|
type RoomState,
|
||||||
} from '../../shared/protocol'
|
} 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 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 phase = ref<'connecting' | 'lobby' | 'room'>('connecting')
|
||||||
const state = ref<RoomState | null>(null)
|
const state = ref<RoomState | null>(null)
|
||||||
const joinCode = ref(initialRoomId)
|
const joinCode = ref(initialRoomId)
|
||||||
|
const nickname = ref(loadNickname())
|
||||||
|
const theme = ref<ThemeId>(loadTheme())
|
||||||
|
const showThemePicker = ref(false)
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const resultDismissed = ref(false)
|
const resultDismissed = ref(false)
|
||||||
/** 悔棋等待弹窗是否被手动收起(对方未响应前允许先看棋盘) */
|
/** 悔棋等待弹窗是否被手动收起(对方未响应前允许先看棋盘) */
|
||||||
@@ -70,6 +94,16 @@ const undoWaitDismissed = ref(false)
|
|||||||
const toast = ref<{ text: string; error: boolean } | null>(null)
|
const toast = ref<{ text: string; error: boolean } | null>(null)
|
||||||
let toastTimer: number | undefined
|
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'>(() => {
|
const view = computed<'lobby' | 'loading' | 'room'>(() => {
|
||||||
if (state.value) return 'room'
|
if (state.value) return 'room'
|
||||||
@@ -82,6 +116,12 @@ function showToast(text: string, error = false): void {
|
|||||||
toastTimer = window.setTimeout(() => (toast.value = null), 2600)
|
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
|
const s = socket
|
||||||
if (!s) return
|
if (!s) return
|
||||||
if (phase.value !== 'room') phase.value = 'connecting'
|
if (phase.value !== 'room') phase.value = 'connecting'
|
||||||
|
// 新加入时带昵称;重连时不覆盖已有昵称(服务端只在非空时设置)
|
||||||
|
const sendPayload: Record<string, string> = { 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
|
busy.value = false
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const ok = res as JoinAck
|
const ok = res as JoinAck
|
||||||
@@ -149,6 +193,18 @@ onMounted(() => {
|
|||||||
if (incoming.status === 'playing') resultDismissed.value = false
|
if (incoming.status === 'playing') resultDismissed.value = false
|
||||||
// 悔棋请求已结束(同意/拒绝/超时/落子取消)时重置弹窗状态
|
// 悔棋请求已结束(同意/拒绝/超时/落子取消)时重置弹窗状态
|
||||||
if (incoming.undoRequestedBy === null) undoWaitDismissed.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'))
|
s.on(EV.notice, (p: NoticePayload) => showToast(p.message, p.level === 'error'))
|
||||||
|
|
||||||
@@ -185,7 +241,9 @@ function createRoom(): void {
|
|||||||
}
|
}
|
||||||
busy.value = true
|
busy.value = true
|
||||||
// 统一约定:载荷在前、回调在后,服务端只从最后一个参数取 ack
|
// 统一约定:载荷在前、回调在后,服务端只从最后一个参数取 ack
|
||||||
s.emit(EV.create, {}, (res: CreateAck | ActionAck) => {
|
const createPayload: Record<string, string> = {}
|
||||||
|
if (nickname.value.trim()) createPayload.nickname = nickname.value.trim()
|
||||||
|
s.emit(EV.create, createPayload, (res: CreateAck | ActionAck) => {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
showToast((res as { error: string }).error, true)
|
showToast((res as { error: string }).error, true)
|
||||||
@@ -225,6 +283,21 @@ function leaveRoom(): void {
|
|||||||
history.replaceState(null, '', location.pathname)
|
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<EndReason, string> = {
|
|||||||
offline: '对手离线判负',
|
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 = <T,>(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 result = computed<{ title: string; sub: string } | null>(() => {
|
||||||
const s = state.value
|
const s = state.value
|
||||||
if (!s || s.status !== 'over' || s.winner === null || s.seat === null) return null
|
if (!s || s.status !== 'over' || s.winner === null || s.seat === null) return null
|
||||||
@@ -344,8 +478,45 @@ async function copyShareLink(): Promise<void> {
|
|||||||
|
|
||||||
<!-- 大厅 -->
|
<!-- 大厅 -->
|
||||||
<section v-if="view === 'lobby'" class="screen lobby">
|
<section v-if="view === 'lobby'" class="screen lobby">
|
||||||
<h1>五子棋</h1>
|
<button class="ghost small theme-btn" @click="showThemePicker = true">
|
||||||
<p class="muted tip">两人对弈 · 手机浏览器直接玩</p>
|
{{ THEMES.find((t) => t.id === theme)?.icon }} 换换心情
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<svg class="couple" viewBox="0 0 200 120" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M30 95 C 30 70, 55 62, 62 80 C 68 62, 92 70, 92 95 C 92 108, 78 116, 61 110 C 44 116, 30 108, 30 95 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M108 92 C 108 72, 128 64, 134 78 C 139 64, 159 72, 159 92 C 159 103, 145 110, 133 104 C 120 110, 108 103, 108 92 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
opacity="0.65"
|
||||||
|
transform="translate(18 -6) scale(0.9)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M78 62 L 90 52 L 102 62 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
opacity="0.85"
|
||||||
|
/>
|
||||||
|
<circle cx="72" cy="88" r="3" fill="currentColor" />
|
||||||
|
<circle cx="50" cy="88" r="3" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<h1>双人对弈 · 五子棋</h1>
|
||||||
|
<p class="muted tip">只属于你们两个人的小小棋盘</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-model="nickname"
|
||||||
|
class="nick"
|
||||||
|
type="text"
|
||||||
|
maxlength="12"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="给对方起个名字吧(可选)"
|
||||||
|
@blur="saveNickname(nickname)"
|
||||||
|
/>
|
||||||
|
|
||||||
<button class="primary big" :disabled="busy || !connected" @click="createRoom">
|
<button class="primary big" :disabled="busy || !connected" @click="createRoom">
|
||||||
创建房间
|
创建房间
|
||||||
@@ -385,14 +556,21 @@ async function copyShareLink(): Promise<void> {
|
|||||||
<b class="mono">{{ state!.roomId }}</b>
|
<b class="mono">{{ state!.roomId }}</b>
|
||||||
</div>
|
</div>
|
||||||
<div class="who">
|
<div class="who">
|
||||||
<span class="chip">你执{{ myColorText }}</span>
|
<span class="chip">
|
||||||
|
{{ myNick }}
|
||||||
|
<i>{{ myColorText }}</i>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
class="dot"
|
class="dot"
|
||||||
:class="{ off: !opponentOnline, wait: !opponentJoined }"
|
:class="{ off: !opponentOnline, wait: !opponentJoined }"
|
||||||
:title="opponentJoined ? (opponentOnline ? '对手在线' : '对手掉线') : '等待对手'"
|
:title="opponentJoined ? (opponentOnline ? '对手在线' : '对手掉线') : '等待对手'"
|
||||||
/>
|
/>
|
||||||
|
<span v-if="opponentJoined" class="chip faint">{{ oppNick }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="ghost small" @click="copyShareLink">复制邀请链接</button>
|
<button class="ghost small" @click="copyShareLink">邀请</button>
|
||||||
|
<button class="ghost small" :title="'切换主题'" @click="showThemePicker = true">
|
||||||
|
{{ THEMES.find((t) => t.id === theme)?.icon }}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p class="status" :class="{ mine: canPlace }">{{ statusText }}</p>
|
<p class="status" :class="{ mine: canPlace }">{{ statusText }}</p>
|
||||||
@@ -403,7 +581,11 @@ async function copyShareLink(): Promise<void> {
|
|||||||
:win-line="state!.winLine"
|
:win-line="state!.winLine"
|
||||||
:seat="state!.seat"
|
:seat="state!.seat"
|
||||||
:my-turn="canPlace"
|
:my-turn="canPlace"
|
||||||
|
:theme="theme"
|
||||||
|
:emoji-fx="emojiFx"
|
||||||
|
:heart-fx="heartFx"
|
||||||
@place="place"
|
@place="place"
|
||||||
|
@longpress="sendEmoji"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<footer class="actions">
|
<footer class="actions">
|
||||||
@@ -455,9 +637,26 @@ async function copyShareLink(): Promise<void> {
|
|||||||
|
|
||||||
<!-- 终局 -->
|
<!-- 终局 -->
|
||||||
<div v-else-if="showResultDialog" class="mask">
|
<div v-else-if="showResultDialog" class="mask">
|
||||||
<div class="dialog">
|
<div class="dialog result-card">
|
||||||
|
<div class="result-heart" aria-hidden="true">💗</div>
|
||||||
<h2>{{ result!.title }}</h2>
|
<h2>{{ result!.title }}</h2>
|
||||||
<p>{{ result!.sub }}</p>
|
<p class="result-line">{{ result!.sub }}</p>
|
||||||
|
<p v-if="endMoveText" class="result-line">{{ endMoveText }}</p>
|
||||||
|
<p class="sweet">{{ resultSweet }}</p>
|
||||||
|
<dl class="result-stats">
|
||||||
|
<div>
|
||||||
|
<dt>第 {{ state!.stats.round }} 局</dt>
|
||||||
|
<dd>
|
||||||
|
{{ state!.black.nickname || '黑棋' }} {{ state!.stats.blackWins }} ·
|
||||||
|
{{ state!.white.nickname || '白棋' }} {{ state!.stats.whiteWins }} ·
|
||||||
|
平 {{ state!.stats.draws }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>本局 {{ state!.moveCount }} 手</dt>
|
||||||
|
<dd>一起下棋的时光最甜 ✨</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button @click="resultDismissed = true">看棋盘</button>
|
<button @click="resultDismissed = true">看棋盘</button>
|
||||||
<button class="primary" @click="restart(false)">再来一局</button>
|
<button class="primary" @click="restart(false)">再来一局</button>
|
||||||
@@ -467,6 +666,29 @@ async function copyShareLink(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 主题选择 -->
|
||||||
|
<div v-if="showThemePicker" class="mask">
|
||||||
|
<div class="dialog">
|
||||||
|
<h2>给棋盘换件衣裳</h2>
|
||||||
|
<p>选一个你们喜欢的氛围</p>
|
||||||
|
<div class="theme-grid">
|
||||||
|
<button
|
||||||
|
v-for="t in THEMES"
|
||||||
|
:key="t.id"
|
||||||
|
class="theme-item"
|
||||||
|
:class="{ active: t.id === theme }"
|
||||||
|
@click="setTheme(t.id)"
|
||||||
|
>
|
||||||
|
<span class="theme-icon">{{ t.icon }}</span>
|
||||||
|
<span>{{ t.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="ghost" @click="showThemePicker = false">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -488,17 +710,47 @@ async function copyShareLink(): Promise<void> {
|
|||||||
/* ---------------- 大厅 ---------------- */
|
/* ---------------- 大厅 ---------------- */
|
||||||
|
|
||||||
.lobby {
|
.lobby {
|
||||||
|
position: relative;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
/* 浪漫渐变背景 */
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 60% at 50% 0%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 70%),
|
||||||
|
radial-gradient(90% 50% at 50% 100%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.couple {
|
||||||
|
width: 150px;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lobby h1 {
|
.lobby h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 30px;
|
font-size: 26px;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick {
|
||||||
|
max-width: 320px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tip {
|
.tip {
|
||||||
@@ -583,6 +835,7 @@ async function copyShareLink(): Promise<void> {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
@@ -590,6 +843,16 @@ async function copyShareLink(): Promise<void> {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--panel-2);
|
background: var(--panel-2);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip i {
|
||||||
|
font-style: normal;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.faint {
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
@@ -643,4 +906,103 @@ async function copyShareLink(): Promise<void> {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- 终局卡片 ---------------- */
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-heart {
|
||||||
|
font-size: 40px;
|
||||||
|
animation: heartBeat 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heartBeat {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-line {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sweet {
|
||||||
|
margin: 2px 0 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats div + div {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 主题选择 ---------------- */
|
||||||
|
|
||||||
|
.theme-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--bg);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-item.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-icon {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+335
-53
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { BOARD_SIZE, type Cell, type Player } from '../../../shared/protocol'
|
import { BOARD_SIZE, type Cell, type Player } from '../../../shared/protocol'
|
||||||
|
import type { ThemeId } from '../theme'
|
||||||
|
import { getBoardColors } from '../theme'
|
||||||
|
import { feedback } from '../sound'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
board: Cell[]
|
board: Cell[]
|
||||||
@@ -10,14 +13,21 @@ const props = defineProps<{
|
|||||||
seat: Player | null
|
seat: Player | null
|
||||||
/** 轮到自己时为 true */
|
/** 轮到自己时为 true */
|
||||||
myTurn: boolean
|
myTurn: boolean
|
||||||
|
theme: ThemeId
|
||||||
|
/** 收到对手表情(id 递增触发动画) */
|
||||||
|
emojiFx: { id: number; emoji: string } | null
|
||||||
|
/** 爱心特效触发计数(彩蛋 / 双击) */
|
||||||
|
heartFx: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'place', x: number, y: number): void }>()
|
const emit = defineEmits<{
|
||||||
|
(e: 'place', x: number, y: number): void
|
||||||
|
(e: 'longpress'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const host = ref<HTMLDivElement | null>(null)
|
const host = ref<HTMLDivElement | null>(null)
|
||||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
/** 逻辑像素边长、格宽、边距(= 半个格宽,使最外线到边缘留白对称) */
|
|
||||||
let size = 0
|
let size = 0
|
||||||
let cell = 0
|
let cell = 0
|
||||||
let pad = 0
|
let pad = 0
|
||||||
@@ -30,24 +40,56 @@ const STAR_POINTS: Array<[number, number]> = [
|
|||||||
[7, 7],
|
[7, 7],
|
||||||
]
|
]
|
||||||
|
|
||||||
const COLORS = {
|
/* ------------------------------------------------------------------ */
|
||||||
boardTop: '#e8c088',
|
/* 动画状态(普通对象,配合 rAF 循环,不依赖 Vue 响应式) */
|
||||||
boardBottom: '#d9a866',
|
/* ------------------------------------------------------------------ */
|
||||||
grid: 'rgba(60, 35, 10, 0.75)',
|
|
||||||
border: 'rgba(50, 28, 6, 0.95)',
|
interface FX {
|
||||||
|
newStone: { idx: number; t0: number } | null
|
||||||
|
winScan: { idxs: number[]; t0: number } | null
|
||||||
|
hearts: Array<{ x: number; y: number; vx: number; vy: number; size: number; t0: number; kind: 'heart' | 'emoji'; emoji?: string }>
|
||||||
|
dbl: { x: number; y: number; t0: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fx: FX = { newStone: null, winScan: null, hearts: [], dbl: null }
|
||||||
|
|
||||||
|
let rafId = 0
|
||||||
|
|
||||||
|
const easeOutBack = (t: number): number => {
|
||||||
|
const c1 = 1.70158
|
||||||
|
const c3 = c1 + 1
|
||||||
|
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function colors() {
|
||||||
|
return getBoardColors(props.theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主题强调色(UI accent 与棋盘特效共用),按主题缓存 */
|
||||||
|
let accentCache = '#ef4444'
|
||||||
|
let accentTheme: ThemeId | '' = ''
|
||||||
|
function accent(): string {
|
||||||
|
if (accentTheme !== props.theme) {
|
||||||
|
accentTheme = props.theme
|
||||||
|
accentCache =
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#ef4444'
|
||||||
|
}
|
||||||
|
return accentCache
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 布局与绘制 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function ctx2d(): CanvasRenderingContext2D | null {
|
function ctx2d(): CanvasRenderingContext2D | null {
|
||||||
return canvas.value?.getContext('2d') ?? null
|
return canvas.value?.getContext('2d') ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按容器尺寸与 DPR 重建画布,再整体重绘 */
|
|
||||||
function layout(): void {
|
function layout(): void {
|
||||||
const el = host.value
|
const el = host.value
|
||||||
const c = canvas.value
|
const c = canvas.value
|
||||||
if (!el || !c) return
|
if (!el || !c) return
|
||||||
|
|
||||||
// 棋盘为正方形:宽度受限,且给底部操作区留出空间
|
|
||||||
const availW = el.clientWidth || window.innerWidth - 32
|
const availW = el.clientWidth || window.innerWidth - 32
|
||||||
const availH = window.innerHeight * 0.6
|
const availH = window.innerHeight * 0.6
|
||||||
size = Math.floor(Math.min(availW, availH))
|
size = Math.floor(Math.min(availW, availH))
|
||||||
@@ -69,18 +111,21 @@ function layout(): void {
|
|||||||
function draw(): void {
|
function draw(): void {
|
||||||
const g = ctx2d()
|
const g = ctx2d()
|
||||||
if (!g || size === 0) return
|
if (!g || size === 0) return
|
||||||
|
const t = performance.now()
|
||||||
|
const col = colors()
|
||||||
|
const a = accent()
|
||||||
|
|
||||||
g.clearRect(0, 0, size, size)
|
g.clearRect(0, 0, size, size)
|
||||||
|
|
||||||
// 木纹底色
|
// 棋盘底色
|
||||||
const bg = g.createLinearGradient(0, 0, size, size)
|
const bg = g.createLinearGradient(0, 0, size, size)
|
||||||
bg.addColorStop(0, COLORS.boardTop)
|
bg.addColorStop(0, col.board.top)
|
||||||
bg.addColorStop(1, COLORS.boardBottom)
|
bg.addColorStop(1, col.board.bottom)
|
||||||
g.fillStyle = bg
|
g.fillStyle = bg
|
||||||
g.fillRect(0, 0, size, size)
|
g.fillRect(0, 0, size, size)
|
||||||
|
|
||||||
// 网格
|
// 网格
|
||||||
g.strokeStyle = COLORS.grid
|
g.strokeStyle = col.board.grid
|
||||||
g.lineWidth = Math.max(1, cell * 0.02)
|
g.lineWidth = Math.max(1, cell * 0.02)
|
||||||
g.beginPath()
|
g.beginPath()
|
||||||
for (let i = 0; i < BOARD_SIZE; i++) {
|
for (let i = 0; i < BOARD_SIZE; i++) {
|
||||||
@@ -92,71 +137,138 @@ function draw(): void {
|
|||||||
}
|
}
|
||||||
g.stroke()
|
g.stroke()
|
||||||
|
|
||||||
// 外框加粗
|
// 外框
|
||||||
g.strokeStyle = COLORS.border
|
g.strokeStyle = col.board.border
|
||||||
g.lineWidth = Math.max(1.5, cell * 0.05)
|
g.lineWidth = Math.max(1.5, cell * 0.05)
|
||||||
g.strokeRect(pad, pad, size - cell, size - cell)
|
g.strokeRect(pad, pad, size - cell, size - cell)
|
||||||
|
|
||||||
// 星位
|
// 星位
|
||||||
g.fillStyle = COLORS.border
|
g.fillStyle = col.board.star
|
||||||
for (const [x, y] of STAR_POINTS) {
|
for (const [x, y] of STAR_POINTS) {
|
||||||
g.beginPath()
|
g.beginPath()
|
||||||
g.arc(pad + x * cell, pad + y * cell, cell * 0.09, 0, Math.PI * 2)
|
g.arc(pad + x * cell, pad + y * cell, cell * 0.09, 0, Math.PI * 2)
|
||||||
g.fill()
|
g.fill()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 棋子
|
|
||||||
const r = cell * 0.44
|
const r = cell * 0.44
|
||||||
|
|
||||||
|
// 棋子
|
||||||
for (let i = 0; i < props.board.length; i++) {
|
for (let i = 0; i < props.board.length; i++) {
|
||||||
const v = props.board[i]
|
const v = props.board[i]
|
||||||
if (v === 0) continue
|
if (v === 0) continue
|
||||||
const x = i % BOARD_SIZE
|
const x = pad + (i % BOARD_SIZE) * cell
|
||||||
const y = Math.floor(i / BOARD_SIZE)
|
const y = pad + Math.floor(i / BOARD_SIZE) * cell
|
||||||
drawStone(g, pad + x * cell, pad + y * cell, r, v)
|
|
||||||
|
// 新落子弹跳动画
|
||||||
|
if (fx.newStone && fx.newStone.idx === i) {
|
||||||
|
const p = Math.min(1, (t - fx.newStone.t0) / 220)
|
||||||
|
const scale = easeOutBack(p)
|
||||||
|
drawStone(g, x, y, r * scale, v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
drawStone(g, x, y, r, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最后一手标记(与胜利高亮不重复绘制)
|
// 最后一手呼吸光晕
|
||||||
if (props.lastMove !== null && !props.winLine) {
|
if (props.lastMove !== null && !props.winLine) {
|
||||||
const x = props.lastMove % BOARD_SIZE
|
const lx = pad + (props.lastMove % BOARD_SIZE) * cell
|
||||||
const y = Math.floor(props.lastMove / BOARD_SIZE)
|
const ly = pad + Math.floor(props.lastMove / BOARD_SIZE) * cell
|
||||||
const v = props.board[props.lastMove]
|
const v = props.board[props.lastMove]
|
||||||
g.fillStyle = v === 1 ? '#f9fafb' : '#111827'
|
const pulse = 0.5 + 0.5 * Math.sin(t / 340)
|
||||||
|
g.fillStyle = v === 1 ? 'rgba(249,250,251,0.7)' : 'rgba(17,24,39,0.7)'
|
||||||
g.beginPath()
|
g.beginPath()
|
||||||
g.arc(pad + x * cell, pad + y * cell, cell * 0.1, 0, Math.PI * 2)
|
g.arc(lx, ly, cell * (0.12 + 0.05 * pulse), 0, Math.PI * 2)
|
||||||
g.fill()
|
g.fill()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 五连高亮
|
// 五连:静态高亮 + 扫光动画
|
||||||
if (props.winLine?.length) {
|
if (props.winLine?.length) {
|
||||||
g.strokeStyle = '#ef4444'
|
const idxs = props.winLine
|
||||||
|
g.strokeStyle = a
|
||||||
g.lineWidth = Math.max(2, cell * 0.07)
|
g.lineWidth = Math.max(2, cell * 0.07)
|
||||||
for (const i of props.winLine) {
|
for (const i of idxs) {
|
||||||
const x = i % BOARD_SIZE
|
const x = pad + (i % BOARD_SIZE) * cell
|
||||||
const y = Math.floor(i / BOARD_SIZE)
|
const y = pad + Math.floor(i / BOARD_SIZE) * cell
|
||||||
g.beginPath()
|
g.beginPath()
|
||||||
g.arc(pad + x * cell, pad + y * cell, r + cell * 0.12, 0, Math.PI * 2)
|
g.arc(x, y, r + cell * 0.12, 0, Math.PI * 2)
|
||||||
g.stroke()
|
g.stroke()
|
||||||
}
|
}
|
||||||
|
if (fx.winScan) {
|
||||||
|
const p = Math.min(1, (t - fx.winScan.t0) / 900)
|
||||||
|
const first = idxs[0]!
|
||||||
|
const lastIdx = idxs[idxs.length - 1]!
|
||||||
|
const fx0 = pad + (first % BOARD_SIZE) * cell
|
||||||
|
const fy0 = pad + Math.floor(first / BOARD_SIZE) * cell
|
||||||
|
const fx1 = pad + (lastIdx % BOARD_SIZE) * cell
|
||||||
|
const fy1 = pad + Math.floor(lastIdx / BOARD_SIZE) * cell
|
||||||
|
const sx = fx0 + (fx1 - fx0) * p
|
||||||
|
const sy = fy0 + (fy1 - fy0) * p
|
||||||
|
const alpha = 0.75 * Math.sin(Math.min(1, p * 2) * Math.PI)
|
||||||
|
g.save()
|
||||||
|
g.shadowColor = a
|
||||||
|
g.shadowBlur = 22
|
||||||
|
g.globalAlpha = alpha
|
||||||
|
g.fillStyle = a
|
||||||
|
g.beginPath()
|
||||||
|
g.arc(sx, sy, r * 1.1, 0, Math.PI * 2)
|
||||||
|
g.fill()
|
||||||
|
g.restore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 双击小爱心脉冲
|
||||||
|
if (fx.dbl) {
|
||||||
|
const p = Math.min(1, (t - fx.dbl.t0) / 700)
|
||||||
|
const alpha = 1 - p
|
||||||
|
const s = (0.5 + p * 1.4) * cell * 0.9
|
||||||
|
g.save()
|
||||||
|
g.globalAlpha = alpha
|
||||||
|
fillHeart(g, fx.dbl.x, fx.dbl.y, s, a)
|
||||||
|
g.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 爱心雨 / 表情粒子
|
||||||
|
drawHearts(g, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHearts(g: CanvasRenderingContext2D, t: number): void {
|
||||||
|
for (const h of fx.hearts) {
|
||||||
|
const age = t - h.t0
|
||||||
|
const life = 1500
|
||||||
|
if (age > life) continue
|
||||||
|
const p = age / life
|
||||||
|
const alpha = 1 - p
|
||||||
|
g.save()
|
||||||
|
g.globalAlpha = alpha
|
||||||
|
g.font = `${h.size + p * 8}px serif`
|
||||||
|
g.textAlign = 'center'
|
||||||
|
g.textBaseline = 'middle'
|
||||||
|
g.fillText(h.emoji ?? '❤️', h.x + h.vx * p * 90, h.y + h.vy * p * 120 + p * p * 40)
|
||||||
|
g.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillHeart(g: CanvasRenderingContext2D, cx: number, cy: number, s: number, color: string): void {
|
||||||
|
g.fillStyle = color
|
||||||
|
g.beginPath()
|
||||||
|
g.moveTo(cx, cy + s * 0.32)
|
||||||
|
g.bezierCurveTo(cx - s, cy - s * 0.5, cx - s * 0.55, cy - s, cx, cy - s * 0.22)
|
||||||
|
g.bezierCurveTo(cx + s * 0.55, cy - s, cx + s, cy - s * 0.5, cx, cy + s * 0.32)
|
||||||
|
g.closePath()
|
||||||
|
g.fill()
|
||||||
|
}
|
||||||
|
|
||||||
function drawStone(g: CanvasRenderingContext2D, cx: number, cy: number, r: number, v: Cell): void {
|
function drawStone(g: CanvasRenderingContext2D, cx: number, cy: number, r: number, v: Cell): void {
|
||||||
// 阴影
|
|
||||||
g.save()
|
g.save()
|
||||||
g.shadowColor = 'rgba(0, 0, 0, 0.35)'
|
g.shadowColor = 'rgba(0, 0, 0, 0.35)'
|
||||||
g.shadowBlur = r * 0.35
|
g.shadowBlur = r * 0.35
|
||||||
g.shadowOffsetY = r * 0.12
|
g.shadowOffsetY = r * 0.12
|
||||||
|
|
||||||
|
const stops = v === 1 ? colors().stone.black : colors().stone.white
|
||||||
const grad = g.createRadialGradient(cx - r * 0.35, cy - r * 0.35, r * 0.1, cx, cy, r)
|
const grad = g.createRadialGradient(cx - r * 0.35, cy - r * 0.35, r * 0.1, cx, cy, r)
|
||||||
if (v === 1) {
|
grad.addColorStop(0, stops[0])
|
||||||
grad.addColorStop(0, '#6b7280')
|
grad.addColorStop(0.55, stops[1])
|
||||||
grad.addColorStop(0.5, '#1f2937')
|
grad.addColorStop(1, stops[2])
|
||||||
grad.addColorStop(1, '#030712')
|
|
||||||
} else {
|
|
||||||
grad.addColorStop(0, '#ffffff')
|
|
||||||
grad.addColorStop(0.65, '#f3f4f6')
|
|
||||||
grad.addColorStop(1, '#b9bec7')
|
|
||||||
}
|
|
||||||
g.fillStyle = grad
|
g.fillStyle = grad
|
||||||
g.beginPath()
|
g.beginPath()
|
||||||
g.arc(cx, cy, r, 0, Math.PI * 2)
|
g.arc(cx, cy, r, 0, Math.PI * 2)
|
||||||
@@ -164,19 +276,87 @@ function drawStone(g: CanvasRenderingContext2D, cx: number, cy: number, r: numbe
|
|||||||
g.restore()
|
g.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 触摸/点击 → 最近的交叉点,带半格容差,避免误落 */
|
function isActive(): boolean {
|
||||||
function onPick(ev: PointerEvent): void {
|
if (fx.newStone) return true
|
||||||
const c = canvas.value
|
if (fx.winScan && performance.now() - fx.winScan.t0 < 1100) return true
|
||||||
if (!c || props.seat === null || !props.myTurn) return
|
if (fx.dbl && performance.now() - fx.dbl.t0 < 800) return true
|
||||||
|
if (fx.hearts.length > 0) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const rect = c.getBoundingClientRect()
|
function frame(): void {
|
||||||
const px = ev.clientX - rect.left
|
draw()
|
||||||
const py = ev.clientY - rect.top
|
if (isActive()) rafId = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAnim(): void {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
rafId = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 触摸交互:短按落子 / 长按发爱心 / 双击彩蛋 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
let downAt = { x: 0, y: 0, t: 0 }
|
||||||
|
let longpressTimer: number | undefined
|
||||||
|
let held = false
|
||||||
|
let lastUp = { t: 0, x: 0, y: 0 }
|
||||||
|
|
||||||
|
function canvasPos(ev: PointerEvent): [number, number] {
|
||||||
|
const rect = canvas.value!.getBoundingClientRect()
|
||||||
|
return [ev.clientX - rect.left, ev.clientY - rect.top]
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDown(ev: PointerEvent): void {
|
||||||
|
const [px, py] = canvasPos(ev)
|
||||||
|
downAt = { x: px, y: py, t: performance.now() }
|
||||||
|
held = false
|
||||||
|
window.clearTimeout(longpressTimer)
|
||||||
|
longpressTimer = window.setTimeout(() => {
|
||||||
|
held = true
|
||||||
|
emit('longpress')
|
||||||
|
}, 450)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(ev: PointerEvent): void {
|
||||||
|
if (longpressTimer === undefined) return
|
||||||
|
const [px, py] = canvasPos(ev)
|
||||||
|
if (Math.hypot(px - downAt.x, py - downAt.y) > 14) {
|
||||||
|
window.clearTimeout(longpressTimer)
|
||||||
|
longpressTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp(ev: PointerEvent): void {
|
||||||
|
window.clearTimeout(longpressTimer)
|
||||||
|
longpressTimer = undefined
|
||||||
|
const [px, py] = canvasPos(ev)
|
||||||
|
const now = performance.now()
|
||||||
|
|
||||||
|
const isDbl = now - lastUp.t < 350 && Math.hypot(px - lastUp.x, py - lastUp.y) < 1.5 * cell
|
||||||
|
lastUp = { t: now, x: px, y: py }
|
||||||
|
|
||||||
|
if (held) {
|
||||||
|
held = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双击彩蛋:棋盘上点燃一颗小爱心(不落子)
|
||||||
|
if (isDbl) {
|
||||||
|
fx.dbl = { x: px, y: py, t0: now }
|
||||||
|
burstHearts(px, py, 6, '💖')
|
||||||
|
startAnim()
|
||||||
|
feedback('heart')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常落子(带半格容差)
|
||||||
|
if (props.seat === null || !props.myTurn) return
|
||||||
|
const rect = canvas.value!.getBoundingClientRect()
|
||||||
const x = Math.round((px - pad) / cell)
|
const x = Math.round((px - pad) / cell)
|
||||||
const y = Math.round((py - pad) / cell)
|
const y = Math.round((py - pad) / cell)
|
||||||
if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return
|
if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return
|
||||||
|
|
||||||
const dx = px - (pad + x * cell)
|
const dx = px - (pad + x * cell)
|
||||||
const dy = py - (pad + y * cell)
|
const dy = py - (pad + y * cell)
|
||||||
if (Math.hypot(dx, dy) > cell * 0.55) return
|
if (Math.hypot(dx, dy) > cell * 0.55) return
|
||||||
@@ -185,6 +365,93 @@ function onPick(ev: PointerEvent): void {
|
|||||||
emit('place', x, y)
|
emit('place', x, y)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function burstHearts(x: number, y: number, count: number, emoji = '❤️'): void {
|
||||||
|
const t = performance.now()
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const ang = Math.random() * Math.PI * 2
|
||||||
|
const speed = 0.5 + Math.random() * 0.9
|
||||||
|
fx.hearts.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vx: Math.cos(ang) * speed,
|
||||||
|
vy: Math.sin(ang) * speed - 0.6,
|
||||||
|
size: 10 + Math.random() * 10,
|
||||||
|
t0: t,
|
||||||
|
kind: 'heart',
|
||||||
|
emoji,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 回收过期粒子
|
||||||
|
fx.hearts = fx.hearts.filter((h) => t - h.t0 < 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 响应式触发 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
// 落子(含对方落子)→ 弹跳动画 + 音效
|
||||||
|
watch(
|
||||||
|
() => props.board,
|
||||||
|
(next, prev) => {
|
||||||
|
if (!prev) return
|
||||||
|
for (let i = 0; i < next.length; i++) {
|
||||||
|
if (prev[i] !== next[i] && next[i] !== 0) {
|
||||||
|
fx.newStone = { idx: i, t0: performance.now() }
|
||||||
|
if (props.seat !== null) feedback('place')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startAnim()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 五连 → 扫光动画 + 胜利音效
|
||||||
|
watch(
|
||||||
|
() => props.winLine,
|
||||||
|
(line) => {
|
||||||
|
if (line?.length) {
|
||||||
|
fx.winScan = { idxs: line, t0: performance.now() }
|
||||||
|
startAnim()
|
||||||
|
if (props.seat !== null) feedback('win')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 收到对手表情 → 上浮动画
|
||||||
|
watch(
|
||||||
|
() => props.emojiFx,
|
||||||
|
(fxIn) => {
|
||||||
|
if (!fxIn) return
|
||||||
|
const t = performance.now()
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
fx.hearts.push({
|
||||||
|
x: size * (0.3 + Math.random() * 0.4),
|
||||||
|
y: size * (0.3 + Math.random() * 0.2),
|
||||||
|
vx: (Math.random() - 0.5) * 1.2,
|
||||||
|
vy: -1.2 - Math.random() * 0.8,
|
||||||
|
size: 16 + Math.random() * 12,
|
||||||
|
t0: t,
|
||||||
|
kind: 'emoji',
|
||||||
|
emoji: fxIn.emoji,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
startAnim()
|
||||||
|
if (props.seat !== null) feedback('emoji')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 爱心特效(彩蛋触发:撒一大片)
|
||||||
|
watch(
|
||||||
|
() => props.heartFx,
|
||||||
|
(n, prev) => {
|
||||||
|
if (n !== undefined && prev !== undefined && n > prev && n !== 0) {
|
||||||
|
burstHearts(size / 2, size * 0.4, 18)
|
||||||
|
startAnim()
|
||||||
|
if (props.seat !== null) feedback('heart')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
let ro: ResizeObserver | null = null
|
let ro: ResizeObserver | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -197,15 +464,28 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
ro?.disconnect()
|
ro?.disconnect()
|
||||||
window.removeEventListener('orientationchange', layout)
|
window.removeEventListener('orientationchange', layout)
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
window.clearTimeout(longpressTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 棋盘数据变化即重绘(Canvas 不受 Vue 响应式影响,需手动触发)
|
// 主题切换也会改变绘制画风
|
||||||
watch(() => [props.board, props.lastMove, props.winLine], draw, { deep: true })
|
watch(
|
||||||
|
() => props.theme,
|
||||||
|
() => draw(),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="host" class="board-host">
|
<div ref="host" class="board-host">
|
||||||
<canvas ref="canvas" class="board-canvas" @pointerdown.prevent="onPick" />
|
<canvas
|
||||||
|
ref="canvas"
|
||||||
|
class="board-canvas"
|
||||||
|
@pointerdown.prevent="onDown"
|
||||||
|
@pointermove.prevent="onMove"
|
||||||
|
@pointerup.prevent="onUp"
|
||||||
|
@pointercancel="onUp"
|
||||||
|
@contextmenu.prevent
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -222,7 +502,9 @@ watch(() => [props.board, props.lastMove, props.winLine], draw, { deep: true })
|
|||||||
.board-canvas {
|
.board-canvas {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
/* 交给脚本处理指针事件,禁止浏览器手势介入 */
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 无外部资源的音效与触觉反馈。
|
||||||
|
* Web Audio 合成(首次用户手势解锁 AudioContext),配合手机振动。
|
||||||
|
*/
|
||||||
|
import type { SoundKind } from '../../shared/protocol'
|
||||||
|
|
||||||
|
/* --------------------- Web Audio 合成 --------------------- */
|
||||||
|
|
||||||
|
let ctx: AudioContext | null = null
|
||||||
|
|
||||||
|
function ac(): AudioContext | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||||
|
if (!AC) return null
|
||||||
|
if (!ctx) ctx = new AC()
|
||||||
|
if (ctx.state === 'suspended') void ctx.resume()
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
function tone(
|
||||||
|
freq: number,
|
||||||
|
opts: { dur?: number; type?: OscillatorType; vol?: number; delay?: number; slideTo?: number } = {},
|
||||||
|
): void {
|
||||||
|
const c = ac()
|
||||||
|
if (!c) return
|
||||||
|
const { dur = 0.1, type = 'sine', vol = 0.12, delay = 0, slideTo } = opts
|
||||||
|
const t0 = c.currentTime + delay
|
||||||
|
const osc = c.createOscillator()
|
||||||
|
const gain = c.createGain()
|
||||||
|
osc.type = type
|
||||||
|
osc.frequency.setValueAtTime(freq, t0)
|
||||||
|
if (slideTo && slideTo > 0) osc.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur)
|
||||||
|
gain.gain.setValueAtTime(0, t0)
|
||||||
|
gain.gain.linearRampToValueAtTime(vol, t0 + 0.012)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
|
||||||
|
osc.connect(gain)
|
||||||
|
gain.connect(c.destination)
|
||||||
|
osc.start(t0)
|
||||||
|
osc.stop(t0 + dur + 0.03)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCENES: Record<SoundKind, () => void> = {
|
||||||
|
place: () => tone(210, { dur: 0.09, type: 'triangle', vol: 0.2 }),
|
||||||
|
win: () => {
|
||||||
|
tone(523.25, { dur: 0.14, vol: 0.14 })
|
||||||
|
tone(659.25, { dur: 0.14, vol: 0.14, delay: 0.11 })
|
||||||
|
tone(783.99, { dur: 0.16, vol: 0.14, delay: 0.22 })
|
||||||
|
tone(1046.5, { dur: 0.28, vol: 0.16, delay: 0.33 })
|
||||||
|
},
|
||||||
|
undo: () => tone(300, { dur: 0.16, type: 'triangle', vol: 0.14, slideTo: 150 }),
|
||||||
|
emoji: () => {
|
||||||
|
tone(880, { dur: 0.1, vol: 0.1 })
|
||||||
|
tone(1318.5, { dur: 0.14, vol: 0.1, delay: 0.07 })
|
||||||
|
},
|
||||||
|
heart: () => {
|
||||||
|
tone(587.33, { dur: 0.16, vol: 0.16 })
|
||||||
|
tone(880, { dur: 0.16, vol: 0.14, delay: 0.14 })
|
||||||
|
tone(1174.66, { dur: 0.3, vol: 0.14, delay: 0.28 })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playSound(kind: SoundKind): void {
|
||||||
|
SCENES[kind]?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------- 触觉反馈 --------------------- */
|
||||||
|
|
||||||
|
const VIBRATION: Record<SoundKind, number | number[]> = {
|
||||||
|
place: 12,
|
||||||
|
win: [40, 30, 40],
|
||||||
|
undo: 20,
|
||||||
|
emoji: 15,
|
||||||
|
heart: [50, 40, 60],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vibrate(kind: SoundKind): void {
|
||||||
|
if (typeof navigator === 'undefined' || !('vibrate' in navigator)) return
|
||||||
|
try {
|
||||||
|
navigator.vibrate?.(VIBRATION[kind] as number)
|
||||||
|
} catch {
|
||||||
|
/* 部分浏览器抛异常,忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 落子/表情等线程感反馈合一:音效 + 震动 */
|
||||||
|
export function feedback(kind: SoundKind): void {
|
||||||
|
playSound(kind)
|
||||||
|
vibrate(kind)
|
||||||
|
}
|
||||||
+1
-1
@@ -63,7 +63,7 @@ button:disabled {
|
|||||||
|
|
||||||
button.primary {
|
button.primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #06210f;
|
color: var(--on-accent, #06210f);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* 主题系统:四套主题(雅致木纹 / 心动粉 / 暗夜紫 / 水墨江南)。
|
||||||
|
* 一套数据源同时驱动 CSS 变量(UI 底色)与 Canvas 棋盘配色。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ThemeId = 'classic' | 'pink' | 'night' | 'ink'
|
||||||
|
|
||||||
|
export interface BoardColors {
|
||||||
|
board: { top: string; bottom: string; grid: string; border: string; star: string }
|
||||||
|
stone: {
|
||||||
|
black: [string, string, string]
|
||||||
|
white: [string, string, string]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeDef {
|
||||||
|
id: ThemeId
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
/** 注入全局 CSS 变量(UI 主题色) */
|
||||||
|
css: Record<string, string>
|
||||||
|
board: BoardColors
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEMES: ThemeDef[] = [
|
||||||
|
{
|
||||||
|
id: 'classic',
|
||||||
|
name: '雅致木纹',
|
||||||
|
icon: '🪵',
|
||||||
|
css: {
|
||||||
|
'--bg': '#111827',
|
||||||
|
'--panel': '#1f2937',
|
||||||
|
'--panel-2': '#374151',
|
||||||
|
'--accent': '#22c55e',
|
||||||
|
'--accent-dim': '#16a34a',
|
||||||
|
'--on-accent': '#06210f',
|
||||||
|
},
|
||||||
|
board: {
|
||||||
|
board: { top: '#e8c088', bottom: '#d9a866', grid: 'rgba(60,35,10,0.75)', border: 'rgba(50,28,6,0.95)', star: 'rgba(50,28,6,0.95)' },
|
||||||
|
stone: {
|
||||||
|
black: ['#6b7280', '#1f2937', '#030712'],
|
||||||
|
white: ['#ffffff', '#f3f4f6', '#b9bec7'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pink',
|
||||||
|
name: '心动粉',
|
||||||
|
icon: '💗',
|
||||||
|
css: {
|
||||||
|
'--bg': '#25131f',
|
||||||
|
'--panel': '#3c2433',
|
||||||
|
'--panel-2': '#552a43',
|
||||||
|
'--accent': '#f472b6',
|
||||||
|
'--accent-dim': '#ec4899',
|
||||||
|
'--on-accent': '#4a0d2e',
|
||||||
|
},
|
||||||
|
board: {
|
||||||
|
board: { top: '#fadcf0', bottom: '#f4c3e0', grid: 'rgba(190,90,150,0.55)', border: 'rgba(160,60,120,0.85)', star: 'rgba(160,60,120,0.85)' },
|
||||||
|
stone: {
|
||||||
|
black: ['#c084fc', '#a855f7', '#581c87'],
|
||||||
|
white: ['#ffffff', '#fbe4f3', '#f3a8c9'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'night',
|
||||||
|
name: '暗夜紫',
|
||||||
|
icon: '🌙',
|
||||||
|
css: {
|
||||||
|
'--bg': '#0b0b1a',
|
||||||
|
'--panel': '#151531',
|
||||||
|
'--panel-2': '#232350',
|
||||||
|
'--accent': '#a78bfa',
|
||||||
|
'--accent-dim': '#8b5cf6',
|
||||||
|
'--on-accent': '#1e1b4b',
|
||||||
|
},
|
||||||
|
board: {
|
||||||
|
board: { top: '#28204f', bottom: '#181236', grid: 'rgba(167,139,250,0.4)', border: 'rgba(139,92,246,0.75)', star: 'rgba(167,139,250,0.9)' },
|
||||||
|
stone: {
|
||||||
|
black: ['#c4b5fd', '#7c3aed', '#1e1b4b'],
|
||||||
|
white: ['#ffffff', '#e0e7ff', '#a5b4fc'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ink',
|
||||||
|
name: '水墨江南',
|
||||||
|
icon: '🏮',
|
||||||
|
css: {
|
||||||
|
'--bg': '#101820',
|
||||||
|
'--panel': '#1b2832',
|
||||||
|
'--panel-2': '#2a3a45',
|
||||||
|
'--accent': '#5eead4',
|
||||||
|
'--accent-dim': '#2dd4bf',
|
||||||
|
'--on-accent': '#083344',
|
||||||
|
},
|
||||||
|
board: {
|
||||||
|
board: { top: '#f4f0e4', bottom: '#e6ddc6', grid: 'rgba(40,40,40,0.55)', border: 'rgba(25,25,25,0.85)', star: 'rgba(25,25,25,0.85)' },
|
||||||
|
stone: {
|
||||||
|
black: ['#4b5563', '#111827', '#000000'],
|
||||||
|
white: ['#ffffff', '#f8fafc', '#cbd5e1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const THEME_KEY = 'wuziqi.theme'
|
||||||
|
|
||||||
|
export function applyTheme(id: ThemeId): void {
|
||||||
|
const def = THEMES.find((t) => t.id === id) ?? THEMES[0]!
|
||||||
|
const root = document.documentElement
|
||||||
|
root.dataset.theme = id
|
||||||
|
for (const [k, v] of Object.entries(def.css)) {
|
||||||
|
root.style.setProperty(k, v)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_KEY, id)
|
||||||
|
} catch {
|
||||||
|
/* 忽略隐私模式 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadTheme(): ThemeId {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(THEME_KEY) as ThemeId | null
|
||||||
|
return saved && THEMES.some((t) => t.id === saved) ? saved : 'classic'
|
||||||
|
} catch {
|
||||||
|
return 'classic'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBoardColors(id: ThemeId): BoardColors {
|
||||||
|
return (THEMES.find((t) => t.id === id) ?? THEMES[0]!).board
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user