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