dsh-dock 0.7.1 → 0.9.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.
- package/README.md +136 -0
- package/client.js +9457 -3597
- package/features/games/games/breakout.jsx +10 -8
- package/features/games/games/gomoku.jsx +89 -20
- package/features/games/games/racer.jsx +3 -5
- package/features/games/games/shared.jsx +118 -0
- package/features/games/games/snake.jsx +58 -17
- package/features/games/games/sokoban.jsx +35 -8
- package/features/games/games/tank.jsx +15 -3
- package/features/games/games/tetris.jsx +90 -23
- package/features/games/games/xiangqi.jsx +91 -14
- package/features/games/view.jsx +33 -26
- package/features/games/xiangqi-core.js +48 -19
- package/features/mobile-relay/gateway.js +217 -81
- package/features/mobile-relay/host.js +305 -230
- package/features/mobile-relay/view.jsx +107 -140
- package/index.js +20 -1
- package/package.json +2 -1
- package/src/client.jsx +5 -3
- package/src/host-core.js +7 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
+
let bag = state.bag && state.bag.length ? state.bag.slice() : seedBag();
|
|
78
90
|
const type = state.next != null ? state.next : bag.pop();
|
|
79
|
-
|
|
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) {
|
|
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
|
-
|
|
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> : <
|
|
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>←/→ 移动 · ↓
|
|
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
|
-
<
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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%,
|
|
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
|
` };
|