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