+ {/* 顶部头像:玩家胜利显示 stand_kara;AI 胜利显示 stand */}
+ {(isPlayerWin || isAIWin) && (
+
+

+
+ )}
{isPlayerWin && (
<>
-
- 🎉 胜利!🎉
-
+
+ {/* 左侧庆祝图标 */}
+
+
+
+ 胜利!
+
+
+ {/* 右侧庆祝图标 */}
+
+
@@ -67,15 +134,34 @@ export const GameOverPanel: React.FC = ({ winner, onRestart
{isAIWin && (
<>
-
- 😢 失败
-
+
+ {/* 失败图标 - 破碎的盾牌 */}
+
+
+
+ 失败
+
+
+ {/* 失败图标 - 雨滴 */}
+
+
{PLAYER_NAMES[PlayerEnum.WHITE]} 获胜
- 再接再厉,下次一定能赢!
+ 打得不错,再接再厉,下次一定能赢!
>
)}
{isDraw && (
@@ -96,14 +182,46 @@ export const GameOverPanel: React.FC = ({ winner, onRestart
-
⚫
+ {/* 黑色棋子 SVG */}
+
黑方
{PLAYER_NAMES[PlayerEnum.BLACK]}
-
⚪
+ {/* 白色棋子 SVG */}
+
白方 AI
{PLAYER_NAMES[PlayerEnum.WHITE]}
@@ -120,13 +238,58 @@ export const GameOverPanel: React.FC
= ({ winner, onRestart
className="group relative px-8 py-4 bg-gradient-to-r from-emerald-500 via-green-500 to-teal-500 text-white font-black text-lg rounded-full shadow-2xl transform hover:scale-110 transition-all duration-300 border-4 border-yellow-400/80 hover:rotate-2 overflow-hidden"
>
- 🔄 再来一局
+ {/* 循环箭头 SVG */}
+
+ 再来一局
{/* 悬浮光效 */}
+ {gameForShare && debugShareEnabled && (
+
+
+
分享你的战局
+
+ setName(e.target.value)} placeholder="你的名字(必填)" className="flex-1 rounded-lg bg-slate-800/70 border border-white/10 px-2 py-1 text-sm text-white" />
+
+
+ {/* 不显示 URL,仅提供复制功能 */}
+ {copied && (
+
+ )}
+
+
+ )}
+
{/* 底部装饰文字 */}
diff --git a/example/game_07_skill_gomoku/ui/src/components/GameStageLayout.tsx b/example/game_07_skill_gomoku/ui/src/components/GameStageLayout.tsx
new file mode 100644
index 0000000..28cf263
--- /dev/null
+++ b/example/game_07_skill_gomoku/ui/src/components/GameStageLayout.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+
+// Presentational layout for the main game stage
+// Extracted from App to keep App slim (state/effects only) and make layout reusable.
+
+const cx = (...classes: Array) => classes.filter(Boolean).join(' ');
+
+export interface GameStageLayoutProps {
+ // left column overlays
+ topHUD: React.ReactNode;
+ board: React.ReactNode;
+ bottomHUD: React.ReactNode;
+ boardBlocked?: boolean;
+
+ // right column
+ rightTop: React.ReactNode;
+ rightMiddle: React.ReactNode;
+ rightBottom: React.ReactNode;
+}
+
+export const GameStageLayout: React.FC = ({
+ topHUD,
+ board,
+ bottomHUD,
+ boardBlocked = false,
+ rightTop,
+ rightMiddle,
+ rightBottom
+}) => {
+ return (
+
+ {/* Overlay pattern */}
+
+
+
+
+ {/* Left column */}
+
+ {/* Board stage with spacing reserved for HUD overlays */}
+
+ {/* Ambient glow behind board */}
+
+
+ {/* Board and overlays (draft/selector/pending are expected to be part of this node) */}
+ {board}
+
+
+ {/* Top HUD overlay */}
+
+
+ {/* Bottom HUD overlay */}
+
+
+
+ {/* Right column */}
+
+
{rightTop}
+
{rightMiddle}
+
{rightBottom}
+
+
+
+
+ );
+};
+
+export default GameStageLayout;
+
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..d962e5d 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('');
@@ -197,7 +199,7 @@ export const HandPanel: React.FC = ({
}, [isOpponent, animateThinking, cards.length, clearTimers]);
if (cards.length === 0) {
return (
-
+
{isOpponent ? '白方无手牌' : '暂无手牌'}
@@ -212,7 +214,7 @@ export const HandPanel: React.FC
= ({
const angleStep = total > 1 ? angleRange / (total - 1) : 0;
// 增加卡牌间距,确保能看清所有卡面
const spacingBase = total > 1 ? Math.min(65, 50 + total * 1.8) : 0;
- const baseWidth = total >= 5 ? 9.8 : 10.8;
+ const baseWidth = total >= 5 ? 9.4 : 10.2;
return (
= ({
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);
@@ -279,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 7d72184..09d8df4 100644
--- a/example/game_07_skill_gomoku/ui/src/components/Hud.tsx
+++ b/example/game_07_skill_gomoku/ui/src/components/Hud.tsx
@@ -7,6 +7,8 @@ import { CardView } from './CardView';
import { GameIcon, FreezeIcon, SkipIcon, CurrentTurnIcon } from './IconLibrary';
import { ComicBubble } from './ComicBubble';
import standPng from '../assets/stand.png';
+import standKaraPng from '../assets/stand_kara.png';
+import skill5Png from '../assets/skill5.png';
const cx = (...classes: Array) => classes.filter(Boolean).join(' ');
@@ -139,18 +141,20 @@ export const AvatarBadge: React.FC = ({ player, characters, st
{/* 头像 */}
- {isBlack ? (
-
- {AVATAR_GLYPHS[player]}
-
- ) : (
-

- )}
+ {(() => {
+ const ch = character?.name ?? '';
+ const hasZhangXingchao = /张兴朝/.test(ch);
+ const base = isBlack ? standKaraPng : standPng;
+ const src = hasZhangXingchao ? skill5Png : base;
+ return (
+

+ );
+ })()}
{/* 玩家信息 */}
@@ -224,7 +228,7 @@ export const OpponentHUD: React.FC = ({
}) => (
{/* 左侧玩家信息 - 固定宽度 */}
-
+
{bubble && (
@@ -239,7 +243,7 @@ export const OpponentHUD: React.FC = ({
{/* 中央手牌区域 - 靠右对齐,避免与棋盘重叠 */}
-
+
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,16 +285,18 @@ export const PlayerHUD: React.FC = ({
graveyardCount,
shichahaiCount,
onCardHover,
+ onCardClick,
onCardDragStart,
onCardDragEnd,
isCurrent,
className,
bubble = null,
- align = 'center'
+ align = 'center',
+ allowTapPlay = false
}) => (
{/* 左侧玩家信息 - 紧凑设计 */}
-
+
{bubble && (
@@ -300,7 +308,7 @@ export const PlayerHUD: React.FC
= ({
'text-xs font-medium text-slate-300',
disabled && 'opacity-60'
)}>
- 拖拽发动技能卡
+ {allowTapPlay ? '轻点发动技能卡' : '拖拽发动技能卡'}
= ({
{/* 手牌区域 - 可切换对齐(避免遮挡棋盘) */}
-
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..d96006b 100644
--- a/example/game_07_skill_gomoku/ui/src/components/PendingCardPanel.tsx
+++ b/example/game_07_skill_gomoku/ui/src/components/PendingCardPanel.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import standPng from '../assets/stand.png';
-import { PLAYER_NAMES, PlayerEnum } from '../core/constants';
+import { PLAYER_NAMES, PlayerEnum, GamePhaseEnum } from '../core/constants';
import type { GameStatus, Player, RawCard } from '../types';
import { CardView } from './CardView';
@@ -18,6 +18,8 @@ export interface PendingCardPanelProps {
isInstant?: boolean;
// 瞬发确认时间(毫秒)
confirmMs?: number;
+ // 当前阶段(用于控制提示文案/按钮显示)
+ phase?: GameStatus['phase'];
}
export const PendingCardPanel: React.FC
= ({
@@ -31,7 +33,8 @@ export const PendingCardPanel: React.FC = ({
onReact,
onCancel,
isInstant = false,
- confirmMs = 7000
+ confirmMs = 7000,
+ phase
}) => {
if (!pendingCard) return null;
const [appear, setAppear] = useState(false);
@@ -42,6 +45,8 @@ export const PendingCardPanel: React.FC = ({
const actingPlayer = pendingCard.player;
const isResponderAI = aiEnabled && responder === PlayerEnum.WHITE;
const canCounter = responder === PlayerEnum.BLACK && availableCounters.length > 0;
+ const inCounterWindow = phase === GamePhaseEnum.COUNTER_WINDOW;
+ const inTargeting = phase === GamePhaseEnum.CARD_TARGETING;
return (
@@ -129,7 +134,7 @@ export const PendingCardPanel: React.FC
= ({
) : (
- {pendingCard.player === PlayerEnum.WHITE && responder === PlayerEnum.BLACK ? (
+ {pendingCard.player === PlayerEnum.WHITE && responder === PlayerEnum.BLACK && inCounterWindow ? (
// 敌方技能,无可反击:给出情绪化按钮等价"放弃反击"
{['可恶', '我知道了', '竟然'].map((label, i) => (
@@ -151,30 +156,38 @@ export const PendingCardPanel: React.FC
= ({
))}
) : pendingCard.player === PlayerEnum.BLACK ? (
- // 我方技能:瞬发显示倒计时+"发动",非瞬发不显示倒计时,仅允许取消
- isInstant ? (
+ // 我方技能:根据阶段调整 UI
+ inCounterWindow ? (
+ // 反击窗口
+ isInstant ? (
+
+
+ onResolve(false, null)} ms={confirmMs} />
+
+ ) : (
+
+
+ 等待对手是否反击…
+
+ )
+ ) : inTargeting ? (
+ // 目标选择阶段:仅提示“请选择目标”(取消按钮在该阶段隐藏,以免引擎不支持)
-
- onResolve(false, null)} ms={confirmMs} />
-
- ) : (
-
-
- 等待对手是否反击…
+ 请选择技能目标
- )
+ ) : null
) : null}
)}
@@ -184,34 +197,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 (