dsh-dock 0.11.2 → 0.11.3
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.
- package/LICENSE +18 -18
- package/README.md +242 -396
- package/client.js +173 -9
- package/cordis.patch.yml +2 -2
- package/features/animation/host.js +119 -119
- package/features/animation/view.jsx +1662 -1662
- package/features/balance/host.js +494 -494
- package/features/balance/view.js +351 -351
- package/features/games/games/breakout.jsx +227 -227
- package/features/games/games/gomoku.jsx +244 -244
- package/features/games/games/racer.jsx +169 -169
- package/features/games/games/shared.jsx +118 -118
- package/features/games/games/snake.jsx +144 -144
- package/features/games/games/sokoban.jsx +180 -180
- package/features/games/games/tank.jsx +128 -128
- package/features/games/games/tetris.jsx +308 -308
- package/features/games/games/xiangqi.jsx +277 -277
- package/features/games/tank-core.js +152 -152
- package/features/games/view.jsx +639 -639
- package/features/games/xiangqi-core.js +299 -299
- package/features/heartbeat/view.js +35 -35
- package/features/mobile-relay/gateway.js +472 -472
- package/features/mobile-relay/host.js +570 -570
- package/features/mobile-relay/view.jsx +300 -300
- package/features/modelconfig/host.js +406 -406
- package/features/modelconfig/view.js +490 -490
- package/features/notify/host.js +374 -374
- package/features/notify/view.jsx +751 -751
- package/features/theme/view.js +61 -61
- package/features/tokenlog/host.js +882 -883
- package/features/tokenlog/view.jsx +1117 -932
- package/features/visionproxy/host.js +315 -315
- package/index.js +195 -195
- package/package.json +66 -64
- package/src/client.jsx +677 -677
- package/src/host-core.js +207 -207
- package/src/shared.js +192 -192
- package/src/task-track.js +354 -359
|
@@ -1,244 +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
|
-
` };
|
|
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
|
+
` };
|