From 9bb9a126b374a7e14e59f9d6a8d69ec35939d9a5 Mon Sep 17 00:00:00 2001 From: bagaking Date: Wed, 5 Nov 2025 00:52:06 +0800 Subject: [PATCH 01/51] fix(game07): prevent BGM from auto-resuming after skill resolves; add quick music/SFX toggles under settings; switch settings icon to sliders\n\n- Gate App auto-play by user-enabled flag from useBackgroundMusic\n- Expose in useBackgroundMusic and persist it\n- Only start BGM on AI start when enabled\n- Add two floating toggles (BGM/SFX) below settings button\n- Replace settings gear icon with sliders for clarity --- example/game_07_skill_gomoku/ui/src/App.tsx | 9 ++- .../src/app/components/AiSettingsTrigger.tsx | 73 +++++++++++++++---- .../ui/src/app/hooks/useBackgroundMusic.ts | 25 +++++-- 3 files changed, 82 insertions(+), 25 deletions(-) diff --git a/example/game_07_skill_gomoku/ui/src/App.tsx b/example/game_07_skill_gomoku/ui/src/App.tsx index 1e9fc23..3705fcf 100644 --- a/example/game_07_skill_gomoku/ui/src/App.tsx +++ b/example/game_07_skill_gomoku/ui/src/App.tsx @@ -170,12 +170,12 @@ const App: React.FC = () => { useAIActions(gameState, { playCard, placeStone, resolveCard, selectTarget, advanceTurnIfBlocked }, aiSettings, updateAiStatus, visualsActive); - // 进入 PLAYING 阶段即尝试播放 BGM(兼容非 AI 开局) + // 进入 PLAYING 阶段仅在用户开启 BGM 时尝试播放(避免技能结算返回 PLAYING 时误触发) useEffect(() => { - if (gameState.phase === GamePhaseEnum.PLAYING && !bgm.isPlaying) { + if (gameState.phase === GamePhaseEnum.PLAYING && !bgm.isPlaying && bgm.enabled) { try { bgm.play(); } catch {} } - }, [gameState.phase]); + }, [gameState.phase, bgm.isPlaying, bgm.enabled]); useEffect(() => { if (typeof window === 'undefined') return; @@ -268,7 +268,8 @@ const App: React.FC = () => { setAiBubble({ id, text: '未配置云端 AI,将使用本地策略' }); window.setTimeout(() => setAiBubble(prev => (prev && prev.id === id ? null : prev)), 1600); } - try { bgm.play(); } catch {} + // 仅在用户偏好开启时开播 BGM + try { if (bgm.enabled) bgm.play(); } catch {} startGame(true); }} /> diff --git a/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx b/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx index 80cc3de..072d2c9 100644 --- a/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx +++ b/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { useBackgroundMusic } from '../hooks/useBackgroundMusic'; +import { useSoundEffects } from '../hooks/useSoundEffects'; export interface AiSettingsTriggerProps { onOpen: (open: boolean) => void; @@ -10,23 +12,66 @@ export interface AiSettingsTriggerProps { * Replaces previous CSS (.ai-settings__trigger) with Tailwind utilities. */ export const AiSettingsTrigger: React.FC = ({ onOpen, hasConfig }) => { - const base = 'fixed right-6 top-6 z-20 rounded-full border p-2 backdrop-blur transition ' + - 'shadow-[0_8px_18px_rgba(15,23,42,0.35)] hover:-translate-y-px hover:shadow-[0_12px_24px_rgba(15,23,42,0.45)]'; + const bgm = useBackgroundMusic(); + const sfx = useSoundEffects(); + + const btnBase = 'rounded-full border p-2 backdrop-blur transition shadow-[0_8px_18px_rgba(15,23,42,0.35)] hover:-translate-y-px hover:shadow-[0_12px_24px_rgba(15,23,42,0.45)]'; const ready = 'border-emerald-300/50 bg-emerald-400/20 text-emerald-200 hover:bg-emerald-400/30'; const idle = 'border-amber-300/50 bg-slate-900/80 text-amber-300/80'; + return ( - +
+ {/* Settings button: swap to sliders icon for better affordance */} + + + {/* Quick audio toggles */} +
+ {/* Music toggle */} + + + {/* SFX toggle */} + +
+
); }; diff --git a/example/game_07_skill_gomoku/ui/src/app/hooks/useBackgroundMusic.ts b/example/game_07_skill_gomoku/ui/src/app/hooks/useBackgroundMusic.ts index 4496b72..6a8a10f 100644 --- a/example/game_07_skill_gomoku/ui/src/app/hooks/useBackgroundMusic.ts +++ b/example/game_07_skill_gomoku/ui/src/app/hooks/useBackgroundMusic.ts @@ -6,7 +6,9 @@ let sharedAudio: HTMLAudioElement | null = null; let sharedDidInit = false; let sharedIsPlaying = false; let sharedVolume = 0.2; -const listeners = new Set<(s: { isPlaying: boolean; volume: number }) => void>(); +// Persisted user preference: whether BGM is allowed to auto-play +let sharedEnabled = true; +const listeners = new Set<(s: { isPlaying: boolean; volume: number; enabled: boolean }) => void>(); type BgmPrefs = { enabled: boolean; @@ -40,6 +42,7 @@ const writePrefs = (prefs: BgmPrefs) => { export const useBackgroundMusic = () => { const [isPlaying, setIsPlaying] = useState(sharedIsPlaying); const [volume, setVolume] = useState(sharedVolume); + const [enabled, setEnabled] = useState(sharedEnabled); const audioRef = useRef(sharedAudio); // lazy init to avoid constructing audio element during SSR @@ -58,9 +61,10 @@ export const useBackgroundMusic = () => { // load persisted prefs once useEffect(() => { // subscribe to global state changes - const listener = (s: { isPlaying: boolean; volume: number }) => { + const listener = (s: { isPlaying: boolean; volume: number; enabled: boolean }) => { setIsPlaying(s.isPlaying); setVolume(s.volume); + setEnabled(s.enabled); }; listeners.add(listener); // first-time global init only @@ -69,7 +73,9 @@ export const useBackgroundMusic = () => { const prefs = readPrefs(); sharedVolume = prefs.volume; setVolume(sharedVolume); - if (prefs.enabled) { + sharedEnabled = prefs.enabled; + setEnabled(sharedEnabled); + if (sharedEnabled) { const audio = ensureAudio(); audio.volume = sharedVolume; audio.play().then(() => { @@ -96,7 +102,7 @@ export const useBackgroundMusic = () => { }, [volume]); const notify = () => { - listeners.forEach(fn => fn({ isPlaying: sharedIsPlaying, volume: sharedVolume })); + listeners.forEach(fn => fn({ isPlaying: sharedIsPlaying, volume: sharedVolume, enabled: sharedEnabled })); }; const play = useCallback(async () => { @@ -105,7 +111,9 @@ export const useBackgroundMusic = () => { await audio.play(); sharedIsPlaying = true; setIsPlaying(true); - writePrefs({ enabled: true, volume: sharedVolume }); + sharedEnabled = true; // user explicitly enabled + setEnabled(true); + writePrefs({ enabled: sharedEnabled, volume: sharedVolume }); notify(); } catch (err) { sharedIsPlaying = false; @@ -119,7 +127,9 @@ export const useBackgroundMusic = () => { sharedAudio.pause(); sharedIsPlaying = false; setIsPlaying(false); - writePrefs({ enabled: false, volume: sharedVolume }); + sharedEnabled = false; // user explicitly disabled + setEnabled(false); + writePrefs({ enabled: sharedEnabled, volume: sharedVolume }); notify(); }, []); @@ -131,12 +141,13 @@ export const useBackgroundMusic = () => { return { isPlaying, + enabled, volume, setVolume: (v: number) => { sharedVolume = Math.min(1, Math.max(0, v)); setVolume(sharedVolume); if (sharedAudio) sharedAudio.volume = sharedVolume; - writePrefs({ enabled: sharedIsPlaying, volume: sharedVolume }); + writePrefs({ enabled: sharedEnabled, volume: sharedVolume }); notify(); }, play, From 2adf0dccd764281d20ec5d124a7227245d64c426 Mon Sep 17 00:00:00 2001 From: bagaking Date: Wed, 5 Nov 2025 00:54:26 +0800 Subject: [PATCH 02/51] chore(game07/ui): use @tabler/icons-react for settings/music/sfx icons (no hand-drawn SVG) --- .../src/app/components/AiSettingsTrigger.tsx | 18 +++++-------- package-lock.json | 27 +++++++++++++++++++ package.json | 1 + 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx b/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx index 072d2c9..82f824a 100644 --- a/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx +++ b/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useBackgroundMusic } from '../hooks/useBackgroundMusic'; import { useSoundEffects } from '../hooks/useSoundEffects'; +import { IconAdjustmentsHorizontal, IconMusic, IconMusicOff, IconVolume, IconVolumeOff } from '@tabler/icons-react'; export interface AiSettingsTriggerProps { onOpen: (open: boolean) => void; @@ -29,10 +30,7 @@ export const AiSettingsTrigger: React.FC = ({ onOpen, ha title="AI 设置" aria-label="AI 设置" > - {/* sliders icon */} - + {/* Quick audio toggles */} @@ -46,11 +44,9 @@ export const AiSettingsTrigger: React.FC = ({ onOpen, ha aria-label="音乐开关" > {bgm.isPlaying ? ( - // note icon - + ) : ( - // note off icon - + )} @@ -63,11 +59,9 @@ export const AiSettingsTrigger: React.FC = ({ onOpen, ha aria-label="音效开关" > {sfx.enabled ? ( - // speaker icon - + ) : ( - // speaker off icon - + )} diff --git a/package-lock.json b/package-lock.json index c56b7fd..7bacdea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@babel/runtime": "^7.3.4", "@khgame/schema": "^0.0.11", + "@tabler/icons-react": "^3.35.0", "assert": "^1.4.1", "chalk": "^2.4.2", "deep-equal": "^1.0.1", @@ -4933,6 +4934,32 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@tabler/icons": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.35.0.tgz", + "integrity": "sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.35.0.tgz", + "integrity": "sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.35.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@tailwindcss/line-clamp": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz", diff --git a/package.json b/package.json index eea566c..0ed8a45 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "dependencies": { "@babel/runtime": "^7.3.4", "@khgame/schema": "^0.0.11", + "@tabler/icons-react": "^3.35.0", "assert": "^1.4.1", "chalk": "^2.4.2", "deep-equal": "^1.0.1", From 42a9aa14a1b071459a7577987dc5184868096b67 Mon Sep 17 00:00:00 2001 From: bagaking Date: Wed, 5 Nov 2025 01:00:15 +0800 Subject: [PATCH 03/51] =?UTF-8?q?fix(game07/ai):=20pause=20AI=20during=20v?= =?UTF-8?q?isual=20animations=20and=20cancel=20in-flight=20tasks;=20correc?= =?UTF-8?q?t=20visual=20cleanup=20timing=20for=20counters=20(=E4=B8=9C?= =?UTF-8?q?=E5=B1=B1=E5=86=8D=E8=B5=B7=E7=AD=89)\n\n-=20add=20visualsActiv?= =?UTF-8?q?e=20to=20AI=20effect=20deps;=20cancel=20timers=20immediately\n-?= =?UTF-8?q?=20guard=20async=20decision/fallback=20against=20canceled=20tas?= =?UTF-8?q?ks\n-=20defer=20processed=20marking=20for=20local=20auto=20ston?= =?UTF-8?q?e=20until=20executed\n-=20tune=20useVisualEffects=20cleanup=20b?= =?UTF-8?q?y=20role/effectId=20(counter-restore-board/instant-win/etc)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game_07_skill_gomoku/ui/src/ai/useAIActions.ts | 13 +++++++++++-- .../ui/src/app/hooks/useVisualEffects.ts | 7 +++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/example/game_07_skill_gomoku/ui/src/ai/useAIActions.ts b/example/game_07_skill_gomoku/ui/src/ai/useAIActions.ts index 002fafc..9204371 100644 --- a/example/game_07_skill_gomoku/ui/src/ai/useAIActions.ts +++ b/example/game_07_skill_gomoku/ui/src/ai/useAIActions.ts @@ -205,10 +205,14 @@ export const useAIActions = ( cachedLocalSuggestion = localSuggestion ?? null; if (localSuggestion && shouldAutoplaySuggestion(localSuggestion)) { aiLog.info('[local:auto]', formatLocalSuggestion(localSuggestion)); - processedRef.current.add(key); + // 标记当前任务为活跃,真正执行成功后再记为 processed + activeKeyRef.current = key; const pos = { row: localSuggestion.row, col: localSuggestion.col }; timerRef.current = window.setTimeout(() => { + // 若在等待期间出现动画或任务被取消,则不执行 + if (activeKeyRef.current !== key) return; clearRunningTask(); + processedRef.current.add(key); actions.placeStone(pos.row, pos.col); }, 520); return; @@ -250,6 +254,7 @@ export const useAIActions = ( activeKeyRef.current = key; timerRef.current = window.setTimeout(async () => { + // 若期间出现动画或任务被取消,直接忽略 if (activeKeyRef.current !== key) return; const feedbackMessage = reason; @@ -272,6 +277,8 @@ export const useAIActions = ( const runFallback = () => { aiLog.warn(`[${scenarioPayload.kind}] 默认回退策略`); + // 若在等待期间出现动画或任务被取消,则不执行 + if (activeKeyRef.current !== key) return; fallbackDecision(scenarioPayload, gameState, actions, cachedLocalSuggestion); finalize(); }; @@ -285,6 +292,8 @@ export const useAIActions = ( try { const decision = await requestAiDecision(aiSettings, scenarioPayload, { feedback: feedbackMessage }); aiLog.info('[response_json]', decision); + // 动画期间可能已取消当前任务 + if (activeKeyRef.current !== key) return; const outcome = decision ? applyDecision(decision, scenarioPayload, gameState, actions) : { success: false, reason: '未提供有效决策' }; @@ -342,7 +351,7 @@ export const useAIActions = ( return () => { clearRunningTask(false); }; - }, [gameState, aiSettings, playCard, placeStone, resolveCard, selectTarget, updateAiStatus]); + }, [gameState, aiSettings, playCard, placeStone, resolveCard, selectTarget, updateAiStatus, visualsActive]); useEffect(() => () => { if (timerRef.current !== null) { diff --git a/example/game_07_skill_gomoku/ui/src/app/hooks/useVisualEffects.ts b/example/game_07_skill_gomoku/ui/src/app/hooks/useVisualEffects.ts index 8c2dc96..82098fe 100644 --- a/example/game_07_skill_gomoku/ui/src/app/hooks/useVisualEffects.ts +++ b/example/game_07_skill_gomoku/ui/src/app/hooks/useVisualEffects.ts @@ -23,8 +23,11 @@ export function useVisualEffects(gameState: GameStatus): VisualEffectEvent[] { next.forEach(ev => { trackerRef.current.add(ev.id); setActive(prev => [...prev, ev]); - const isCounterSkill = ev.effectId?.startsWith('Counter'); - const cleanupDelay = isCounterSkill ? 4200 : 3000; + // 根据角色与具体技能调整动画占用时长 + const isCounterSkill = ev.role === 'counter'; + let cleanupDelay = isCounterSkill ? 4200 : 3000; + if (ev.effectId === SkillEffect.CounterRestoreBoard) cleanupDelay = 4200; // 东山再起:反击动画更长 + if (ev.effectId === SkillEffect.InstantWin || ev.effectId === SkillEffect.CounterReverseWin) cleanupDelay = 4200; window.setTimeout(() => { setActive(prev => prev.filter(x => x.id !== ev.id)); }, cleanupDelay); From 6ce7e614aefe21349505d47b44bd1a15707fc807 Mon Sep 17 00:00:00 2001 From: bagaking Date: Wed, 5 Nov 2025 01:01:43 +0800 Subject: [PATCH 04/51] feat(game07/sfx): add draw-card SFX and unlock AudioContext on first user gesture; ensure SFX plays for skills via resumed context\n\n- useSoundEffects: context resume + user-gesture unlock; new 'draw-card' effect\n- App: play draw SFX when hand count increases --- example/game_07_skill_gomoku/ui/src/App.tsx | 14 +++++++++++ .../ui/src/app/hooks/useSoundEffects.ts | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/example/game_07_skill_gomoku/ui/src/App.tsx b/example/game_07_skill_gomoku/ui/src/App.tsx index 3705fcf..8b6887a 100644 --- a/example/game_07_skill_gomoku/ui/src/App.tsx +++ b/example/game_07_skill_gomoku/ui/src/App.tsx @@ -66,6 +66,7 @@ const App: React.FC = () => { const bgm = useBackgroundMusic(); const sfx = useSoundEffects(); const playedSfxRef = useRef>(new Set()); + const prevHandCountsRef = useRef<[number, number]>([0, 0]); // 落子音效:检测棋谱长度变化(在 engine 初始化之后挂载) const lastHistoryLenRef = useRef(0); // AI 互动:头像气泡 & 玩家久未行动跟踪 @@ -137,6 +138,19 @@ const App: React.FC = () => { } }); }, [activeVisuals, sfx]); + + // 抽卡音效:当任意一方手牌数量增加时播一次提示音 + useEffect(() => { + const curr: [number, number] = [ + (gameState.hands[PlayerEnum.BLACK] ?? []).length, + (gameState.hands[PlayerEnum.WHITE] ?? []).length + ]; + const prev = prevHandCountsRef.current; + if (curr[0] > prev[0] || curr[1] > prev[1]) { + try { sfx.playEffect('draw-card'); } catch {} + } + prevHandCountsRef.current = curr; + }, [gameState.hands[PlayerEnum.BLACK]?.length, gameState.hands[PlayerEnum.WHITE]?.length]); const { showFinalPanel, winLineLit } = useWinPresentation(gameState); useAutoResolvePending(gameState, resolveCard); // 玩家久未行动提示(气泡 + 棋盘轻晃) diff --git a/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts b/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts index c304c24..de79f0d 100644 --- a/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts +++ b/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts @@ -9,6 +9,7 @@ const STORAGE_KEY = 'game07.sfx'; let sharedEnabled = true; let sharedVolume = 0.5; let sharedCtx: AudioContext | null = null; +let unlockAttached = false; const listeners = new Set<(s: { enabled: boolean; volume: number }) => void>(); const notify = () => { listeners.forEach(fn => fn({ enabled: sharedEnabled, volume: sharedVolume })); }; @@ -35,6 +36,23 @@ const ensureCtx = () => { if (!sharedCtx) { sharedCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); } + // Try auto-resume and set up a one-time unlock on user gesture + try { if (sharedCtx.state === 'suspended') void sharedCtx.resume(); } catch {} + if (!unlockAttached) { + unlockAttached = true; + const unlock = async () => { + if (!sharedCtx) return; + try { await sharedCtx.resume(); } catch {} + if (sharedCtx.state === 'running') { + window.removeEventListener('pointerdown', unlock); + window.removeEventListener('keydown', unlock); + window.removeEventListener('touchstart', unlock, { capture: true } as any); + } + }; + window.addEventListener('pointerdown', unlock, { once: true }); + window.addEventListener('keydown', unlock, { once: true }); + window.addEventListener('touchstart', unlock, { once: true, capture: true } as any); + } return sharedCtx; }; @@ -108,6 +126,7 @@ export const useSoundEffects = () => { const setEnabled = useCallback((v: boolean) => { sharedEnabled = v; + try { void ensureCtx().resume(); } catch {} writePrefs({ enabled: sharedEnabled, volume: sharedVolume }); notify(); }, []); @@ -124,7 +143,13 @@ export const useSoundEffects = () => { const playEffect = useCallback((effectId?: string, role?: 'attacker' | 'counter' | 'normal') => { if (!sharedEnabled) return; + try { void ensureCtx().resume(); } catch {} switch (effectId) { + case 'draw-card': + // 轻微“抽卡”感的噪声 + 高频提示 + synth.noise(1600, 6, 90, 0.35); + setTimeout(() => synth.tone(1200, 70), 40); + break; case 'remove-to-shichahai': // 力拔山兮 synth.noise(800, 4, 120, 0.65); // 拍打 synth.tone(300, 120); // 低沉 From 75d12250dd80afd82ab1f0ed908801730b14d7fa Mon Sep 17 00:00:00 2001 From: bagaking Date: Wed, 5 Nov 2025 01:10:33 +0800 Subject: [PATCH 05/51] feat(game07/sfx): play-card SFX on pendingAction start; per-side draw SFX pitch; richer skill-specific sequences (mini-melodies)\n\n- App: detect pendingAction creation -> play 'play-card' sfx; per-side draw-card using role\n- SFX: add tone scheduling (seq/chord), enrich mappings (summon, restore, reverse, instant-win, etc.) --- example/game_07_skill_gomoku/ui/src/App.tsx | 17 +++- .../ui/src/app/hooks/useSoundEffects.ts | 94 ++++++++++++++----- 2 files changed, 87 insertions(+), 24 deletions(-) diff --git a/example/game_07_skill_gomoku/ui/src/App.tsx b/example/game_07_skill_gomoku/ui/src/App.tsx index 8b6887a..ac6c561 100644 --- a/example/game_07_skill_gomoku/ui/src/App.tsx +++ b/example/game_07_skill_gomoku/ui/src/App.tsx @@ -67,6 +67,8 @@ const App: React.FC = () => { const sfx = useSoundEffects(); const playedSfxRef = useRef>(new Set()); const prevHandCountsRef = useRef<[number, number]>([0, 0]); + // 出牌音效:记录最近一次 pendingAction id + const lastPendingIdRef = useRef(null); // 落子音效:检测棋谱长度变化(在 engine 初始化之后挂载) const lastHistoryLenRef = useRef(0); // AI 互动:头像气泡 & 玩家久未行动跟踪 @@ -146,11 +148,20 @@ const App: React.FC = () => { (gameState.hands[PlayerEnum.WHITE] ?? []).length ]; const prev = prevHandCountsRef.current; - if (curr[0] > prev[0] || curr[1] > prev[1]) { - try { sfx.playEffect('draw-card'); } catch {} - } + if (curr[0] > prev[0]) { try { sfx.playEffect('draw-card', 'attacker'); } catch {} } + if (curr[1] > prev[1]) { try { sfx.playEffect('draw-card', 'counter'); } catch {} } prevHandCountsRef.current = curr; }, [gameState.hands[PlayerEnum.BLACK]?.length, gameState.hands[PlayerEnum.WHITE]?.length]); + + // 出牌音效:当 pendingAction 创建(进入技能释放流程)时即刻播放 + useEffect(() => { + const id = gameState.pendingAction?.id ?? null; + if (id && id !== lastPendingIdRef.current) { + const role = gameState.pendingAction?.player === PlayerEnum.BLACK ? 'attacker' : 'counter'; + try { sfx.playEffect('play-card', role as any); } catch {} + } + lastPendingIdRef.current = id; + }, [gameState.pendingAction?.id]); const { showFinalPanel, winLineLit } = useWinPresentation(gameState); useAutoResolvePending(gameState, resolveCard); // 玩家久未行动提示(气泡 + 棋盘轻晃) diff --git a/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts b/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts index de79f0d..308e4f8 100644 --- a/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts +++ b/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts @@ -58,23 +58,25 @@ const ensureCtx = () => { // Low-level synth helpers use sharedVolume/sharedEnabled const synth = { - tone: (freq: number, durMs = 140) => { + // Basic tone, optionally scheduled with offset and custom waveform/volume + tone: (freq: number, durMs = 140, startOffsetMs = 0, opts?: { type?: OscillatorType; vol?: number }) => { if (!sharedEnabled) return; const ctx = ensureCtx(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); - osc.type = 'triangle'; + osc.type = opts?.type ?? 'triangle'; osc.frequency.value = freq; - gain.gain.value = 0.0001; + const vol = Math.max(0.0001, Math.min(1, opts?.vol ?? 0.4)) * sharedVolume; osc.connect(gain).connect(ctx.destination); - const now = ctx.currentTime; + const now = ctx.currentTime + startOffsetMs / 1000; gain.gain.setValueAtTime(0.0001, now); - gain.gain.linearRampToValueAtTime(sharedVolume * 0.4, now + 0.02); + gain.gain.linearRampToValueAtTime(vol, now + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, now + durMs / 1000); osc.start(now); osc.stop(now + durMs / 1000 + 0.02); }, - noise: (centerHz: number, q = 4, durMs = 100, peak = 0.6) => { + // Bandpassed noise whoosh + noise: (centerHz: number, q = 4, durMs = 100, peak = 0.6, startOffsetMs = 0) => { if (!sharedEnabled) return; const ctx = ensureCtx(); const seconds = durMs / 1000; @@ -92,12 +94,20 @@ const synth = { biquad.Q.value = q; const gain = ctx.createGain(); src.connect(biquad).connect(gain).connect(ctx.destination); - const now = ctx.currentTime; + const now = ctx.currentTime + startOffsetMs / 1000; gain.gain.setValueAtTime(0.0001, now); gain.gain.linearRampToValueAtTime(sharedVolume * peak, now + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, now + seconds); src.start(now); src.stop(now + seconds + 0.02); + }, + // Schedule a simple polyphonic chord + chord: (freqs: number[], durMs = 180, startOffsetMs = 0, opts?: { type?: OscillatorType; vol?: number }) => { + freqs.forEach(f => synth.tone(f, durMs, startOffsetMs, opts)); + }, + // Schedule a melody/sequence + seq: (steps: Array<{ f: number; d: number; at: number; type?: OscillatorType; vol?: number }>) => { + steps.forEach(step => synth.tone(step.f, step.d, step.at, { type: step.type, vol: step.vol })); } }; @@ -145,33 +155,75 @@ export const useSoundEffects = () => { if (!sharedEnabled) return; try { void ensureCtx().resume(); } catch {} switch (effectId) { - case 'draw-card': - // 轻微“抽卡”感的噪声 + 高频提示 - synth.noise(1600, 6, 90, 0.35); - setTimeout(() => synth.tone(1200, 70), 40); - break; + case 'draw-card': { + // 轻柔抽卡:滤波噪声 + 轻提示,根据 role 稍微区分音高 + const base = role === 'counter' ? 1080 : 1240; + synth.noise(1600, 6, 80, 0.28); + synth.tone(base, 70, 30, { type: 'sine', vol: 0.28 }); + break; } + case 'play-card': { + // 出牌:卡片刷动 + 轻敲 + synth.noise(2200, 8, 70, 0.25); + synth.tone(1800, 40, 10, { type: 'triangle', vol: 0.22 }); + synth.tone(900, 60, 25, { type: 'sine', vol: 0.18 }); + break; } case 'remove-to-shichahai': // 力拔山兮 - synth.noise(800, 4, 120, 0.65); // 拍打 - synth.tone(300, 120); // 低沉 + synth.noise(800, 4, 140, 0.65); // 拍打 + synth.tone(260, 130, 0, { type: 'sawtooth', vol: 0.4 }); // 低沉 + synth.tone(180, 180, 40, { type: 'sine', vol: 0.28 }); // 回响 break; case 'freeze-opponent': - synth.tone(1400, 80); - synth.tone(1200, 100); + // 冰晶:细碎高频 + 轻和弦 + synth.tone(1700, 60, 0, { type: 'sine', vol: 0.22 }); + synth.tone(1400, 80, 30, { type: 'triangle', vol: 0.2 }); + synth.chord([1200, 1600], 120, 80, { type: 'sine', vol: 0.16 }); break; case 'clean-sweep': - synth.noise(1000, 2.5, 160, 0.5); + // 大扫除:呼的一下 + 下滑音 + synth.noise(1000, 2.5, 180, 0.5); + synth.tone(1000, 140, 40, { vol: 0.22 }); + synth.tone(700, 120, 120, { vol: 0.18 }); break; case 'skip-next-turn': - synth.noise(600, 2.0, 120, 0.4); + synth.noise(600, 2.0, 140, 0.4); break; case 'summon-character': - synth.tone(660, 90); - setTimeout(() => synth.tone(880, 110), 80); + // 召唤:小型上行分解和弦 + synth.seq([ + { f: 523, d: 110, at: 0, type: 'triangle', vol: 0.22 }, + { f: 659, d: 110, at: 90, type: 'triangle', vol: 0.22 }, + { f: 784, d: 140, at: 180, type: 'triangle', vol: 0.22 } + ]); + synth.chord([523, 659, 784], 160, 340, { type: 'sine', vol: 0.2 }); break; case 'force-exit': synth.tone(880, 80); - setTimeout(() => synth.tone(440, 120), 60); + synth.tone(440, 120, 70); break; + case 'instant-win': { + // 胜利:强力和弦 + 光晕 + synth.chord([523, 659, 783], 220, 0, { type: 'sawtooth', vol: 0.28 }); + synth.noise(1400, 5, 220, 0.35, 40); + break; } + case 'counter-restore-board': { + // 东山再起:上行恢复式旋律 + synth.seq([ + { f: 392, d: 120, at: 0, type: 'sine', vol: 0.2 }, + { f: 523, d: 120, at: 110, type: 'sine', vol: 0.2 }, + { f: 659, d: 130, at: 220, type: 'sine', vol: 0.22 }, + { f: 784, d: 160, at: 350, type: 'triangle', vol: 0.24 } + ]); + synth.chord([523, 659, 784], 240, 520, { type: 'triangle', vol: 0.22 }); + break; } + case 'counter-reverse-win': { + // 两极反转:紧张的上扬+和弦 + synth.seq([ + { f: 740, d: 80, at: 0 }, + { f: 880, d: 90, at: 70 }, + { f: 988, d: 100, at: 150 } + ]); + synth.chord([740, 880, 988], 220, 260, { type: 'sawtooth', vol: 0.26 }); + break; } default: { // 基础:根据角色选择音高 const base = role === 'counter' ? 1320 : role === 'attacker' ? 880 : 660; From 96f3c51347e4b98f34963315ce9db88eac0545ec Mon Sep 17 00:00:00 2001 From: bagaking Date: Wed, 5 Nov 2025 10:46:44 +0800 Subject: [PATCH 06/51] feat(game07/sfx+fx): - Carry card tags/subtype into VisualEffectEvent for per-card SFX mapping - Add variation pool for repeating skills; rotate sequences - Spatialize SFX with StereoPanner based on board cell column - Enrich playEffect to accept {tags, subtype, pan}; playForEvent wires them --- .../ui/src/app/hooks/useSoundEffects.ts | 151 ++++++++++++------ .../ui/src/core/skillPerformance.ts | 2 + example/game_07_skill_gomoku/ui/src/types.ts | 3 + 3 files changed, 111 insertions(+), 45 deletions(-) diff --git a/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts b/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts index 308e4f8..c23267d 100644 --- a/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts +++ b/example/game_07_skill_gomoku/ui/src/app/hooks/useSoundEffects.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { VisualEffectEvent } from '../../types'; +import { BOARD_SIZE } from '../../core/constants'; type SfxPrefs = { enabled: boolean; volume: number }; @@ -12,6 +13,15 @@ let sharedCtx: AudioContext | null = null; let unlockAttached = false; const listeners = new Set<(s: { enabled: boolean; volume: number }) => void>(); +// simple rotating index per effect key to add variation +const varIndex: Map = new Map(); +const nextVar = (key: string, modulo: number) => { + const cur = varIndex.get(key) ?? 0; + const nx = (cur + 1) % modulo; + varIndex.set(key, nx); + return cur; +}; + const notify = () => { listeners.forEach(fn => fn({ enabled: sharedEnabled, volume: sharedVolume })); }; const readPrefs = (): SfxPrefs => { @@ -58,8 +68,8 @@ const ensureCtx = () => { // Low-level synth helpers use sharedVolume/sharedEnabled const synth = { - // Basic tone, optionally scheduled with offset and custom waveform/volume - tone: (freq: number, durMs = 140, startOffsetMs = 0, opts?: { type?: OscillatorType; vol?: number }) => { + // Basic tone, optionally scheduled with offset and custom waveform/volume; supports pan + tone: (freq: number, durMs = 140, startOffsetMs = 0, opts?: { type?: OscillatorType; vol?: number; pan?: number }) => { if (!sharedEnabled) return; const ctx = ensureCtx(); const osc = ctx.createOscillator(); @@ -67,7 +77,14 @@ const synth = { osc.type = opts?.type ?? 'triangle'; osc.frequency.value = freq; const vol = Math.max(0.0001, Math.min(1, opts?.vol ?? 0.4)) * sharedVolume; - osc.connect(gain).connect(ctx.destination); + let dest: AudioNode = ctx.destination; + if (typeof opts?.pan === 'number' && (ctx as any).createStereoPanner) { + const panner = (ctx as any).createStereoPanner(); + panner.pan.value = Math.max(-1, Math.min(1, opts.pan)); + panner.connect(ctx.destination); + dest = panner; + } + osc.connect(gain).connect(dest); const now = ctx.currentTime + startOffsetMs / 1000; gain.gain.setValueAtTime(0.0001, now); gain.gain.linearRampToValueAtTime(vol, now + 0.02); @@ -76,7 +93,7 @@ const synth = { osc.stop(now + durMs / 1000 + 0.02); }, // Bandpassed noise whoosh - noise: (centerHz: number, q = 4, durMs = 100, peak = 0.6, startOffsetMs = 0) => { + noise: (centerHz: number, q = 4, durMs = 100, peak = 0.6, startOffsetMs = 0, opts?: { pan?: number }) => { if (!sharedEnabled) return; const ctx = ensureCtx(); const seconds = durMs / 1000; @@ -93,7 +110,14 @@ const synth = { biquad.frequency.value = centerHz; biquad.Q.value = q; const gain = ctx.createGain(); - src.connect(biquad).connect(gain).connect(ctx.destination); + let dest: AudioNode = ctx.destination; + if (opts?.pan != null && (ctx as any).createStereoPanner) { + const panner = (ctx as any).createStereoPanner(); + panner.pan.value = Math.max(-1, Math.min(1, opts.pan)); + panner.connect(ctx.destination); + dest = panner; + } + src.connect(biquad).connect(gain).connect(dest); const now = ctx.currentTime + startOffsetMs / 1000; gain.gain.setValueAtTime(0.0001, now); gain.gain.linearRampToValueAtTime(sharedVolume * peak, now + 0.01); @@ -102,12 +126,12 @@ const synth = { src.stop(now + seconds + 0.02); }, // Schedule a simple polyphonic chord - chord: (freqs: number[], durMs = 180, startOffsetMs = 0, opts?: { type?: OscillatorType; vol?: number }) => { + chord: (freqs: number[], durMs = 180, startOffsetMs = 0, opts?: { type?: OscillatorType; vol?: number; pan?: number }) => { freqs.forEach(f => synth.tone(f, durMs, startOffsetMs, opts)); }, // Schedule a melody/sequence - seq: (steps: Array<{ f: number; d: number; at: number; type?: OscillatorType; vol?: number }>) => { - steps.forEach(step => synth.tone(step.f, step.d, step.at, { type: step.type, vol: step.vol })); + seq: (steps: Array<{ f: number; d: number; at: number; type?: OscillatorType; vol?: number; pan?: number }>) => { + steps.forEach(step => synth.tone(step.f, step.d, step.at, { type: step.type, vol: step.vol, pan: step.pan })); } }; @@ -151,90 +175,127 @@ export const useSoundEffects = () => { const playTone = useCallback((freq: number, durMs = 140) => synth.tone(freq, durMs), []); const playPlace = useCallback((durMs = 90) => synth.noise(900, 4, durMs, 0.55), []); - const playEffect = useCallback((effectId?: string, role?: 'attacker' | 'counter' | 'normal') => { + const playEffect = useCallback((effectId?: string, role?: 'attacker' | 'counter' | 'normal', opts?: { tags?: string; subtype?: string; pan?: number }) => { if (!sharedEnabled) return; try { void ensureCtx().resume(); } catch {} + const tagSet = new Set((opts?.tags ?? '').split(/[|,;\s]+/).map(s => s.trim()).filter(Boolean)); + const subtype = (opts?.subtype ?? '').toLowerCase(); + const pan = typeof opts?.pan === 'number' ? Math.max(-1, Math.min(1, opts.pan)) : undefined; switch (effectId) { case 'draw-card': { // 轻柔抽卡:滤波噪声 + 轻提示,根据 role 稍微区分音高 const base = role === 'counter' ? 1080 : 1240; - synth.noise(1600, 6, 80, 0.28); - synth.tone(base, 70, 30, { type: 'sine', vol: 0.28 }); + synth.noise(1600, 6, 80, 0.28, 0, { pan }); + synth.tone(base, 70, 30, { type: 'sine', vol: 0.28, pan }); break; } case 'play-card': { // 出牌:卡片刷动 + 轻敲 - synth.noise(2200, 8, 70, 0.25); - synth.tone(1800, 40, 10, { type: 'triangle', vol: 0.22 }); - synth.tone(900, 60, 25, { type: 'sine', vol: 0.18 }); + const t = tagSet.has('Counter') ? 1900 : tagSet.has('Recovery') ? 1750 : 1800; + synth.noise(2200, 8, 70, 0.25, 0, { pan }); + synth.tone(t, 40, 10, { type: 'triangle', vol: 0.22, pan }); + synth.tone(900, 60, 25, { type: 'sine', vol: 0.18, pan }); break; } case 'remove-to-shichahai': // 力拔山兮 - synth.noise(800, 4, 140, 0.65); // 拍打 - synth.tone(260, 130, 0, { type: 'sawtooth', vol: 0.4 }); // 低沉 - synth.tone(180, 180, 40, { type: 'sine', vol: 0.28 }); // 回响 + synth.noise(800, 4, 140, 0.65, 0, { pan }); // 拍打 + synth.tone(260, 130, 0, { type: 'sawtooth', vol: 0.4, pan }); // 低沉 + synth.tone(180, 180, 40, { type: 'sine', vol: 0.28, pan }); // 回响 break; case 'freeze-opponent': // 冰晶:细碎高频 + 轻和弦 - synth.tone(1700, 60, 0, { type: 'sine', vol: 0.22 }); - synth.tone(1400, 80, 30, { type: 'triangle', vol: 0.2 }); - synth.chord([1200, 1600], 120, 80, { type: 'sine', vol: 0.16 }); + synth.tone(1700, 60, 0, { type: 'sine', vol: 0.22, pan }); + synth.tone(1400, 80, 30, { type: 'triangle', vol: 0.2, pan }); + synth.chord([1200, 1600], 120, 80, { type: 'sine', vol: 0.16, pan }); break; case 'clean-sweep': // 大扫除:呼的一下 + 下滑音 - synth.noise(1000, 2.5, 180, 0.5); - synth.tone(1000, 140, 40, { vol: 0.22 }); - synth.tone(700, 120, 120, { vol: 0.18 }); + synth.noise(1000, 2.5, 180, 0.5, 0, { pan }); + synth.tone(1000, 140, 40, { vol: 0.22, pan }); + synth.tone(700, 120, 120, { vol: 0.18, pan }); break; case 'skip-next-turn': - synth.noise(600, 2.0, 140, 0.4); + synth.noise(600, 2.0, 140, 0.4, 0, { pan }); break; case 'summon-character': // 召唤:小型上行分解和弦 - synth.seq([ - { f: 523, d: 110, at: 0, type: 'triangle', vol: 0.22 }, - { f: 659, d: 110, at: 90, type: 'triangle', vol: 0.22 }, - { f: 784, d: 140, at: 180, type: 'triangle', vol: 0.22 } - ]); - synth.chord([523, 659, 784], 160, 340, { type: 'sine', vol: 0.2 }); + const up = ['aura', 'blessing', 'guardian'].includes(subtype); + const seq = up + ? [ + { f: 523, d: 110, at: 0, type: 'triangle', vol: 0.22, pan }, + { f: 659, d: 110, at: 90, type: 'triangle', vol: 0.22, pan }, + { f: 784, d: 140, at: 180, type: 'triangle', vol: 0.22, pan } + ] + : [ + { f: 784, d: 110, at: 0, type: 'triangle', vol: 0.22, pan }, + { f: 659, d: 110, at: 90, type: 'triangle', vol: 0.22, pan }, + { f: 523, d: 140, at: 180, type: 'triangle', vol: 0.22, pan } + ]; + synth.seq(seq); + synth.chord([523, 659, 784], 160, 340, { type: 'sine', vol: 0.2, pan }); break; case 'force-exit': - synth.tone(880, 80); - synth.tone(440, 120, 70); + if (nextVar('force-exit', 2) === 0) { + synth.tone(880, 80, 0, { pan }); + synth.tone(440, 120, 70, { pan }); + } else { + synth.tone(660, 90, 0, { pan }); + synth.tone(990, 120, 70, { pan }); + } break; case 'instant-win': { // 胜利:强力和弦 + 光晕 - synth.chord([523, 659, 783], 220, 0, { type: 'sawtooth', vol: 0.28 }); - synth.noise(1400, 5, 220, 0.35, 40); + const variants = [ + [523, 659, 783], + [659, 783, 987] + ]; + const v = variants[nextVar('instant-win', variants.length)]; + synth.chord(v, 220, 0, { type: 'sawtooth', vol: 0.28, pan }); + synth.noise(1400, 5, 220, 0.35, 40, { pan }); break; } case 'counter-restore-board': { // 东山再起:上行恢复式旋律 + const paths = [ + [392, 523, 659, 784], + [349, 494, 659, 784] + ]; + const idx = nextVar('counter-restore-board', paths.length); + const steps = paths[idx]; synth.seq([ - { f: 392, d: 120, at: 0, type: 'sine', vol: 0.2 }, - { f: 523, d: 120, at: 110, type: 'sine', vol: 0.2 }, - { f: 659, d: 130, at: 220, type: 'sine', vol: 0.22 }, - { f: 784, d: 160, at: 350, type: 'triangle', vol: 0.24 } + { f: steps[0], d: 120, at: 0, type: 'sine', vol: 0.2, pan }, + { f: steps[1], d: 120, at: 110, type: 'sine', vol: 0.2, pan }, + { f: steps[2], d: 130, at: 220, type: 'sine', vol: 0.22, pan }, + { f: steps[3], d: 160, at: 350, type: 'triangle', vol: 0.24, pan } ]); - synth.chord([523, 659, 784], 240, 520, { type: 'triangle', vol: 0.22 }); + synth.chord([523, 659, 784], 240, 520, { type: 'triangle', vol: 0.22, pan }); break; } case 'counter-reverse-win': { // 两极反转:紧张的上扬+和弦 + const bases = [[740, 880, 988], [659, 830, 987]]; + const pickIdx = nextVar('counter-reverse-win', bases.length); + const seqB = bases[pickIdx]; synth.seq([ - { f: 740, d: 80, at: 0 }, - { f: 880, d: 90, at: 70 }, - { f: 988, d: 100, at: 150 } + { f: seqB[0], d: 80, at: 0, pan }, + { f: seqB[1], d: 90, at: 70, pan }, + { f: seqB[2], d: 100, at: 150, pan } ]); - synth.chord([740, 880, 988], 220, 260, { type: 'sawtooth', vol: 0.26 }); + synth.chord(seqB, 220, 260, { type: 'sawtooth', vol: 0.26, pan }); break; } default: { // 基础:根据角色选择音高 const base = role === 'counter' ? 1320 : role === 'attacker' ? 880 : 660; - synth.tone(base, 120); + synth.tone(base, 120, 0, { pan }); break; } } }, []); const playForEvent = useCallback((ev: VisualEffectEvent) => { - playEffect(ev.effectId, ev.role); + // derive pan from board column if provided (center=0, left=-1, right=+1) + let pan: number | undefined = undefined; + if (ev.cell && typeof ev.cell.col === 'number') { + const ratio = ev.cell.col / (BOARD_SIZE - 1); + pan = Math.max(-1, Math.min(1, ratio * 2 - 1)) * 0.6; + } + playEffect(ev.effectId, ev.role, { tags: ev.cardTags, subtype: ev.cardSubtype, pan }); }, [playEffect]); return { enabled, setEnabled, volume, setVolume, playForEvent, playEffect, playTone, playPlace }; diff --git a/example/game_07_skill_gomoku/ui/src/core/skillPerformance.ts b/example/game_07_skill_gomoku/ui/src/core/skillPerformance.ts index dfa8421..1aeb829 100644 --- a/example/game_07_skill_gomoku/ui/src/core/skillPerformance.ts +++ b/example/game_07_skill_gomoku/ui/src/core/skillPerformance.ts @@ -45,6 +45,8 @@ class SkillPerformanceManager { createdAt: timestamp, sequence, role, + cardTags: card.tags, + cardSubtype: card.subtype, ...(meta || {}) }; } diff --git a/example/game_07_skill_gomoku/ui/src/types.ts b/example/game_07_skill_gomoku/ui/src/types.ts index 50cd706..4a8250f 100644 --- a/example/game_07_skill_gomoku/ui/src/types.ts +++ b/example/game_07_skill_gomoku/ui/src/types.ts @@ -95,6 +95,9 @@ export interface VisualEffectEvent { // 可选:用于棋盘定点动画(例如移除棋子飞入什刹海) cell?: { row: number; col: number }; owner?: Player; + // 可选:卡牌的标签/子类型(用于音效与表演的细粒度映射) + cardTags?: string; + cardSubtype?: string; } export type EffectMetadata = { From 071bbde0cbd6eb0d7159c708e531107e9c787af1 Mon Sep 17 00:00:00 2001 From: bagaking Date: Wed, 5 Nov 2025 14:15:14 +0800 Subject: [PATCH 07/51] feat(game07/ui): block mobile phones with overlay; enable tap-to-play on tablets (touch devices with width >= 768)\n\n- Add MobileBlocker overlay\n- Add allowTapPlay path: CardView/HandPanel/PlayerHUD support hand click\n- App: device detection (pointer: coarse + width); pass onCardClick and allowTapPlay to PlayerHUD --- example/game_07_skill_gomoku/ui/src/App.tsx | 29 +++++++++++++++++++ .../ui/src/app/components/MobileBlocker.tsx | 16 ++++++++++ .../ui/src/components/CardView.tsx | 6 ++-- .../ui/src/components/HandPanel.tsx | 7 +++-- .../ui/src/components/Hud.tsx | 8 ++++- 5 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 example/game_07_skill_gomoku/ui/src/app/components/MobileBlocker.tsx diff --git a/example/game_07_skill_gomoku/ui/src/App.tsx b/example/game_07_skill_gomoku/ui/src/App.tsx index ac6c561..f97bdd4 100644 --- a/example/game_07_skill_gomoku/ui/src/App.tsx +++ b/example/game_07_skill_gomoku/ui/src/App.tsx @@ -348,6 +348,15 @@ const App: React.FC = () => { backgroundPosition: 'center center', backgroundSize: '100% auto' }}> + {isPhone && ( +
+ {/* 阻挡层(手机不支持) */} + {(() => { + const MobileBlocker = require('./app/components/MobileBlocker').MobileBlocker as React.FC; + return ; + })()} +
+ )} {/* Backdrop decorative layers */} @@ -497,6 +506,11 @@ const App: React.FC = () => { graveyardCount={playerGraveyard.length} shichahaiCount={shichahaiByPlayer[PlayerEnum.BLACK].length} onCardHover={handlePlayerCardHover} + onCardClick={(idx) => { + if (!canPlayCard) return; + try { lastPlayerActionRef.current = Date.now(); setPlayerInactivityLevel(0); } catch {} + playCard(idx); + }} onCardDragStart={index => { if (!canPlayCard) return; setDraggedCardIndex(index); @@ -507,6 +521,7 @@ const App: React.FC = () => { isCurrent={currentPlayer === PlayerEnum.BLACK} bubble={playerBubble} align="end" + allowTapPlay={isPad} className="pr-4 md:pr-8" /> @@ -630,3 +645,17 @@ const partitionShichahai = (entries: GameStatus['shichahai']): Record { + const calc = () => { + const coarse = typeof window !== 'undefined' && matchMedia && window.matchMedia('(pointer: coarse)').matches; + const width = typeof window !== 'undefined' ? window.innerWidth : 1024; + setIsPhone(coarse && width < 768); + setIsPad(coarse && width >= 768); + }; + calc(); + window.addEventListener('resize', calc); + return () => window.removeEventListener('resize', calc); + }, []); diff --git a/example/game_07_skill_gomoku/ui/src/app/components/MobileBlocker.tsx b/example/game_07_skill_gomoku/ui/src/app/components/MobileBlocker.tsx new file mode 100644 index 0000000..c1aee17 --- /dev/null +++ b/example/game_07_skill_gomoku/ui/src/app/components/MobileBlocker.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +export const MobileBlocker: React.FC = () => { + return ( +
+
+
暂不支持手机端
+

请使用电脑或平板(Pad)打开本 Demo,以获得完整的拖拽与交互体验。

+
提示:本页面会自动适配触控设备的点击释放,但在手机尺寸下不开放游戏。
+
+
+ ); +}; + +export default MobileBlocker; + diff --git a/example/game_07_skill_gomoku/ui/src/components/CardView.tsx b/example/game_07_skill_gomoku/ui/src/components/CardView.tsx index 6c71f4b..36186ec 100644 --- a/example/game_07_skill_gomoku/ui/src/components/CardView.tsx +++ b/example/game_07_skill_gomoku/ui/src/components/CardView.tsx @@ -98,6 +98,7 @@ export interface CardViewProps { onDragStart?: (event: React.DragEvent) => void; onDragEnd?: () => void; compact?: boolean; + enableHandClick?: boolean; } export const CardView: React.FC = ({ @@ -112,7 +113,8 @@ export const CardView: React.FC = ({ onHoverEnd, onDragStart, onDragEnd, - compact = false + compact = false, + enableHandClick = false }) => { // Debug: 检查卡牌数据 console.log('CardView render:', { @@ -185,7 +187,7 @@ export const CardView: React.FC = ({ if (nameZhLength >= 7) return 'text-[1.08rem]'; return 'text-[1.2rem]'; }, [nameZhLength]); - const enableClick = !disabled && variant !== 'hand' && typeof onClick === 'function'; + const enableClick = !disabled && ((variant !== 'hand' && typeof onClick === 'function') || (variant === 'hand' && enableHandClick)); const baseWidth = (() => { if (style?.width) return undefined; if (variant === 'list') return '11rem'; diff --git a/example/game_07_skill_gomoku/ui/src/components/HandPanel.tsx b/example/game_07_skill_gomoku/ui/src/components/HandPanel.tsx index 3df72a0..9aace45 100644 --- a/example/game_07_skill_gomoku/ui/src/components/HandPanel.tsx +++ b/example/game_07_skill_gomoku/ui/src/components/HandPanel.tsx @@ -14,6 +14,7 @@ export interface HandPanelProps { className?: string; isOpponent?: boolean; animateThinking?: boolean; + allowTapPlay?: boolean; } export const HandPanel: React.FC = ({ @@ -25,7 +26,8 @@ export const HandPanel: React.FC = ({ onCardDragEnd, className, isOpponent = false, - animateThinking = false + animateThinking = false, + allowTapPlay = false }) => { const [pickedIdx, setPickedIdx] = React.useState(null); const [pickedStyle, setPickedStyle] = React.useState(''); @@ -259,6 +261,7 @@ export const HandPanel: React.FC = ({ variant="hand" disabled={disabled} revealBack={isOpponent} + enableHandClick={allowTapPlay && !isOpponent && !disabled && typeof onCardClick === 'function'} style={{ width: `${baseWidth}rem`, // raise selected card; rely on CSS transitions inside CardView wrapper @@ -269,7 +272,7 @@ export const HandPanel: React.FC = ({ isOpponent && isLeavingCard && 'animate-card-fly-out-opponent' )} onClick={() => { - if (!disabled && !isOpponent) onCardClick?.(idx); + if (!disabled && !isOpponent && allowTapPlay) onCardClick?.(idx); }} onHover={hoverCard => { if (!disabled && !isOpponent) onCardHover?.(hoverCard); diff --git a/example/game_07_skill_gomoku/ui/src/components/Hud.tsx b/example/game_07_skill_gomoku/ui/src/components/Hud.tsx index 7d72184..e6cbdef 100644 --- a/example/game_07_skill_gomoku/ui/src/components/Hud.tsx +++ b/example/game_07_skill_gomoku/ui/src/components/Hud.tsx @@ -261,12 +261,14 @@ export interface PlayerHudProps { graveyardCount: number; shichahaiCount: number; onCardHover?: (card: RawCard | null) => void; + onCardClick?: (index: number) => void; onCardDragStart?: (index: number) => void; onCardDragEnd?: () => void; isCurrent: boolean; className?: string; bubble?: { text: string } | null; align?: 'center' | 'end'; + allowTapPlay?: boolean; } export const PlayerHUD: React.FC = ({ @@ -279,12 +281,14 @@ export const PlayerHUD: React.FC = ({ graveyardCount, shichahaiCount, onCardHover, + onCardClick, onCardDragStart, onCardDragEnd, isCurrent, className, bubble = null, - align = 'center' + align = 'center', + allowTapPlay = false }) => (
{/* 左侧玩家信息 - 紧凑设计 */} @@ -315,8 +319,10 @@ export const PlayerHUD: React.FC = ({ cards={handCards} disabled={disabled} onCardHover={onCardHover} + onCardClick={onCardClick} onCardDragStart={onCardDragStart} onCardDragEnd={onCardDragEnd} + allowTapPlay={allowTapPlay} />
From 1477cf2a143ea78464f56e60ce2959a3595a558d Mon Sep 17 00:00:00 2001 From: bagaking Date: Wed, 5 Nov 2025 14:30:25 +0800 Subject: [PATCH 08/51] fix(game07/ui): move device detection hooks inside App; avoid require() for MobileBlocker to prevent invalid hook call; use static import --- example/game_07_skill_gomoku/ui/src/App.tsx | 39 +++++++++------------ 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/example/game_07_skill_gomoku/ui/src/App.tsx b/example/game_07_skill_gomoku/ui/src/App.tsx index f97bdd4..38d2ce0 100644 --- a/example/game_07_skill_gomoku/ui/src/App.tsx +++ b/example/game_07_skill_gomoku/ui/src/App.tsx @@ -34,6 +34,7 @@ import { useVisualEffects } from './app/hooks/useVisualEffects'; import { useAutoResolvePending } from './app/hooks/useAutoResolvePending'; import { useWinPresentation } from './app/hooks/useWinPresentation'; import { useInactivityPrompt } from './app/hooks/useInactivityPrompt'; +import { MobileBlocker } from './app/components/MobileBlocker'; // Utility for conditional classnames const cx = (...classes: Array) => classes.filter(Boolean).join(' '); @@ -80,6 +81,20 @@ const App: React.FC = () => { const aiVictoryShownRef = useRef(false); // 飞行卡牌动画状态 const [flyingCards, setFlyingCards] = useState([]); + // 设备类型:阻挡手机,仅在触控平板上允许点击释放 + const [isPhone, setIsPhone] = useState(false); + const [isPad, setIsPad] = useState(false); + useEffect(() => { + const calc = () => { + const coarse = typeof window !== 'undefined' && typeof matchMedia !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; + const width = typeof window !== 'undefined' ? window.innerWidth : 1024; + setIsPhone(!!coarse && width < 768); + setIsPad(!!coarse && width >= 768); + }; + calc(); + window.addEventListener('resize', calc); + return () => window.removeEventListener('resize', calc); + }, []); const opponentHandRef = useRef(null); const playerHandRef = useRef(null); const draftPanelRef = useRef(null); @@ -348,15 +363,7 @@ const App: React.FC = () => { backgroundPosition: 'center center', backgroundSize: '100% auto' }}> - {isPhone && ( -
- {/* 阻挡层(手机不支持) */} - {(() => { - const MobileBlocker = require('./app/components/MobileBlocker').MobileBlocker as React.FC; - return ; - })()} -
- )} + {isPhone && } {/* Backdrop decorative layers */} @@ -645,17 +652,3 @@ const partitionShichahai = (entries: GameStatus['shichahai']): Record { - const calc = () => { - const coarse = typeof window !== 'undefined' && matchMedia && window.matchMedia('(pointer: coarse)').matches; - const width = typeof window !== 'undefined' ? window.innerWidth : 1024; - setIsPhone(coarse && width < 768); - setIsPad(coarse && width >= 768); - }; - calc(); - window.addEventListener('resize', calc); - return () => window.removeEventListener('resize', calc); - }, []); From c1c5add18d1be4d1389e920adfd2ed732516b8b6 Mon Sep 17 00:00:00 2001 From: bagaking Date: Wed, 5 Nov 2025 14:39:27 +0800 Subject: [PATCH 09/51] fix(game07/ui): show mobile blocker on SETUP screen; remove overlay from in-game stage --- example/game_07_skill_gomoku/ui/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/game_07_skill_gomoku/ui/src/App.tsx b/example/game_07_skill_gomoku/ui/src/App.tsx index 38d2ce0..df82559 100644 --- a/example/game_07_skill_gomoku/ui/src/App.tsx +++ b/example/game_07_skill_gomoku/ui/src/App.tsx @@ -296,7 +296,8 @@ const App: React.FC = () => { if (phase === GamePhaseEnum.SETUP) { return ( <> -
+
+ {isPhone && } { backgroundPosition: 'center center', backgroundSize: '100% auto' }}> - {isPhone && } {/* Backdrop decorative layers */} From 9c8ed72078cd6df386fabfdf4c7205df83d06fc2 Mon Sep 17 00:00:00 2001 From: bagaking Date: Wed, 5 Nov 2025 14:41:12 +0800 Subject: [PATCH 10/51] =?UTF-8?q?feat(game07/pad):=20disable=20HTML5=20dra?= =?UTF-8?q?g=20on=20tablets;=20use=20tap-to-play=20exclusively=20on=20Pad\?= =?UTF-8?q?n\n-=20HandPanel:=20when=20allowTapPlay,=20omit=20onDragStart/o?= =?UTF-8?q?nDragEnd=20so=20CardView=20isn't=20draggable\n-=20PlayerHUD:=20?= =?UTF-8?q?show=20'=E8=BD=BB=E7=82=B9=E5=8F=91=E5=8A=A8=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E5=8D=A1'=20hint=20when=20allowTapPlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/src/components/HandPanel.tsx | 22 ++++++++----------- .../ui/src/components/Hud.tsx | 2 +- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/example/game_07_skill_gomoku/ui/src/components/HandPanel.tsx b/example/game_07_skill_gomoku/ui/src/components/HandPanel.tsx index 9aace45..6c541e2 100644 --- a/example/game_07_skill_gomoku/ui/src/components/HandPanel.tsx +++ b/example/game_07_skill_gomoku/ui/src/components/HandPanel.tsx @@ -282,19 +282,15 @@ export const HandPanel: React.FC = ({ onCardHover?.(null); } }} - onDragStart={event => { - if (!isOpponent) { - event.dataTransfer.setData('application/x-card-index', String(idx)); - event.dataTransfer.setData('text/plain', String(idx)); - onCardDragStart?.(idx); - } - }} - onDragEnd={() => { - if (!isOpponent) { - onCardDragEnd?.(); - onCardHover?.(null); - } - }} + onDragStart={(!isOpponent && !allowTapPlay) ? (event => { + event.dataTransfer.setData('application/x-card-index', String(idx)); + event.dataTransfer.setData('text/plain', String(idx)); + onCardDragStart?.(idx); + }) : undefined} + onDragEnd={(!isOpponent && !allowTapPlay) ? (() => { + onCardDragEnd?.(); + onCardHover?.(null); + }) : undefined} />
); diff --git a/example/game_07_skill_gomoku/ui/src/components/Hud.tsx b/example/game_07_skill_gomoku/ui/src/components/Hud.tsx index e6cbdef..86f445e 100644 --- a/example/game_07_skill_gomoku/ui/src/components/Hud.tsx +++ b/example/game_07_skill_gomoku/ui/src/components/Hud.tsx @@ -304,7 +304,7 @@ export const PlayerHUD: React.FC = ({ 'text-xs font-medium text-slate-300', disabled && 'opacity-60' )}> - 拖拽发动技能卡 + {allowTapPlay ? '轻点发动技能卡' : '拖拽发动技能卡'} Date: Wed, 5 Nov 2025 14:47:51 +0800 Subject: [PATCH 11/51] fix(game07/ui): accurate 7s countdown for instant skills\n\n- PendingCardPanel.CountdownCancel now tracks absolute end time and schedules timeout; no off-by-one (previously fired at ~6s) --- .../ui/src/components/PendingCardPanel.tsx | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/example/game_07_skill_gomoku/ui/src/components/PendingCardPanel.tsx b/example/game_07_skill_gomoku/ui/src/components/PendingCardPanel.tsx index 41940b4..8cf6d57 100644 --- a/example/game_07_skill_gomoku/ui/src/components/PendingCardPanel.tsx +++ b/example/game_07_skill_gomoku/ui/src/components/PendingCardPanel.tsx @@ -184,34 +184,43 @@ export const PendingCardPanel: React.FC = ({ }; const CountdownCancel: React.FC<{ onCancel?: () => void; onTimeout: () => void; ms?: number }> = ({ onCancel, onTimeout, ms = 3000 }) => { - const [remain, setRemain] = useState(Math.max(1, Math.round(ms / 1000))); - const cancelledRef = React.useRef(false); - const firedRef = React.useRef(false); + // Display remaining seconds, computed against a fixed end time to avoid off-by-one + const [remain, setRemain] = useState(() => Math.max(1, Math.ceil(ms / 1000))); + const endRef = React.useRef(Date.now() + ms); + const cancelRef = React.useRef(false); + const timeoutRef = React.useRef(null); + const tickRef = React.useRef(null); + useEffect(() => { - const t1 = window.setInterval(() => { - setRemain(prev => { - if (prev <= 1) { - window.clearInterval(t1); - if (!cancelledRef.current && !firedRef.current) { - firedRef.current = true; - onTimeout(); - } - return 0; - } - return prev - 1; - }); - }, 1000); - return () => window.clearInterval(t1); - }, [onTimeout, ms]); + // reset timers when ms changes + endRef.current = Date.now() + ms; + setRemain(Math.max(1, Math.ceil(ms / 1000))); + cancelRef.current = false; + if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); timeoutRef.current = null; } + if (tickRef.current !== null) { window.clearInterval(tickRef.current); tickRef.current = null; } + + timeoutRef.current = window.setTimeout(() => { + if (!cancelRef.current) onTimeout(); + }, ms) as unknown as number; + + const update = () => { + const leftMs = Math.max(0, endRef.current - Date.now()); + setRemain(Math.max(0, Math.ceil(leftMs / 1000))); + }; + update(); + tickRef.current = window.setInterval(update, 250) as unknown as number; + + return () => { + if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); timeoutRef.current = null; } + if (tickRef.current !== null) { window.clearInterval(tickRef.current); tickRef.current = null; } + }; + }, [ms, onTimeout]); return (
+ )} + {/* Music toggle */} +
+ + {/* 分类标签 */} +
+ + {(['Attack', 'Counter', 'Support'] as CardType[]).map(type => ( + + ))} +
+
+ + {/* 卡牌网格 */} +
+
+ {displayCards.map(card => { + const tid = card._tid ?? card.tid; + return ( +
setSelectedCard(card)} + > +
+ + {/* 悬浮提示 */} +
+
+
+

点击查看详情

+
+
+
+
+ ); + })} +
+ + {displayCards.length === 0 && ( +
+
+ + + +
+

暂无卡牌

+
+ )} +
+
+ + + {/* 卡牌详情弹窗 */} + {selectedCard && ( + <> +
setSelectedCard(null)} + /> +
+
e.stopPropagation()} + > +
+
+

卡牌详情

+ +
+ +
+
+
+ + )} + + ); +}; diff --git a/example/game_07_skill_gomoku/ui/src/components/CardCollectionTrigger.tsx b/example/game_07_skill_gomoku/ui/src/components/CardCollectionTrigger.tsx new file mode 100644 index 0000000..290ded2 --- /dev/null +++ b/example/game_07_skill_gomoku/ui/src/components/CardCollectionTrigger.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +export interface CardCollectionTriggerProps { + onClick: () => void; +} + +export const CardCollectionTrigger: React.FC = ({ onClick }) => { + return ( + + ); +}; diff --git a/example/game_07_skill_gomoku/ui/src/components/CombinedShichahaiPanel.tsx b/example/game_07_skill_gomoku/ui/src/components/CombinedShichahaiPanel.tsx new file mode 100644 index 0000000..6c209b9 --- /dev/null +++ b/example/game_07_skill_gomoku/ui/src/components/CombinedShichahaiPanel.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import type { ShichahaiEntry } from '../types'; +import { PLAYER_NAMES, PlayerEnum } from '../core/constants'; +import { SeaIcon } from './IconLibrary'; + +export interface CombinedShichahaiPanelProps { + shichahai: ShichahaiEntry[]; + onPositionHover?: (position: { row: number; col: number } | null) => void; +} + +const cx = (...classes: Array) => classes.filter(Boolean).join(' '); + +export const CombinedShichahaiPanel: React.FC = ({ + shichahai, + onPositionHover +}) => { + // 按回合排序,最新的在最后 + const sortedShichahai = [...shichahai].sort((a, b) => a.turn - b.turn); + + return ( +
+ {/* 科技感顶部光条 */} +
+ + {/* 侧边光效 */} +
+ +
+
+ +

什刹海

+
+
+ {shichahai.length} 枚棋子 +
+
+ +
+
+ {/* 科技感边框 */} +
+
+
+ +
+
+ {sortedShichahai.length > 0 ? ( + sortedShichahai.map(entry => ( +
{ + if (onPositionHover) { + onPositionHover({ row: entry.row, col: entry.col }); + } + }} + onMouseLeave={() => { + if (onPositionHover) { + onPositionHover(null); + } + }} + title={`${PLAYER_NAMES[entry.owner]}: (${entry.row + 1}, ${entry.col + 1}) · 第 ${entry.turn + 1} 手`} + > + {/* 棋子 */} +
+
+
+ {/* 信息 */} +
+
+ {PLAYER_NAMES[entry.owner]} +
+
+ ({entry.row + 1},{entry.col + 1}) +
+
+ T{entry.turn + 1} +
+
+
+ )) + ) : ( +
+
+ +
暂无什刹海记录
+
+
+ )} +
+
+
+
+
+ ); +}; diff --git a/example/game_07_skill_gomoku/ui/src/components/GameLog.tsx b/example/game_07_skill_gomoku/ui/src/components/GameLog.tsx index 2545999..39959c6 100644 --- a/example/game_07_skill_gomoku/ui/src/components/GameLog.tsx +++ b/example/game_07_skill_gomoku/ui/src/components/GameLog.tsx @@ -45,11 +45,10 @@ export const GameLog: React.FC = ({ logs, onPositionHover, turnCou return (
void; // 添加位置悬停回调 + onPositionHover?: (position: { row: number; col: number } | null) => void; + mode?: 'graveyard-only' | 'both'; // 新增:控制显示模式 } const cx = (...classes: Array) => classes.filter(Boolean).join(' '); @@ -29,8 +30,16 @@ const variantStyles: Record<'player' | 'opponent', { borderGlow: string; accentC } }; -export const ZonePanel: React.FC = ({ title, graveyard, shichahai, variant = 'player', onPositionHover }) => { +export const ZonePanel: React.FC = ({ + title, + graveyard, + shichahai = [], + variant = 'player', + onPositionHover, + mode = 'both' +}) => { const palette = variantStyles[variant]; + const isGraveyardOnly = mode === 'graveyard-only'; return (
= ({ title, graveyard, shichaha palette.borderGlow )} style={{ - height: 'clamp(220px, 28vh, 320px)', - minHeight: '220px', - maxHeight: 'clamp(240px, 32vh, 340px)', - background: palette.bgSolid, // 使用纯色半透明背景,不使用毛玻璃 - border: `1px solid rgba(255,255,255,0.15)`, // 增强边框可见度 + height: isGraveyardOnly ? 'clamp(100px, 14vh, 140px)' : 'clamp(220px, 28vh, 320px)', + minHeight: isGraveyardOnly ? '100px' : '220px', + maxHeight: isGraveyardOnly ? 'clamp(120px, 16vh, 160px)' : 'clamp(240px, 32vh, 340px)', + background: palette.bgSolid, + border: `1px solid rgba(255,255,255,0.15)`, boxShadow: ` inset 0 1px 0 rgba(255,255,255,0.1), inset 0 -1px 0 rgba(0,0,0,0.2), @@ -70,7 +79,7 @@ export const ZonePanel: React.FC = ({ title, graveyard, shichaha />

{title}

@@ -78,38 +87,42 @@ export const ZonePanel: React.FC = ({ title, graveyard, shichaha
{graveyard.length}
-
- - {shichahai.length} -
-
- -
- {/* 墓地:横向滚动卡片,固定高度 */} -
-
- - 墓地 + {!isGraveyardOnly && (
-
+ > + + {shichahai.length} +
+ )} +
+ +
+ {/* 墓地:横向滚动卡片 */} +
+ {!isGraveyardOnly && ( +
+ + 墓地 +
+
+ )}
{/* 科技感边框 */}
= ({ title, graveyard, shichaha
- {/* 什刹海:横向滚动卡片,固定高度 */} -
-
- - 什刹海 -
-
-
- {/* 科技感边框 */} -
-
-
-
-
- {shichahai.length > 0 ? ( - shichahai.map(entry => ( -
{ - // 在棋盘上高亮这个位置 - if (onPositionHover) { - onPositionHover({ row: entry.row, col: entry.col }); - } - }} - onMouseLeave={() => { - // 清除高亮 - if (onPositionHover) { - onPositionHover(null); - } - }} - title={`${PLAYER_NAMES[entry.owner]}: (${entry.row + 1}, ${entry.col + 1})`} - > - {/* 用棋子表示 */} -
+ {!isGraveyardOnly && ( + <> + {/* 什刹海:横向滚动卡片 */} +
+
+ + 什刹海 +
+
+
+
+
+
+
+
+ {shichahai.length > 0 ? ( + shichahai.map(entry => (
-
-
-
{PLAYER_NAMES[entry.owner]}
-
({entry.row + 1},{entry.col + 1}) · T{entry.turn}
+ key={entry.id} + className="shrink-0 flex flex-col min-w-[100px] max-w-[120px] h-[80px] items-center gap-1 rounded-lg px-2 py-1.5 shadow-sm hover:ring-1 hover:ring-cyan-400/50 transition-all duration-200 cursor-pointer group" + style={{ + background: 'rgba(8, 47, 73, 0.95)', + border: '1px solid rgba(6, 182, 212, 0.3)', + boxShadow: '0 2px 8px rgba(0,0,0,0.4)' + }} + onMouseEnter={() => { + if (onPositionHover) { + onPositionHover({ row: entry.row, col: entry.col }); + } + }} + onMouseLeave={() => { + if (onPositionHover) { + onPositionHover(null); + } + }} + title={`${PLAYER_NAMES[entry.owner]}: (${entry.row + 1}, ${entry.col + 1})`} + > +
+
+
+
+
{PLAYER_NAMES[entry.owner]}
+
({entry.row + 1},{entry.col + 1}) · T{entry.turn}
+
+
+ )) + ) : ( +
+
+ +
暂无什刹海记录
+
-
- )) - ) : ( -
-
- -
暂无什刹海记录
-
+ )}
- )} +
-
-
+ + )} {graveyard.length === 0 && shichahai.length === 0 && (
diff --git a/example/game_07_skill_gomoku/ui/src/components/index.ts b/example/game_07_skill_gomoku/ui/src/components/index.ts index 097aa57..392bf0c 100644 --- a/example/game_07_skill_gomoku/ui/src/components/index.ts +++ b/example/game_07_skill_gomoku/ui/src/components/index.ts @@ -30,6 +30,15 @@ export type { AvatarBadgeProps, OpponentHudProps, PlayerHudProps } from './Hud'; export { ZonePanel } from './ZonePanel'; export type { ZonePanelProps } from './ZonePanel'; +export { CombinedShichahaiPanel } from './CombinedShichahaiPanel'; +export type { CombinedShichahaiPanelProps } from './CombinedShichahaiPanel'; + +export { CardCollectionPanel } from './CardCollectionPanel'; +export type { CardCollectionPanelProps } from './CardCollectionPanel'; + +export { CardCollectionTrigger } from './CardCollectionTrigger'; +export type { CardCollectionTriggerProps } from './CardCollectionTrigger'; + export { SkillEffectLayer } from './SkillEffectLayer'; export type { SkillEffectLayerProps } from './SkillEffectLayer'; From 5a4b96d36075de0f5d24ce5d4655526712cd45ed Mon Sep 17 00:00:00 2001 From: bagaking Date: Thu, 6 Nov 2025 01:05:21 +0800 Subject: [PATCH 27/51] docs(game07): present card list as tables synced with cards.csv; remove all cost mentions; clarify merged Shichahai location under Game Log. --- example/game_07_skill_gomoku/UIDESIGN.md | 113 +++++++---------------- 1 file changed, 32 insertions(+), 81 deletions(-) diff --git a/example/game_07_skill_gomoku/UIDESIGN.md b/example/game_07_skill_gomoku/UIDESIGN.md index f341656..659c949 100644 --- a/example/game_07_skill_gomoku/UIDESIGN.md +++ b/example/game_07_skill_gomoku/UIDESIGN.md @@ -93,87 +93,38 @@ ### 3. 卡牌一览(以表为准) -以下条目与 example/game_07_skill_gomoku/cards.csv 保持一致,仅做 UI 呈现与交互侧的补充: - -- 飞沙走石(1001,Attack/Standard,Normal/PreMove,Common,cost 1) - - 效果:移除敌方棋子到什刹海;目标落点下回合封禁;自身放弃本回合落子 - - 可被反击:拾金不昧(1008)、擒拿(1009) - - 台词:你管这个叫五子棋? - -- 静如止水(1002,Attack,Instant/PreMove,Rare,cost 2) - - 效果:冻结对手,跳过对方下一回合(仍可使用反击卡) - - 可被反击:水滴石穿(1010) - - 台词:嗯嗯,嗯 - -- 力拔山兮(1003,Attack,Instant/PreMove,Legendary,cost 3) - - 效果:直接胜利;可被“两极反转”“东山再起”破解 - - 可被反击:两极反转(1011)、东山再起(1012) - - 台词:这可是我的最终奥义 - -- 我是保洁(1004,Attack/Fusion,Instant/PreMove,Rare,cost 1,需张兴朝在场) - - 效果:随机扫走敌我各一枚棋子至什刹海;目标落点下回合封禁 - - 可被反击:滚!(1013) - - 失败条件:张兴朝离场或被“滚!”打断 - - 台词:我这招叫做保洁上门 - -- 这是棒球(1005,Attack/Standard,Normal/PreMove,Rare,cost 1,需张兴朝在场) - - 效果:等同“飞沙走石” - - 免疫:擒拿(1009)、滚!(1013);可被拾金不昧(1008)在生效后挽回 - - 台词:呀咧呀咧 - -- 时光倒流(1006,Attack,Instant/PreMove,Legendary,cost 3) - - 效果:双方最近一手撤回(不恢复手牌/墓地/牌堆) - - 台词:这一招好 - -- 调呈离山·东(1007,Attack/Fusion,Instant/PreMove,Rare,cost 2,需张兴朝在场) - - 效果:跳过对方下一回合 - - 可被反击:滚!(1013) - - 失败条件:张兴朝离场或被“滚!”打断 - - 台词:就是调走张呈,离开山东! - -- 拾金不昧(1008,Counter/Reactive,Normal/Reaction,Common,cost 1) - - 触发:己方棋子被移除时 - - 效果:将该棋子放回任意空位(先手不转移) - - 台词:嘿,拾金不昧 - -- 擒拿(1009,Counter/Reactive,Instant/Reaction,Common,cost 1) - - 触发:对方发动“飞沙走石”时 - - 效果:阻止移除(blocks=1001) - - 台词:我只是受过九年制义务教育 - -- 水滴石穿(1010,Counter/Reactive,Instant/Reaction,Common,cost 1) - - 触发:己方被冻结时 - - 效果:解除冻结,不失去行动权 - - 台词:水滴石穿 - -- 两极反转(1011,Counter/Legendary,Instant/Reaction,Legendary,cost 2,需张兴朝在场) - - 触发:对方发动力拔山兮时 - - 效果:直接反夺胜利 - - 台词:哈哈哈哈哈哈,两极反转 - -- 东山再起(1012,Counter/Reactive,Instant/Reaction,Rare,cost 2) - - 触发:力拔山兮结算后 - - 效果:强制恢复继续对局,并立刻进入我方回合 - - 台词:那我就,东山再起 - -- 滚!(1013,Counter/Fusion,Instant/Reaction,Rare,cost 1,需张兴朝在场) - - 触发:对方发动合体技时 - - 效果:阻止其效果(可能被“你骂老人”反击) - - 台词:诶, 怎么又走了 - -- 你骂老人(1014,Counter/Legendary,Instant/Reaction,Legendary,cost 2) - - 触发:己方合体技被“滚!”阻断时 - - 效果:令对手下回合无法行动 - - 台词:你骂老人! - -- 技能五(1015,Support,Instant/Anytime,Common,cost 1) - - 效果:召唤“张兴朝”入场,使合体技可发动;召唤当回合无法立刻发动合体技 - - 台词:技能五子·张技能五 - -- See You Again(1016,Support,Instant/PreMove,Common,cost 1) - - 效果:强制让对方“张兴朝”退场(target=3001) - - 台词:胜天半子·王金宝 -- 右键:查看关联的“反制卡”提示(显示哪些卡能反制当前技能)。 +以下基于 example/game_07_skill_gomoku/cards.csv,同步为表格显示(无“费用/cost”概念)。 + +- 攻击类(Attack) + +| tid | 名称 | 类型 | 速度/时机 | 依赖 | 主要效果 | 反制/免疫 | 备注/失败条件 | 台词 | +|---|---|---|---|---|---|---|---|---| +| 1001 | 飞沙走石 | Attack/Standard | Normal/PreMove | - | 移除敌方棋子至什刹海;目标落点下回合封禁;自身放弃本回合落子 | 可被 拾金不昧(1008)、擒拿(1009) | 被擒拿或拾金不昧打断时取消 | 你管这个叫五子棋? | +| 1002 | 静如止水 | Attack | Instant/PreMove | - | 冻结敌方,跳过对方下一回合(仍可用反击卡) | 可被 水滴石穿(1010) | | 嗯嗯,嗯 | +| 1003 | 力拔山兮 | Attack/Legendary | Instant/PreMove | - | 直接胜利 | 可被 两极反转(1011)、东山再起(1012) | | 这可是我的最终奥义 | +| 1004 | 我是保洁 | Attack/Fusion | Instant/PreMove | 需张兴朝在场 | 随机扫走敌我各一枚棋子至什刹海;目标落点下回合封禁 | 可被 滚!(1013) | 张兴朝离场或被“滚!”打断 | 我这招叫做保洁上门 | +| 1005 | 这是棒球 | Attack/Standard | Normal/PreMove | 需张兴朝在场 | 等同“飞沙走石” | 免疫:擒拿/滚!;可被拾金不昧挽回 | 只能被拾金不昧挽回 | 呀咧呀咧 | +| 1006 | 时光倒流 | Attack/Legendary | Instant/PreMove | - | 撤回双方最近一手(不恢复手牌/墓地/牌堆) | | | 这一招好 | +| 1007 | 调呈离山·东 | Attack/Fusion | Instant/PreMove | 需张兴朝在场 | 跳过对方下一回合 | 可被 滚!(1013) | 张兴朝离场或被“滚!”打断 | 就是调走张呈,离开山东! | + +- 反击类(Counter) + +| tid | 名称 | 类型 | 速度/时机 | 触发 | 主要效果 | 备注/依赖 | 台词 | +|---|---|---|---|---|---|---|---| +| 1008 | 拾金不昧 | Counter/Reactive | Normal/Reaction | 己方棋子被移除 | 将该棋子放回任意空位(先手不转移) | | 嘿,拾金不昧 | +| 1009 | 擒拿 | Counter/Reactive | Instant/Reaction | 敌方使用“飞沙走石” | 阻止移除(blocks=1001) | | 我只是受过九年制义务教育 | +| 1010 | 水滴石穿 | Counter/Reactive | Instant/Reaction | 己方被冻结 | 解除冻结,不失去行动权 | | 水滴石穿 | +| 1011 | 两极反转 | Counter/Legendary | Instant/Reaction | 敌方发动力拔山兮 | 直接反夺胜利 | 需张兴朝在场 | 哈哈哈哈哈哈,两极反转 | +| 1012 | 东山再起 | Counter/Reactive | Instant/Reaction | 力拔山兮结算后 | 强制恢复继续对局,并立刻进入我方回合 | | 那我就,东山再起 | +| 1013 | 滚! | Counter/Fusion | Instant/Reaction | 敌方发动合体技 | 阻止其效果 | 可能被“你骂老人”反击;需张兴朝在场 | 诶, 怎么又走了 | +| 1014 | 你骂老人 | Counter/Legendary | Instant/Reaction | 己方合体技被“滚!”阻断时 | 令对手下回合无法行动 | | 你骂老人! | + +- 辅助类(Support) + +| tid | 名称 | 类型 | 速度/时机 | 主要效果 | 备注 | 台词 | +|---|---|---|---|---|---|---| +| 1015 | 技能五 | Support | Instant/Anytime | 召唤“张兴朝”入场,使合体技可发动 | 召唤当回合无法立刻发动合体技 | 技能五子·张技能五 | +| 1016 | See You Again | Support | Instant/PreMove | 强制让对方“张兴朝”退场(target=3001) | | 胜天半子·王金宝 | ## 四、弹窗系统 From dc15d981cec84d8cdf59c117a40d866c09788230 Mon Sep 17 00:00:00 2001 From: bagaking Date: Thu, 6 Nov 2025 01:19:29 +0800 Subject: [PATCH 28/51] =?UTF-8?q?feat(ui):=20add=20small=20Chinese=20capti?= =?UTF-8?q?ons=20under=20top-right=20action=20icons=20(=E8=AE=BE=E7=BD=AE/?= =?UTF-8?q?=E5=9B=BE=E9=89=B4/=E9=9F=B3=E4=B9=90=E5=BC=80=E5=85=B3/?= =?UTF-8?q?=E9=9F=B3=E6=95=88=E5=BC=80=E5=85=B3);=20wire=20Card=20Collecti?= =?UTF-8?q?on=20panel=20via=20trigger;=20enhance=20AI=20Settings=20panel?= =?UTF-8?q?=20with=20standing=20portraits,=20larger=20prompt=20with=20exam?= =?UTF-8?q?ples,=20and=20compact=20inputs.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/game_07_skill_gomoku/cards.csv | 2 +- .../game_07_skill_gomoku/ui/public/cards.json | 2 +- example/game_07_skill_gomoku/ui/src/App.tsx | 3 +- .../src/app/components/AiSettingsTrigger.tsx | 50 +++++--- .../ui/src/components/AiSettingsPanel.tsx | 113 +++++++++-------- .../ui/src/components/CardCollectionPanel.tsx | 119 ++++++++++++++---- 6 files changed, 189 insertions(+), 100 deletions(-) diff --git a/example/game_07_skill_gomoku/cards.csv b/example/game_07_skill_gomoku/cards.csv index 0f74fcf..00137b9 100644 --- a/example/game_07_skill_gomoku/cards.csv +++ b/example/game_07_skill_gomoku/cards.csv @@ -1,7 +1,7 @@ @,enum,enum?,string,string,enum,uint,enum,enum,string,string?,enum,string?,uint[]?,uint?,uint[]?,string?,string?,string?,string,string, tid,type,subtype,nameZh,nameEn,rarity,cost,speed,timing,effect,triggerCondition,effectId,effectParams,counteredBy,requiresCharacter,requiresCards,failCondition,tags,notes,quote,artwork, 1001,Attack,Standard,飞沙走石,Sandstorm,Common,1,Normal,PreMove,指定敌方棋子移出棋盘,进入什刹海;目标落点下回合被封禁,同时自己放弃本回合落子。,,RemoveToShichahai,,1008|1009,,,被擒拿或拾金不昧打断时取消,Removal|Shichahai,,你管这个叫五子棋?,, -1002,Attack,Standard,静如止水,Time Freeze,Rare,2,Instant,PreMove,冻结敌方,跳过对方下一回合 (无法落子和释放技能),,FreezeOpponent,turns=1,1010,,,冻结期间仍可使用反击卡,Attack|Freeze,,嗯嗯,嗯,, +1002,Attack,Standard,静如止水,Time Freeze,Rare,2,Instant,PreMove,冻结敌方,跳过对方下一回合 (无法落子和释放技能),可以被「水滴石穿」破解,,FreezeOpponent,turns=1,1010,,,冻结期间仍可使用反击卡,Attack|Freeze,,嗯嗯,嗯,, 1003,Attack,Legendary,力拔山兮,Instant Win,Legendary,3,Instant,PreMove,直接摔坏棋盘并宣布胜利,可以被 「东山再起」和「两级反转」破解,,InstantWin,,1011|1012,,,若被反制则视作发动失败,DirectWin|Legendary,,这可是我的最终奥义,, 1004,Attack,Fusion,我是保洁,Cleaner Service,Rare,1,Instant,PreMove,同时随机扫走敌我各一枚棋子,移出棋盘,进入什刹海;目标落点下回合被封禁,己方张兴朝在场,CleanSweep,,1013,3001,,张兴朝离场或被滚!打断,Fusion|Removal|Random|Shichahai,,我这招叫做保洁上门,, 1005,Attack,Standard,这是棒球,Baseball Strike,Rare,1,Normal,PreMove,合体技,效果等同于「飞沙走石」,无法被「擒拿」或「滚!」 防御,,RemoveToShichahai,"ignoreSeize=true,immuneShout=true",1008,3001,,只能被拾金不昧挽回,Attack|Fusion|Removal|Shichahai|Unseizeable,,呀咧呀咧,, diff --git a/example/game_07_skill_gomoku/ui/public/cards.json b/example/game_07_skill_gomoku/ui/public/cards.json index aba03ae..a7324c3 100644 --- a/example/game_07_skill_gomoku/ui/public/cards.json +++ b/example/game_07_skill_gomoku/ui/public/cards.json @@ -51,7 +51,7 @@ "cost": 2, "speed": "instant", "timing": "pre-move", - "effect": "冻结敌方,跳过对方下一回合 (无法落子和释放技能)", + "effect": "冻结敌方,跳过对方下一回合 (无法落子和释放技能),可以被「水滴石穿」破解", "triggerCondition": "", "effectId": "freeze-opponent", "effectParams": "turns=1", diff --git a/example/game_07_skill_gomoku/ui/src/App.tsx b/example/game_07_skill_gomoku/ui/src/App.tsx index ae98d37..3620953 100644 --- a/example/game_07_skill_gomoku/ui/src/App.tsx +++ b/example/game_07_skill_gomoku/ui/src/App.tsx @@ -343,7 +343,7 @@ const App: React.FC = () => { <>
{isPhone && } - + setIsCardCollectionOpen(true)} /> { @@ -366,6 +366,7 @@ const App: React.FC = () => { onClose={() => setIsSettingsOpen(false)} onSave={setAiSettings} /> + setIsCardCollectionOpen(false)} /> ); } diff --git a/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx b/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx index ca8d3cc..53a799c 100644 --- a/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx +++ b/example/game_07_skill_gomoku/ui/src/app/components/AiSettingsTrigger.tsx @@ -21,23 +21,30 @@ export const AiSettingsTrigger: React.FC = ({ onOpen, ha const ready = 'border-emerald-300/50 bg-emerald-400/20 text-emerald-200 hover:bg-emerald-400/30'; const idle = 'border-amber-300/50 bg-slate-900/80 text-amber-300/80'; + const labelStyle: React.CSSProperties = { fontSize: '10px', lineHeight: '10px', textShadow: '0 1px 2px rgba(0,0,0,0.9), 0 -1px 2px rgba(0,0,0,0.6)' }; + const Caption: React.FC<{ text: string }> = ({ text }) => ( + {text} + ); + return (
- {/* Settings button: swap to sliders icon for better affordance */} - + {/* Settings */} +
+ + +
- {/* Quick audio toggles */} -
- {/* Card Collection button */} - {onOpenCardCollection && ( + {/* Card Collection */} + {onOpenCardCollection && ( +
- )} + +
+ )} - {/* Music toggle */} + {/* Music toggle */} +
+ +
- {/* SFX toggle */} + {/* SFX toggle */} +
+
); diff --git a/example/game_07_skill_gomoku/ui/src/components/AiSettingsPanel.tsx b/example/game_07_skill_gomoku/ui/src/components/AiSettingsPanel.tsx index d21ab65..97d17ff 100644 --- a/example/game_07_skill_gomoku/ui/src/components/AiSettingsPanel.tsx +++ b/example/game_07_skill_gomoku/ui/src/components/AiSettingsPanel.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'; import type { AiSettings } from '../ai/openAiClient'; import { useBackgroundMusic } from '../app/hooks/useBackgroundMusic'; import { useSoundEffects } from '../app/hooks/useSoundEffects'; +import standPng from '../assets/stand.png'; +import standKaraPng from '../assets/stand_kara.png'; export interface AiSettingsPanelProps { open: boolean; settings: AiSettings; @@ -58,12 +60,18 @@ export const AiSettingsPanel: React.FC = ({ open, settings return (
-
-
-

AI 配置

- +
+ {/* Header with standing portraits */} +
+

AI 配置

+ + {/* portraits */} +
+ black stand + white stand +
-
+ {/* 音频设置 */}
音频设置 @@ -75,58 +83,49 @@ export const AiSettingsPanel: React.FC = ({ open, settings 使用默认
-