dsh-dock 0.7.1 → 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.
@@ -1,5 +1,6 @@
1
1
  /* 趣味游戏 · 推箱子(经典机台)——纯 Client;把箱子推到目标点上即可过关。 */
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { focusStage, useGameControls, playSfx } from "./shared.jsx";
3
4
 
4
5
  // 关卡:'#' 墙 · ' ' 地板 · '.' 目标 · '$' 箱子 · '@' 人 · '*' 箱子在目标 · '+' 人在目标
5
6
  const LEVELS = [
@@ -9,6 +10,16 @@ const LEVELS = [
9
10
  ["######", "#@ #", "# $ #", "# . #", "# #", "######"],
10
11
  ["#######", "# @ #", "# $ #", "# $ .#", "# . #", "#######"],
11
12
  ["########", "# #", "# $ $ #", "# . . #", "# @ #", "########"],
13
+ // —— 扩充关:由易到难(已用求解器验证全部可解)——
14
+ ["########", "# ## #", "#@ $ . #", "# ## #", "########"],
15
+ ["########", "# @ #", "# $$$ #", "# ... #", "# #", "########"],
16
+ ["########", "#@ #", "# $ $ #", "# # #", "# . #. #", "########"],
17
+ ["########", "# @ #", "# $$ #", "# ..# #", "# #", "########"],
18
+ ["########", "#@ #", "# $ $ #", "# #", "# .# . #", "########"],
19
+ ["#########", "# #", "# $ $ $ #", "# ... #", "# @ #", "# #", "#########"],
20
+ ["#########", "# @ #", "# $#$$ #", "# ... #", "# ## #", "#########"],
21
+ ["#########", "#@ # #", "# $ $ #", "# ### #", "# . . #", "#########"],
22
+ ["#########", "# ###", "# $$ # #", "# .. #@ #", "# $$ # #", "# .. #", "#########"],
12
23
  ];
13
24
 
14
25
  function cloneGrid(grid) {
@@ -55,6 +66,7 @@ function step(state, dr, dc) {
55
66
  if (br < 0 || br >= grid.length || bc < 0 || bc >= grid[0].length) return state;
56
67
  const beyond = grid[br][bc];
57
68
  if (beyond === "#" || isBox(beyond)) return state;
69
+ playSfx("push");
58
70
  grid[br][bc] = isTarget(beyond) ? "*" : "$";
59
71
  grid[nr][nc] = isTarget(target) ? "+" : "@";
60
72
  return Object.assign({}, state, {
@@ -66,6 +78,7 @@ function step(state, dr, dc) {
66
78
  });
67
79
  }
68
80
  grid[nr][nc] = isTarget(target) ? "+" : "@";
81
+ playSfx("step");
69
82
  return Object.assign({}, state, {
70
83
  grid,
71
84
  moves: state.moves + 1,
@@ -94,12 +107,12 @@ export function SokobanGame(props) {
94
107
  const levelRef = useRef(level);
95
108
  levelRef.current = level;
96
109
 
97
- useEffect(() => {
98
- if (props.paused) return;
99
- if (stageRef.current) stageRef.current.focus();
100
- }, [level, props.paused]);
101
-
102
110
  const move = useCallback((dr, dc) => setState((s) => step(s, dr, dc)), []);
111
+ const prevWon = useRef(false);
112
+ useEffect(() => {
113
+ if (state.wonNew && !prevWon.current) playSfx("win");
114
+ prevWon.current = state.wonNew;
115
+ }, [state.wonNew]);
103
116
  const nextLevel = useCallback(() => {
104
117
  const n = (levelRef.current + 1) % LEVELS.length;
105
118
  setLevel(n);
@@ -116,9 +129,12 @@ export function SokobanGame(props) {
116
129
  else if (event.key === "Enter" && state.wonNew) { event.preventDefault(); nextLevel(); }
117
130
  }, [move, state.wonNew, reset, nextLevel]);
118
131
 
132
+ useGameControls(stageRef, props.paused, onKeyDown);
133
+
119
134
  const grid = state.grid;
120
135
  const rows = grid.length, cols = grid[0].length;
121
- const width = Math.min(cols * 26, 340);
136
+ // 宽度同时钳制:单元格理想宽、容器可用宽、按关卡长宽比折算的视口可用高——保证等比完整显示不裁切。
137
+ const width = "min(" + (cols * 26) + "px, 100%, calc((100vh - 250px) * " + cols + " / " + rows + "))";
122
138
 
123
139
  return <section className="dgame-game" aria-label="推箱子">
124
140
  <div className="dgame-game-head"><div><h3>推箱子</h3><p>用方向键把箱子推到目标格子上;全部到位即过关(U 撤销、R 重置)。</p></div><div className="dgame-score"><span>步数 <strong>{state.moves}</strong></span><span>推箱 {state.pushes}</span></div></div>
@@ -127,7 +143,7 @@ export function SokobanGame(props) {
127
143
  <button type="button" onClick={() => setState((s) => undo(s))}>撤销</button>
128
144
  <button type="button" onClick={reset}>重置</button>
129
145
  </div>
130
- <div className="dgame-sokoban-stage" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} style={{ width, gridTemplateColumns: "repeat(" + cols + ",1fr)", gridTemplateRows: "repeat(" + rows + ",1fr)", aspectRatio: cols + " / " + rows }} aria-label="推箱子游戏区域,使用方向键移动,U 撤销,R 重置">
146
+ <div className="dgame-sokoban-stage" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} onClick={() => focusStage(stageRef)} style={{ width, gridTemplateColumns: "repeat(" + cols + ",1fr)", gridTemplateRows: "repeat(" + rows + ",1fr)", aspectRatio: cols + " / " + rows }} aria-label="推箱子游戏区域,使用方向键移动,U 撤销,R 重置">
131
147
  {grid.map((row, r) => row.map((ch, c) => {
132
148
  let cls = "dgame-soko-cell";
133
149
  if (ch === "#") cls += " wall";
@@ -143,8 +159,19 @@ export function SokobanGame(props) {
143
159
  }
144
160
 
145
161
  export function SokobanPreview() {
162
+ // 迷你关卡画面:墙/地/箱/目标/小人色块,与真实游戏元素一致。
163
+ const ROWS = [
164
+ "#######",
165
+ "# #",
166
+ "# .$@ #",
167
+ "# .* #",
168
+ "#######",
169
+ ];
170
+ const cls = { "#": "wall", " ": "floor", ".": "target", "$": "box", "*": "boxT", "@": "player", "+": "player" };
146
171
  return <span className="dgcov-prev dgcov-prev-soko" aria-hidden="true">
147
- {["#####", "#@$.#", "# . #", "#####"].map((row, r) => <b key={r}>{row}</b>)}
172
+ <span className="dgp-soko-grid">
173
+ {ROWS.flatMap((row, r) => row.split("").map((ch, c) => <i key={r + "-" + c} className={"dgp-soko-" + cls[ch]} />))}
174
+ </span>
148
175
  </span>;
149
176
  }
150
177
 
@@ -1,6 +1,7 @@
1
1
  /* 趣味游戏 · 坦克大战(经典机台)——纯 Client;击毁敌方坦克、守住基地过关。 */
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { GRID, EMPTY, STEEL, BRICK, BASE, initialState, step, nextWave, findBase } from "../tank-core.js";
4
+ import { focusStage, useGameControls } from "./shared.jsx";
4
5
 
5
6
  const DIR_ANGLE = { up: 0, right: 90, down: 180, left: 270 };
6
7
  const ENEMY_COLORS = ["#f87171", "#fb923c", "#c084fc", "#4ade80", "#38bdf8"];
@@ -52,7 +53,7 @@ export function TankGame(props) {
52
53
  if (dir && keys.current.dir === dir) keys.current.dir = null;
53
54
  if (event.key === " ") keys.current.shoot = false;
54
55
  }, []);
55
- useEffect(() => { if (pausedRef.current) return; if (stageRef.current) stageRef.current.focus(); }, [props.paused]);
56
+ useGameControls(stageRef, pausedRef.current, onKeyDown, onKeyUp);
56
57
 
57
58
  const grid = snap.grid;
58
59
  const player = snap.player;
@@ -92,7 +93,7 @@ export function TankGame(props) {
92
93
 
93
94
  return <section className="dgame-game" aria-label="坦克大战">
94
95
  <div className="dgame-game-head"><div><h3>坦克大战</h3><p>方向键移动,空格开炮;击毁敌方坦克,别让敌军摧毁中央基地。</p></div><div className="dgame-score"><span>得分 <strong>{snap.score}</strong></span><span>生命 × {player && player.lives}</span></div></div>
95
- <div className="dgame-tank-stage" ref={stageRef} tabIndex={0} role="grid" onKeyDown={onKeyDown} onKeyUp={onKeyUp} aria-label="坦克大战游戏区域,方向键移动,空格开炮">
96
+ <div className="dgame-tank-stage" ref={stageRef} tabIndex={0} role="grid" onKeyDown={onKeyDown} onKeyUp={onKeyUp} onClick={() => focusStage(stageRef)} aria-label="坦克大战游戏区域,方向键移动,空格开炮">
96
97
  <div className="dgame-tank-grid">{cells}</div>
97
98
  {status === "over" ? <div className="dgame-over"><strong>胜败已分 · 得 {snap.score} 分</strong><button type="button" onClick={start}>重新开始</button></div> : null}
98
99
  {status === "wave" ? <div className="dgame-over"><strong>第 {snap.wave} 波清空!</strong><button type="button" onClick={nextLvl}>下一波</button></div> : null}
@@ -106,8 +107,19 @@ export function TankGame(props) {
106
107
  }
107
108
 
108
109
  export function TankPreview() {
110
+ // 迷你战场:砖墙/钢墙色块 + 我方坦克 + 敌方坦克 + 基地,与真实游戏元素一致。
111
+ const ROWS = [
112
+ "..B..B..",
113
+ "S.....E.",
114
+ "..B.B...",
115
+ "B..S...B",
116
+ "...P..F.",
117
+ ];
118
+ const cls = { ".": "void", B: "brick", S: "steel", P: "pw", E: "en", F: "base" };
109
119
  return <span className="dgcov-prev dgcov-prev-tank" aria-hidden="true">
110
- {["#...#", "#...#", "#...#", "+++++", "+..1+", "+++++"].map((row, r) => <b key={r}>{row}</b>)}
120
+ <span className="dgp-tank-grid">
121
+ {ROWS.flatMap((row, r) => row.split("").map((ch, c) => <i key={r + "-" + c} className={"dgp-tank-" + cls[ch]} />))}
122
+ </span>
111
123
  </span>;
112
124
  }
113
125
 
@@ -1,5 +1,6 @@
1
1
  /* 趣味游戏 · 俄罗斯方块(经典机台)——纯 Client,键盘/按钮可玩;不写会话、不改工作区。 */
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { focusStage, useGameControls, playSfx } from "./shared.jsx";
3
4
 
4
5
  const COLS = 10, ROWS = 20;
5
6
  const SHAPES = [
@@ -26,6 +27,15 @@ function rotations(shape) {
26
27
  }
27
28
  const PIECES = SHAPES.map((s) => ({ color: s.color, rots: rotations(s) }));
28
29
 
30
+ // 精确网格线(viewBox 1000×2000,轨道与方块单元格逐一对齐,杜绝背景纹理的亚像素漂移)。
31
+ const TETRIS_GRID = (() => {
32
+ const lines = [];
33
+ let k = 0;
34
+ for (let i = 1; i < 10; i += 1) lines.push(<line key={k++} x1={i * 100} y1={0} x2={i * 100} y2={2000} />);
35
+ for (let j = 1; j < 20; j += 1) lines.push(<line key={k++} x1={0} y1={j * 100} x2={1000} y2={j * 100} />);
36
+ return lines;
37
+ })();
38
+
29
39
  function emptyBoard() {
30
40
  return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
31
41
  }
@@ -62,9 +72,10 @@ function seedBag() {
62
72
  }
63
73
  return bag;
64
74
  }
75
+ let PIECE_SEQ = 0;
65
76
  function makePiece(type, x, y) {
66
77
  const p = PIECES[type];
67
- return { type, rot: 0, x, y: y != null ? y : -1, mat: p.rots[0], color: p.color };
78
+ return { id: ++PIECE_SEQ, type, rot: 0, x, y: y != null ? y : -1, mat: p.rots[0], color: p.color };
68
79
  }
69
80
  function initial() {
70
81
  const bag = seedBag();
@@ -72,11 +83,16 @@ function initial() {
72
83
  const next = bag.pop();
73
84
  return { board: emptyBoard(), piece, next, bag, score: 0, lines: 0, level: 1, over: false };
74
85
  }
75
- // 用"下一个"方块生成当前块并补足 7-bag。
86
+ // 用"下一个"方块生成当前块并补足 7-bag。bag 耗尽时立即重洗新 bag,
87
+ // 保证预览 next 永远不为空(否则会与实际生成的方块不一致)。
76
88
  function spawnFrom(state) {
77
- const bag = state.bag && state.bag.length ? state.bag.slice() : seedBag();
89
+ let bag = state.bag && state.bag.length ? state.bag.slice() : seedBag();
78
90
  const type = state.next != null ? state.next : bag.pop();
79
- const next = bag.length ? bag.pop() : null;
91
+ let next = bag.length ? bag.pop() : null;
92
+ if (next == null) {
93
+ bag = seedBag();
94
+ next = bag.pop();
95
+ }
80
96
  const piece = makePiece(type, 3);
81
97
  const over = collides(state.board, piece.mat, piece.x, piece.y) || state.over;
82
98
  return Object.assign({}, state, { bag, next, piece, over });
@@ -89,7 +105,9 @@ function advance(state) {
89
105
  return Object.assign({}, state, { piece: Object.assign({}, p, { y: p.y + 1 }), score: state.score + 1 });
90
106
  }
91
107
  const merged = merge(state.board, p.mat, p.x, p.y, p.color);
108
+ playSfx("lock");
92
109
  const cleared = clearLines(merged);
110
+ if (cleared.cleared) playSfx(cleared.cleared >= 4 ? "win" : "clear");
93
111
  const lines = state.lines + cleared.cleared;
94
112
  const level = Math.floor(lines / 10) + 1;
95
113
  const base = [0, 100, 300, 500, 800][cleared.cleared] || 800;
@@ -105,7 +123,9 @@ function hardDrop(state) {
105
123
  while (!collides(state.board, state.piece.mat, state.piece.x, y + 1)) { y += 1; dist += 1; }
106
124
  const p = Object.assign({}, state.piece, { y });
107
125
  const merged = merge(state.board, p.mat, p.x, p.y, p.color);
126
+ playSfx("lock");
108
127
  const cleared = clearLines(merged);
128
+ if (cleared.cleared) playSfx(cleared.cleared >= 4 ? "win" : "clear");
109
129
  const lines = state.lines + cleared.cleared;
110
130
  const level = Math.floor(lines / 10) + 1;
111
131
  const base = [0, 100, 300, 500, 800][cleared.cleared] || 800;
@@ -119,6 +139,7 @@ function hardDrop(state) {
119
139
  const KEY_ACTIONS = {
120
140
  "ArrowLeft": "left", "a": "left", "A": "left",
121
141
  "ArrowRight": "right", "d": "right", "D": "right",
142
+ // 下键 = 快速下滑(一格一格连降,松开即停;空格/回车才是直落到底)。
122
143
  "ArrowDown": "down", "s": "down", "S": "down",
123
144
  "ArrowUp": "cw", "x": "cw", "X": "cw",
124
145
  "z": "ccw", "Z": "ccw",
@@ -136,27 +157,66 @@ export function TetrisGame(props) {
136
157
  const act = useCallback((action) => setState((s) => {
137
158
  if (pausedRef.current) return s;
138
159
  if (action === "left") {
160
+ playSfx("move");
139
161
  const x2 = s.piece.x - 1;
140
162
  if (collides(s.board, s.piece.mat, x2, s.piece.y)) return s;
141
163
  return Object.assign({}, s, { piece: Object.assign({}, s.piece, { x: x2 }) });
142
164
  }
143
165
  if (action === "right") {
166
+ playSfx("move");
144
167
  const x2 = s.piece.x + 1;
145
168
  if (collides(s.board, s.piece.mat, x2, s.piece.y)) return s;
146
169
  return Object.assign({}, s, { piece: Object.assign({}, s.piece, { x: x2 }) });
147
170
  }
148
171
  if (action === "down") return advance(s);
149
172
  if (action === "drop") return hardDrop(s);
150
- if (action === "cw" || action === "ccw") return rotatePiece(s, action === "cw" ? 1 : -1);
173
+ if (action === "cw" || action === "ccw") { playSfx("rotate"); return rotatePiece(s, action === "cw" ? 1 : -1); }
151
174
  return s;
152
175
  }), []);
153
176
 
177
+ // 下键快速下滑:按住时以 45ms/格 连降(松开即停,可中途左右修正),不直接到底。
178
+ // 只对"按住时正在下落的那个方块"生效——它一旦落定生成新方块,连降自动结束。
179
+ const softRef = useRef(null);
180
+ const stateRef = useRef(state); stateRef.current = state;
181
+ const holdIdRef = useRef(null);
182
+ const stopSoft = useCallback(() => {
183
+ if (softRef.current) { clearInterval(softRef.current); softRef.current = null; }
184
+ }, []);
185
+ useEffect(() => {
186
+ if (state.over || manualPause || props.paused) stopSoft();
187
+ return stopSoft;
188
+ }, [state.over, manualPause, props.paused, stopSoft]);
189
+
154
190
  const onKeyDown = useCallback((event) => {
155
191
  const action = KEY_ACTIONS[event.key];
156
- if (action) { event.preventDefault(); act(action); return; }
192
+ if (action) {
193
+ event.preventDefault();
194
+ if (action === "down") {
195
+ const pieceId = stateRef.current.piece && stateRef.current.piece.id;
196
+ act("down");
197
+ if (!softRef.current && !pausedRef.current) {
198
+ holdIdRef.current = pieceId;
199
+ softRef.current = setInterval(() => {
200
+ const cur = stateRef.current;
201
+ if (pausedRef.current || cur.over || !cur.piece || cur.piece.id !== holdIdRef.current) { stopSoft(); return; }
202
+ act("down");
203
+ }, 45);
204
+ }
205
+ return;
206
+ }
207
+ act(action);
208
+ return;
209
+ }
157
210
  if (event.key.toLowerCase() === "p") { event.preventDefault(); setManualPause((p) => !p); }
158
211
  else if (event.key.toLowerCase() === "r") { event.preventDefault(); setState(initial()); setManualPause(false); }
159
- }, [act]);
212
+ }, [act, stopSoft]);
213
+ const onKeyUp = useCallback((event) => {
214
+ if (KEY_ACTIONS[event.key] === "down") stopSoft();
215
+ }, [stopSoft]);
216
+
217
+ useEffect(() => {
218
+ if (state.over) playSfx("over");
219
+ }, [state.over]);
160
220
 
161
221
  useEffect(() => {
162
222
  if (pausedRef.current || state.over) return;
@@ -165,10 +225,7 @@ export function TetrisGame(props) {
165
225
  return () => clearInterval(timer);
166
226
  }, [state.level, state.over, act, props.paused, manualPause]);
167
227
 
168
- useEffect(() => {
169
- if (pausedRef.current || state.over) return;
170
- if (stageRef.current) stageRef.current.focus();
171
- }, [state.over, props.paused, manualPause]);
228
+ useGameControls(stageRef, pausedRef.current, onKeyDown, onKeyUp);
172
229
 
173
230
  const display = state.board.map((row) => row.slice());
174
231
  if (!state.over) {
@@ -188,14 +245,14 @@ export function TetrisGame(props) {
188
245
  return <section className="dgame-game" aria-label="俄罗斯方块">
189
246
  <div className="dgame-game-head"><div><h3>俄罗斯方块</h3><p>拼满一行即消除,速度随等级加快;方块堆到顶端就结束。</p></div><div className="dgame-score"><span>得分 <strong>{state.score}</strong></span><span>等级 {state.level} · 消行 {state.lines}</span></div></div>
190
247
  <div className="dgame-tetris">
191
- <div className="dgame-tetris-board" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} aria-label="俄罗斯方块游戏区域,使用方向键与空格控制">
248
+ <div className="dgame-tetris-board" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onBlur={stopSoft} onClick={() => focusStage(stageRef)} aria-label="俄罗斯方块游戏区域,使用方向键与空格控制">
192
249
  {display.map((row, rr) => row.map((cell, cc) => <i key={rr + "-" + cc} className="dgame-tetris-cell" style={cell ? { background: cell, boxShadow: "0 0 8px " + cell + "aa, inset 0 0 4px rgba(255,255,255,.5)" } : undefined} />))}
193
- {state.over ? <div className="dgame-over"><strong>方块堆到顶了</strong><button type="button" onClick={() => setState(initial())}>再来一局</button></div> : <span className="dgame-tetris-bg" aria-hidden="true" />}
250
+ {state.over ? <div className="dgame-over"><strong>方块堆到顶了</strong><button type="button" onClick={() => setState(initial())}>再来一局</button></div> : <svg className="dgame-tetris-lines" viewBox="0 0 1000 2000" preserveAspectRatio="none" aria-hidden="true">{TETRIS_GRID}</svg>}
194
251
  {!state.over && (props.paused || manualPause) ? <div className="dgame-over"><strong>已暂停</strong><button type="button" onClick={() => setManualPause(false)}>继续(P)</button></div> : null}
195
252
  </div>
196
253
  <div className="dgame-tetris-side">
197
254
  <div className="dgame-tetris-next"><span>下一个</span><div className="dgame-tetris-nextbox">{nextMat.map((row, r) => row.map((cell, c) => <i key={r + "-" + c} className="dgame-tetris-cell" style={cell ? { background: PIECES[nextType].color } : undefined} />))}</div></div>
198
- <div className="dgame-tetris-keys"><b>操作</b><span>←/→ 移动 · ↓ 下落 · ↑/X 旋转 · Z 反向 · 空格 直落 · P 暂停 · R 重开</span></div>
255
+ <div className="dgame-tetris-keys"><b>操作</b><span>←/→ 移动 · ↓ 快速下滑 · ↑/X 旋转 · Z 反向 · 空格 直落 · P 暂停 · R 重开</span></div>
199
256
  </div>
200
257
  </div>
201
258
  <div className="dgame-controls"><button type="button" aria-label="左移" onClick={() => act("left")}>← 左移</button><span>方向键 / X、Z、空格、P</span><button type="button" aria-label="右移" onClick={() => act("right")}>右移 {'->'}</button></div>
@@ -218,18 +275,28 @@ function rotatePiece(state, dir) {
218
275
  }
219
276
 
220
277
  export function TetrisPreview() {
278
+ // 迷你棋盘:底部已堆方块 + 悬浮下落中的 T 块,与真实游戏画面一致。
279
+ const COLORS = { I: "I", O: "O", T: "T", S: "S", Z: "Z", J: "J", L: "L" };
280
+ const ROWS = [
281
+ "..........",
282
+ "....TT....",
283
+ "..........",
284
+ ".......Z..",
285
+ "..OO.S.Z..",
286
+ "SS..LSOZZ.",
287
+ "JJSLLSOZZI",
288
+ "JJSLLOSOII",
289
+ ];
290
+ const isFall = (r) => r === 1;
221
291
  return <span className="dgcov-prev dgcov-prev-tetris" aria-hidden="true">
222
- <i style={{ left: "34%", top: "10%", background: "#22d3ee" }} />
223
- <i style={{ left: "24%", top: "40%", background: "#facc15" }} />
224
- <i style={{ left: "46%", top: "48%", background: "#c084fc" }} />
225
- <i style={{ left: "68%", top: "26%", background: "#4ade80" }} />
226
- <i style={{ left: "54%", top: "68%", background: "#f87171" }} />
227
- <i style={{ left: "26%", top: "74%", background: "#60a5fa" }} />
228
- <i style={{ left: "72%", top: "60%", background: "#fb923c" }} />
229
- <b />
292
+ <span className="dgp-tet-grid">
293
+ {ROWS.flatMap((row, r) => row.split("").map((ch, c) => (
294
+ <i key={r + "-" + c} className={COLORS[ch] ? "c" + COLORS[ch] + (isFall(r) ? " fall" : "") : ""} />
295
+ )))}
296
+ </span>
230
297
  </span>;
231
298
  }
232
299
 
233
300
  export const tetrisGame = { Game: TetrisGame, Preview: TetrisPreview, css: `
234
- .dgame-tetris{display:flex;gap:12px;align-items:flex-start;align-self:center;width:min(100%,360px);justify-content:center}.dgame-tetris-board{position:relative;flex:none;display:grid;grid-template-columns:repeat(10,1fr);grid-template-rows:repeat(20,1fr);width:min(100%,188px);aspect-ratio:10/20;border:1px solid color-mix(in srgb,rgb(34 211 238) 35%,var(--dsw-alias-border-l1));border-radius:8px;overflow:hidden;outline:none;background:linear-gradient(180deg,#0c1024,#131a3d);padding:0;box-shadow:inset 0 0 30px rgb(0 0 0/.45)}.dgame-tetris-board:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,rgb(34 211 238) 40%,transparent)}.dgame-tetris-cell{display:block;background:transparent;border-radius:2px;margin:1px}.dgame-tetris-nextbox{display:grid;grid-template-columns:repeat(4,1fr);grid-template-rows:repeat(2,1fr);width:60px;height:32px}.dgame-tetris-side{flex:none;display:flex;flex-direction:column;gap:9px;min-width:112px;color:var(--dsw-alias-label-tertiary);font-size:11px}.dgame-tetris-side b{color:var(--dsw-alias-label-secondary);font-size:12px}.dgame-tetris-side span{font-size:11px}.dgame-tetris-nextbox .dgame-tetris-cell{margin:1px}.dgame-tetris-keys span{display:block;line-height:1.6;color:var(--dsw-alias-label-tertiary)}.dgame-tetris-next{display:flex;flex-direction:column;gap:5px;align-items:center}.dgame-tetris-next span{color:var(--dsw-alias-label-secondary)}.dgame-tetris-bg{position:absolute;inset:0;pointer-events:none;background-image:repeating-linear-gradient(0deg,color-mix(in srgb,#60a5fa 7%,transparent) 0 1px,transparent 1px calc(100% / 20)),repeating-linear-gradient(90deg,color-mix(in srgb,#60a5fa 7%,transparent) 0 1px,transparent 1px calc(100% / 10));opacity:.5}@media (max-width:680px){.dgame-tetris{width:100%;gap:8px}.dgame-tetris-board{width:min(100%,170px)}}
301
+ .dgame-tetris{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-start;align-self:center;width:fit-content;max-width:100%;margin-inline:auto;justify-content:center}.dgame-tetris-board{position:relative;flex:none;display:grid;grid-template-columns:repeat(10,1fr);grid-template-rows:repeat(20,1fr);width:min(100%,188px);aspect-ratio:10/20;border:1px solid color-mix(in srgb,rgb(34 211 238) 35%,var(--dsw-alias-border-l1));border-radius:8px;overflow:hidden;outline:none;background:linear-gradient(180deg,#0c1024,#131a3d);padding:0;box-shadow:inset 0 0 30px rgb(0 0 0/.45)}.dgame-tetris-board:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,rgb(34 211 238) 40%,transparent)}.dgame-tetris-cell{display:block;background:transparent;border-radius:2px;margin:1px}.dgame-tetris-nextbox{display:grid;grid-template-columns:repeat(4,1fr);grid-template-rows:repeat(2,1fr);width:60px;height:32px}.dgame-tetris-side{flex:1 1 96px;display:flex;flex-direction:column;gap:9px;min-width:0;max-width:220px;color:var(--dsw-alias-label-tertiary);font-size:11px}.dgame-tetris-side b{color:var(--dsw-alias-label-secondary);font-size:12px}.dgame-tetris-side span{font-size:11px}.dgame-tetris-nextbox .dgame-tetris-cell{margin:1px}.dgame-tetris-keys span{display:block;line-height:1.6;color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere}.dgame-tetris-next{display:flex;flex-direction:column;gap:5px;align-items:center}.dgame-tetris-next span{color:var(--dsw-alias-label-secondary)}.dgame-tetris-lines{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}.dgame-tetris-lines line{stroke:#60a5fa;stroke-opacity:.12;stroke-width:2}@media (max-width:680px){.dgame-tetris{width:100%;gap:8px}.dgame-tetris-board{width:min(100%,170px)}}
235
302
  ` };
@@ -2,8 +2,40 @@
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import {
4
4
  initialBoard, cloneBoard, pieceChar, inCheck, hasLegalMoves,
5
- legalMoves, bestMove, applyMove,
5
+ legalMoves, bestMove, applyMove, posKey,
6
6
  } from "../xiangqi-core.js";
7
+ import { focusStage, useGameControls, playSfx } from "./shared.jsx";
8
+
9
+ // 正规棋盘:SVG 精确绘制(viewBox 900×1000,交点对齐各格中心)。
10
+ // 外框加粗;中间竖线在楚河汉界断开;两侧九宫斜线;炮位/兵位直角标。
11
+ const XIANGQI_LINES = (() => {
12
+ const P = (n) => n * 100 + 50;
13
+ const parts = [];
14
+ let k = 0;
15
+ parts.push(<rect key="frame" x={P(0)} y={P(0)} width={800} height={900} strokeWidth={7} fill="none" />);
16
+ for (let r = 1; r <= 8; r += 1) parts.push(<line key={k++} x1={P(0)} y1={P(r)} x2={P(8)} y2={P(r)} />);
17
+ for (let c = 1; c <= 7; c += 1) {
18
+ parts.push(<line key={k++} x1={P(c)} y1={P(0)} x2={P(c)} y2={P(4)} />);
19
+ parts.push(<line key={k++} x1={P(c)} y1={P(5)} x2={P(c)} y2={P(9)} />);
20
+ }
21
+ parts.push(<path key={k++} d={`M${P(3)} ${P(0)}L${P(5)} ${P(2)}M${P(5)} ${P(0)}L${P(3)} ${P(2)}M${P(3)} ${P(7)}L${P(5)} ${P(9)}M${P(5)} ${P(7)}L${P(3)} ${P(9)}`} />);
22
+ // 炮位/兵位的四角直角小标(贴边的点只画内侧两角)。
23
+ const t = 14, o = 16;
24
+ const marks = [[1, 2], [7, 2], [1, 7], [7, 7], [0, 3], [2, 3], [4, 3], [6, 3], [8, 3], [0, 6], [2, 6], [4, 6], [6, 6], [8, 6]];
25
+ for (const [c, r] of marks) {
26
+ const seg = [];
27
+ if (c > 0) seg.push(
28
+ `M${P(c) - o - t} ${P(r) - o}H${P(c) - o}V${P(r) - o - t}`,
29
+ `M${P(c) - o - t} ${P(r) + o}H${P(c) - o}V${P(r) + o + t}`,
30
+ );
31
+ if (c < 8) seg.push(
32
+ `M${P(c) + o} ${P(r) - o}H${P(c) + o + t}V${P(r) - o - t}`,
33
+ `M${P(c) + o} ${P(r) + o}H${P(c) + o + t}V${P(r) + o + t}`,
34
+ );
35
+ parts.push(<path key={k++} className="dgame-xiangqi-mark" strokeWidth={2.6} d={seg.join("")} />);
36
+ }
37
+ return parts;
38
+ })();
7
39
 
8
40
  export function XiangqiGame(props) {
9
41
  const [board, setBoard] = useState(initialBoard);
@@ -15,6 +47,9 @@ export function XiangqiGame(props) {
15
47
  const [last, setLast] = useState(null);
16
48
  const [thinking, setThinking] = useState(false);
17
49
  const [history, setHistory] = useState([]);
50
+ // 已出现过的局面指纹:AI 搜索时排除重复局面,杜绝"反复将军无限循环"。
51
+ const [repKeys, setRepKeys] = useState(() => [posKey(initialBoard())]);
52
+ const pushKey = useCallback((b) => setRepKeys((ks) => (ks[ks.length - 1] === posKey(b) ? ks : ks.concat(posKey(b)))), []);
18
53
 
19
54
  const stageRef = useRef(null);
20
55
  const boardRef = useRef(board); boardRef.current = board;
@@ -27,6 +62,7 @@ export function XiangqiGame(props) {
27
62
  const b = initialBoard();
28
63
  boardRef.current = b;
29
64
  setBoard(b); setTurn("r"); setSel(null); setTargets([]); setOver(null); setCheck(false); setLast(null); setThinking(false); setHistory([]);
65
+ setRepKeys([posKey(b)]);
30
66
  }, []);
31
67
 
32
68
  const undo = useCallback(() => {
@@ -38,20 +74,24 @@ export function XiangqiGame(props) {
38
74
  boardRef.current = b;
39
75
  setBoard(b); setHistory(hist); setSel(null); setTargets([]); setLast(null); setOver(null);
40
76
  setCheck(inCheck(b, "r"));
77
+ // 重建局面指纹(历史各局面 + 当前)。
78
+ setRepKeys(hist.map(posKey).concat(posKey(b)));
41
79
  }, []);
42
80
 
43
81
  const moveRed = useCallback((tr, tc) => {
44
82
  if (turnRef.current !== "r" || overRef.current || !sel) return;
45
83
  const fr = sel[0], fc = sel[1];
84
+ if (boardRef.current[tr][tc]) playSfx("capture"); else playSfx("step");
46
85
  const nb = applyMove(boardRef.current, fr, fc, tr, tc);
47
86
  setHistory((h) => [...h, boardRef.current]);
48
87
  boardRef.current = nb;
88
+ pushKey(nb);
49
89
  setBoard(nb);
50
90
  setSel(null); setTargets([]); setLast([tr, tc]);
51
91
  setTurn("b");
52
92
  if (!hasLegalMoves(nb, "b")) { setOver("r"); setCheck(false); }
53
93
  else setCheck(inCheck(nb, "b"));
54
- }, [sel]);
94
+ }, [sel, pushKey]);
55
95
 
56
96
  const select = useCallback((r, c) => {
57
97
  if (overRef.current || turnRef.current !== "r" || thinkingRef.current) return;
@@ -66,16 +106,21 @@ export function XiangqiGame(props) {
66
106
  setSel(null); setTargets([]);
67
107
  }, [sel, targets, moveRed]);
68
108
 
69
- // AI 回合(执黑)。
109
+ // AI 回合(执黑):搜索时排除已出现过的局面,禁止长将循环。
70
110
  useEffect(() => {
71
111
  if (turn !== "b" || over || props.paused) return;
72
112
  const t = setTimeout(() => {
73
- const mv = bestMove(boardRef.current, "b", 3);
113
+ // 局面出现次数表:AI 优先走新局面;全部重复时也倾向去往出现最少的局面。
114
+ const seenCounts = new Map();
115
+ for (const k of repKeys) seenCounts.set(k, (seenCounts.get(k) || 0) + 1);
116
+ const mv = bestMove(boardRef.current, "b", 3, seenCounts);
74
117
  if (mv) {
75
118
  const [fr, fc, tr, tc] = mv;
119
+ if (boardRef.current[tr][tc]) playSfx("capture"); else playSfx("step");
76
120
  const nb = applyMove(boardRef.current, fr, fc, tr, tc);
77
121
  setHistory((h) => [...h, boardRef.current]);
78
122
  boardRef.current = nb;
123
+ pushKey(nb);
79
124
  setBoard(nb); setLast([tr, tc]); setTurn("r");
80
125
  if (!hasLegalMoves(nb, "r")) { setOver("b"); setCheck(false); }
81
126
  else setCheck(inCheck(nb, "r"));
@@ -84,23 +129,31 @@ export function XiangqiGame(props) {
84
129
  }, 90);
85
130
  setThinking(true);
86
131
  return () => clearTimeout(t);
87
- }, [turn, over, props.paused]);
88
-
89
- useEffect(() => {
90
- if (props.paused) return;
91
- if (stageRef.current) stageRef.current.focus();
92
- }, [turn, over, props.paused]);
132
+ }, [turn, over, props.paused, repKeys, pushKey]);
93
133
 
94
134
  const onKeyDown = useCallback((event) => {
95
135
  if (event.key.toLowerCase() === "r") { event.preventDefault(); restart(); }
96
136
  else if (event.key.toLowerCase() === "u") { event.preventDefault(); undo(); }
97
137
  }, [restart, undo]);
138
+ useGameControls(stageRef, props.paused, onKeyDown);
98
139
 
99
140
  const status = over === "r" ? "红方胜 · 恭喜" : over === "b" ? "黑方胜 · AI 赢" : (thinking ? "AI 思考中…" : (check ? "将军!" : (turn === "r" ? "轮到你(红方)" : "轮到 AI(黑方)")));
141
+ const prevCheck = useRef(false);
142
+ useEffect(() => {
143
+ if (over === "r") playSfx("win");
144
+ else if (over === "b") playSfx("over");
145
+ else if (!thinking && check && !prevCheck.current) playSfx("check");
146
+ prevCheck.current = !thinking && check;
147
+ }, [check, thinking, over]);
100
148
 
101
149
  return <section className="dgame-game" aria-label="象棋">
102
150
  <div className="dgame-game-head"><div><h3>中国象棋</h3><p>你执红先行,AI 执黑;吃掉对方将/帅即胜。点击选中红子,再点高亮落点走棋。</p></div><div className="dgame-score"><span>当前</span><strong className="dg-gomoku-status">{status}</strong><button type="button" onClick={undo}>撤销</button><button type="button" onClick={restart}>重开</button></div></div>
103
- <div className="dgame-xiangqi-board" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} role="grid" aria-label="中国象棋棋盘">
151
+ <div className="dgame-xiangqi-board" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} onClick={() => focusStage(stageRef)} role="grid" aria-label="中国象棋棋盘">
152
+ <svg className="dgame-xiangqi-lines" viewBox="0 0 900 1000" preserveAspectRatio="none" aria-hidden="true">
153
+ {XIANGQI_LINES}
154
+ <text className="dgame-xiangqi-rivertext" x={230} y={505}>楚 河</text>
155
+ <text className="dgame-xiangqi-rivertext" x={670} y={505}>漢 界</text>
156
+ </svg>
104
157
  {board.map((row, r) => row.map((p, c) => {
105
158
  const isSel = sel && sel[0] === r && sel[1] === c;
106
159
  const isTargetMark = targets.some(([tr, tc]) => tr === r && tc === c);
@@ -108,7 +161,7 @@ export function XiangqiGame(props) {
108
161
  return <button type="button" key={r + "-" + c} role="gridcell"
109
162
  className={"dgame-xiangqi-cell" + (isSel ? " sel" : "") + (isTargetMark ? " target" : "") + (isLast ? " last" : "")}
110
163
  aria-label={"第" + (r + 1) + "行第" + (c + 1) + "列" + (p ? pieceChar(p) : " 空")}
111
- onClick={() => select(r, c)}>
164
+ onClick={() => { focusStage(stageRef); select(r, c); }}>
112
165
  {p ? <span className={"dgame-xiangqi-pc " + p.c}>{pieceChar(p)}</span> : (isTargetMark ? <span className="dgame-xiangqi-dot" aria-hidden="true" /> : null)}
113
166
  </button>;
114
167
  }))}
@@ -118,11 +171,35 @@ export function XiangqiGame(props) {
118
171
  }
119
172
 
120
173
  export function XiangqiPreview() {
174
+ // 迷你象棋盘:河界断线 + 九宫斜线 + 楚河汉界 + 对峙棋子,与真实棋盘同款画法。
175
+ const P = (n) => n * 100 + 50;
176
+ const pc = (c, r, ch, col) => [
177
+ <circle key={"pc" + c + r} cx={P(c)} cy={P(r)} r={44} className={"dgp-xq-pc-" + col} />,
178
+ <text key={"tx" + c + r} x={P(c)} y={P(r) + 2} className={"dgp-xq-tx-" + col}>{ch}</text>,
179
+ ];
121
180
  return <span className="dgcov-prev dgcov-prev-xiangqi" aria-hidden="true">
122
- {["車馬象士將象馬車", "·炮·····炮·", "卒.卒.卒.卒.卒", "兵.兵.兵.兵.兵", "·炮·····炮·", "車馬相仕帥相馬車"].map((row, r) => <b key={r}>{row}</b>)}
181
+ <svg viewBox="0 0 900 1000" preserveAspectRatio="xMidYMid slice">
182
+ <rect x={P(0)} y={P(0)} width={800} height={900} fill="none" strokeWidth={11} />
183
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((r) => <line key={"h" + r} x1={P(0)} y1={P(r)} x2={P(8)} y2={P(r)} />)}
184
+ <line x1={P(0)} y1={P(0)} x2={P(0)} y2={P(9)} />
185
+ <line x1={P(8)} y1={P(0)} x2={P(8)} y2={P(9)} />
186
+ {[1, 2, 3, 4, 5, 6, 7].map((c) => [
187
+ <line key={"vt" + c} x1={P(c)} y1={P(0)} x2={P(c)} y2={P(4)} />,
188
+ <line key={"vb" + c} x1={P(c)} y1={P(5)} x2={P(c)} y2={P(9)} />,
189
+ ])}
190
+ <path d={`M${P(3)} ${P(0)}L${P(5)} ${P(2)}M${P(5)} ${P(0)}L${P(3)} ${P(2)}M${P(3)} ${P(7)}L${P(5)} ${P(9)}M${P(5)} ${P(7)}L${P(3)} ${P(9)}`} />
191
+ <text className="dgp-xq-river" x={235} y={500}>楚河</text>
192
+ <text className="dgp-xq-river" x={665} y={500}>漢界</text>
193
+ {pc(4, 0, "將", "b")}
194
+ {pc(1, 2, "炮", "b")}
195
+ {pc(5, 3, "卒", "b")}
196
+ {pc(3, 6, "兵", "r")}
197
+ {pc(7, 7, "炮", "r")}
198
+ {pc(4, 9, "帥", "r")}
199
+ </svg>
123
200
  </span>;
124
201
  }
125
202
 
126
203
  export const xiangqiGame = { Game: XiangqiGame, Preview: XiangqiPreview, css: `
127
- .dgame-xiangqi-board{display:grid;grid-template-columns:repeat(9,1fr);grid-template-rows:repeat(10,1fr);align-self:center;width:min(100%,320px);aspect-ratio:9/10;padding:6px;border-radius:8px;background:linear-gradient(135deg,#e8c07a,#c08a3e);border:1px solid color-mix(in srgb,#8a5a1c 55%,transparent);box-shadow:0 4px 18px rgb(0 0 0/.35),inset 0 0 0 2px rgba(0,0,0,.14);outline:none;background-image:repeating-linear-gradient(90deg,transparent 0 calc(100%/8),rgba(0,0,0,.14) calc(100%/8) calc(100%/8 + 2px)),repeating-linear-gradient(0deg,transparent 0 calc(100%/9),rgba(85,52,0,.28) calc(100%/9) calc(100%/9 + 2px))}.dgame-xiangqi-board:focus-visible{box-shadow:0 0 0 3px rgba(0,0,0,.25)}.dgame-xiangqi-cell{position:relative;background:transparent;border:none;cursor:pointer;padding:0;margin:0;outline:none;display:grid;place-items:center}.dgame-xiangqi-cell.sel{box-shadow:inset 0 0 0 3px rgba(255,200,80,.55)}.dgame-xiangqi-cell.target .dgame-xiangqi-dot{width:10px;height:10px;border-radius:50%;background:rgba(34,211,238,.55);box-shadow:0 0 8px rgba(34,211,238,.6)}.dgame-xiangqi-cell.last{box-shadow:inset 0 0 0 3px rgba(248,113,113,.5)}.dgame-xiangqi-pc{font-size:clamp(11px,2.2vw,15px);font-weight:700;display:block;z-index:1}.dgame-xiangqi-pc.r{color:#b91c1c;text-shadow:0 1px 0 rgba(255,255,255,.5)}.dgame-xiangqi-pc.b{color:#1f2937;text-shadow:0 1px 0 rgba(255,255,255,.35)}
204
+ .dgame-xiangqi-board{position:relative;display:grid;grid-template-columns:repeat(9,1fr);grid-template-rows:repeat(10,1fr);align-self:center;width:min(100%,min(90vw,340px));aspect-ratio:9/10;padding:13px;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(130% 105% at 26% 8%,#f0cd86,#ddb067 48%,#cb9a51 78%,#bb8844)}.dgame-xiangqi-lines{position:absolute;inset:13px;pointer-events:none}.dgame-xiangqi-lines line,.dgame-xiangqi-lines path{stroke:#66431a;stroke-width:3.4;fill:none;stroke-linecap:square}.dgame-xiangqi-lines .dgame-xiangqi-mark{stroke-width:2.6}.dgame-xiangqi-rivertext{fill:rgba(102,67,26,.7);font-family:KaiTi,"Kaiti SC","STKaiti","FangSong",serif;font-size:46px;letter-spacing:7px;text-anchor:middle;dominant-baseline:central}.dgame-xiangqi-board:focus-visible{box-shadow:0 0 0 3px rgba(0,0,0,.25)}.dgame-xiangqi-cell{position:relative;z-index:1;background:transparent;border:none;cursor:pointer;padding:0;margin:0;outline:none;display:grid;place-items:center;min-width:0;min-height:0}.dgame-xiangqi-cell.target .dgame-xiangqi-dot{width:12px;height:12px;border-radius:50%;background:rgba(34,211,238,.6);box-shadow:0 0 10px rgba(34,211,238,.65),0 0 0 2px rgba(255,255,255,.28)}.dgame-xiangqi-cell.target .dgame-xiangqi-pc{box-shadow:0 0 0 2px rgba(248,113,113,.85),0 0 10px rgba(248,113,113,.5),0 2px 4px rgb(0 0 0/.4),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4)}.dgame-xiangqi-cell.sel .dgame-xiangqi-pc::before{content:'';position:absolute;inset:-3px;border-radius:50%;border:2.5px solid rgba(251,191,36,.95);box-shadow:0 0 12px rgba(251,191,36,.6)}.dgame-xiangqi-cell.last .dgame-xiangqi-pc::after{content:'';position:absolute;right:5%;bottom:5%;width:5px;height:5px;border-radius:50%;background:#ef4444;box-shadow:0 0 4px rgba(239,68,68,.8)}.dgame-xiangqi-pc{position:relative;display:grid;place-items:center;z-index:1;width:min(80%,30px);aspect-ratio:1/1;border-radius:50%;box-sizing:border-box;font-family:KaiTi,"Kaiti SC","STKaiti","FangSong","SimSun",serif;font-size:clamp(12px,1.4vw,15px);font-weight:700;line-height:1;background:radial-gradient(circle at 34% 28%,#fff3d4,#eedaa6 46%,#d8b675 72%,#bd9650);box-shadow:0 2px 4px rgb(0 0 0/.45),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4)}.dgame-xiangqi-pc.r{color:#b91c1c;text-shadow:0 1px 0 rgba(255,255,255,.5)}.dgame-xiangqi-pc.b{color:#1f2937;text-shadow:0 1px 0 rgba(255,255,255,.35)}
128
205
  ` };