dsh-dock 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,8 @@ const C_GRANTED = '#22d3ee';
19
19
  const C_TOPUP = '#fbbf24';
20
20
  const C_OK = '#34d399';
21
21
  const C_ERR = '#f87171';
22
+ // 余额接口有些 Provider 仅返回美元。功能坞统一以人民币展示,汇率与用量模块默认值保持一致。
23
+ const USD_CNY_RATE = 7.2;
22
24
 
23
25
  function accentOf(id) {
24
26
  if (CURATED_ACCENTS[id]) return CURATED_ACCENTS[id];
@@ -28,6 +30,22 @@ function accentOf(id) {
28
30
  return ACCENT_PALETTE[h % ACCENT_PALETTE.length];
29
31
  }
30
32
  const fmt = (v) => (v == null || v === '' ? '—' : String(v));
33
+ function cnyBalanceInfo(info) {
34
+ if (!info) return info;
35
+ if (info.currency === 'CNY') return Object.assign({}, info, { currency: '人民币' });
36
+ if (info.currency !== 'USD') return info;
37
+ const convert = (value) => {
38
+ if (value == null) return value;
39
+ const n = Number(value);
40
+ return Number.isFinite(n) ? n * USD_CNY_RATE : value;
41
+ };
42
+ return Object.assign({}, info, {
43
+ currency: '人民币(估算)',
44
+ totalBalance: convert(info.totalBalance),
45
+ grantedBalance: convert(info.grantedBalance),
46
+ toppedUpBalance: convert(info.toppedUpBalance),
47
+ });
48
+ }
31
49
 
32
50
  // ---- 共享快照:弹层/设置页/首页总揽共用一份数据与刷新 ----
33
51
  const balanceStore = {
@@ -123,12 +141,15 @@ function BalanceView(props) {
123
141
  } else if (b && b.status === "ok") {
124
142
  const infos = Array.isArray(b.infos) ? b.infos : [];
125
143
  balBody = react.createElement("div", { className: "dkb-mains" },
126
- infos.map((i, idx) => react.createElement("div", { key: idx, className: "dkb-main" },
144
+ infos.map((rawInfo, idx) => {
145
+ const i = cnyBalanceInfo(rawInfo);
146
+ return react.createElement("div", { key: idx, className: "dkb-main" },
127
147
  react.createElement("span", { className: "dkb-main-label", style: { color: accent } }, i.currency),
128
148
  react.createElement("span", { className: "dkb-main-value" }, fmt(i.totalBalance)),
129
149
  react.createElement("span", { className: "dkb-main-parts" },
130
150
  i.grantedBalance != null ? react.createElement("span", { className: "dkb-main-part", style: { color: C_GRANTED } }, "赠送 " + fmt(i.grantedBalance)) : null,
131
- i.toppedUpBalance != null ? react.createElement("span", { className: "dkb-main-part", style: { color: C_TOPUP } }, "充值 " + fmt(i.toppedUpBalance)) : null))));
151
+ i.toppedUpBalance != null ? react.createElement("span", { className: "dkb-main-part", style: { color: C_TOPUP } }, "充值 " + fmt(i.toppedUpBalance)) : null));
152
+ }));
132
153
  } else if (b && b.status === "login-required" && b.consoleUrl) {
133
154
  balBody = react.createElement("div", { className: "dkb-main" },
134
155
  react.createElement("a", { href: b.consoleUrl, target: "_blank", rel: "noreferrer", className: "dkb-link", style: { color: accent } }, "去控制台查看余额 →"));
@@ -238,9 +259,9 @@ function chipBalanceText(p) {
238
259
  : fmt(b.remaining);
239
260
  return "余额 剩 " + s + (b.unit ? " " + b.unit : "");
240
261
  }
241
- const info = Array.isArray(b.infos) && b.infos[0] ? b.infos[0] : null;
262
+ const info = cnyBalanceInfo(Array.isArray(b.infos) && b.infos[0] ? b.infos[0] : null);
242
263
  if (!info) return "余额 …";
243
- const cur = info.currency === "USD" ? "$" : "¥";
264
+ const cur = info.currency === "CNY" || info.currency === "人民币(估算)" ? "¥" : "";
244
265
  const v = Number(info.totalBalance);
245
266
  const s = Number.isFinite(v) ? (v >= 10000 ? (v / 10000).toFixed(2) + "万" : String(Math.round(v * 100) / 100)) : fmt(info.totalBalance);
246
267
  return "余额 " + cur + s;
@@ -0,0 +1,227 @@
1
+ /* 趣味游戏 · 打砖块(经典机台)——纯 Client;canvas 渲染,方向键/鼠标/触摸控制挡板。 */
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { focusStage, useGameControls, playSfx } from "./shared.jsx";
4
+
5
+ const W = 320, H = 360;
6
+ const BRICK_COLS = 8, BRICK_ROWS = 5, BRICK_GAP = 4, BRICK_TOP = 22, BRICK_H = 18;
7
+ const PADDLE_W = 60, PADDLE_H = 11, PADDLE_BOTTOM = 20;
8
+ const BALL_R = 5.5;
9
+ const SPEED = 3.4;
10
+ const COLORS = ["#f87171", "#fb923c", "#facc15", "#4ade80", "#38bdf8"];
11
+
12
+ function makeBricks(level) {
13
+ const bricks = [];
14
+ const bw = (W - BRICK_GAP * (BRICK_COLS + 1)) / BRICK_COLS;
15
+ for (let r = 0; r < BRICK_ROWS; r += 1) {
16
+ for (let c = 0; c < BRICK_COLS; c += 1) {
17
+ bricks.push({
18
+ x: BRICK_GAP + c * (bw + BRICK_GAP),
19
+ y: BRICK_TOP + r * (BRICK_H + BRICK_GAP),
20
+ w: bw, h: BRICK_H,
21
+ color: COLORS[(r + level) % COLORS.length],
22
+ alive: true,
23
+ });
24
+ }
25
+ }
26
+ return bricks;
27
+ }
28
+ function draw(ctx, g) {
29
+ ctx.clearRect(0, 0, W, H);
30
+ // 星空背景
31
+ ctx.fillStyle = "#0b1024";
32
+ ctx.fillRect(0, 0, W, H);
33
+ ctx.fillStyle = "#1b2bb0";
34
+ for (const s of g.stars) { ctx.globalAlpha = s.a; ctx.fillRect(s.x, s.y, 1.5, 1.5); }
35
+ ctx.globalAlpha = 1;
36
+ // 砖块
37
+ for (const b of g.bricks) {
38
+ if (!b.alive) continue;
39
+ ctx.fillStyle = b.color;
40
+ ctx.beginPath();
41
+ roundRect(ctx, b.x, b.y, b.w, b.h, 4);
42
+ ctx.fill();
43
+ ctx.fillStyle = "rgba(255,255,255,.25)";
44
+ roundRect(ctx, b.x, b.y, b.w, b.h / 2.4, 4);
45
+ ctx.fill();
46
+ }
47
+ // 挡板
48
+ ctx.fillStyle = "#a5f3fc";
49
+ roundRect(ctx, g.paddle.x, g.paddle.y, PADDLE_W, PADDLE_H, 5);
50
+ ctx.fill();
51
+ // 球
52
+ ctx.fillStyle = "#fef3c7";
53
+ ctx.beginPath(); ctx.arc(g.ball.x, g.ball.y, BALL_R, 0, Math.PI * 2); ctx.fill();
54
+ ctx.fillStyle = "rgba(254,243,199,.5)";
55
+ ctx.beginPath(); ctx.arc(g.ball.x, g.ball.y, BALL_R + 2.5, 0, Math.PI * 2); ctx.fill();
56
+ }
57
+ function roundRect(ctx, x, y, w, h, r) {
58
+ ctx.beginPath();
59
+ ctx.moveTo(x + r, y);
60
+ ctx.arcTo(x + w, y, x + w, y + h, r);
61
+ ctx.arcTo(x + w, y + h, x, y + h, r);
62
+ ctx.arcTo(x, y + h, x, y, r);
63
+ ctx.arcTo(x, y, x + w, y, r);
64
+ ctx.closePath();
65
+ }
66
+ function collideRect(x, y, r, bx, by, bw, bh) {
67
+ const cx = Math.max(bx, Math.min(x, bx + bw));
68
+ const cy = Math.max(by, Math.min(y, by + bh));
69
+ const dx = x - cx, dy = y - cy;
70
+ return dx * dx + dy * dy < r * r;
71
+ }
72
+
73
+ export function BreakoutGame(props) {
74
+ const canvasRef = useRef(null);
75
+ const [score, setScore] = useState(0);
76
+ const [lives, setLives] = useState(3);
77
+ const [level, setLevel] = useState(1);
78
+ const [status, setStatus] = useState("play"); // play | win | over
79
+ const keys = useRef({});
80
+ const pausedRef = useRef(!!(props && props.paused)); pausedRef.current = !!(props && props.paused);
81
+ const game = useRef(null);
82
+ const scoreRef = useRef(0); scoreRef.current = score;
83
+ const statusRef = useRef("play"); statusRef.current = status;
84
+ const livesRef = useRef(3); livesRef.current = lives;
85
+
86
+ const start = useCallback((lvl) => {
87
+ const bricks = makeBricks(lvl);
88
+ game.current = {
89
+ stars: Array.from({ length: 46 }, () => ({ x: Math.random() * W, y: Math.random() * H, a: .25 + Math.random() * .5 })),
90
+ bricks,
91
+ paddle: { x: (W - PADDLE_W) / 2, y: H - PADDLE_BOTTOM - PADDLE_H },
92
+ ball: { x: W / 2, y: H - PADDLE_BOTTOM - PADDLE_H - BALL_R - 1, vx: (Math.random() > .5 ? 1 : -1) * SPEED, vy: -SPEED },
93
+ speed: SPEED * (1 + (lvl - 1) * .09),
94
+ };
95
+ }, []);
96
+
97
+ useEffect(() => {
98
+ start(1); setScore(0); setLives(3); setStatus("play");
99
+ }, [start]);
100
+
101
+ const launchBall = useCallback(() => {
102
+ const g = game.current;
103
+ if (!g) return;
104
+ g.ball.vx = (Math.random() > .5 ? 1 : -1) * SPEED;
105
+ g.ball.vy = -SPEED;
106
+ g.launched = true;
107
+ }, []);
108
+ const resetBall = useCallback(() => {
109
+ const g = game.current;
110
+ if (g) { g.ball.x = W / 2; g.ball.y = H - PADDLE_BOTTOM - PADDLE_H - BALL_R - 1; g.ball.vx = 0; g.ball.vy = 0; g.launched = false; }
111
+ }, []);
112
+
113
+ // rAF 主循环。注意:本地函数不能叫 draw,否则会遮蔽模块级渲染函数导致递归自调用。
114
+ useEffect(() => {
115
+ const frame = () => {
116
+ const g = game.current;
117
+ const ctx = canvasRef.current && canvasRef.current.getContext("2d");
118
+ if (!g || !ctx) return;
119
+ if (pausedRef.current || statusRef.current !== "play") { draw(ctx, g); return; }
120
+ // 挡板移动
121
+ if (keys.current.left) g.paddle.x -= 5.5;
122
+ if (keys.current.right) g.paddle.x += 5.5;
123
+ g.paddle.x = Math.max(0, Math.min(W - PADDLE_W, g.paddle.x));
124
+ // 球
125
+ const b = g.ball;
126
+ if (!b.launched) {
127
+ b.x = g.paddle.x + PADDLE_W / 2;
128
+ b.y = g.paddle.y - BALL_R - 1;
129
+ } else {
130
+ b.x += b.vx; b.y += b.vy;
131
+ // 墙
132
+ if (b.x - BALL_R < 0) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
133
+ if (b.x + BALL_R > W) { b.x = W - BALL_R; b.vx = -Math.abs(b.vx); }
134
+ if (b.y - BALL_R < 0) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
135
+ // 挡板
136
+ if (b.vy > 0 && b.y + BALL_R >= g.paddle.y && b.y - BALL_R <= g.paddle.y + PADDLE_H && b.x >= g.paddle.x - BALL_R && b.x <= g.paddle.x + PADDLE_W + BALL_R) {
137
+ playSfx("bounce");
138
+ const rel = (b.x - (g.paddle.x + PADDLE_W / 2)) / (PADDLE_W / 2);
139
+ const ang = rel * .6;
140
+ const sp = Math.hypot(b.vx, b.vy);
141
+ b.vx = sp * Math.sin(ang);
142
+ b.vy = -Math.abs(sp * Math.cos(ang));
143
+ b.y = g.paddle.y - BALL_R - 1;
144
+ }
145
+ // 砖块
146
+ const maxSpeed = g.speed * 1.6;
147
+ for (const br of g.bricks) {
148
+ if (!br.alive) continue;
149
+ if (collideRect(b.x, b.y, BALL_R, br.x, br.y, br.w, br.h)) {
150
+ br.alive = false;
151
+ playSfx("brick");
152
+ const cx = br.x + br.w / 2, cy = br.y + br.h / 2;
153
+ const dx = b.x - cx, dy = b.y - cy;
154
+ if (Math.abs(dx / br.w) > Math.abs(dy / br.h)) { b.vx = -b.vx; b.x += b.vx * 2; }
155
+ else { b.vy = -b.vy; b.y += b.vy * 2; }
156
+ setScore((s) => s + 10);
157
+ scoreRef.current += 10;
158
+ const remain = g.bricks.some((x) => x.alive);
159
+ if (!remain) {
160
+ playSfx("win");
161
+ setStatus("win"); setLevel((l) => l + 1);
162
+ if (statusRef.current !== "over") statusRef.current = "win";
163
+ }
164
+ break;
165
+ }
166
+ }
167
+ // 掉出
168
+ if (b.y - BALL_R > H) {
169
+ const nl = livesRef.current - 1;
170
+ livesRef.current = nl; setLives(nl);
171
+ playSfx(nl <= 0 ? "over" : "step");
172
+ if (nl <= 0) { setStatus("over"); statusRef.current = "over"; }
173
+ else resetBall();
174
+ }
175
+ }
176
+ draw(ctx, g);
177
+ };
178
+ let raf = requestAnimationFrame(function loop() { frame(); raf = requestAnimationFrame(loop); });
179
+ return () => cancelAnimationFrame(raf);
180
+ }, [resetBall]);
181
+
182
+ const onKeyDown = useCallback((event) => {
183
+ if (pausedRef.current) return;
184
+ if (event.key === "ArrowLeft") { event.preventDefault(); keys.current.left = true; }
185
+ else if (event.key === "ArrowRight") { event.preventDefault(); keys.current.right = true; }
186
+ else if (event.key === " ") { event.preventDefault(); launchBall(); }
187
+ else if (event.key.toLowerCase() === "r") { event.preventDefault(); start(1); setScore(0); setLives(3); setStatus("play"); statusRef.current = "play"; livesRef.current = 3; }
188
+ }, [launchBall, start]);
189
+ const onKeyUp = useCallback((event) => {
190
+ if (event.key === "ArrowLeft") keys.current.left = false;
191
+ else if (event.key === "ArrowRight") keys.current.right = false;
192
+ }, []);
193
+
194
+ const onPointerMove = useCallback((e) => {
195
+ const g = game.current, cv = canvasRef.current;
196
+ if (!g || !cv) return;
197
+ const rect = cv.getBoundingClientRect();
198
+ const scale = rect.width / W;
199
+ g.paddle.x = Math.max(0, Math.min(W - PADDLE_W, (e.clientX - rect.left) / scale - PADDLE_W / 2));
200
+ }, []);
201
+ useGameControls(canvasRef, pausedRef.current, onKeyDown, onKeyUp);
202
+
203
+ return <section className="dgame-game" aria-label="打砖块">
204
+ <div className="dgame-game-head"><div><h3>打砖块</h3><p>移动挡板反弹小球,击碎所有砖块;球落到底部扣一条命。</p></div><div className="dgame-score"><span>得分 <strong>{score}</strong></span><span>命 {lives} · 第 {level} 关</span></div></div>
205
+ <div className="dgame-breakout">
206
+ <canvas ref={canvasRef} width={W} height={H} tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onPointerMove={onPointerMove} onClick={() => focusStage(canvasRef)} className="dgame-breakout-canvas" aria-label="打砖块游戏区域,左右方向键或鼠标移动挡板,空格发球" />
207
+ {status === "win" ? <div className="dgame-over"><strong>全消!进入第 {level} 关</strong><button type="button" onClick={() => { start(level); setStatus("play"); statusRef.current = "play"; }}>下一关</button></div> : null}
208
+ {status === "over" ? <div className="dgame-over"><strong>球掉光了 · 得 {score} 分</strong><button type="button" onClick={() => { start(1); setScore(0); setLives(3); setStatus("play"); statusRef.current = "play"; livesRef.current = 3; }}>重新开始</button></div> : null}
209
+ </div>
210
+ <div className="dgame-controls"><button type="button" aria-label="向左移动" onClick={() => { keys.current.left = false; game.current && (game.current.paddle.x = Math.max(0, game.current.paddle.x - 12)); }}>← 左移</button><span>←/→ 或鼠标移动 · 空格发球</span><button type="button" aria-label="向右移动" onClick={() => { keys.current.right = false; game.current && (game.current.paddle.x = Math.min(W - PADDLE_W, game.current.paddle.x + 12)); }}>右移 {'->'}</button></div>
211
+ </section>;
212
+ }
213
+
214
+ export function BreakoutPreview() {
215
+ const cols = ["#f87171", "#fb923c", "#facc15", "#4ade80", "#38bdf8", "#c084fc"];
216
+ return <span className="dgcov-prev dgcov-prev-breakout" aria-hidden="true">
217
+ {Array.from({ length: 12 }, (_, i) => {
218
+ const row = Math.floor(i / 6), c = i % 6;
219
+ return <i key={i} style={{ left: (5 + c * 15) + "%", top: (10 + row * 12) + "%", background: cols[i % cols.length] }} />;
220
+ })}
221
+ <b />
222
+ </span>;
223
+ }
224
+
225
+ export const breakoutGame = { Game: BreakoutGame, Preview: BreakoutPreview, css: `
226
+ .dgame-breakout{position:relative;align-self:center;width:min(100%,320px);margin:0 auto}.dgame-breakout-canvas{display:block;width:100%;aspect-ratio:320/360;border:1px solid color-mix(in srgb,rgb(56 189 248) 32%,var(--dsw-alias-border-l1));border-radius:10px;outline:none;background:#0b1024;box-shadow:0 4px 18px rgb(0 0 0/.4)}.dgame-breakout-canvas:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,rgb(56 189 248) 35%,transparent)}.dgame-breakout .dgame-over{position:absolute;inset:0;border-radius:10px}
227
+ ` };
@@ -0,0 +1,244 @@
1
+ /* 趣味游戏 · 五子棋(棋盘对战)——纯 Client;玩家执黑先手,AI 执白。 */
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { focusStage, useGameControls, playSfx } from "./shared.jsx";
4
+
5
+ // 正规棋盘:SVG 精确绘制(viewBox 1500×1500,交点对齐各格中心)。外框加粗,五个星位点。
6
+ const GOMOKU_LINES = (() => {
7
+ const P = (n) => n * 100 + 50;
8
+ const parts = [<rect key="frame" x={P(0)} y={P(0)} width={1400} height={1400} strokeWidth={6.5} fill="none" />];
9
+ let k = 0;
10
+ for (let i = 1; i <= 13; i += 1) {
11
+ parts.push(<line key={k++} x1={P(i)} y1={P(0)} x2={P(i)} y2={P(14)} />);
12
+ parts.push(<line key={k++} x1={P(0)} y1={P(i)} x2={P(14)} y2={P(i)} />);
13
+ }
14
+ for (const [r, c] of [[3, 3], [3, 11], [7, 7], [11, 3], [11, 11]]) {
15
+ parts.push(<circle key={k++} className="dgame-gomoku-star" cx={P(c)} cy={P(r)} r={15} />);
16
+ }
17
+ return parts;
18
+ })();
19
+
20
+ const SIZE = 15;
21
+ const DIRS = [[0, 1], [1, 0], [1, 1], [1, -1]];
22
+
23
+ function emptyBoard() {
24
+ return Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
25
+ }
26
+ function inBoard(r, c) {
27
+ return r >= 0 && r < SIZE && c >= 0 && c < SIZE;
28
+ }
29
+ // 在 (r,c) 落 color 后,沿四个方向统计连子数、开放端,计分。
30
+ function lineScore(board, r, c, color) {
31
+ let total = 0;
32
+ for (const [dr, dc] of DIRS) {
33
+ let count = 1;
34
+ let rr = r + dr, cc = c + dc;
35
+ let openEnds = 0;
36
+ while (inBoard(rr, cc) && board[rr][cc] === color) { count += 1; rr += dr; cc += dc; }
37
+ if (inBoard(rr, cc) && board[rr][cc] === null) openEnds += 1;
38
+ rr = r - dr; cc = c - dc;
39
+ while (inBoard(rr, cc) && board[rr][cc] === color) { count += 1; rr -= dr; cc -= dc; }
40
+ if (inBoard(rr, cc) && board[rr][cc] === null) openEnds += 1;
41
+ total += patternScore(count, openEnds);
42
+ }
43
+ return total;
44
+ }
45
+ function patternScore(count, openEnds) {
46
+ if (count >= 5) return 10000000;
47
+ if (openEnds === 0) return 0;
48
+ if (count === 4) return openEnds === 2 ? 1000000 : 100000;
49
+ if (count === 3) return openEnds === 2 ? 10000 : 1000;
50
+ if (count === 2) return openEnds === 2 ? 200 : 20;
51
+ if (count === 1) return openEnds === 2 ? 10 : 1;
52
+ return 0;
53
+ }
54
+ // 返回连成五子(含以上)的坐标数组;未连成返回 null。
55
+ function winLine(board, r, c, color) {
56
+ for (const [dr, dc] of DIRS) {
57
+ const cells = [[r, c]];
58
+ let rr = r + dr, cc = c + dc;
59
+ while (inBoard(rr, cc) && board[rr][cc] === color) { cells.push([rr, cc]); rr += dr; cc += dc; }
60
+ rr = r - dr; cc = c - dc;
61
+ while (inBoard(rr, cc) && board[rr][cc] === color) { cells.push([rr, cc]); rr -= dr; cc -= dc; }
62
+ if (cells.length >= 5) return cells;
63
+ }
64
+ return null;
65
+ }
66
+ function boardFull(board) {
67
+ for (const row of board) for (const cell of row) if (!cell) return false;
68
+ return true;
69
+ }
70
+ // 取有落子的附近空位(距离 ≤2),否则空盘取中心。
71
+ function candidates(board) {
72
+ const set = [];
73
+ const seen = new Set();
74
+ let hasStone = false;
75
+ for (let r = 0; r < SIZE; r += 1) {
76
+ for (let c = 0; c < SIZE; c += 1) {
77
+ if (!board[r][c]) continue;
78
+ hasStone = true;
79
+ for (let dr = -2; dr <= 2; dr += 1) {
80
+ for (let dc = -2; dc <= 2; dc += 1) {
81
+ const rr = r + dr, cc = c + dc;
82
+ if (inBoard(rr, cc) && !board[rr][cc] && !seen.has(rr * SIZE + cc)) {
83
+ seen.add(rr * SIZE + cc);
84
+ set.push([rr, cc]);
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+ if (!hasStone) return [[7, 7], [7, 8], [8, 7]];
91
+ return set;
92
+ }
93
+ // AI 选点:进攻价值 + 防守价值(防对方五连/活四)。
94
+ function aiMove(board) {
95
+ const cands = candidates(board);
96
+ let best = null, bestScore = -1;
97
+ for (const [r, c] of cands) {
98
+ const attack = lineScore(board, r, c, "w");
99
+ const defend = lineScore(board, r, c, "b");
100
+ const score = attack + defend * 0.92;
101
+ if (score > bestScore) { bestScore = score; best = [r, c]; }
102
+ }
103
+ return best || [7, 7];
104
+ }
105
+
106
+ export function GomokuGame(props) {
107
+ const [board, setBoard] = useState(emptyBoard);
108
+ const [turn, setTurn] = useState("b");
109
+ const [winner, setWinner] = useState(null);
110
+ const [winCells, setWinCells] = useState(null);
111
+ const [showResult, setShowResult] = useState(false);
112
+ const [last, setLast] = useState(null);
113
+ const [history, setHistory] = useState([]);
114
+ const [thinking, setThinking] = useState(false);
115
+ const boardRef = useRef(board);
116
+ const stageRef = useRef(null);
117
+ boardRef.current = board;
118
+ const turnRef = useRef(turn);
119
+ turnRef.current = turn;
120
+ const winRef = useRef(winner);
121
+ winRef.current = winner;
122
+
123
+ const finish = useCallback((b, r, c, color) => {
124
+ const line = winLine(b, r, c, color);
125
+ if (line) { setWinner(color); setWinCells(line); setShowResult(true); playSfx(color === "b" ? "win" : "over"); }
126
+ else if (boardFull(b)) { setWinner("draw"); setWinCells(null); setShowResult(true); playSfx("clear"); }
127
+ else playSfx("stone");
128
+ }, []);
129
+
130
+ const place = useCallback((r, c, color) => {
131
+ if (boardRef.current[r][c]) return false;
132
+ const next = boardRef.current.map((row) => row.slice());
133
+ next[r][c] = color;
134
+ setBoard(next);
135
+ boardRef.current = next;
136
+ setLast({ r, c });
137
+ setHistory((h) => h.concat([{ r, c, color }]));
138
+ finish(next, r, c, color);
139
+ return true;
140
+ }, [finish]);
141
+
142
+ // AI 回合:思考片刻后落子(执白)。
143
+ useEffect(() => {
144
+ if (turn !== "w" || winner || props.paused) return;
145
+ setThinking(true);
146
+ const t = setTimeout(() => {
147
+ const [r, c] = aiMove(boardRef.current);
148
+ place(r, c, "w");
149
+ setTurn("b");
150
+ setThinking(false);
151
+ }, 260);
152
+ return () => clearTimeout(t);
153
+ }, [turn, winner, props.paused, place]);
154
+
155
+ const onCell = useCallback((r, c) => {
156
+ if (winRef.current || turnRef.current !== "b" || thinking || boardRef.current[r][c]) return;
157
+ place(r, c, "b");
158
+ setTurn("w");
159
+ }, [thinking, place]);
160
+
161
+ const reset = useCallback(() => {
162
+ setBoard(emptyBoard()); setTurn("b"); setWinner(null); setWinCells(null); setShowResult(false); setLast(null); setHistory([]); setThinking(false);
163
+ }, []);
164
+ const undo = useCallback(() => {
165
+ if (thinking || history.length < 2) return;
166
+ // 回退两步:撤掉 AI 与玩家(对局结束后也允许撤回继续)。
167
+ const h = history.slice(0, -2);
168
+ const next = emptyBoard();
169
+ for (const m of h) next[m.r][m.c] = m.color;
170
+ setBoard(next); boardRef.current = next;
171
+ setHistory(h); setTurn("b"); setWinner(null); setWinCells(null); setShowResult(false);
172
+ setLast(h.length ? h[h.length - 1] : null);
173
+ }, [history, thinking]);
174
+
175
+ const onKeyDown = useCallback((event) => {
176
+ if (event.key.toLowerCase() === "r") { event.preventDefault(); reset(); }
177
+ else if (event.key.toLowerCase() === "u") { event.preventDefault(); undo(); }
178
+ }, [reset, undo]);
179
+ useGameControls(stageRef, props.paused, onKeyDown);
180
+
181
+ const statusText = winner === "b" ? "你赢了 · 五子连珠" : winner === "w" ? "AI 赢了 · 再试一次" : winner === "draw" ? "和棋 · 棋盘已满" : (turn === "b" ? "轮到你落子(黑)" : "AI 思考中…(白)");
182
+
183
+ return <section className="dgame-game" aria-label="五子棋">
184
+ <div className="dgame-game-head"><div><h3>五子棋</h3><p>你执黑先手,AI 执白;先在横竖斜任一线连成五子者胜。</p></div><div className="dgame-score"><span>回合</span><strong className="dg-gomoku-status">{statusText}</strong><button type="button" onClick={undo}>撤销</button><button type="button" onClick={reset}>重开</button></div></div>
185
+ <div className="dgame-gomoku-board" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} onClick={() => focusStage(stageRef)} role="grid" aria-label="十五路五子棋棋盘">
186
+ <svg className="dgame-gomoku-lines" viewBox="0 0 1500 1500" preserveAspectRatio="none" aria-hidden="true">{GOMOKU_LINES}</svg>
187
+ {board.map((row, r) => row.map((cell, c) => {
188
+ const isWin = winCells && winCells.some(([wr, wc]) => wr === r && wc === c);
189
+ return <button type="button" key={r + "-" + c} role="gridcell" className={"dgame-gomoku-cell" + (cell ? " filled " + cell : "") + (last && last.r === r && last.c === c ? " last" : "") + (isWin ? " wincell" : "")}
190
+ aria-label={"第" + (r + 1) + "行第" + (c + 1) + "列" + (cell === "b" ? " 黑子" : cell === "w" ? " 白子" : " 空格")}
191
+ onClick={() => { focusStage(stageRef); onCell(r, c); }}>{cell ? <i className={"dgame-gomoku-stone " + (cell === "b" ? "black" : "white")} /> : null}</button>;
192
+ }))}
193
+ {winner && showResult ? (
194
+ <div className="dgame-gomoku-result" role="status">
195
+ <div className={"dgame-gomoku-result-card " + winner}>
196
+ <span className="dgame-gomoku-result-icon" aria-hidden="true">{winner === "b" ? "🎉" : winner === "w" ? "🤖" : "🤝"}</span>
197
+ <strong>{winner === "b" ? "你赢了!" : winner === "w" ? "AI 赢了" : "和棋"}</strong>
198
+ <p>{winner === "b" ? "五子连珠,漂亮!" : winner === "w" ? "再接再厉,卷土重来。" : "棋盘已满,平分秋色。"}</p>
199
+ <div className="dgame-gomoku-result-actions">
200
+ <button type="button" onClick={(e) => { e.stopPropagation(); setShowResult(false); }}>查看棋盘</button>
201
+ <button type="button" onClick={(e) => { e.stopPropagation(); reset(); }}>再来一局</button>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ ) : null}
206
+ </div>
207
+ <div className="dgame-controls"><button type="button" onClick={undo}>撤销</button><span>{statusText}</span><button type="button" onClick={reset}>重开</button></div>
208
+ </section>;
209
+ }
210
+
211
+ export function GomokuPreview() {
212
+ // 迷你 9 路棋盘:木纹 + 加粗外框 + 星位 + 黑白对弈石子,与真实棋盘同款画法。
213
+ const P = (n) => n * 100 + 50;
214
+ const stones = [[3, 3, "b"], [6, 3, "w"], [4, 4, "w"], [5, 5, "b"], [2, 6, "w"], [6, 6, "b"]];
215
+ return <span className="dgcov-prev dgcov-prev-gomoku" aria-hidden="true">
216
+ <svg viewBox="0 0 900 900" preserveAspectRatio="xMidYMid slice">
217
+ <rect x={P(0)} y={P(0)} width={800} height={800} fill="none" strokeWidth={11} />
218
+ {[1, 2, 3, 4, 5, 6, 7].map((i) => [
219
+ <line key={"v" + i} x1={P(i)} y1={P(0)} x2={P(i)} y2={P(8)} />,
220
+ <line key={"h" + i} x1={P(0)} y1={P(i)} x2={P(8)} y2={P(i)} />,
221
+ ])}
222
+ <circle className="dgp-star" cx={P(4)} cy={P(4)} r={16} />
223
+ {stones.map(([r, c, col], i) => <circle key={i} className={"dgp-stone-" + col} cx={P(c)} cy={P(r)} r={40} />)}
224
+ </svg>
225
+ </span>;
226
+ }
227
+
228
+ export const gomokuGame = { Game: GomokuGame, Preview: GomokuPreview, css: `
229
+ .dgame-gomoku-board{position:relative;display:grid;grid-template-columns:repeat(15,1fr);grid-template-rows:repeat(15,1fr);align-self:center;width:min(100%,min(90vw,330px));aspect-ratio:1/1;padding:12px;border-radius:12px;border:1px solid color-mix(in srgb,#7c4f16 65%,transparent);box-shadow:0 12px 32px rgb(0 0 0/.5),inset 0 1px 0 rgba(255,236,200,.55),inset 0 -3px 9px rgba(90,50,10,.35),inset 3px 0 8px rgba(255,224,170,.28),inset -3px 0 8px rgba(120,70,20,.25);outline:none;background:repeating-linear-gradient(94deg,rgba(124,79,22,.05) 0 2px,transparent 2px 11px),repeating-linear-gradient(87deg,rgba(255,238,196,.05) 0 2px,transparent 2px 14px),radial-gradient(120% 110% at 30% 10%,#edc97f,#dcae62 52%,#cc9a52 80%,#bb8844)}.dgame-gomoku-lines{position:absolute;inset:12px;pointer-events:none}.dgame-gomoku-lines line{stroke:#66431a;stroke-width:3;fill:none}.dgame-gomoku-star{fill:#66431a}.dgame-gomoku-cell{position:relative;z-index:1;background:transparent;border:none;cursor:pointer;padding:0;margin:0;outline:none;min-width:0;min-height:0}.dgame-gomoku-cell:focus-visible::after{content:'';position:absolute;inset:8%;border:2px solid rgba(255,255,255,.7);border-radius:50%}.dgame-gomoku-stone{position:absolute;inset:10%;border-radius:50%;display:block}.dgame-gomoku-stone.black{background:radial-gradient(circle at 34% 30%,#4b5563,#111827 68%);box-shadow:0 2px 5px rgb(0 0 0/.5),inset 0 1px 1px rgba(255,255,255,.25)}.dgame-gomoku-stone.white{background:radial-gradient(circle at 34% 30%,#ffffff,#cbd5e1 70%);box-shadow:0 2px 5px rgb(0 0 0/.4),inset 0 1px 1px rgba(255,255,255,.9)}.dgame-gomoku-cell.last .dgame-gomoku-stone::after{content:'';position:absolute;inset:32%;border-radius:50%;background:rgba(255,80,80,.9)}.dg-gomoku-status{font-size:12px;color:var(--dsw-alias-label-tertiary)}
230
+ .dgame-gomoku-cell.wincell .dgame-gomoku-stone{animation:dg-gomoku-win 1s ease-in-out infinite;z-index:1}
231
+ @keyframes dg-gomoku-win{0%,100%{box-shadow:0 2px 5px rgb(0 0 0/.5),0 0 0 0 rgba(255,214,102,.85)}50%{box-shadow:0 2px 5px rgb(0 0 0/.5),0 0 0 6px rgba(255,214,102,0)}}
232
+ .dgame-gomoku-result{position:absolute;inset:-1px;z-index:5;display:grid;place-items:center;background:rgba(10,10,20,.55);backdrop-filter:blur(2px);border-radius:8px;animation:dg-gomoku-fade .25s ease-out}
233
+ .dgame-gomoku-result-card{display:flex;flex-direction:column;align-items:center;gap:6px;padding:18px 26px;border-radius:14px;background:linear-gradient(160deg,#241f38,#171427);border:1px solid color-mix(in srgb,var(--dgame-accent,#a78bfa) 45%,transparent);box-shadow:0 12px 40px rgb(0 0 0/.55),0 0 0 4px rgba(167,139,250,.08);animation:dg-gomoku-pop .35s cubic-bezier(.22,1.6,.36,1);text-align:center}
234
+ .dgame-gomoku-result-card strong{font-size:18px;color:#fff}
235
+ .dgame-gomoku-result-card.b strong{color:#fbbf24}.dgame-gomoku-result-card.w strong{color:#e5e7eb}.dgame-gomoku-result-card.draw strong{color:#cbd5e1}
236
+ .dgame-gomoku-result-icon{font-size:28px;line-height:1;animation:dg-gomoku-bounce 1.2s ease-in-out infinite}
237
+ .dgame-gomoku-result-card p{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary,#9ca3af)}
238
+ .dgame-gomoku-result-actions{display:flex;gap:10px;margin-top:6px}
239
+ .dgame-gomoku-result-actions button{padding:6px 14px;border-radius:8px;border:1px solid color-mix(in srgb,var(--dgame-accent,#a78bfa) 40%,transparent);background:rgba(167,139,250,.14);color:#ede9fe;font-size:13px;cursor:pointer}
240
+ .dgame-gomoku-result-actions button:hover{background:rgba(167,139,250,.28)}
241
+ @keyframes dg-gomoku-fade{from{opacity:0}to{opacity:1}}
242
+ @keyframes dg-gomoku-pop{from{opacity:0;transform:scale(.6)}to{opacity:1;transform:scale(1)}}
243
+ @keyframes dg-gomoku-bounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}
244
+ ` };