dsh-dock 0.7.0 → 0.7.1
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 +746 -2750
- package/features/animation/view.jsx +491 -18
- package/features/balance/view.js +25 -4
- package/features/games/games/breakout.jsx +225 -0
- package/features/games/games/gomoku.jsx +175 -0
- package/features/games/games/racer.jsx +171 -0
- package/features/games/games/snake.jsx +103 -0
- package/features/games/games/sokoban.jsx +153 -0
- package/features/games/games/tank.jsx +116 -0
- package/features/games/games/tetris.jsx +235 -0
- package/features/games/games/xiangqi.jsx +128 -0
- package/features/games/tank-core.js +152 -0
- package/features/games/view.jsx +632 -0
- package/features/games/xiangqi-core.js +270 -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 +40 -24
- package/src/host-core.js +4 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/* 趣味游戏 · 贪吃蛇(经典机台)——纯 Client;吃到食物变长,撞墙或咬到自身结束。 */
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
const SIZE = 16;
|
|
5
|
+
const DIRS = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], w: [-1, 0], s: [1, 0], a: [0, -1], d: [0, 1], W: [-1, 0], S: [1, 0], A: [0, -1], D: [0, 1] };
|
|
6
|
+
const OPPOSITE = { "-1,0": [1, 0], "1,0": [-1, 0], "0,-1": [0, 1], "0,1": [0, -1] };
|
|
7
|
+
|
|
8
|
+
function randFood(snake) {
|
|
9
|
+
while (true) {
|
|
10
|
+
const r = Math.floor(Math.random() * SIZE), c = Math.floor(Math.random() * SIZE);
|
|
11
|
+
if (!snake.some((s) => s.r === r && s.c === c)) return { r, c };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function initial() {
|
|
15
|
+
const snake = [{ r: 7, c: 5 }, { r: 7, c: 4 }, { r: 7, c: 3 }];
|
|
16
|
+
return { snake, dir: [0, 1], food: randFood(snake), score: 0, over: false };
|
|
17
|
+
}
|
|
18
|
+
function step(state, dir) {
|
|
19
|
+
const head = state.snake[0];
|
|
20
|
+
const nr = head.r + dir[0], nc = head.c + dir[1];
|
|
21
|
+
const body = state.snake.slice(0, -1);
|
|
22
|
+
if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE || body.some((s) => s.r === nr && s.c === nc)) {
|
|
23
|
+
return Object.assign({}, state, { over: true });
|
|
24
|
+
}
|
|
25
|
+
const ate = state.food.r === nr && state.food.c === nc;
|
|
26
|
+
let snake = [{ r: nr, c: nc }, ...state.snake];
|
|
27
|
+
if (ate) {
|
|
28
|
+
const food = randFood(snake);
|
|
29
|
+
return Object.assign({}, state, { snake, food, score: state.score + 1 });
|
|
30
|
+
}
|
|
31
|
+
snake.pop();
|
|
32
|
+
return Object.assign({}, state, { snake });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function SnakeGame(props) {
|
|
36
|
+
const [state, setState] = useState(initial);
|
|
37
|
+
const [paused, setPaused] = useState(false);
|
|
38
|
+
const stageRef = useRef(null);
|
|
39
|
+
const dirRef = useRef(state.dir);
|
|
40
|
+
const overRef = useRef(state.over); overRef.current = state.over;
|
|
41
|
+
const pausedRef = useRef(!!(props && props.paused) || paused); pausedRef.current = !!(props && props.paused) || paused;
|
|
42
|
+
|
|
43
|
+
const start = useCallback(() => { const init = initial(); dirRef.current = init.dir; setState(init); setPaused(false); }, []);
|
|
44
|
+
const changeDir = useCallback((nv) => {
|
|
45
|
+
if (overRef.current || pausedRef.current) return;
|
|
46
|
+
const cur = dirRef.current;
|
|
47
|
+
const opp = OPPOSITE[cur[0] + "," + cur[1]];
|
|
48
|
+
if (opp && opp[0] === nv[0] && opp[1] === nv[1]) return;
|
|
49
|
+
dirRef.current = nv;
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (state.over || pausedRef.current) return;
|
|
54
|
+
const speed = Math.max(70, 150 - state.score * 5);
|
|
55
|
+
const timer = setInterval(() => setState((s) => (s.over || pausedRef.current ? s : step(s, dirRef.current))), speed);
|
|
56
|
+
return () => clearInterval(timer);
|
|
57
|
+
}, [state.over, state.score, props.paused, paused]);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (pausedRef.current || state.over) return;
|
|
61
|
+
if (stageRef.current) stageRef.current.focus();
|
|
62
|
+
}, [state.over, props.paused, paused]);
|
|
63
|
+
|
|
64
|
+
const onKeyDown = useCallback((event) => {
|
|
65
|
+
const d = DIRS[event.key];
|
|
66
|
+
if (d) { event.preventDefault(); changeDir(d); return; }
|
|
67
|
+
if (event.key.toLowerCase() === "p") { event.preventDefault(); setPaused((p) => !p); }
|
|
68
|
+
else if (event.key.toLowerCase() === "r") { event.preventDefault(); start(); }
|
|
69
|
+
}, [changeDir, start]);
|
|
70
|
+
|
|
71
|
+
const cells = [];
|
|
72
|
+
for (let r = 0; r < SIZE; r += 1) {
|
|
73
|
+
for (let c = 0; c < SIZE; c += 1) {
|
|
74
|
+
const idx = state.snake.findIndex((s) => s.r === r && s.c === c);
|
|
75
|
+
const isFood = state.food.r === r && state.food.c === c;
|
|
76
|
+
let cls = "dgame-snake-cell";
|
|
77
|
+
if (idx >= 0) cls += " bob" + (idx === 0 ? " head" : idx < 4 ? " body2" : "");
|
|
78
|
+
if (isFood) cls += " food";
|
|
79
|
+
cells.push(<i key={r + "-" + c} className={cls} aria-hidden="true" />);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return <section className="dgame-game" aria-label="贪吃蛇">
|
|
84
|
+
<div className="dgame-game-head"><div><h3>贪吃蛇</h3><p>方向键控制移动,吃到食物变长并提速;撞墙或咬到自己就结束。</p></div><div className="dgame-score"><span>长度 <strong>{state.score + 3}</strong></span><span>得分 {state.score}</span></div></div>
|
|
85
|
+
<div className="dgame-snake-stage" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} aria-label="贪吃蛇游戏区域,使用方向键控制移动">
|
|
86
|
+
<div className="dgame-snake-grid">{cells}</div>
|
|
87
|
+
{state.over ? <div className="dgame-over"><strong>撞上了 · 得 {state.score} 分</strong><button type="button" onClick={start}>重新开始</button></div> : null}
|
|
88
|
+
{!state.over && pausedRef.current ? <div className="dgame-over"><strong>已暂停</strong><button type="button" onClick={() => setPaused(false)}>继续(P)</button></div> : null}
|
|
89
|
+
</div>
|
|
90
|
+
<div className="dgame-controls"><button type="button" aria-label="向上" onClick={() => changeDir([-1, 0])}>↑</button><span>方向键 / WASD · P 暂停 · R 重开</span><button type="button" aria-label="向下" onClick={() => changeDir([1, 0])}>↓</button></div>
|
|
91
|
+
</section>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function SnakePreview() {
|
|
95
|
+
return <span className="dgcov-prev dgcov-prev-snake" aria-hidden="true">
|
|
96
|
+
<i style={{ left: "12%", top: "62%" }} /><i style={{ left: "24%", top: "62%" }} /><i style={{ left: "36%", top: "62%" }} /><i style={{ left: "48%", top: "62%" }} /><i style={{ left: "60%", top: "62%" }} />
|
|
97
|
+
<b />
|
|
98
|
+
</span>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const snakeGame = { Game: SnakeGame, Preview: SnakePreview, css: `
|
|
102
|
+
.dgame-snake-stage{position:relative;align-self:center;width:min(100%,300px);aspect-ratio:1/1;border:1px solid color-mix(in srgb,rgb(74 222 128) 32%,var(--dsw-alias-border-l1));border-radius:10px;overflow:hidden;outline:none;background:radial-gradient(circle at 50% -10%,color-mix(in srgb,#166534 30%,transparent),transparent 55%),#0a1420}.dgame-snake-stage:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,rgb(74 222 128) 35%,transparent)}.dgame-snake-grid{display:grid;grid-template-columns:repeat(16,1fr);grid-template-rows:repeat(16,1fr);width:100%;height:100%}.dgame-snake-cell{position:relative}.dgame-snake-cell.food::after{content:'';position:absolute;inset:22%;border-radius:50%;background:radial-gradient(circle at 35% 30%,#fecaca,#ef4444 70%);box-shadow:0 0 9px color-mix(in srgb,#ef4444 70%,transparent)}.dgame-snake-cell.bob::after{content:'';position:absolute;inset:20%;border-radius:26%;background:linear-gradient(135deg,#4ade80,#16a34a)}.dgame-snake-cell.head::after{background:linear-gradient(135deg,#bbf7d0,#22c55e);border-radius:30% 30% 28% 28%;box-shadow:0 1px 6px color-mix(in srgb,#22c55e 60%,transparent)}.dgame-snake-cell.head::before{content:'';position:absolute;left:30%;top:30%;width:9%;height:9%;border-radius:50%;background:#0b1b12}.dgame-snake-cell.body2::after{background:linear-gradient(135deg,#34d399,#0d9f6e)}
|
|
103
|
+
` };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/* 趣味游戏 · 推箱子(经典机台)——纯 Client;把箱子推到目标点上即可过关。 */
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
// 关卡:'#' 墙 · ' ' 地板 · '.' 目标 · '$' 箱子 · '@' 人 · '*' 箱子在目标 · '+' 人在目标
|
|
5
|
+
const LEVELS = [
|
|
6
|
+
["######", "# #", "#@$ .#", "# #", "######"],
|
|
7
|
+
["######", "# @ #", "# $ #", "# . #", "######"],
|
|
8
|
+
["#######", "# @ #", "# $$ #", "# .. #", "#######"],
|
|
9
|
+
["######", "#@ #", "# $ #", "# . #", "# #", "######"],
|
|
10
|
+
["#######", "# @ #", "# $ #", "# $ .#", "# . #", "#######"],
|
|
11
|
+
["########", "# #", "# $ $ #", "# . . #", "# @ #", "########"],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function cloneGrid(grid) {
|
|
15
|
+
return grid.map((row) => row.slice());
|
|
16
|
+
}
|
|
17
|
+
function parse(rows) {
|
|
18
|
+
return rows.map((row) => row.split(""));
|
|
19
|
+
}
|
|
20
|
+
function findPlayer(grid) {
|
|
21
|
+
for (let r = 0; r < grid.length; r += 1) {
|
|
22
|
+
for (let c = 0; c < grid[r].length; c += 1) {
|
|
23
|
+
if (grid[r][c] === "@" || grid[r][c] === "+") return { r, c };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { r: 0, c: 0 };
|
|
27
|
+
}
|
|
28
|
+
function isTarget(ch) {
|
|
29
|
+
return ch === "." || ch === "*" || ch === "+";
|
|
30
|
+
}
|
|
31
|
+
function isBox(ch) {
|
|
32
|
+
return ch === "$" || ch === "*";
|
|
33
|
+
}
|
|
34
|
+
// 获胜:没有箱子错位(不存在 '$')。
|
|
35
|
+
function won(grid) {
|
|
36
|
+
for (const row of grid) for (const ch of row) if (ch === "$") return false;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
function makeInitial(levelIndex) {
|
|
40
|
+
const grid = parse(LEVELS[levelIndex]);
|
|
41
|
+
return { grid, moves: 0, pushes: 0, level: levelIndex, wonNew: false, history: [] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function step(state, dr, dc) {
|
|
45
|
+
if (state.wonNew) return state;
|
|
46
|
+
const grid = cloneGrid(state.grid);
|
|
47
|
+
const p = findPlayer(grid);
|
|
48
|
+
const nr = p.r + dr, nc = p.c + dc;
|
|
49
|
+
if (nr < 0 || nr >= grid.length || nc < 0 || nc >= grid[0].length) return state;
|
|
50
|
+
const target = grid[nr][nc];
|
|
51
|
+
if (target === "#") return state;
|
|
52
|
+
grid[p.r][p.c] = isTarget(grid[p.r][p.c]) ? "." : " ";
|
|
53
|
+
if (isBox(target)) {
|
|
54
|
+
const br = nr + dr, bc = nc + dc;
|
|
55
|
+
if (br < 0 || br >= grid.length || bc < 0 || bc >= grid[0].length) return state;
|
|
56
|
+
const beyond = grid[br][bc];
|
|
57
|
+
if (beyond === "#" || isBox(beyond)) return state;
|
|
58
|
+
grid[br][bc] = isTarget(beyond) ? "*" : "$";
|
|
59
|
+
grid[nr][nc] = isTarget(target) ? "+" : "@";
|
|
60
|
+
return Object.assign({}, state, {
|
|
61
|
+
grid,
|
|
62
|
+
moves: state.moves + 1,
|
|
63
|
+
pushes: state.pushes + 1,
|
|
64
|
+
wonNew: won(grid),
|
|
65
|
+
history: state.history.concat([{ grid: state.grid, moves: state.moves, pushes: state.pushes }]),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
grid[nr][nc] = isTarget(target) ? "+" : "@";
|
|
69
|
+
return Object.assign({}, state, {
|
|
70
|
+
grid,
|
|
71
|
+
moves: state.moves + 1,
|
|
72
|
+
wonNew: won(grid),
|
|
73
|
+
history: state.history.concat([{ grid: state.grid, moves: state.moves, pushes: state.pushes }]),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function undo(state) {
|
|
77
|
+
if (!state.history.length) return state;
|
|
78
|
+
const prev = state.history[state.history.length - 1];
|
|
79
|
+
return Object.assign({}, state, {
|
|
80
|
+
grid: cloneGrid(prev.grid),
|
|
81
|
+
moves: prev.moves,
|
|
82
|
+
pushes: prev.pushes,
|
|
83
|
+
history: state.history.slice(0, -1),
|
|
84
|
+
wonNew: false,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const DIRS = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], w: [-1, 0], s: [1, 0], a: [0, -1], d: [0, 1], W: [-1, 0], S: [1, 0], A: [0, -1], D: [0, 1] };
|
|
89
|
+
|
|
90
|
+
export function SokobanGame(props) {
|
|
91
|
+
const [level, setLevel] = useState(0);
|
|
92
|
+
const [state, setState] = useState(() => makeInitial(0));
|
|
93
|
+
const stageRef = useRef(null);
|
|
94
|
+
const levelRef = useRef(level);
|
|
95
|
+
levelRef.current = level;
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (props.paused) return;
|
|
99
|
+
if (stageRef.current) stageRef.current.focus();
|
|
100
|
+
}, [level, props.paused]);
|
|
101
|
+
|
|
102
|
+
const move = useCallback((dr, dc) => setState((s) => step(s, dr, dc)), []);
|
|
103
|
+
const nextLevel = useCallback(() => {
|
|
104
|
+
const n = (levelRef.current + 1) % LEVELS.length;
|
|
105
|
+
setLevel(n);
|
|
106
|
+
setState(makeInitial(n));
|
|
107
|
+
}, []);
|
|
108
|
+
const pickLevel = useCallback((i) => { setLevel(i); setState(makeInitial(i)); }, []);
|
|
109
|
+
const reset = useCallback(() => setState(makeInitial(levelRef.current)), []);
|
|
110
|
+
|
|
111
|
+
const onKeyDown = useCallback((event) => {
|
|
112
|
+
const d = DIRS[event.key];
|
|
113
|
+
if (d) { event.preventDefault(); move(d[0], d[1]); return; }
|
|
114
|
+
if (event.key.toLowerCase() === "u") { event.preventDefault(); setState((s) => undo(s)); }
|
|
115
|
+
else if (event.key.toLowerCase() === "r") { event.preventDefault(); reset(); }
|
|
116
|
+
else if (event.key === "Enter" && state.wonNew) { event.preventDefault(); nextLevel(); }
|
|
117
|
+
}, [move, state.wonNew, reset, nextLevel]);
|
|
118
|
+
|
|
119
|
+
const grid = state.grid;
|
|
120
|
+
const rows = grid.length, cols = grid[0].length;
|
|
121
|
+
const width = Math.min(cols * 26, 340);
|
|
122
|
+
|
|
123
|
+
return <section className="dgame-game" aria-label="推箱子">
|
|
124
|
+
<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>
|
|
125
|
+
<div className="dgame-sokoban-levels" role="tablist" aria-label="选择关卡">
|
|
126
|
+
{LEVELS.map((_, i) => <span key={i}><button type="button" role="tab" aria-selected={i === level} className={i === level ? "on" : ""} onClick={() => pickLevel(i)}>{i + 1}</button></span>)}
|
|
127
|
+
<button type="button" onClick={() => setState((s) => undo(s))}>撤销</button>
|
|
128
|
+
<button type="button" onClick={reset}>重置</button>
|
|
129
|
+
</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 重置">
|
|
131
|
+
{grid.map((row, r) => row.map((ch, c) => {
|
|
132
|
+
let cls = "dgame-soko-cell";
|
|
133
|
+
if (ch === "#") cls += " wall";
|
|
134
|
+
else if (ch === "." || ch === "*" || ch === "+") cls += " target";
|
|
135
|
+
if (ch === "$" || ch === "*") cls += " box";
|
|
136
|
+
if (ch === "@" || ch === "+") cls += " player";
|
|
137
|
+
return <i key={r + "-" + c} className={cls} aria-hidden="true" />;
|
|
138
|
+
}))}
|
|
139
|
+
{state.wonNew ? <div className="dgame-over"><strong>全搞定!第 {level + 1} 关通过</strong><button type="button" onClick={nextLevel}>{level + 1 < LEVELS.length ? "下一关" : "重新开始"}</button></div> : null}
|
|
140
|
+
</div>
|
|
141
|
+
<div className="dgame-controls"><button type="button" aria-label="向上" onClick={() => move(-1, 0)}>↑</button><span>方向键 / WASD · U 撤销 · R 重置</span><button type="button" aria-label="向下" onClick={() => move(1, 0)}>↓</button></div>
|
|
142
|
+
</section>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function SokobanPreview() {
|
|
146
|
+
return <span className="dgcov-prev dgcov-prev-soko" aria-hidden="true">
|
|
147
|
+
{["#####", "#@$.#", "# . #", "#####"].map((row, r) => <b key={r}>{row}</b>)}
|
|
148
|
+
</span>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const sokobanGame = { Game: SokobanGame, Preview: SokobanPreview, css: `
|
|
152
|
+
.dgame-sokoban-stage{position:relative;display:grid;align-self:center;gap:0;padding:6px;border:1px solid color-mix(in srgb,rgb(250 204 21) 32%,var(--dsw-alias-border-l1));border-radius:8px;background:linear-gradient(180deg,#181420,#14101f);outline:none;box-shadow:inset 0 0 26px rgb(0 0 0/.4)}.dgame-sokoban-stage:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,rgb(250 204 21) 30%,transparent)}.dgame-soko-cell{position:relative;display:block;min-width:0;min-height:0}.dgame-soko-cell.wall{background:linear-gradient(180deg,#3a3350,#241f3a);box-shadow:inset 0 1px 0 rgba(255,255,255,.12),inset 0 -1px 0 rgba(0,0,0,.4)}.dgame-soko-cell.target::before{content:'';position:absolute;inset:30%;border-radius:50%;background:radial-gradient(circle at 35% 30%,#fde68a,#f59e0b 70%);box-shadow:0 0 8px color-mix(in srgb,#f59e0b 55%,transparent);opacity:.9}.dgame-soko-cell.box::after{content:'';position:absolute;inset:12%;border-radius:5px;background:linear-gradient(135deg,#d4a24a,#8a5a1c);box-shadow:0 2px 5px rgb(0 0 0/.45),inset 0 1px 0 rgba(255,255,255,.4);border:1px solid color-mix(in srgb,#fde68a 45%,transparent)}.dgame-soko-cell.box.target::after{background:linear-gradient(135deg,#34d399,#0f766e);border-color:#a7f3d0}.dgame-soko-cell.player::before{content:'';position:absolute;inset:18%;border-radius:50%;background:radial-gradient(circle at 35% 30%,#e0f2fe,#38bdf8 70%);box-shadow:0 0 9px color-mix(in srgb,#38bdf8 70%,transparent)}.dgame-sokoban-levels{display:flex;gap:6px;align-items:center;align-self:center;flex-wrap:wrap;width:min(100%,340px);justify-content:center}.dgame-sokoban-levels button{min-width:30px;height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer;transition:all .15s ease}.dgame-sokoban-levels button.on{color:#fde68a;border-color:color-mix(in srgb,#f59e0b 60%,transparent);background:color-mix(in srgb,#f59e0b 18%,transparent)}.dgame-sokoban-levels button:hover{border-color:var(--dgame-accent);color:var(--dsw-alias-label-primary)}
|
|
153
|
+
` };
|
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
|
|
5
|
+
const DIR_ANGLE = { up: 0, right: 90, down: 180, left: 270 };
|
|
6
|
+
const ENEMY_COLORS = ["#f87171", "#fb923c", "#c084fc", "#4ade80", "#38bdf8"];
|
|
7
|
+
|
|
8
|
+
export function TankGame(props) {
|
|
9
|
+
const [snap, setSnap] = useState(initialState);
|
|
10
|
+
const [status, setStatus] = useState("play"); // play | wave | over | win
|
|
11
|
+
const snapRef = useRef(snap); snapRef.current = snap;
|
|
12
|
+
const keys = useRef({ dir: null, shoot: false });
|
|
13
|
+
const pausedRef = useRef(!!(props && props.paused)); pausedRef.current = !!(props && props.paused);
|
|
14
|
+
const statusRef = useRef("play"); statusRef.current = status;
|
|
15
|
+
const boardRef = useRef(null);
|
|
16
|
+
|
|
17
|
+
const start = useCallback(() => {
|
|
18
|
+
setSnap(initialState()); setStatus("play"); statusRef.current = "play";
|
|
19
|
+
}, []);
|
|
20
|
+
const nextLvl = useCallback(() => {
|
|
21
|
+
setSnap((s) => nextWave(s)); setStatus("play"); statusRef.current = "play";
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
// 主循环
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const timer = setInterval(() => {
|
|
27
|
+
if (pausedRef.current) return;
|
|
28
|
+
setSnap((s) => {
|
|
29
|
+
if (s.over || s.win) return s;
|
|
30
|
+
const input = { dir: keys.current.dir, shoot: keys.current.shoot };
|
|
31
|
+
const ns = step(s, input);
|
|
32
|
+
if (ns.over && statusRef.current === "play") { statusRef.current = "over"; setStatus("over"); }
|
|
33
|
+
else if (ns.win && statusRef.current === "play") { statusRef.current = "wave"; setStatus("wave"); }
|
|
34
|
+
return ns;
|
|
35
|
+
});
|
|
36
|
+
}, 120);
|
|
37
|
+
return () => clearInterval(timer);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const stageRef = useRef(null);
|
|
41
|
+
const onKeyDown = useCallback((event) => {
|
|
42
|
+
if (pausedRef.current) return;
|
|
43
|
+
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" };
|
|
44
|
+
const dir = map[event.key];
|
|
45
|
+
if (dir) { event.preventDefault(); keys.current.dir = dir; return; }
|
|
46
|
+
if (event.key === " ") { event.preventDefault(); keys.current.shoot = true; }
|
|
47
|
+
else if (event.key.toLowerCase() === "r") { event.preventDefault(); start(); }
|
|
48
|
+
}, [start]);
|
|
49
|
+
const onKeyUp = useCallback((event) => {
|
|
50
|
+
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" };
|
|
51
|
+
const dir = map[event.key];
|
|
52
|
+
if (dir && keys.current.dir === dir) keys.current.dir = null;
|
|
53
|
+
if (event.key === " ") keys.current.shoot = false;
|
|
54
|
+
}, []);
|
|
55
|
+
useEffect(() => { if (pausedRef.current) return; if (stageRef.current) stageRef.current.focus(); }, [props.paused]);
|
|
56
|
+
|
|
57
|
+
const grid = snap.grid;
|
|
58
|
+
const player = snap.player;
|
|
59
|
+
const enemies = snap.enemies;
|
|
60
|
+
const bullets = snap.bullets;
|
|
61
|
+
let playerPos = null;
|
|
62
|
+
if (player && player.alive) playerPos = { r: player.r, c: player.c };
|
|
63
|
+
const enemyMap = {};
|
|
64
|
+
for (const e of enemies) enemyMap[e.r + "-" + e.c] = e;
|
|
65
|
+
const bulletMap = {};
|
|
66
|
+
for (const b of bullets) bulletMap[b.r + "-" + b.c] = b;
|
|
67
|
+
const basePos = findBase(grid);
|
|
68
|
+
|
|
69
|
+
const cells = [];
|
|
70
|
+
for (let r = 0; r < GRID; r += 1) {
|
|
71
|
+
for (let c = 0; c < GRID; c += 1) {
|
|
72
|
+
const key = r + "-" + c;
|
|
73
|
+
let cls = "dgame-tank-cell";
|
|
74
|
+
let inner = null;
|
|
75
|
+
if (grid[r][c] === STEEL) cls += " steel";
|
|
76
|
+
else if (grid[r][c] === BRICK) cls += " brick";
|
|
77
|
+
else if (grid[r][c] === BASE) cls += " base";
|
|
78
|
+
if (playerPos && playerPos.r === r && playerPos.c === c) {
|
|
79
|
+
cls += " pw";
|
|
80
|
+
inner = <i className="dgame-tank-body pw" style={{ transform: "rotate(" + DIR_ANGLE[player.dir] + "deg)" }} />;
|
|
81
|
+
} else if (enemyMap[key]) {
|
|
82
|
+
const e = enemyMap[key];
|
|
83
|
+
cls += " en";
|
|
84
|
+
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)" }} />;
|
|
85
|
+
}
|
|
86
|
+
if (bullets && bulletMap[key]) cls += " bullet";
|
|
87
|
+
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>);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const statusText = status === "over" ? "基地失守 · 游戏结束" : status === "wave" ? "第 " + snap.wave + " 波清空!进入下一波" : ("第 " + snap.wave + " 波 · 待歼灭 " + (snap.remaining + snap.enemies.length) + " 辆 · 生命 × " + (player && player.lives));
|
|
92
|
+
|
|
93
|
+
return <section className="dgame-game" aria-label="坦克大战">
|
|
94
|
+
<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-grid">{cells}</div>
|
|
97
|
+
{status === "over" ? <div className="dgame-over"><strong>胜败已分 · 得 {snap.score} 分</strong><button type="button" onClick={start}>重新开始</button></div> : null}
|
|
98
|
+
{status === "wave" ? <div className="dgame-over"><strong>第 {snap.wave} 波清空!</strong><button type="button" onClick={nextLvl}>下一波</button></div> : null}
|
|
99
|
+
</div>
|
|
100
|
+
<div className="dgame-controls dgame-tank-controls">
|
|
101
|
+
<button type="button" aria-label="向上" onClick={() => { keys.current.dir = "up"; setTimeout(() => { if (keys.current.dir === "up") keys.current.dir = null; }, 90); }}>↑</button>
|
|
102
|
+
<span>方向键 / WASD · 空格开炮 · R 重开</span>
|
|
103
|
+
<button type="button" className="dgame-tank-fire" aria-label="开炮" onClick={() => { keys.current.shoot = true; setTimeout(() => { keys.current.shoot = false; }, 90); }}>开炮</button>
|
|
104
|
+
</div>
|
|
105
|
+
</section>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function TankPreview() {
|
|
109
|
+
return <span className="dgcov-prev dgcov-prev-tank" aria-hidden="true">
|
|
110
|
+
{["#...#", "#...#", "#...#", "+++++", "+..1+", "+++++"].map((row, r) => <b key={r}>{row}</b>)}
|
|
111
|
+
</span>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const tankGame = { Game: TankGame, Preview: TankPreview, css: `
|
|
115
|
+
.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}
|
|
116
|
+
` };
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/* 趣味游戏 · 俄罗斯方块(经典机台)——纯 Client,键盘/按钮可玩;不写会话、不改工作区。 */
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
const COLS = 10, ROWS = 20;
|
|
5
|
+
const SHAPES = [
|
|
6
|
+
{ color: "#22d3ee", mat: [[1, 1, 1, 1]] },
|
|
7
|
+
{ color: "#facc15", mat: [[1, 1], [1, 1]] },
|
|
8
|
+
{ color: "#c084fc", mat: [[0, 1, 0], [1, 1, 1]] },
|
|
9
|
+
{ color: "#4ade80", mat: [[0, 1, 1], [1, 1, 0]] },
|
|
10
|
+
{ color: "#f87171", mat: [[1, 1, 0], [0, 1, 1]] },
|
|
11
|
+
{ color: "#60a5fa", mat: [[1, 0, 0], [1, 1, 1]] },
|
|
12
|
+
{ color: "#fb923c", mat: [[0, 0, 1], [1, 1, 1]] },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function rotateCW(mat) {
|
|
16
|
+
return mat[0].map((_, col) => mat.map((row) => row[col]).reverse());
|
|
17
|
+
}
|
|
18
|
+
function rotations(shape) {
|
|
19
|
+
const list = [shape.mat];
|
|
20
|
+
let cur = shape.mat;
|
|
21
|
+
for (let i = 0; i < 3; i += 1) {
|
|
22
|
+
cur = rotateCW(cur);
|
|
23
|
+
if (!list.some((m) => JSON.stringify(m) === JSON.stringify(cur))) list.push(cur);
|
|
24
|
+
}
|
|
25
|
+
return list;
|
|
26
|
+
}
|
|
27
|
+
const PIECES = SHAPES.map((s) => ({ color: s.color, rots: rotations(s) }));
|
|
28
|
+
|
|
29
|
+
function emptyBoard() {
|
|
30
|
+
return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
|
|
31
|
+
}
|
|
32
|
+
function collides(board, mat, x, y) {
|
|
33
|
+
for (let r = 0; r < mat.length; r += 1) {
|
|
34
|
+
for (let c = 0; c < mat[r].length; c += 1) {
|
|
35
|
+
if (!mat[r][c]) continue;
|
|
36
|
+
const bx = x + c, by = y + r;
|
|
37
|
+
if (bx < 0 || bx >= COLS || by >= ROWS || (by >= 0 && board[by][bx])) return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
function merge(board, mat, x, y, color) {
|
|
43
|
+
const next = board.map((row) => row.slice());
|
|
44
|
+
for (let r = 0; r < mat.length; r += 1) {
|
|
45
|
+
for (let c = 0; c < mat[r].length; c += 1) {
|
|
46
|
+
if (mat[r][c] && y + r >= 0) next[y + r][x + c] = color;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return next;
|
|
50
|
+
}
|
|
51
|
+
function clearLines(board) {
|
|
52
|
+
const kept = board.filter((row) => row.some((cell) => !cell));
|
|
53
|
+
const cleared = ROWS - kept.length;
|
|
54
|
+
while (kept.length < ROWS) kept.unshift(Array(COLS).fill(null));
|
|
55
|
+
return { board: kept, cleared };
|
|
56
|
+
}
|
|
57
|
+
function seedBag() {
|
|
58
|
+
const bag = SHAPES.map((_, i) => i);
|
|
59
|
+
for (let i = bag.length - 1; i > 0; i -= 1) {
|
|
60
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
61
|
+
[bag[i], bag[j]] = [bag[j], bag[i]];
|
|
62
|
+
}
|
|
63
|
+
return bag;
|
|
64
|
+
}
|
|
65
|
+
function makePiece(type, x, y) {
|
|
66
|
+
const p = PIECES[type];
|
|
67
|
+
return { type, rot: 0, x, y: y != null ? y : -1, mat: p.rots[0], color: p.color };
|
|
68
|
+
}
|
|
69
|
+
function initial() {
|
|
70
|
+
const bag = seedBag();
|
|
71
|
+
const piece = makePiece(bag.pop(), 3);
|
|
72
|
+
const next = bag.pop();
|
|
73
|
+
return { board: emptyBoard(), piece, next, bag, score: 0, lines: 0, level: 1, over: false };
|
|
74
|
+
}
|
|
75
|
+
// 用"下一个"方块生成当前块并补足 7-bag。
|
|
76
|
+
function spawnFrom(state) {
|
|
77
|
+
const bag = state.bag && state.bag.length ? state.bag.slice() : seedBag();
|
|
78
|
+
const type = state.next != null ? state.next : bag.pop();
|
|
79
|
+
const next = bag.length ? bag.pop() : null;
|
|
80
|
+
const piece = makePiece(type, 3);
|
|
81
|
+
const over = collides(state.board, piece.mat, piece.x, piece.y) || state.over;
|
|
82
|
+
return Object.assign({}, state, { bag, next, piece, over });
|
|
83
|
+
}
|
|
84
|
+
// 推进一格:能下移则下移,否则落地合并、消行、生成下一个。
|
|
85
|
+
function advance(state) {
|
|
86
|
+
if (state.over) return state;
|
|
87
|
+
const p = state.piece;
|
|
88
|
+
if (!collides(state.board, p.mat, p.x, p.y + 1)) {
|
|
89
|
+
return Object.assign({}, state, { piece: Object.assign({}, p, { y: p.y + 1 }), score: state.score + 1 });
|
|
90
|
+
}
|
|
91
|
+
const merged = merge(state.board, p.mat, p.x, p.y, p.color);
|
|
92
|
+
const cleared = clearLines(merged);
|
|
93
|
+
const lines = state.lines + cleared.cleared;
|
|
94
|
+
const level = Math.floor(lines / 10) + 1;
|
|
95
|
+
const base = [0, 100, 300, 500, 800][cleared.cleared] || 800;
|
|
96
|
+
const scored = Object.assign({}, state, {
|
|
97
|
+
board: cleared.board, lines, level,
|
|
98
|
+
score: state.score + base * state.level,
|
|
99
|
+
});
|
|
100
|
+
return spawnFrom(scored);
|
|
101
|
+
}
|
|
102
|
+
function hardDrop(state) {
|
|
103
|
+
if (state.over) return state;
|
|
104
|
+
let y = state.piece.y, dist = 0;
|
|
105
|
+
while (!collides(state.board, state.piece.mat, state.piece.x, y + 1)) { y += 1; dist += 1; }
|
|
106
|
+
const p = Object.assign({}, state.piece, { y });
|
|
107
|
+
const merged = merge(state.board, p.mat, p.x, p.y, p.color);
|
|
108
|
+
const cleared = clearLines(merged);
|
|
109
|
+
const lines = state.lines + cleared.cleared;
|
|
110
|
+
const level = Math.floor(lines / 10) + 1;
|
|
111
|
+
const base = [0, 100, 300, 500, 800][cleared.cleared] || 800;
|
|
112
|
+
const scored = Object.assign({}, state, {
|
|
113
|
+
board: cleared.board, lines, level,
|
|
114
|
+
score: state.score + base * state.level + dist * 2,
|
|
115
|
+
});
|
|
116
|
+
return spawnFrom(scored);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const KEY_ACTIONS = {
|
|
120
|
+
"ArrowLeft": "left", "a": "left", "A": "left",
|
|
121
|
+
"ArrowRight": "right", "d": "right", "D": "right",
|
|
122
|
+
"ArrowDown": "down", "s": "down", "S": "down",
|
|
123
|
+
"ArrowUp": "cw", "x": "cw", "X": "cw",
|
|
124
|
+
"z": "ccw", "Z": "ccw",
|
|
125
|
+
" ": "drop", "Enter": "drop",
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export function TetrisGame(props) {
|
|
129
|
+
const [state, setState] = useState(initial);
|
|
130
|
+
const [manualPause, setManualPause] = useState(false);
|
|
131
|
+
const pausedRef = useRef(!!(props && props.paused));
|
|
132
|
+
pausedRef.current = !!(props && props.paused) || manualPause;
|
|
133
|
+
const stageRef = useRef(null);
|
|
134
|
+
|
|
135
|
+
// 事件委托统一处理(避免按钮/键盘两套逻辑)。
|
|
136
|
+
const act = useCallback((action) => setState((s) => {
|
|
137
|
+
if (pausedRef.current) return s;
|
|
138
|
+
if (action === "left") {
|
|
139
|
+
const x2 = s.piece.x - 1;
|
|
140
|
+
if (collides(s.board, s.piece.mat, x2, s.piece.y)) return s;
|
|
141
|
+
return Object.assign({}, s, { piece: Object.assign({}, s.piece, { x: x2 }) });
|
|
142
|
+
}
|
|
143
|
+
if (action === "right") {
|
|
144
|
+
const x2 = s.piece.x + 1;
|
|
145
|
+
if (collides(s.board, s.piece.mat, x2, s.piece.y)) return s;
|
|
146
|
+
return Object.assign({}, s, { piece: Object.assign({}, s.piece, { x: x2 }) });
|
|
147
|
+
}
|
|
148
|
+
if (action === "down") return advance(s);
|
|
149
|
+
if (action === "drop") return hardDrop(s);
|
|
150
|
+
if (action === "cw" || action === "ccw") return rotatePiece(s, action === "cw" ? 1 : -1);
|
|
151
|
+
return s;
|
|
152
|
+
}), []);
|
|
153
|
+
|
|
154
|
+
const onKeyDown = useCallback((event) => {
|
|
155
|
+
const action = KEY_ACTIONS[event.key];
|
|
156
|
+
if (action) { event.preventDefault(); act(action); return; }
|
|
157
|
+
if (event.key.toLowerCase() === "p") { event.preventDefault(); setManualPause((p) => !p); }
|
|
158
|
+
else if (event.key.toLowerCase() === "r") { event.preventDefault(); setState(initial()); setManualPause(false); }
|
|
159
|
+
}, [act]);
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (pausedRef.current || state.over) return;
|
|
163
|
+
const speed = Math.max(80, 720 - (state.level - 1) * 70);
|
|
164
|
+
const timer = setInterval(() => act("down"), speed);
|
|
165
|
+
return () => clearInterval(timer);
|
|
166
|
+
}, [state.level, state.over, act, props.paused, manualPause]);
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (pausedRef.current || state.over) return;
|
|
170
|
+
if (stageRef.current) stageRef.current.focus();
|
|
171
|
+
}, [state.over, props.paused, manualPause]);
|
|
172
|
+
|
|
173
|
+
const display = state.board.map((row) => row.slice());
|
|
174
|
+
if (!state.over) {
|
|
175
|
+
const m = state.piece.mat;
|
|
176
|
+
for (let r = 0; r < m.length; r += 1) {
|
|
177
|
+
for (let c = 0; c < m[r].length; c += 1) {
|
|
178
|
+
if (m[r][c]) {
|
|
179
|
+
const by = state.piece.y + r, bx = state.piece.x + c;
|
|
180
|
+
if (by >= 0 && by < ROWS && bx >= 0 && bx < COLS) display[by][bx] = state.piece.color;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const nextType = state.next != null ? state.next : 0;
|
|
186
|
+
const nextMat = PIECES[nextType].rots[0];
|
|
187
|
+
|
|
188
|
+
return <section className="dgame-game" aria-label="俄罗斯方块">
|
|
189
|
+
<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
|
+
<div className="dgame-tetris">
|
|
191
|
+
<div className="dgame-tetris-board" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} aria-label="俄罗斯方块游戏区域,使用方向键与空格控制">
|
|
192
|
+
{display.map((row, rr) => row.map((cell, cc) => <i key={rr + "-" + cc} className="dgame-tetris-cell" style={cell ? { background: cell, boxShadow: "0 0 8px " + cell + "aa, inset 0 0 4px rgba(255,255,255,.5)" } : undefined} />))}
|
|
193
|
+
{state.over ? <div className="dgame-over"><strong>方块堆到顶了</strong><button type="button" onClick={() => setState(initial())}>再来一局</button></div> : <span className="dgame-tetris-bg" aria-hidden="true" />}
|
|
194
|
+
{!state.over && (props.paused || manualPause) ? <div className="dgame-over"><strong>已暂停</strong><button type="button" onClick={() => setManualPause(false)}>继续(P)</button></div> : null}
|
|
195
|
+
</div>
|
|
196
|
+
<div className="dgame-tetris-side">
|
|
197
|
+
<div className="dgame-tetris-next"><span>下一个</span><div className="dgame-tetris-nextbox">{nextMat.map((row, r) => row.map((cell, c) => <i key={r + "-" + c} className="dgame-tetris-cell" style={cell ? { background: PIECES[nextType].color } : undefined} />))}</div></div>
|
|
198
|
+
<div className="dgame-tetris-keys"><b>操作</b><span>←/→ 移动 · ↓ 下落 · ↑/X 旋转 · Z 反向 · 空格 直落 · P 暂停 · R 重开</span></div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
<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>
|
|
202
|
+
</section>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 旋转:带简单墙踢。
|
|
206
|
+
function rotatePiece(state, dir) {
|
|
207
|
+
const rots = PIECES[state.piece.type].rots;
|
|
208
|
+
const len = rots.length;
|
|
209
|
+
const nextRot = ((state.piece.rot + dir) % len + len) % len;
|
|
210
|
+
const mat = rots[nextRot];
|
|
211
|
+
const kicks = [0, -1, 1, -2, 2, -1 - (state.piece.y < 0 ? 1 : 0), 1 + (state.piece.y < 0 ? 1 : 0), -1, 1];
|
|
212
|
+
for (const kx of kicks) {
|
|
213
|
+
if (!collides(state.board, mat, state.piece.x + kx, state.piece.y)) {
|
|
214
|
+
return Object.assign({}, state, { piece: Object.assign({}, state.piece, { rot: nextRot, x: state.piece.x + kx, mat }) });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return state;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function TetrisPreview() {
|
|
221
|
+
return <span className="dgcov-prev dgcov-prev-tetris" aria-hidden="true">
|
|
222
|
+
<i style={{ left: "34%", top: "10%", background: "#22d3ee" }} />
|
|
223
|
+
<i style={{ left: "24%", top: "40%", background: "#facc15" }} />
|
|
224
|
+
<i style={{ left: "46%", top: "48%", background: "#c084fc" }} />
|
|
225
|
+
<i style={{ left: "68%", top: "26%", background: "#4ade80" }} />
|
|
226
|
+
<i style={{ left: "54%", top: "68%", background: "#f87171" }} />
|
|
227
|
+
<i style={{ left: "26%", top: "74%", background: "#60a5fa" }} />
|
|
228
|
+
<i style={{ left: "72%", top: "60%", background: "#fb923c" }} />
|
|
229
|
+
<b />
|
|
230
|
+
</span>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const tetrisGame = { Game: TetrisGame, Preview: TetrisPreview, css: `
|
|
234
|
+
.dgame-tetris{display:flex;gap:12px;align-items:flex-start;align-self:center;width:min(100%,360px);justify-content:center}.dgame-tetris-board{position:relative;flex:none;display:grid;grid-template-columns:repeat(10,1fr);grid-template-rows:repeat(20,1fr);width:min(100%,188px);aspect-ratio:10/20;border:1px solid color-mix(in srgb,rgb(34 211 238) 35%,var(--dsw-alias-border-l1));border-radius:8px;overflow:hidden;outline:none;background:linear-gradient(180deg,#0c1024,#131a3d);padding:0;box-shadow:inset 0 0 30px rgb(0 0 0/.45)}.dgame-tetris-board:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,rgb(34 211 238) 40%,transparent)}.dgame-tetris-cell{display:block;background:transparent;border-radius:2px;margin:1px}.dgame-tetris-nextbox{display:grid;grid-template-columns:repeat(4,1fr);grid-template-rows:repeat(2,1fr);width:60px;height:32px}.dgame-tetris-side{flex:none;display:flex;flex-direction:column;gap:9px;min-width:112px;color:var(--dsw-alias-label-tertiary);font-size:11px}.dgame-tetris-side b{color:var(--dsw-alias-label-secondary);font-size:12px}.dgame-tetris-side span{font-size:11px}.dgame-tetris-nextbox .dgame-tetris-cell{margin:1px}.dgame-tetris-keys span{display:block;line-height:1.6;color:var(--dsw-alias-label-tertiary)}.dgame-tetris-next{display:flex;flex-direction:column;gap:5px;align-items:center}.dgame-tetris-next span{color:var(--dsw-alias-label-secondary)}.dgame-tetris-bg{position:absolute;inset:0;pointer-events:none;background-image:repeating-linear-gradient(0deg,color-mix(in srgb,#60a5fa 7%,transparent) 0 1px,transparent 1px calc(100% / 20)),repeating-linear-gradient(90deg,color-mix(in srgb,#60a5fa 7%,transparent) 0 1px,transparent 1px calc(100% / 10));opacity:.5}@media (max-width:680px){.dgame-tetris{width:100%;gap:8px}.dgame-tetris-board{width:min(100%,170px)}}
|
|
235
|
+
` };
|