dsh-dock 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -13
- package/client.js +4870 -3421
- package/features/animation/view.jsx +491 -18
- package/features/balance/view.js +25 -4
- package/features/games/games/breakout.jsx +227 -0
- package/features/games/games/gomoku.jsx +244 -0
- package/features/games/games/racer.jsx +169 -0
- package/features/games/games/shared.jsx +118 -0
- package/features/games/games/snake.jsx +144 -0
- package/features/games/games/sokoban.jsx +180 -0
- package/features/games/games/tank.jsx +128 -0
- package/features/games/games/tetris.jsx +302 -0
- package/features/games/games/xiangqi.jsx +205 -0
- package/features/games/tank-core.js +152 -0
- package/features/games/view.jsx +639 -0
- package/features/games/xiangqi-core.js +299 -0
- package/features/tokenlog/host.js +2 -2
- package/features/tokenlog/view.jsx +12 -21
- package/index.js +0 -2
- package/package.json +1 -1
- package/src/client.jsx +41 -24
- package/src/host-core.js +4 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/* 趣味游戏 · 坦克大战(经典机台)——纯 Client;击毁敌方坦克、守住基地过关。 */
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { GRID, EMPTY, STEEL, BRICK, BASE, initialState, step, nextWave, findBase } from "../tank-core.js";
|
|
4
|
+
import { focusStage, useGameControls } from "./shared.jsx";
|
|
5
|
+
|
|
6
|
+
const DIR_ANGLE = { up: 0, right: 90, down: 180, left: 270 };
|
|
7
|
+
const ENEMY_COLORS = ["#f87171", "#fb923c", "#c084fc", "#4ade80", "#38bdf8"];
|
|
8
|
+
|
|
9
|
+
export function TankGame(props) {
|
|
10
|
+
const [snap, setSnap] = useState(initialState);
|
|
11
|
+
const [status, setStatus] = useState("play"); // play | wave | over | win
|
|
12
|
+
const snapRef = useRef(snap); snapRef.current = snap;
|
|
13
|
+
const keys = useRef({ dir: null, shoot: false });
|
|
14
|
+
const pausedRef = useRef(!!(props && props.paused)); pausedRef.current = !!(props && props.paused);
|
|
15
|
+
const statusRef = useRef("play"); statusRef.current = status;
|
|
16
|
+
const boardRef = useRef(null);
|
|
17
|
+
|
|
18
|
+
const start = useCallback(() => {
|
|
19
|
+
setSnap(initialState()); setStatus("play"); statusRef.current = "play";
|
|
20
|
+
}, []);
|
|
21
|
+
const nextLvl = useCallback(() => {
|
|
22
|
+
setSnap((s) => nextWave(s)); setStatus("play"); statusRef.current = "play";
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
// 主循环
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const timer = setInterval(() => {
|
|
28
|
+
if (pausedRef.current) return;
|
|
29
|
+
setSnap((s) => {
|
|
30
|
+
if (s.over || s.win) return s;
|
|
31
|
+
const input = { dir: keys.current.dir, shoot: keys.current.shoot };
|
|
32
|
+
const ns = step(s, input);
|
|
33
|
+
if (ns.over && statusRef.current === "play") { statusRef.current = "over"; setStatus("over"); }
|
|
34
|
+
else if (ns.win && statusRef.current === "play") { statusRef.current = "wave"; setStatus("wave"); }
|
|
35
|
+
return ns;
|
|
36
|
+
});
|
|
37
|
+
}, 120);
|
|
38
|
+
return () => clearInterval(timer);
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const stageRef = useRef(null);
|
|
42
|
+
const onKeyDown = useCallback((event) => {
|
|
43
|
+
if (pausedRef.current) return;
|
|
44
|
+
const map = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", w: "up", s: "down", a: "left", d: "right", W: "up", S: "down", A: "left", D: "right" };
|
|
45
|
+
const dir = map[event.key];
|
|
46
|
+
if (dir) { event.preventDefault(); keys.current.dir = dir; return; }
|
|
47
|
+
if (event.key === " ") { event.preventDefault(); keys.current.shoot = true; }
|
|
48
|
+
else if (event.key.toLowerCase() === "r") { event.preventDefault(); start(); }
|
|
49
|
+
}, [start]);
|
|
50
|
+
const onKeyUp = useCallback((event) => {
|
|
51
|
+
const map = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", w: "up", s: "down", a: "left", d: "right", W: "up", S: "down", A: "left", D: "right" };
|
|
52
|
+
const dir = map[event.key];
|
|
53
|
+
if (dir && keys.current.dir === dir) keys.current.dir = null;
|
|
54
|
+
if (event.key === " ") keys.current.shoot = false;
|
|
55
|
+
}, []);
|
|
56
|
+
useGameControls(stageRef, pausedRef.current, onKeyDown, onKeyUp);
|
|
57
|
+
|
|
58
|
+
const grid = snap.grid;
|
|
59
|
+
const player = snap.player;
|
|
60
|
+
const enemies = snap.enemies;
|
|
61
|
+
const bullets = snap.bullets;
|
|
62
|
+
let playerPos = null;
|
|
63
|
+
if (player && player.alive) playerPos = { r: player.r, c: player.c };
|
|
64
|
+
const enemyMap = {};
|
|
65
|
+
for (const e of enemies) enemyMap[e.r + "-" + e.c] = e;
|
|
66
|
+
const bulletMap = {};
|
|
67
|
+
for (const b of bullets) bulletMap[b.r + "-" + b.c] = b;
|
|
68
|
+
const basePos = findBase(grid);
|
|
69
|
+
|
|
70
|
+
const cells = [];
|
|
71
|
+
for (let r = 0; r < GRID; r += 1) {
|
|
72
|
+
for (let c = 0; c < GRID; c += 1) {
|
|
73
|
+
const key = r + "-" + c;
|
|
74
|
+
let cls = "dgame-tank-cell";
|
|
75
|
+
let inner = null;
|
|
76
|
+
if (grid[r][c] === STEEL) cls += " steel";
|
|
77
|
+
else if (grid[r][c] === BRICK) cls += " brick";
|
|
78
|
+
else if (grid[r][c] === BASE) cls += " base";
|
|
79
|
+
if (playerPos && playerPos.r === r && playerPos.c === c) {
|
|
80
|
+
cls += " pw";
|
|
81
|
+
inner = <i className="dgame-tank-body pw" style={{ transform: "rotate(" + DIR_ANGLE[player.dir] + "deg)" }} />;
|
|
82
|
+
} else if (enemyMap[key]) {
|
|
83
|
+
const e = enemyMap[key];
|
|
84
|
+
cls += " en";
|
|
85
|
+
inner = <i className="dgame-tank-body en" style={{ background: ENEMY_COLORS[(e.r + e.c) % ENEMY_COLORS.length], transform: "rotate(" + DIR_ANGLE[e.dir] + "deg)" }} />;
|
|
86
|
+
}
|
|
87
|
+
if (bullets && bulletMap[key]) cls += " bullet";
|
|
88
|
+
cells.push(<span key={key} className={cls}>{inner}{bulletMap[key] ? <i className="dgame-tank-bullet" aria-hidden="true" /> : null}{grid[r][c] === BASE ? <b className="dgame-tank-flag" aria-hidden="true">⚑</b> : null}</span>);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const statusText = status === "over" ? "基地失守 · 游戏结束" : status === "wave" ? "第 " + snap.wave + " 波清空!进入下一波" : ("第 " + snap.wave + " 波 · 待歼灭 " + (snap.remaining + snap.enemies.length) + " 辆 · 生命 × " + (player && player.lives));
|
|
93
|
+
|
|
94
|
+
return <section className="dgame-game" aria-label="坦克大战">
|
|
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>
|
|
96
|
+
<div className="dgame-tank-stage" ref={stageRef} tabIndex={0} role="grid" onKeyDown={onKeyDown} onKeyUp={onKeyUp} onClick={() => focusStage(stageRef)} aria-label="坦克大战游戏区域,方向键移动,空格开炮">
|
|
97
|
+
<div className="dgame-tank-grid">{cells}</div>
|
|
98
|
+
{status === "over" ? <div className="dgame-over"><strong>胜败已分 · 得 {snap.score} 分</strong><button type="button" onClick={start}>重新开始</button></div> : null}
|
|
99
|
+
{status === "wave" ? <div className="dgame-over"><strong>第 {snap.wave} 波清空!</strong><button type="button" onClick={nextLvl}>下一波</button></div> : null}
|
|
100
|
+
</div>
|
|
101
|
+
<div className="dgame-controls dgame-tank-controls">
|
|
102
|
+
<button type="button" aria-label="向上" onClick={() => { keys.current.dir = "up"; setTimeout(() => { if (keys.current.dir === "up") keys.current.dir = null; }, 90); }}>↑</button>
|
|
103
|
+
<span>方向键 / WASD · 空格开炮 · R 重开</span>
|
|
104
|
+
<button type="button" className="dgame-tank-fire" aria-label="开炮" onClick={() => { keys.current.shoot = true; setTimeout(() => { keys.current.shoot = false; }, 90); }}>开炮</button>
|
|
105
|
+
</div>
|
|
106
|
+
</section>;
|
|
107
|
+
}
|
|
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" };
|
|
119
|
+
return <span className="dgcov-prev dgcov-prev-tank" aria-hidden="true">
|
|
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>
|
|
123
|
+
</span>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const tankGame = { Game: TankGame, Preview: TankPreview, css: `
|
|
127
|
+
.dgame-tank-stage{position:relative;align-self:center;width:min(100%,320px);aspect-ratio:1/1;border:1px solid color-mix(in srgb,rgb(250 204 21) 32%,var(--dsw-alias-border-l1));border-radius:8px;overflow:hidden;outline:none;background:linear-gradient(180deg,#1a1425,#100d1c);box-shadow:inset 0 0 26px rgb(0 0 0/.5)}.dgame-tank-stage:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,rgb(250 204 21) 30%,transparent)}.dgame-tank-grid{display:grid;grid-template-columns:repeat(15,1fr);grid-template-rows:repeat(15,1fr);width:100%;height:100%;gap:0}.dgame-tank-cell{position:relative;background:rgba(255,255,255,.03);min-width:0;min-height:0}.dgame-tank-cell.steel{background:linear-gradient(180deg,#94a3b8,#64748b)}.dgame-tank-cell.brick{background:repeating-linear-gradient(45deg,#a3672a 0 55%,#8a5a1c 55% 100%);box-shadow:inset 0 0 0 1px rgba(0,0,0,.25)}.dgame-tank-cell.base{background:#fbbf24}.dgame-tank-cell.bullet::before{content:'';position:absolute;inset:38%;background:#fef3c7;box-shadow:0 0 5px #fef3c7;z-index:4}.dgame-tank-flag{position:absolute;inset:0;display:grid;place-items:center;color:#7c2d12;font-size:18px;z-index:2}.dgame-tank-body{position:absolute;inset:16%;display:block;border-radius:16%;z-index:3;transition:transform .1s ease}.dgame-tank-body.pw{background:linear-gradient(135deg,#fde047,#eab308);box-shadow:0 0 7px color-mix(in srgb,#eab308 55%,transparent)}.dgame-tank-body.en{background:#f87171;box-shadow:0 0 5px color-mix(in srgb,#f87171 40%,transparent)}.dgame-tank-body::before{content:'';position:absolute;left:38%;top:-30%;width:24%;height:64%;background:rgba(255,255,255,.5);border-radius:3px}.dgame-tank-controls{grid-template-columns:1fr auto 1fr}
|
|
128
|
+
` };
|
|
@@ -0,0 +1,302 @@
|
|
|
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
|
+
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
|
+
|
|
39
|
+
function emptyBoard() {
|
|
40
|
+
return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
|
|
41
|
+
}
|
|
42
|
+
function collides(board, mat, x, y) {
|
|
43
|
+
for (let r = 0; r < mat.length; r += 1) {
|
|
44
|
+
for (let c = 0; c < mat[r].length; c += 1) {
|
|
45
|
+
if (!mat[r][c]) continue;
|
|
46
|
+
const bx = x + c, by = y + r;
|
|
47
|
+
if (bx < 0 || bx >= COLS || by >= ROWS || (by >= 0 && board[by][bx])) return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
function merge(board, mat, x, y, color) {
|
|
53
|
+
const next = board.map((row) => row.slice());
|
|
54
|
+
for (let r = 0; r < mat.length; r += 1) {
|
|
55
|
+
for (let c = 0; c < mat[r].length; c += 1) {
|
|
56
|
+
if (mat[r][c] && y + r >= 0) next[y + r][x + c] = color;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return next;
|
|
60
|
+
}
|
|
61
|
+
function clearLines(board) {
|
|
62
|
+
const kept = board.filter((row) => row.some((cell) => !cell));
|
|
63
|
+
const cleared = ROWS - kept.length;
|
|
64
|
+
while (kept.length < ROWS) kept.unshift(Array(COLS).fill(null));
|
|
65
|
+
return { board: kept, cleared };
|
|
66
|
+
}
|
|
67
|
+
function seedBag() {
|
|
68
|
+
const bag = SHAPES.map((_, i) => i);
|
|
69
|
+
for (let i = bag.length - 1; i > 0; i -= 1) {
|
|
70
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
71
|
+
[bag[i], bag[j]] = [bag[j], bag[i]];
|
|
72
|
+
}
|
|
73
|
+
return bag;
|
|
74
|
+
}
|
|
75
|
+
let PIECE_SEQ = 0;
|
|
76
|
+
function makePiece(type, x, y) {
|
|
77
|
+
const p = PIECES[type];
|
|
78
|
+
return { id: ++PIECE_SEQ, type, rot: 0, x, y: y != null ? y : -1, mat: p.rots[0], color: p.color };
|
|
79
|
+
}
|
|
80
|
+
function initial() {
|
|
81
|
+
const bag = seedBag();
|
|
82
|
+
const piece = makePiece(bag.pop(), 3);
|
|
83
|
+
const next = bag.pop();
|
|
84
|
+
return { board: emptyBoard(), piece, next, bag, score: 0, lines: 0, level: 1, over: false };
|
|
85
|
+
}
|
|
86
|
+
// 用"下一个"方块生成当前块并补足 7-bag。bag 耗尽时立即重洗新 bag,
|
|
87
|
+
// 保证预览 next 永远不为空(否则会与实际生成的方块不一致)。
|
|
88
|
+
function spawnFrom(state) {
|
|
89
|
+
let bag = state.bag && state.bag.length ? state.bag.slice() : seedBag();
|
|
90
|
+
const type = state.next != null ? state.next : bag.pop();
|
|
91
|
+
let next = bag.length ? bag.pop() : null;
|
|
92
|
+
if (next == null) {
|
|
93
|
+
bag = seedBag();
|
|
94
|
+
next = bag.pop();
|
|
95
|
+
}
|
|
96
|
+
const piece = makePiece(type, 3);
|
|
97
|
+
const over = collides(state.board, piece.mat, piece.x, piece.y) || state.over;
|
|
98
|
+
return Object.assign({}, state, { bag, next, piece, over });
|
|
99
|
+
}
|
|
100
|
+
// 推进一格:能下移则下移,否则落地合并、消行、生成下一个。
|
|
101
|
+
function advance(state) {
|
|
102
|
+
if (state.over) return state;
|
|
103
|
+
const p = state.piece;
|
|
104
|
+
if (!collides(state.board, p.mat, p.x, p.y + 1)) {
|
|
105
|
+
return Object.assign({}, state, { piece: Object.assign({}, p, { y: p.y + 1 }), score: state.score + 1 });
|
|
106
|
+
}
|
|
107
|
+
const merged = merge(state.board, p.mat, p.x, p.y, p.color);
|
|
108
|
+
playSfx("lock");
|
|
109
|
+
const cleared = clearLines(merged);
|
|
110
|
+
if (cleared.cleared) playSfx(cleared.cleared >= 4 ? "win" : "clear");
|
|
111
|
+
const lines = state.lines + cleared.cleared;
|
|
112
|
+
const level = Math.floor(lines / 10) + 1;
|
|
113
|
+
const base = [0, 100, 300, 500, 800][cleared.cleared] || 800;
|
|
114
|
+
const scored = Object.assign({}, state, {
|
|
115
|
+
board: cleared.board, lines, level,
|
|
116
|
+
score: state.score + base * state.level,
|
|
117
|
+
});
|
|
118
|
+
return spawnFrom(scored);
|
|
119
|
+
}
|
|
120
|
+
function hardDrop(state) {
|
|
121
|
+
if (state.over) return state;
|
|
122
|
+
let y = state.piece.y, dist = 0;
|
|
123
|
+
while (!collides(state.board, state.piece.mat, state.piece.x, y + 1)) { y += 1; dist += 1; }
|
|
124
|
+
const p = Object.assign({}, state.piece, { y });
|
|
125
|
+
const merged = merge(state.board, p.mat, p.x, p.y, p.color);
|
|
126
|
+
playSfx("lock");
|
|
127
|
+
const cleared = clearLines(merged);
|
|
128
|
+
if (cleared.cleared) playSfx(cleared.cleared >= 4 ? "win" : "clear");
|
|
129
|
+
const lines = state.lines + cleared.cleared;
|
|
130
|
+
const level = Math.floor(lines / 10) + 1;
|
|
131
|
+
const base = [0, 100, 300, 500, 800][cleared.cleared] || 800;
|
|
132
|
+
const scored = Object.assign({}, state, {
|
|
133
|
+
board: cleared.board, lines, level,
|
|
134
|
+
score: state.score + base * state.level + dist * 2,
|
|
135
|
+
});
|
|
136
|
+
return spawnFrom(scored);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const KEY_ACTIONS = {
|
|
140
|
+
"ArrowLeft": "left", "a": "left", "A": "left",
|
|
141
|
+
"ArrowRight": "right", "d": "right", "D": "right",
|
|
142
|
+
// 下键 = 快速下滑(一格一格连降,松开即停;空格/回车才是直落到底)。
|
|
143
|
+
"ArrowDown": "down", "s": "down", "S": "down",
|
|
144
|
+
"ArrowUp": "cw", "x": "cw", "X": "cw",
|
|
145
|
+
"z": "ccw", "Z": "ccw",
|
|
146
|
+
" ": "drop", "Enter": "drop",
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export function TetrisGame(props) {
|
|
150
|
+
const [state, setState] = useState(initial);
|
|
151
|
+
const [manualPause, setManualPause] = useState(false);
|
|
152
|
+
const pausedRef = useRef(!!(props && props.paused));
|
|
153
|
+
pausedRef.current = !!(props && props.paused) || manualPause;
|
|
154
|
+
const stageRef = useRef(null);
|
|
155
|
+
|
|
156
|
+
// 事件委托统一处理(避免按钮/键盘两套逻辑)。
|
|
157
|
+
const act = useCallback((action) => setState((s) => {
|
|
158
|
+
if (pausedRef.current) return s;
|
|
159
|
+
if (action === "left") {
|
|
160
|
+
playSfx("move");
|
|
161
|
+
const x2 = s.piece.x - 1;
|
|
162
|
+
if (collides(s.board, s.piece.mat, x2, s.piece.y)) return s;
|
|
163
|
+
return Object.assign({}, s, { piece: Object.assign({}, s.piece, { x: x2 }) });
|
|
164
|
+
}
|
|
165
|
+
if (action === "right") {
|
|
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 === "down") return advance(s);
|
|
172
|
+
if (action === "drop") return hardDrop(s);
|
|
173
|
+
if (action === "cw" || action === "ccw") { playSfx("rotate"); return rotatePiece(s, action === "cw" ? 1 : -1); }
|
|
174
|
+
return s;
|
|
175
|
+
}), []);
|
|
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
|
+
|
|
190
|
+
const onKeyDown = useCallback((event) => {
|
|
191
|
+
const action = KEY_ACTIONS[event.key];
|
|
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
|
+
}
|
|
210
|
+
if (event.key.toLowerCase() === "p") { event.preventDefault(); setManualPause((p) => !p); }
|
|
211
|
+
else if (event.key.toLowerCase() === "r") { event.preventDefault(); setState(initial()); setManualPause(false); }
|
|
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]);
|
|
220
|
+
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (pausedRef.current || state.over) return;
|
|
223
|
+
const speed = Math.max(80, 720 - (state.level - 1) * 70);
|
|
224
|
+
const timer = setInterval(() => act("down"), speed);
|
|
225
|
+
return () => clearInterval(timer);
|
|
226
|
+
}, [state.level, state.over, act, props.paused, manualPause]);
|
|
227
|
+
|
|
228
|
+
useGameControls(stageRef, pausedRef.current, onKeyDown, onKeyUp);
|
|
229
|
+
|
|
230
|
+
const display = state.board.map((row) => row.slice());
|
|
231
|
+
if (!state.over) {
|
|
232
|
+
const m = state.piece.mat;
|
|
233
|
+
for (let r = 0; r < m.length; r += 1) {
|
|
234
|
+
for (let c = 0; c < m[r].length; c += 1) {
|
|
235
|
+
if (m[r][c]) {
|
|
236
|
+
const by = state.piece.y + r, bx = state.piece.x + c;
|
|
237
|
+
if (by >= 0 && by < ROWS && bx >= 0 && bx < COLS) display[by][bx] = state.piece.color;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const nextType = state.next != null ? state.next : 0;
|
|
243
|
+
const nextMat = PIECES[nextType].rots[0];
|
|
244
|
+
|
|
245
|
+
return <section className="dgame-game" aria-label="俄罗斯方块">
|
|
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>
|
|
247
|
+
<div className="dgame-tetris">
|
|
248
|
+
<div className="dgame-tetris-board" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onBlur={stopSoft} onClick={() => focusStage(stageRef)} aria-label="俄罗斯方块游戏区域,使用方向键与空格控制">
|
|
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} />))}
|
|
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>}
|
|
251
|
+
{!state.over && (props.paused || manualPause) ? <div className="dgame-over"><strong>已暂停</strong><button type="button" onClick={() => setManualPause(false)}>继续(P)</button></div> : null}
|
|
252
|
+
</div>
|
|
253
|
+
<div className="dgame-tetris-side">
|
|
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>
|
|
255
|
+
<div className="dgame-tetris-keys"><b>操作</b><span>←/→ 移动 · ↓ 快速下滑 · ↑/X 旋转 · Z 反向 · 空格 直落 · P 暂停 · R 重开</span></div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
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>
|
|
259
|
+
</section>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 旋转:带简单墙踢。
|
|
263
|
+
function rotatePiece(state, dir) {
|
|
264
|
+
const rots = PIECES[state.piece.type].rots;
|
|
265
|
+
const len = rots.length;
|
|
266
|
+
const nextRot = ((state.piece.rot + dir) % len + len) % len;
|
|
267
|
+
const mat = rots[nextRot];
|
|
268
|
+
const kicks = [0, -1, 1, -2, 2, -1 - (state.piece.y < 0 ? 1 : 0), 1 + (state.piece.y < 0 ? 1 : 0), -1, 1];
|
|
269
|
+
for (const kx of kicks) {
|
|
270
|
+
if (!collides(state.board, mat, state.piece.x + kx, state.piece.y)) {
|
|
271
|
+
return Object.assign({}, state, { piece: Object.assign({}, state.piece, { rot: nextRot, x: state.piece.x + kx, mat }) });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return state;
|
|
275
|
+
}
|
|
276
|
+
|
|
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;
|
|
291
|
+
return <span className="dgcov-prev dgcov-prev-tetris" aria-hidden="true">
|
|
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>
|
|
297
|
+
</span>;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export const tetrisGame = { Game: TetrisGame, Preview: TetrisPreview, css: `
|
|
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)}}
|
|
302
|
+
` };
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/* 趣味游戏 · 象棋(棋盘对战)——纯 Client;玩家执红先行,AI 执黑。 */
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
initialBoard, cloneBoard, pieceChar, inCheck, hasLegalMoves,
|
|
5
|
+
legalMoves, bestMove, applyMove, posKey,
|
|
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
|
+
})();
|
|
39
|
+
|
|
40
|
+
export function XiangqiGame(props) {
|
|
41
|
+
const [board, setBoard] = useState(initialBoard);
|
|
42
|
+
const [turn, setTurn] = useState("r");
|
|
43
|
+
const [sel, setSel] = useState(null); // [r,c]
|
|
44
|
+
const [targets, setTargets] = useState([]); // [r,c][]
|
|
45
|
+
const [over, setOver] = useState(null); // 'r' | 'b' | 'draw'
|
|
46
|
+
const [check, setCheck] = useState(false);
|
|
47
|
+
const [last, setLast] = useState(null);
|
|
48
|
+
const [thinking, setThinking] = useState(false);
|
|
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)))), []);
|
|
53
|
+
|
|
54
|
+
const stageRef = useRef(null);
|
|
55
|
+
const boardRef = useRef(board); boardRef.current = board;
|
|
56
|
+
const turnRef = useRef(turn); turnRef.current = turn;
|
|
57
|
+
const overRef = useRef(over); overRef.current = over;
|
|
58
|
+
const thinkingRef = useRef(thinking); thinkingRef.current = thinking;
|
|
59
|
+
const historyRef = useRef(history); historyRef.current = history;
|
|
60
|
+
|
|
61
|
+
const restart = useCallback(() => {
|
|
62
|
+
const b = initialBoard();
|
|
63
|
+
boardRef.current = b;
|
|
64
|
+
setBoard(b); setTurn("r"); setSel(null); setTargets([]); setOver(null); setCheck(false); setLast(null); setThinking(false); setHistory([]);
|
|
65
|
+
setRepKeys([posKey(b)]);
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const undo = useCallback(() => {
|
|
69
|
+
if (thinkingRef.current || overRef.current || turnRef.current !== "r") return;
|
|
70
|
+
const h = historyRef.current;
|
|
71
|
+
if (h.length < 2) return;
|
|
72
|
+
const hist = h.slice(0, -2);
|
|
73
|
+
const b = hist.length ? cloneBoard(hist[hist.length - 1]) : initialBoard();
|
|
74
|
+
boardRef.current = b;
|
|
75
|
+
setBoard(b); setHistory(hist); setSel(null); setTargets([]); setLast(null); setOver(null);
|
|
76
|
+
setCheck(inCheck(b, "r"));
|
|
77
|
+
// 重建局面指纹(历史各局面 + 当前)。
|
|
78
|
+
setRepKeys(hist.map(posKey).concat(posKey(b)));
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const moveRed = useCallback((tr, tc) => {
|
|
82
|
+
if (turnRef.current !== "r" || overRef.current || !sel) return;
|
|
83
|
+
const fr = sel[0], fc = sel[1];
|
|
84
|
+
if (boardRef.current[tr][tc]) playSfx("capture"); else playSfx("step");
|
|
85
|
+
const nb = applyMove(boardRef.current, fr, fc, tr, tc);
|
|
86
|
+
setHistory((h) => [...h, boardRef.current]);
|
|
87
|
+
boardRef.current = nb;
|
|
88
|
+
pushKey(nb);
|
|
89
|
+
setBoard(nb);
|
|
90
|
+
setSel(null); setTargets([]); setLast([tr, tc]);
|
|
91
|
+
setTurn("b");
|
|
92
|
+
if (!hasLegalMoves(nb, "b")) { setOver("r"); setCheck(false); }
|
|
93
|
+
else setCheck(inCheck(nb, "b"));
|
|
94
|
+
}, [sel, pushKey]);
|
|
95
|
+
|
|
96
|
+
const select = useCallback((r, c) => {
|
|
97
|
+
if (overRef.current || turnRef.current !== "r" || thinkingRef.current) return;
|
|
98
|
+
const p = boardRef.current[r][c];
|
|
99
|
+
if (p && p.c === "r") {
|
|
100
|
+
setSel([r, c]);
|
|
101
|
+
const list = legalMoves(boardRef.current, "r").filter((m) => m[0] === r && m[1] === c).map((m) => [m[2], m[3]]);
|
|
102
|
+
setTargets(list);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (sel && targets.some(([tr, tc]) => tr === r && tc === c)) { moveRed(r, c); return; }
|
|
106
|
+
setSel(null); setTargets([]);
|
|
107
|
+
}, [sel, targets, moveRed]);
|
|
108
|
+
|
|
109
|
+
// AI 回合(执黑):搜索时排除已出现过的局面,禁止长将循环。
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (turn !== "b" || over || props.paused) return;
|
|
112
|
+
const t = setTimeout(() => {
|
|
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);
|
|
117
|
+
if (mv) {
|
|
118
|
+
const [fr, fc, tr, tc] = mv;
|
|
119
|
+
if (boardRef.current[tr][tc]) playSfx("capture"); else playSfx("step");
|
|
120
|
+
const nb = applyMove(boardRef.current, fr, fc, tr, tc);
|
|
121
|
+
setHistory((h) => [...h, boardRef.current]);
|
|
122
|
+
boardRef.current = nb;
|
|
123
|
+
pushKey(nb);
|
|
124
|
+
setBoard(nb); setLast([tr, tc]); setTurn("r");
|
|
125
|
+
if (!hasLegalMoves(nb, "r")) { setOver("b"); setCheck(false); }
|
|
126
|
+
else setCheck(inCheck(nb, "r"));
|
|
127
|
+
} else setOver("r");
|
|
128
|
+
setThinking(false);
|
|
129
|
+
}, 90);
|
|
130
|
+
setThinking(true);
|
|
131
|
+
return () => clearTimeout(t);
|
|
132
|
+
}, [turn, over, props.paused, repKeys, pushKey]);
|
|
133
|
+
|
|
134
|
+
const onKeyDown = useCallback((event) => {
|
|
135
|
+
if (event.key.toLowerCase() === "r") { event.preventDefault(); restart(); }
|
|
136
|
+
else if (event.key.toLowerCase() === "u") { event.preventDefault(); undo(); }
|
|
137
|
+
}, [restart, undo]);
|
|
138
|
+
useGameControls(stageRef, props.paused, onKeyDown);
|
|
139
|
+
|
|
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]);
|
|
148
|
+
|
|
149
|
+
return <section className="dgame-game" aria-label="象棋">
|
|
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>
|
|
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>
|
|
157
|
+
{board.map((row, r) => row.map((p, c) => {
|
|
158
|
+
const isSel = sel && sel[0] === r && sel[1] === c;
|
|
159
|
+
const isTargetMark = targets.some(([tr, tc]) => tr === r && tc === c);
|
|
160
|
+
const isLast = last && last[0] === r && last[1] === c;
|
|
161
|
+
return <button type="button" key={r + "-" + c} role="gridcell"
|
|
162
|
+
className={"dgame-xiangqi-cell" + (isSel ? " sel" : "") + (isTargetMark ? " target" : "") + (isLast ? " last" : "")}
|
|
163
|
+
aria-label={"第" + (r + 1) + "行第" + (c + 1) + "列" + (p ? pieceChar(p) : " 空")}
|
|
164
|
+
onClick={() => { focusStage(stageRef); select(r, c); }}>
|
|
165
|
+
{p ? <span className={"dgame-xiangqi-pc " + p.c}>{pieceChar(p)}</span> : (isTargetMark ? <span className="dgame-xiangqi-dot" aria-hidden="true" /> : null)}
|
|
166
|
+
</button>;
|
|
167
|
+
}))}
|
|
168
|
+
</div>
|
|
169
|
+
<div className="dgame-controls"><button type="button" onClick={undo}>撤销</button><span>{status} · R 重开 · U 撤销</span><button type="button" onClick={restart}>重开</button></div>
|
|
170
|
+
</section>;
|
|
171
|
+
}
|
|
172
|
+
|
|
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
|
+
];
|
|
180
|
+
return <span className="dgcov-prev dgcov-prev-xiangqi" aria-hidden="true">
|
|
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>
|
|
200
|
+
</span>;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const xiangqiGame = { Game: XiangqiGame, Preview: XiangqiPreview, css: `
|
|
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)}
|
|
205
|
+
` };
|