feat: 情侣向焕新——主题、音效动效、战绩、表情互动与爱心彩蛋

- 四套主题(雅致木纹/心动粉/暗夜紫/水墨江南):CSS 变量 + Canvas 棋盘配色联动
- 落子弹跳、最后一手呼吸光晕、五连扫光动画、终局爱心跳动卡片
- Web Audio 合成音效 + 手机震动反馈(落子/胜利/悔棋/表情/爱心)
- 自定义昵称(服务端座位持久化)+ 房间战绩统计(局数/胜负/和棋/决胜手数)
- 长按棋盘随机发送甜蜜表情,实时漂浮同步给对手(3 秒冷却防刷)
- 爱心连珠彩蛋:服务端模板检测棋盘爱心形状(含旋转镜像),触发全场爱心雨
- 双击棋盘点燃小爱心;随机甜蜜结算文案;大厅双人剪影插画与浪漫渐变
- 首页暴露局域网地址(vite host: true)
This commit is contained in:
root
2026-09-11 16:11:22 +08:00
parent 59bfc98afa
commit 3b59c94fe8
11 changed files with 1248 additions and 91 deletions
+372 -10
View File
@@ -4,16 +4,19 @@ import { io } from 'socket.io-client'
import type { Socket } from 'socket.io-client'
import Board from './components/Board.vue'
import {
EMOJIS,
EV,
OFFLINE_CLAIM_MS,
type ActionAck,
type CreateAck,
type EmojiPayload,
type EndReason,
type JoinAck,
type NoticePayload,
type Player,
type RoomState,
} from '../../shared/protocol'
import { THEMES, applyTheme, loadTheme, type ThemeId } from './theme'
/* ------------------------------------------------------------------ */
/* 本地会话:房间号 + 重连凭证,用于刷新/掉线后自动回到原座位 */
@@ -53,6 +56,24 @@ function clearSession(): void {
const initialRoomId = (new URLSearchParams(location.search).get('r') ?? '').trim().toUpperCase()
const NICKNAME_KEY = 'wuziqi.nickname'
function loadNickname(): string {
try {
return (localStorage.getItem(NICKNAME_KEY) ?? '').slice(0, 12)
} catch {
return ''
}
}
function saveNickname(name: string): void {
try {
localStorage.setItem(NICKNAME_KEY, name.trim().slice(0, 12))
} catch {
/* 隐私模式忽略 */
}
}
/* ------------------------------------------------------------------ */
/* 状态 */
/* ------------------------------------------------------------------ */
@@ -63,6 +84,9 @@ const connected = ref(false)
const phase = ref<'connecting' | 'lobby' | 'room'>('connecting')
const state = ref<RoomState | null>(null)
const joinCode = ref(initialRoomId)
const nickname = ref(loadNickname())
const theme = ref<ThemeId>(loadTheme())
const showThemePicker = ref(false)
const busy = ref(false)
const resultDismissed = ref(false)
/** 悔棋等待弹窗是否被手动收起(对方未响应前允许先看棋盘) */
@@ -70,6 +94,16 @@ const undoWaitDismissed = ref(false)
const toast = ref<{ text: string; error: boolean } | null>(null)
let toastTimer: number | undefined
/** 收到的表情动画请求(id 递增以触发 watch) */
const emojiFx = ref<{ id: number; emoji: string } | null>(null)
let emojiId = 0
/** 爱心特效触发计数(彩蛋 heartBy 变化时 +1 */
const heartFx = ref(0)
let lastHeartBy: Player | null | undefined = undefined
// 应用初始主题
applyTheme(theme.value)
/** 只有拿到服务端状态才认为真正进入了房间 */
const view = computed<'lobby' | 'loading' | 'room'>(() => {
if (state.value) return 'room'
@@ -82,6 +116,12 @@ function showToast(text: string, error = false): void {
toastTimer = window.setTimeout(() => (toast.value = null), 2600)
}
function showSweetToast(text: string): void {
toast.value = { text, error: false }
if (toastTimer) window.clearTimeout(toastTimer)
toastTimer = window.setTimeout(() => (toast.value = null), 3800)
}
/* ------------------------------------------------------------------ */
/* 连接与房间进入 */
/* ------------------------------------------------------------------ */
@@ -90,8 +130,12 @@ function doJoin(payload: { roomId: string; resumeToken?: string }, notifyOnFail:
const s = socket
if (!s) return
if (phase.value !== 'room') phase.value = 'connecting'
// 新加入时带昵称;重连时不覆盖已有昵称(服务端只在非空时设置)
const sendPayload: Record<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
if (res.ok) {
const ok = res as JoinAck
@@ -149,6 +193,18 @@ onMounted(() => {
if (incoming.status === 'playing') resultDismissed.value = false
// 悔棋请求已结束(同意/拒绝/超时/落子取消)时重置弹窗状态
if (incoming.undoRequestedBy === null) undoWaitDismissed.value = false
// 爱心连珠彩蛋触发:heartBy 从 null 变为某方 → 播放全场爱心雨
if (incoming.heartBy !== null && incoming.heartBy !== lastHeartBy) {
lastHeartBy = incoming.heartBy
heartFx.value += 1
showSweetToast('你们在棋盘上摆出了一颗心 💗')
}
if (incoming.heartBy === null) lastHeartBy = null
})
// 收到对手表情 → 播放漂浮动画
s.on(EV.emojiRecv, (p: EmojiPayload) => {
emojiId += 1
emojiFx.value = { id: emojiId, emoji: p.emoji }
})
s.on(EV.notice, (p: NoticePayload) => showToast(p.message, p.level === 'error'))
@@ -185,7 +241,9 @@ function createRoom(): void {
}
busy.value = true
// 统一约定:载荷在前、回调在后,服务端只从最后一个参数取 ack
s.emit(EV.create, {}, (res: CreateAck | ActionAck) => {
const createPayload: Record<string, string> = {}
if (nickname.value.trim()) createPayload.nickname = nickname.value.trim()
s.emit(EV.create, createPayload, (res: CreateAck | ActionAck) => {
busy.value = false
if (!res.ok) {
showToast((res as { error: string }).error, true)
@@ -225,6 +283,21 @@ function leaveRoom(): void {
history.replaceState(null, '', location.pathname)
}
/** 长按棋盘 → 随机发一个甜蜜表情给对手,并本地立即可视化 */
function sendEmoji(): void {
const emoji = EMOJIS[Math.floor(Math.random() * EMOJIS.length)]!
// 乐观反馈:本地先播放
emojiId += 1
emojiFx.value = { id: emojiId, emoji }
act(EV.emojiSend, { emoji })
}
function setTheme(id: ThemeId): void {
theme.value = id
applyTheme(id)
showThemePicker.value = false
}
/* ------------------------------------------------------------------ */
/* 派生状态 */
/* ------------------------------------------------------------------ */
@@ -295,6 +368,67 @@ const END_REASON_TEXT: Record<EndReason, string> = {
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 s = state.value
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">
<h1>五子棋</h1>
<p class="muted tip">两人对弈 · 手机浏览器直接玩</p>
<button class="ghost small theme-btn" @click="showThemePicker = true">
{{ 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">
创建房间
@@ -385,14 +556,21 @@ async function copyShareLink(): Promise<void> {
<b class="mono">{{ state!.roomId }}</b>
</div>
<div class="who">
<span class="chip">你执{{ myColorText }}</span>
<span class="chip">
{{ myNick }}
<i>{{ myColorText }}</i>
</span>
<span
class="dot"
:class="{ off: !opponentOnline, wait: !opponentJoined }"
:title="opponentJoined ? (opponentOnline ? '对手在线' : '对手掉线') : '等待对手'"
/>
<span v-if="opponentJoined" class="chip faint">{{ oppNick }}</span>
</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>
<p class="status" :class="{ mine: canPlace }">{{ statusText }}</p>
@@ -403,7 +581,11 @@ async function copyShareLink(): Promise<void> {
:win-line="state!.winLine"
:seat="state!.seat"
:my-turn="canPlace"
:theme="theme"
:emoji-fx="emojiFx"
:heart-fx="heartFx"
@place="place"
@longpress="sendEmoji"
/>
<footer class="actions">
@@ -455,9 +637,26 @@ async function copyShareLink(): Promise<void> {
<!-- 终局 -->
<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>
<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">
<button @click="resultDismissed = true">看棋盘</button>
<button class="primary" @click="restart(false)">再来一局</button>
@@ -467,6 +666,29 @@ async function copyShareLink(): Promise<void> {
</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>
</template>
@@ -488,17 +710,47 @@ async function copyShareLink(): Promise<void> {
/* ---------------- 大厅 ---------------- */
.lobby {
position: relative;
justify-content: center;
align-items: center;
gap: 14px;
padding: 24px;
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 {
margin: 0;
font-size: 30px;
letter-spacing: 0.1em;
font-size: 26px;
letter-spacing: 0.08em;
color: var(--text);
}
.nick {
max-width: 320px;
text-align: center;
}
.tip {
@@ -583,6 +835,7 @@ async function copyShareLink(): Promise<void> {
align-items: center;
gap: 8px;
margin-left: auto;
min-width: 0;
}
.chip {
@@ -590,6 +843,16 @@ async function copyShareLink(): Promise<void> {
border-radius: 999px;
background: var(--panel-2);
font-size: 12px;
white-space: nowrap;
}
.chip i {
font-style: normal;
opacity: 0.75;
}
.chip.faint {
opacity: 0.85;
}
.dot {
@@ -643,4 +906,103 @@ async function copyShareLink(): Promise<void> {
font-size: 13px;
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>
+335 -53
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
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<{
board: Cell[]
@@ -10,14 +13,21 @@ const props = defineProps<{
seat: Player | null
/** 轮到自己时为 true */
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 canvas = ref<HTMLCanvasElement | null>(null)
/** 逻辑像素边长、格宽、边距(= 半个格宽,使最外线到边缘留白对称) */
let size = 0
let cell = 0
let pad = 0
@@ -30,24 +40,56 @@ const STAR_POINTS: Array<[number, number]> = [
[7, 7],
]
const COLORS = {
boardTop: '#e8c088',
boardBottom: '#d9a866',
grid: 'rgba(60, 35, 10, 0.75)',
border: 'rgba(50, 28, 6, 0.95)',
/* ------------------------------------------------------------------ */
/* 动画状态(普通对象,配合 rAF 循环,不依赖 Vue 响应式) */
/* ------------------------------------------------------------------ */
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 {
return canvas.value?.getContext('2d') ?? null
}
/** 按容器尺寸与 DPR 重建画布,再整体重绘 */
function layout(): void {
const el = host.value
const c = canvas.value
if (!el || !c) return
// 棋盘为正方形:宽度受限,且给底部操作区留出空间
const availW = el.clientWidth || window.innerWidth - 32
const availH = window.innerHeight * 0.6
size = Math.floor(Math.min(availW, availH))
@@ -69,18 +111,21 @@ function layout(): void {
function draw(): void {
const g = ctx2d()
if (!g || size === 0) return
const t = performance.now()
const col = colors()
const a = accent()
g.clearRect(0, 0, size, size)
// 木纹底色
// 棋盘底色
const bg = g.createLinearGradient(0, 0, size, size)
bg.addColorStop(0, COLORS.boardTop)
bg.addColorStop(1, COLORS.boardBottom)
bg.addColorStop(0, col.board.top)
bg.addColorStop(1, col.board.bottom)
g.fillStyle = bg
g.fillRect(0, 0, size, size)
// 网格
g.strokeStyle = COLORS.grid
g.strokeStyle = col.board.grid
g.lineWidth = Math.max(1, cell * 0.02)
g.beginPath()
for (let i = 0; i < BOARD_SIZE; i++) {
@@ -92,71 +137,138 @@ function draw(): void {
}
g.stroke()
// 外框加粗
g.strokeStyle = COLORS.border
// 外框
g.strokeStyle = col.board.border
g.lineWidth = Math.max(1.5, cell * 0.05)
g.strokeRect(pad, pad, size - cell, size - cell)
// 星位
g.fillStyle = COLORS.border
g.fillStyle = col.board.star
for (const [x, y] of STAR_POINTS) {
g.beginPath()
g.arc(pad + x * cell, pad + y * cell, cell * 0.09, 0, Math.PI * 2)
g.fill()
}
// 棋子
const r = cell * 0.44
// 棋子
for (let i = 0; i < props.board.length; i++) {
const v = props.board[i]
if (v === 0) continue
const x = i % BOARD_SIZE
const y = Math.floor(i / BOARD_SIZE)
drawStone(g, pad + x * cell, pad + y * cell, r, v)
const x = pad + (i % BOARD_SIZE) * cell
const y = pad + Math.floor(i / BOARD_SIZE) * cell
// 新落子弹跳动画
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) {
const x = props.lastMove % BOARD_SIZE
const y = Math.floor(props.lastMove / BOARD_SIZE)
const lx = pad + (props.lastMove % BOARD_SIZE) * cell
const ly = pad + Math.floor(props.lastMove / BOARD_SIZE) * cell
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.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()
}
// 五连高亮
// 五连:静态高亮 + 扫光动画
if (props.winLine?.length) {
g.strokeStyle = '#ef4444'
const idxs = props.winLine
g.strokeStyle = a
g.lineWidth = Math.max(2, cell * 0.07)
for (const i of props.winLine) {
const x = i % BOARD_SIZE
const y = Math.floor(i / BOARD_SIZE)
for (const i of idxs) {
const x = pad + (i % BOARD_SIZE) * cell
const y = pad + Math.floor(i / BOARD_SIZE) * cell
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()
}
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 {
// 阴影
g.save()
g.shadowColor = 'rgba(0, 0, 0, 0.35)'
g.shadowBlur = r * 0.35
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)
if (v === 1) {
grad.addColorStop(0, '#6b7280')
grad.addColorStop(0.5, '#1f2937')
grad.addColorStop(1, '#030712')
} else {
grad.addColorStop(0, '#ffffff')
grad.addColorStop(0.65, '#f3f4f6')
grad.addColorStop(1, '#b9bec7')
}
grad.addColorStop(0, stops[0])
grad.addColorStop(0.55, stops[1])
grad.addColorStop(1, stops[2])
g.fillStyle = grad
g.beginPath()
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()
}
/** 触摸/点击 → 最近的交叉点,带半格容差,避免误落 */
function onPick(ev: PointerEvent): void {
const c = canvas.value
if (!c || props.seat === null || !props.myTurn) return
function isActive(): boolean {
if (fx.newStone) return true
if (fx.winScan && performance.now() - fx.winScan.t0 < 1100) return true
if (fx.dbl && performance.now() - fx.dbl.t0 < 800) return true
if (fx.hearts.length > 0) return true
return false
}
const rect = c.getBoundingClientRect()
const px = ev.clientX - rect.left
const py = ev.clientY - rect.top
function frame(): void {
draw()
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 y = Math.round((py - pad) / cell)
if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return
const dx = px - (pad + x * cell)
const dy = py - (pad + y * cell)
if (Math.hypot(dx, dy) > cell * 0.55) return
@@ -185,6 +365,93 @@ function onPick(ev: PointerEvent): void {
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
onMounted(() => {
@@ -197,15 +464,28 @@ onMounted(() => {
onBeforeUnmount(() => {
ro?.disconnect()
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>
<template>
<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>
</template>
@@ -222,7 +502,9 @@ watch(() => [props.board, props.lastMove, props.winLine], draw, { deep: true })
.board-canvas {
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
/* 交给脚本处理指针事件,禁止浏览器手势介入 */
touch-action: none;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
</style>
+89
View File
@@ -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
View File
@@ -63,7 +63,7 @@ button:disabled {
button.primary {
background: var(--accent);
color: #06210f;
color: var(--on-accent, #06210f);
font-weight: 600;
}
+135
View File
@@ -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
}