dsh-dock 0.9.6 → 0.9.7

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.
Files changed (35) hide show
  1. package/LICENSE +18 -18
  2. package/README.md +325 -325
  3. package/client.js +42 -8
  4. package/cordis.patch.yml +2 -2
  5. package/features/animation/host.js +660 -660
  6. package/features/animation/view.jsx +2247 -2247
  7. package/features/balance/host.js +494 -494
  8. package/features/balance/view.js +351 -351
  9. package/features/games/games/breakout.jsx +227 -227
  10. package/features/games/games/gomoku.jsx +244 -244
  11. package/features/games/games/racer.jsx +169 -169
  12. package/features/games/games/shared.jsx +118 -118
  13. package/features/games/games/snake.jsx +144 -144
  14. package/features/games/games/sokoban.jsx +180 -180
  15. package/features/games/games/tank.jsx +128 -128
  16. package/features/games/games/tetris.jsx +308 -308
  17. package/features/games/games/xiangqi.jsx +277 -277
  18. package/features/games/tank-core.js +152 -152
  19. package/features/games/view.jsx +639 -639
  20. package/features/games/xiangqi-core.js +299 -299
  21. package/features/heartbeat/view.js +35 -35
  22. package/features/mobile-relay/gateway.js +472 -472
  23. package/features/mobile-relay/host.js +570 -569
  24. package/features/mobile-relay/view.jsx +300 -300
  25. package/features/modelconfig/host.js +403 -403
  26. package/features/modelconfig/view.js +490 -490
  27. package/features/theme/view.js +61 -61
  28. package/features/tokenlog/host.js +678 -678
  29. package/features/tokenlog/view.jsx +561 -561
  30. package/features/visionproxy/host.js +315 -315
  31. package/index.js +175 -118
  32. package/package.json +59 -59
  33. package/src/client.jsx +668 -668
  34. package/src/host-core.js +98 -95
  35. package/src/shared.js +167 -130
@@ -1,308 +1,308 @@
1
- /* 趣味游戏 · 俄罗斯方块(经典机台)——纯 Client,键盘/按钮可玩;不写会话、不改工作区。 */
2
- import { useCallback, useEffect, useRef, useState } from "react";
3
- import { focusStage, useGameControls, playSfx } from "./shared.jsx";
4
-
5
- const COLS = 10, ROWS = 20;
6
- const SHAPES = [
7
- { color: "#22d3ee", mat: [[1, 1, 1, 1]] },
8
- { color: "#facc15", mat: [[1, 1], [1, 1]] },
9
- { color: "#c084fc", mat: [[0, 1, 0], [1, 1, 1]] },
10
- { color: "#4ade80", mat: [[0, 1, 1], [1, 1, 0]] },
11
- { color: "#f87171", mat: [[1, 1, 0], [0, 1, 1]] },
12
- { color: "#60a5fa", mat: [[1, 0, 0], [1, 1, 1]] },
13
- { color: "#fb923c", mat: [[0, 0, 1], [1, 1, 1]] },
14
- ];
15
-
16
- function rotateCW(mat) {
17
- return mat[0].map((_, col) => mat.map((row) => row[col]).reverse());
18
- }
19
- function rotations(shape) {
20
- const list = [shape.mat];
21
- let cur = shape.mat;
22
- for (let i = 0; i < 3; i += 1) {
23
- cur = rotateCW(cur);
24
- if (!list.some((m) => JSON.stringify(m) === JSON.stringify(cur))) list.push(cur);
25
- }
26
- return list;
27
- }
28
- const PIECES = SHAPES.map((s) => ({ color: s.color, rots: rotations(s) }));
29
-
30
- // 精确网格(viewBox 1000×2000,与方块单元格逐一对齐,杜绝背景纹理的亚像素漂移):
31
- // 每个空格画一个深色内嵌圆角坑(经典机台的"格子凹槽"),再叠亮色网格线,空格一眼可辨。
32
- const TETRIS_GRID = (() => {
33
- const parts = [];
34
- let k = 0;
35
- for (let j = 0; j < 20; j += 1) {
36
- for (let i = 0; i < 10; i += 1) {
37
- parts.push(<rect key={k++} className="dgame-tetris-pit" x={i * 100 + 7} y={j * 100 + 7} width={86} height={86} rx={10} />);
38
- }
39
- }
40
- for (let i = 1; i < 10; i += 1) parts.push(<line key={k++} x1={i * 100} y1={0} x2={i * 100} y2={2000} />);
41
- for (let j = 1; j < 20; j += 1) parts.push(<line key={k++} x1={0} y1={j * 100} x2={1000} y2={j * 100} />);
42
- return parts;
43
- })();
44
-
45
- function emptyBoard() {
46
- return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
47
- }
48
- function collides(board, mat, x, y) {
49
- for (let r = 0; r < mat.length; r += 1) {
50
- for (let c = 0; c < mat[r].length; c += 1) {
51
- if (!mat[r][c]) continue;
52
- const bx = x + c, by = y + r;
53
- if (bx < 0 || bx >= COLS || by >= ROWS || (by >= 0 && board[by][bx])) return true;
54
- }
55
- }
56
- return false;
57
- }
58
- function merge(board, mat, x, y, color) {
59
- const next = board.map((row) => row.slice());
60
- for (let r = 0; r < mat.length; r += 1) {
61
- for (let c = 0; c < mat[r].length; c += 1) {
62
- if (mat[r][c] && y + r >= 0) next[y + r][x + c] = color;
63
- }
64
- }
65
- return next;
66
- }
67
- function clearLines(board) {
68
- const kept = board.filter((row) => row.some((cell) => !cell));
69
- const cleared = ROWS - kept.length;
70
- while (kept.length < ROWS) kept.unshift(Array(COLS).fill(null));
71
- return { board: kept, cleared };
72
- }
73
- function seedBag() {
74
- const bag = SHAPES.map((_, i) => i);
75
- for (let i = bag.length - 1; i > 0; i -= 1) {
76
- const j = Math.floor(Math.random() * (i + 1));
77
- [bag[i], bag[j]] = [bag[j], bag[i]];
78
- }
79
- return bag;
80
- }
81
- let PIECE_SEQ = 0;
82
- function makePiece(type, x, y) {
83
- const p = PIECES[type];
84
- return { id: ++PIECE_SEQ, type, rot: 0, x, y: y != null ? y : -1, mat: p.rots[0], color: p.color };
85
- }
86
- function initial() {
87
- const bag = seedBag();
88
- const piece = makePiece(bag.pop(), 3);
89
- const next = bag.pop();
90
- return { board: emptyBoard(), piece, next, bag, score: 0, lines: 0, level: 1, over: false };
91
- }
92
- // 用"下一个"方块生成当前块并补足 7-bag。bag 耗尽时立即重洗新 bag,
93
- // 保证预览 next 永远不为空(否则会与实际生成的方块不一致)。
94
- function spawnFrom(state) {
95
- let bag = state.bag && state.bag.length ? state.bag.slice() : seedBag();
96
- const type = state.next != null ? state.next : bag.pop();
97
- let next = bag.length ? bag.pop() : null;
98
- if (next == null) {
99
- bag = seedBag();
100
- next = bag.pop();
101
- }
102
- const piece = makePiece(type, 3);
103
- const over = collides(state.board, piece.mat, piece.x, piece.y) || state.over;
104
- return Object.assign({}, state, { bag, next, piece, over });
105
- }
106
- // 推进一格:能下移则下移,否则落地合并、消行、生成下一个。
107
- function advance(state) {
108
- if (state.over) return state;
109
- const p = state.piece;
110
- if (!collides(state.board, p.mat, p.x, p.y + 1)) {
111
- return Object.assign({}, state, { piece: Object.assign({}, p, { y: p.y + 1 }), score: state.score + 1 });
112
- }
113
- const merged = merge(state.board, p.mat, p.x, p.y, p.color);
114
- playSfx("lock");
115
- const cleared = clearLines(merged);
116
- if (cleared.cleared) playSfx(cleared.cleared >= 4 ? "win" : "clear");
117
- const lines = state.lines + cleared.cleared;
118
- const level = Math.floor(lines / 10) + 1;
119
- const base = [0, 100, 300, 500, 800][cleared.cleared] || 800;
120
- const scored = Object.assign({}, state, {
121
- board: cleared.board, lines, level,
122
- score: state.score + base * state.level,
123
- });
124
- return spawnFrom(scored);
125
- }
126
- function hardDrop(state) {
127
- if (state.over) return state;
128
- let y = state.piece.y, dist = 0;
129
- while (!collides(state.board, state.piece.mat, state.piece.x, y + 1)) { y += 1; dist += 1; }
130
- const p = Object.assign({}, state.piece, { y });
131
- const merged = merge(state.board, p.mat, p.x, p.y, p.color);
132
- playSfx("lock");
133
- const cleared = clearLines(merged);
134
- if (cleared.cleared) playSfx(cleared.cleared >= 4 ? "win" : "clear");
135
- const lines = state.lines + cleared.cleared;
136
- const level = Math.floor(lines / 10) + 1;
137
- const base = [0, 100, 300, 500, 800][cleared.cleared] || 800;
138
- const scored = Object.assign({}, state, {
139
- board: cleared.board, lines, level,
140
- score: state.score + base * state.level + dist * 2,
141
- });
142
- return spawnFrom(scored);
143
- }
144
-
145
- const KEY_ACTIONS = {
146
- "ArrowLeft": "left", "a": "left", "A": "left",
147
- "ArrowRight": "right", "d": "right", "D": "right",
148
- // 下键 = 快速下滑(一格一格连降,松开即停;空格/回车才是直落到底)。
149
- "ArrowDown": "down", "s": "down", "S": "down",
150
- "ArrowUp": "cw", "x": "cw", "X": "cw",
151
- "z": "ccw", "Z": "ccw",
152
- " ": "drop", "Enter": "drop",
153
- };
154
-
155
- export function TetrisGame(props) {
156
- const [state, setState] = useState(initial);
157
- const [manualPause, setManualPause] = useState(false);
158
- const pausedRef = useRef(!!(props && props.paused));
159
- pausedRef.current = !!(props && props.paused) || manualPause;
160
- const stageRef = useRef(null);
161
-
162
- // 事件委托统一处理(避免按钮/键盘两套逻辑)。
163
- const act = useCallback((action) => setState((s) => {
164
- if (pausedRef.current) return s;
165
- if (action === "left") {
166
- playSfx("move");
167
- const x2 = s.piece.x - 1;
168
- if (collides(s.board, s.piece.mat, x2, s.piece.y)) return s;
169
- return Object.assign({}, s, { piece: Object.assign({}, s.piece, { x: x2 }) });
170
- }
171
- if (action === "right") {
172
- playSfx("move");
173
- const x2 = s.piece.x + 1;
174
- if (collides(s.board, s.piece.mat, x2, s.piece.y)) return s;
175
- return Object.assign({}, s, { piece: Object.assign({}, s.piece, { x: x2 }) });
176
- }
177
- if (action === "down") return advance(s);
178
- if (action === "drop") return hardDrop(s);
179
- if (action === "cw" || action === "ccw") { playSfx("rotate"); return rotatePiece(s, action === "cw" ? 1 : -1); }
180
- return s;
181
- }), []);
182
-
183
- // 下键快速下滑:按住时以 45ms/格 连降(松开即停,可中途左右修正),不直接到底。
184
- // 只对"按住时正在下落的那个方块"生效——它一旦落定生成新方块,连降自动结束。
185
- const softRef = useRef(null);
186
- const stateRef = useRef(state); stateRef.current = state;
187
- const holdIdRef = useRef(null);
188
- const stopSoft = useCallback(() => {
189
- if (softRef.current) { clearInterval(softRef.current); softRef.current = null; }
190
- }, []);
191
- useEffect(() => {
192
- if (state.over || manualPause || props.paused) stopSoft();
193
- return stopSoft;
194
- }, [state.over, manualPause, props.paused, stopSoft]);
195
-
196
- const onKeyDown = useCallback((event) => {
197
- const action = KEY_ACTIONS[event.key];
198
- if (action) {
199
- event.preventDefault();
200
- if (action === "down") {
201
- const pieceId = stateRef.current.piece && stateRef.current.piece.id;
202
- act("down");
203
- if (!softRef.current && !pausedRef.current) {
204
- holdIdRef.current = pieceId;
205
- softRef.current = setInterval(() => {
206
- const cur = stateRef.current;
207
- if (pausedRef.current || cur.over || !cur.piece || cur.piece.id !== holdIdRef.current) { stopSoft(); return; }
208
- act("down");
209
- }, 45);
210
- }
211
- return;
212
- }
213
- act(action);
214
- return;
215
- }
216
- if (event.key.toLowerCase() === "p") { event.preventDefault(); setManualPause((p) => !p); }
217
- else if (event.key.toLowerCase() === "r") { event.preventDefault(); setState(initial()); setManualPause(false); }
218
- }, [act, stopSoft]);
219
- const onKeyUp = useCallback((event) => {
220
- if (KEY_ACTIONS[event.key] === "down") stopSoft();
221
- }, [stopSoft]);
222
-
223
- useEffect(() => {
224
- if (state.over) playSfx("over");
225
- }, [state.over]);
226
-
227
- useEffect(() => {
228
- if (pausedRef.current || state.over) return;
229
- const speed = Math.max(80, 720 - (state.level - 1) * 70);
230
- const timer = setInterval(() => act("down"), speed);
231
- return () => clearInterval(timer);
232
- }, [state.level, state.over, act, props.paused, manualPause]);
233
-
234
- useGameControls(stageRef, pausedRef.current, onKeyDown, onKeyUp);
235
-
236
- const display = state.board.map((row) => row.slice());
237
- if (!state.over) {
238
- const m = state.piece.mat;
239
- for (let r = 0; r < m.length; r += 1) {
240
- for (let c = 0; c < m[r].length; c += 1) {
241
- if (m[r][c]) {
242
- const by = state.piece.y + r, bx = state.piece.x + c;
243
- if (by >= 0 && by < ROWS && bx >= 0 && bx < COLS) display[by][bx] = state.piece.color;
244
- }
245
- }
246
- }
247
- }
248
- const nextType = state.next != null ? state.next : 0;
249
- const nextMat = PIECES[nextType].rots[0];
250
-
251
- return <section className="dgame-game" aria-label="俄罗斯方块">
252
- <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>
253
- <div className="dgame-tetris">
254
- <div className="dgame-tetris-board" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onBlur={stopSoft} onClick={() => focusStage(stageRef)} aria-label="俄罗斯方块游戏区域,使用方向键与空格控制">
255
- {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} />))}
256
- {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>}
257
- {!state.over && (props.paused || manualPause) ? <div className="dgame-over"><strong>已暂停</strong><button type="button" onClick={() => setManualPause(false)}>继续(P)</button></div> : null}
258
- </div>
259
- <div className="dgame-tetris-side">
260
- <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>
261
- <div className="dgame-tetris-keys"><b>操作</b><span>←/→ 移动 · ↓ 快速下滑 · ↑/X 旋转 · Z 反向 · 空格 直落 · P 暂停 · R 重开</span></div>
262
- </div>
263
- </div>
264
- <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>
265
- </section>;
266
- }
267
-
268
- // 旋转:带简单墙踢。
269
- function rotatePiece(state, dir) {
270
- const rots = PIECES[state.piece.type].rots;
271
- const len = rots.length;
272
- const nextRot = ((state.piece.rot + dir) % len + len) % len;
273
- const mat = rots[nextRot];
274
- const kicks = [0, -1, 1, -2, 2, -1 - (state.piece.y < 0 ? 1 : 0), 1 + (state.piece.y < 0 ? 1 : 0), -1, 1];
275
- for (const kx of kicks) {
276
- if (!collides(state.board, mat, state.piece.x + kx, state.piece.y)) {
277
- return Object.assign({}, state, { piece: Object.assign({}, state.piece, { rot: nextRot, x: state.piece.x + kx, mat }) });
278
- }
279
- }
280
- return state;
281
- }
282
-
283
- export function TetrisPreview() {
284
- // 迷你棋盘:底部已堆方块 + 悬浮下落中的 T 块,与真实游戏画面一致。
285
- const COLORS = { I: "I", O: "O", T: "T", S: "S", Z: "Z", J: "J", L: "L" };
286
- const ROWS = [
287
- "..........",
288
- "....TT....",
289
- "..........",
290
- ".......Z..",
291
- "..OO.S.Z..",
292
- "SS..LSOZZ.",
293
- "JJSLLSOZZI",
294
- "JJSLLOSOII",
295
- ];
296
- const isFall = (r) => r === 1;
297
- return <span className="dgcov-prev dgcov-prev-tetris" aria-hidden="true">
298
- <span className="dgp-tet-grid">
299
- {ROWS.flatMap((row, r) => row.split("").map((ch, c) => (
300
- <i key={r + "-" + c} className={COLORS[ch] ? "c" + COLORS[ch] + (isFall(r) ? " fall" : "") : ""} />
301
- )))}
302
- </span>
303
- </span>;
304
- }
305
-
306
- export const tetrisGame = { Game: TetrisGame, Preview: TetrisPreview, css: `
307
- .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{position:relative;z-index:1;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:rgb(96 165 250/.28);stroke-width:3}.dgame-tetris-lines .dgame-tetris-pit{fill:rgb(0 0 0/.42);stroke:rgb(255 255 255/.07);stroke-width:2}@media (max-width:680px){.dgame-tetris{width:100%;gap:8px}.dgame-tetris-board{width:min(100%,170px)}}
308
- ` };
1
+ /* 趣味游戏 · 俄罗斯方块(经典机台)——纯 Client,键盘/按钮可玩;不写会话、不改工作区。 */
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { focusStage, useGameControls, playSfx } from "./shared.jsx";
4
+
5
+ const COLS = 10, ROWS = 20;
6
+ const SHAPES = [
7
+ { color: "#22d3ee", mat: [[1, 1, 1, 1]] },
8
+ { color: "#facc15", mat: [[1, 1], [1, 1]] },
9
+ { color: "#c084fc", mat: [[0, 1, 0], [1, 1, 1]] },
10
+ { color: "#4ade80", mat: [[0, 1, 1], [1, 1, 0]] },
11
+ { color: "#f87171", mat: [[1, 1, 0], [0, 1, 1]] },
12
+ { color: "#60a5fa", mat: [[1, 0, 0], [1, 1, 1]] },
13
+ { color: "#fb923c", mat: [[0, 0, 1], [1, 1, 1]] },
14
+ ];
15
+
16
+ function rotateCW(mat) {
17
+ return mat[0].map((_, col) => mat.map((row) => row[col]).reverse());
18
+ }
19
+ function rotations(shape) {
20
+ const list = [shape.mat];
21
+ let cur = shape.mat;
22
+ for (let i = 0; i < 3; i += 1) {
23
+ cur = rotateCW(cur);
24
+ if (!list.some((m) => JSON.stringify(m) === JSON.stringify(cur))) list.push(cur);
25
+ }
26
+ return list;
27
+ }
28
+ const PIECES = SHAPES.map((s) => ({ color: s.color, rots: rotations(s) }));
29
+
30
+ // 精确网格(viewBox 1000×2000,与方块单元格逐一对齐,杜绝背景纹理的亚像素漂移):
31
+ // 每个空格画一个深色内嵌圆角坑(经典机台的"格子凹槽"),再叠亮色网格线,空格一眼可辨。
32
+ const TETRIS_GRID = (() => {
33
+ const parts = [];
34
+ let k = 0;
35
+ for (let j = 0; j < 20; j += 1) {
36
+ for (let i = 0; i < 10; i += 1) {
37
+ parts.push(<rect key={k++} className="dgame-tetris-pit" x={i * 100 + 7} y={j * 100 + 7} width={86} height={86} rx={10} />);
38
+ }
39
+ }
40
+ for (let i = 1; i < 10; i += 1) parts.push(<line key={k++} x1={i * 100} y1={0} x2={i * 100} y2={2000} />);
41
+ for (let j = 1; j < 20; j += 1) parts.push(<line key={k++} x1={0} y1={j * 100} x2={1000} y2={j * 100} />);
42
+ return parts;
43
+ })();
44
+
45
+ function emptyBoard() {
46
+ return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
47
+ }
48
+ function collides(board, mat, x, y) {
49
+ for (let r = 0; r < mat.length; r += 1) {
50
+ for (let c = 0; c < mat[r].length; c += 1) {
51
+ if (!mat[r][c]) continue;
52
+ const bx = x + c, by = y + r;
53
+ if (bx < 0 || bx >= COLS || by >= ROWS || (by >= 0 && board[by][bx])) return true;
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+ function merge(board, mat, x, y, color) {
59
+ const next = board.map((row) => row.slice());
60
+ for (let r = 0; r < mat.length; r += 1) {
61
+ for (let c = 0; c < mat[r].length; c += 1) {
62
+ if (mat[r][c] && y + r >= 0) next[y + r][x + c] = color;
63
+ }
64
+ }
65
+ return next;
66
+ }
67
+ function clearLines(board) {
68
+ const kept = board.filter((row) => row.some((cell) => !cell));
69
+ const cleared = ROWS - kept.length;
70
+ while (kept.length < ROWS) kept.unshift(Array(COLS).fill(null));
71
+ return { board: kept, cleared };
72
+ }
73
+ function seedBag() {
74
+ const bag = SHAPES.map((_, i) => i);
75
+ for (let i = bag.length - 1; i > 0; i -= 1) {
76
+ const j = Math.floor(Math.random() * (i + 1));
77
+ [bag[i], bag[j]] = [bag[j], bag[i]];
78
+ }
79
+ return bag;
80
+ }
81
+ let PIECE_SEQ = 0;
82
+ function makePiece(type, x, y) {
83
+ const p = PIECES[type];
84
+ return { id: ++PIECE_SEQ, type, rot: 0, x, y: y != null ? y : -1, mat: p.rots[0], color: p.color };
85
+ }
86
+ function initial() {
87
+ const bag = seedBag();
88
+ const piece = makePiece(bag.pop(), 3);
89
+ const next = bag.pop();
90
+ return { board: emptyBoard(), piece, next, bag, score: 0, lines: 0, level: 1, over: false };
91
+ }
92
+ // 用"下一个"方块生成当前块并补足 7-bag。bag 耗尽时立即重洗新 bag,
93
+ // 保证预览 next 永远不为空(否则会与实际生成的方块不一致)。
94
+ function spawnFrom(state) {
95
+ let bag = state.bag && state.bag.length ? state.bag.slice() : seedBag();
96
+ const type = state.next != null ? state.next : bag.pop();
97
+ let next = bag.length ? bag.pop() : null;
98
+ if (next == null) {
99
+ bag = seedBag();
100
+ next = bag.pop();
101
+ }
102
+ const piece = makePiece(type, 3);
103
+ const over = collides(state.board, piece.mat, piece.x, piece.y) || state.over;
104
+ return Object.assign({}, state, { bag, next, piece, over });
105
+ }
106
+ // 推进一格:能下移则下移,否则落地合并、消行、生成下一个。
107
+ function advance(state) {
108
+ if (state.over) return state;
109
+ const p = state.piece;
110
+ if (!collides(state.board, p.mat, p.x, p.y + 1)) {
111
+ return Object.assign({}, state, { piece: Object.assign({}, p, { y: p.y + 1 }), score: state.score + 1 });
112
+ }
113
+ const merged = merge(state.board, p.mat, p.x, p.y, p.color);
114
+ playSfx("lock");
115
+ const cleared = clearLines(merged);
116
+ if (cleared.cleared) playSfx(cleared.cleared >= 4 ? "win" : "clear");
117
+ const lines = state.lines + cleared.cleared;
118
+ const level = Math.floor(lines / 10) + 1;
119
+ const base = [0, 100, 300, 500, 800][cleared.cleared] || 800;
120
+ const scored = Object.assign({}, state, {
121
+ board: cleared.board, lines, level,
122
+ score: state.score + base * state.level,
123
+ });
124
+ return spawnFrom(scored);
125
+ }
126
+ function hardDrop(state) {
127
+ if (state.over) return state;
128
+ let y = state.piece.y, dist = 0;
129
+ while (!collides(state.board, state.piece.mat, state.piece.x, y + 1)) { y += 1; dist += 1; }
130
+ const p = Object.assign({}, state.piece, { y });
131
+ const merged = merge(state.board, p.mat, p.x, p.y, p.color);
132
+ playSfx("lock");
133
+ const cleared = clearLines(merged);
134
+ if (cleared.cleared) playSfx(cleared.cleared >= 4 ? "win" : "clear");
135
+ const lines = state.lines + cleared.cleared;
136
+ const level = Math.floor(lines / 10) + 1;
137
+ const base = [0, 100, 300, 500, 800][cleared.cleared] || 800;
138
+ const scored = Object.assign({}, state, {
139
+ board: cleared.board, lines, level,
140
+ score: state.score + base * state.level + dist * 2,
141
+ });
142
+ return spawnFrom(scored);
143
+ }
144
+
145
+ const KEY_ACTIONS = {
146
+ "ArrowLeft": "left", "a": "left", "A": "left",
147
+ "ArrowRight": "right", "d": "right", "D": "right",
148
+ // 下键 = 快速下滑(一格一格连降,松开即停;空格/回车才是直落到底)。
149
+ "ArrowDown": "down", "s": "down", "S": "down",
150
+ "ArrowUp": "cw", "x": "cw", "X": "cw",
151
+ "z": "ccw", "Z": "ccw",
152
+ " ": "drop", "Enter": "drop",
153
+ };
154
+
155
+ export function TetrisGame(props) {
156
+ const [state, setState] = useState(initial);
157
+ const [manualPause, setManualPause] = useState(false);
158
+ const pausedRef = useRef(!!(props && props.paused));
159
+ pausedRef.current = !!(props && props.paused) || manualPause;
160
+ const stageRef = useRef(null);
161
+
162
+ // 事件委托统一处理(避免按钮/键盘两套逻辑)。
163
+ const act = useCallback((action) => setState((s) => {
164
+ if (pausedRef.current) return s;
165
+ if (action === "left") {
166
+ playSfx("move");
167
+ const x2 = s.piece.x - 1;
168
+ if (collides(s.board, s.piece.mat, x2, s.piece.y)) return s;
169
+ return Object.assign({}, s, { piece: Object.assign({}, s.piece, { x: x2 }) });
170
+ }
171
+ if (action === "right") {
172
+ playSfx("move");
173
+ const x2 = s.piece.x + 1;
174
+ if (collides(s.board, s.piece.mat, x2, s.piece.y)) return s;
175
+ return Object.assign({}, s, { piece: Object.assign({}, s.piece, { x: x2 }) });
176
+ }
177
+ if (action === "down") return advance(s);
178
+ if (action === "drop") return hardDrop(s);
179
+ if (action === "cw" || action === "ccw") { playSfx("rotate"); return rotatePiece(s, action === "cw" ? 1 : -1); }
180
+ return s;
181
+ }), []);
182
+
183
+ // 下键快速下滑:按住时以 45ms/格 连降(松开即停,可中途左右修正),不直接到底。
184
+ // 只对"按住时正在下落的那个方块"生效——它一旦落定生成新方块,连降自动结束。
185
+ const softRef = useRef(null);
186
+ const stateRef = useRef(state); stateRef.current = state;
187
+ const holdIdRef = useRef(null);
188
+ const stopSoft = useCallback(() => {
189
+ if (softRef.current) { clearInterval(softRef.current); softRef.current = null; }
190
+ }, []);
191
+ useEffect(() => {
192
+ if (state.over || manualPause || props.paused) stopSoft();
193
+ return stopSoft;
194
+ }, [state.over, manualPause, props.paused, stopSoft]);
195
+
196
+ const onKeyDown = useCallback((event) => {
197
+ const action = KEY_ACTIONS[event.key];
198
+ if (action) {
199
+ event.preventDefault();
200
+ if (action === "down") {
201
+ const pieceId = stateRef.current.piece && stateRef.current.piece.id;
202
+ act("down");
203
+ if (!softRef.current && !pausedRef.current) {
204
+ holdIdRef.current = pieceId;
205
+ softRef.current = setInterval(() => {
206
+ const cur = stateRef.current;
207
+ if (pausedRef.current || cur.over || !cur.piece || cur.piece.id !== holdIdRef.current) { stopSoft(); return; }
208
+ act("down");
209
+ }, 45);
210
+ }
211
+ return;
212
+ }
213
+ act(action);
214
+ return;
215
+ }
216
+ if (event.key.toLowerCase() === "p") { event.preventDefault(); setManualPause((p) => !p); }
217
+ else if (event.key.toLowerCase() === "r") { event.preventDefault(); setState(initial()); setManualPause(false); }
218
+ }, [act, stopSoft]);
219
+ const onKeyUp = useCallback((event) => {
220
+ if (KEY_ACTIONS[event.key] === "down") stopSoft();
221
+ }, [stopSoft]);
222
+
223
+ useEffect(() => {
224
+ if (state.over) playSfx("over");
225
+ }, [state.over]);
226
+
227
+ useEffect(() => {
228
+ if (pausedRef.current || state.over) return;
229
+ const speed = Math.max(80, 720 - (state.level - 1) * 70);
230
+ const timer = setInterval(() => act("down"), speed);
231
+ return () => clearInterval(timer);
232
+ }, [state.level, state.over, act, props.paused, manualPause]);
233
+
234
+ useGameControls(stageRef, pausedRef.current, onKeyDown, onKeyUp);
235
+
236
+ const display = state.board.map((row) => row.slice());
237
+ if (!state.over) {
238
+ const m = state.piece.mat;
239
+ for (let r = 0; r < m.length; r += 1) {
240
+ for (let c = 0; c < m[r].length; c += 1) {
241
+ if (m[r][c]) {
242
+ const by = state.piece.y + r, bx = state.piece.x + c;
243
+ if (by >= 0 && by < ROWS && bx >= 0 && bx < COLS) display[by][bx] = state.piece.color;
244
+ }
245
+ }
246
+ }
247
+ }
248
+ const nextType = state.next != null ? state.next : 0;
249
+ const nextMat = PIECES[nextType].rots[0];
250
+
251
+ return <section className="dgame-game" aria-label="俄罗斯方块">
252
+ <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>
253
+ <div className="dgame-tetris">
254
+ <div className="dgame-tetris-board" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onBlur={stopSoft} onClick={() => focusStage(stageRef)} aria-label="俄罗斯方块游戏区域,使用方向键与空格控制">
255
+ {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} />))}
256
+ {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>}
257
+ {!state.over && (props.paused || manualPause) ? <div className="dgame-over"><strong>已暂停</strong><button type="button" onClick={() => setManualPause(false)}>继续(P)</button></div> : null}
258
+ </div>
259
+ <div className="dgame-tetris-side">
260
+ <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>
261
+ <div className="dgame-tetris-keys"><b>操作</b><span>←/→ 移动 · ↓ 快速下滑 · ↑/X 旋转 · Z 反向 · 空格 直落 · P 暂停 · R 重开</span></div>
262
+ </div>
263
+ </div>
264
+ <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>
265
+ </section>;
266
+ }
267
+
268
+ // 旋转:带简单墙踢。
269
+ function rotatePiece(state, dir) {
270
+ const rots = PIECES[state.piece.type].rots;
271
+ const len = rots.length;
272
+ const nextRot = ((state.piece.rot + dir) % len + len) % len;
273
+ const mat = rots[nextRot];
274
+ const kicks = [0, -1, 1, -2, 2, -1 - (state.piece.y < 0 ? 1 : 0), 1 + (state.piece.y < 0 ? 1 : 0), -1, 1];
275
+ for (const kx of kicks) {
276
+ if (!collides(state.board, mat, state.piece.x + kx, state.piece.y)) {
277
+ return Object.assign({}, state, { piece: Object.assign({}, state.piece, { rot: nextRot, x: state.piece.x + kx, mat }) });
278
+ }
279
+ }
280
+ return state;
281
+ }
282
+
283
+ export function TetrisPreview() {
284
+ // 迷你棋盘:底部已堆方块 + 悬浮下落中的 T 块,与真实游戏画面一致。
285
+ const COLORS = { I: "I", O: "O", T: "T", S: "S", Z: "Z", J: "J", L: "L" };
286
+ const ROWS = [
287
+ "..........",
288
+ "....TT....",
289
+ "..........",
290
+ ".......Z..",
291
+ "..OO.S.Z..",
292
+ "SS..LSOZZ.",
293
+ "JJSLLSOZZI",
294
+ "JJSLLOSOII",
295
+ ];
296
+ const isFall = (r) => r === 1;
297
+ return <span className="dgcov-prev dgcov-prev-tetris" aria-hidden="true">
298
+ <span className="dgp-tet-grid">
299
+ {ROWS.flatMap((row, r) => row.split("").map((ch, c) => (
300
+ <i key={r + "-" + c} className={COLORS[ch] ? "c" + COLORS[ch] + (isFall(r) ? " fall" : "") : ""} />
301
+ )))}
302
+ </span>
303
+ </span>;
304
+ }
305
+
306
+ export const tetrisGame = { Game: TetrisGame, Preview: TetrisPreview, css: `
307
+ .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{position:relative;z-index:1;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:rgb(96 165 250/.28);stroke-width:3}.dgame-tetris-lines .dgame-tetris-pit{fill:rgb(0 0 0/.42);stroke:rgb(255 255 255/.07);stroke-width:2}@media (max-width:680px){.dgame-tetris{width:100%;gap:8px}.dgame-tetris-board{width:min(100%,170px)}}
308
+ ` };