dsh-dock 0.9.5 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -18
- package/README.md +325 -325
- package/client.js +42 -8
- package/cordis.patch.yml +2 -2
- package/features/animation/host.js +660 -660
- package/features/animation/view.jsx +2247 -2247
- package/features/balance/host.js +494 -494
- package/features/balance/view.js +351 -351
- package/features/games/games/breakout.jsx +227 -227
- package/features/games/games/gomoku.jsx +244 -244
- package/features/games/games/racer.jsx +169 -169
- package/features/games/games/shared.jsx +118 -118
- package/features/games/games/snake.jsx +144 -144
- package/features/games/games/sokoban.jsx +180 -180
- package/features/games/games/tank.jsx +128 -128
- package/features/games/games/tetris.jsx +308 -308
- package/features/games/games/xiangqi.jsx +277 -277
- package/features/games/tank-core.js +152 -152
- package/features/games/view.jsx +639 -639
- package/features/games/xiangqi-core.js +299 -299
- package/features/heartbeat/view.js +35 -35
- package/features/mobile-relay/gateway.js +472 -415
- package/features/mobile-relay/host.js +570 -546
- package/features/mobile-relay/view.jsx +300 -300
- package/features/modelconfig/host.js +403 -403
- package/features/modelconfig/view.js +490 -490
- package/features/theme/view.js +61 -61
- package/features/tokenlog/host.js +678 -678
- package/features/tokenlog/view.jsx +561 -561
- package/features/visionproxy/host.js +315 -315
- package/index.js +175 -118
- package/package.json +59 -59
- package/src/client.jsx +668 -668
- package/src/host-core.js +98 -95
- package/src/shared.js +167 -130
|
@@ -1,180 +1,180 @@
|
|
|
1
|
-
/* 趣味游戏 · 推箱子(经典机台)——纯 Client;把箱子推到目标点上即可过关。 */
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { focusStage, useGameControls, playSfx } from "./shared.jsx";
|
|
4
|
-
|
|
5
|
-
// 关卡:'#' 墙 · ' ' 地板 · '.' 目标 · '$' 箱子 · '@' 人 · '*' 箱子在目标 · '+' 人在目标
|
|
6
|
-
const LEVELS = [
|
|
7
|
-
["######", "# #", "#@$ .#", "# #", "######"],
|
|
8
|
-
["######", "# @ #", "# $ #", "# . #", "######"],
|
|
9
|
-
["#######", "# @ #", "# $$ #", "# .. #", "#######"],
|
|
10
|
-
["######", "#@ #", "# $ #", "# . #", "# #", "######"],
|
|
11
|
-
["#######", "# @ #", "# $ #", "# $ .#", "# . #", "#######"],
|
|
12
|
-
["########", "# #", "# $ $ #", "# . . #", "# @ #", "########"],
|
|
13
|
-
// —— 扩充关:由易到难(已用求解器验证全部可解)——
|
|
14
|
-
["########", "# ## #", "#@ $ . #", "# ## #", "########"],
|
|
15
|
-
["########", "# @ #", "# $$$ #", "# ... #", "# #", "########"],
|
|
16
|
-
["########", "#@ #", "# $ $ #", "# # #", "# . #. #", "########"],
|
|
17
|
-
["########", "# @ #", "# $$ #", "# ..# #", "# #", "########"],
|
|
18
|
-
["########", "#@ #", "# $ $ #", "# #", "# .# . #", "########"],
|
|
19
|
-
["#########", "# #", "# $ $ $ #", "# ... #", "# @ #", "# #", "#########"],
|
|
20
|
-
["#########", "# @ #", "# $#$$ #", "# ... #", "# ## #", "#########"],
|
|
21
|
-
["#########", "#@ # #", "# $ $ #", "# ### #", "# . . #", "#########"],
|
|
22
|
-
["#########", "# ###", "# $$ # #", "# .. #@ #", "# $$ # #", "# .. #", "#########"],
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
function cloneGrid(grid) {
|
|
26
|
-
return grid.map((row) => row.slice());
|
|
27
|
-
}
|
|
28
|
-
function parse(rows) {
|
|
29
|
-
return rows.map((row) => row.split(""));
|
|
30
|
-
}
|
|
31
|
-
function findPlayer(grid) {
|
|
32
|
-
for (let r = 0; r < grid.length; r += 1) {
|
|
33
|
-
for (let c = 0; c < grid[r].length; c += 1) {
|
|
34
|
-
if (grid[r][c] === "@" || grid[r][c] === "+") return { r, c };
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return { r: 0, c: 0 };
|
|
38
|
-
}
|
|
39
|
-
function isTarget(ch) {
|
|
40
|
-
return ch === "." || ch === "*" || ch === "+";
|
|
41
|
-
}
|
|
42
|
-
function isBox(ch) {
|
|
43
|
-
return ch === "$" || ch === "*";
|
|
44
|
-
}
|
|
45
|
-
// 获胜:没有箱子错位(不存在 '$')。
|
|
46
|
-
function won(grid) {
|
|
47
|
-
for (const row of grid) for (const ch of row) if (ch === "$") return false;
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
50
|
-
function makeInitial(levelIndex) {
|
|
51
|
-
const grid = parse(LEVELS[levelIndex]);
|
|
52
|
-
return { grid, moves: 0, pushes: 0, level: levelIndex, wonNew: false, history: [] };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function step(state, dr, dc) {
|
|
56
|
-
if (state.wonNew) return state;
|
|
57
|
-
const grid = cloneGrid(state.grid);
|
|
58
|
-
const p = findPlayer(grid);
|
|
59
|
-
const nr = p.r + dr, nc = p.c + dc;
|
|
60
|
-
if (nr < 0 || nr >= grid.length || nc < 0 || nc >= grid[0].length) return state;
|
|
61
|
-
const target = grid[nr][nc];
|
|
62
|
-
if (target === "#") return state;
|
|
63
|
-
grid[p.r][p.c] = isTarget(grid[p.r][p.c]) ? "." : " ";
|
|
64
|
-
if (isBox(target)) {
|
|
65
|
-
const br = nr + dr, bc = nc + dc;
|
|
66
|
-
if (br < 0 || br >= grid.length || bc < 0 || bc >= grid[0].length) return state;
|
|
67
|
-
const beyond = grid[br][bc];
|
|
68
|
-
if (beyond === "#" || isBox(beyond)) return state;
|
|
69
|
-
playSfx("push");
|
|
70
|
-
grid[br][bc] = isTarget(beyond) ? "*" : "$";
|
|
71
|
-
grid[nr][nc] = isTarget(target) ? "+" : "@";
|
|
72
|
-
return Object.assign({}, state, {
|
|
73
|
-
grid,
|
|
74
|
-
moves: state.moves + 1,
|
|
75
|
-
pushes: state.pushes + 1,
|
|
76
|
-
wonNew: won(grid),
|
|
77
|
-
history: state.history.concat([{ grid: state.grid, moves: state.moves, pushes: state.pushes }]),
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
grid[nr][nc] = isTarget(target) ? "+" : "@";
|
|
81
|
-
playSfx("step");
|
|
82
|
-
return Object.assign({}, state, {
|
|
83
|
-
grid,
|
|
84
|
-
moves: state.moves + 1,
|
|
85
|
-
wonNew: won(grid),
|
|
86
|
-
history: state.history.concat([{ grid: state.grid, moves: state.moves, pushes: state.pushes }]),
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
function undo(state) {
|
|
90
|
-
if (!state.history.length) return state;
|
|
91
|
-
const prev = state.history[state.history.length - 1];
|
|
92
|
-
return Object.assign({}, state, {
|
|
93
|
-
grid: cloneGrid(prev.grid),
|
|
94
|
-
moves: prev.moves,
|
|
95
|
-
pushes: prev.pushes,
|
|
96
|
-
history: state.history.slice(0, -1),
|
|
97
|
-
wonNew: false,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
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] };
|
|
102
|
-
|
|
103
|
-
export function SokobanGame(props) {
|
|
104
|
-
const [level, setLevel] = useState(0);
|
|
105
|
-
const [state, setState] = useState(() => makeInitial(0));
|
|
106
|
-
const stageRef = useRef(null);
|
|
107
|
-
const levelRef = useRef(level);
|
|
108
|
-
levelRef.current = level;
|
|
109
|
-
|
|
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]);
|
|
116
|
-
const nextLevel = useCallback(() => {
|
|
117
|
-
const n = (levelRef.current + 1) % LEVELS.length;
|
|
118
|
-
setLevel(n);
|
|
119
|
-
setState(makeInitial(n));
|
|
120
|
-
}, []);
|
|
121
|
-
const pickLevel = useCallback((i) => { setLevel(i); setState(makeInitial(i)); }, []);
|
|
122
|
-
const reset = useCallback(() => setState(makeInitial(levelRef.current)), []);
|
|
123
|
-
|
|
124
|
-
const onKeyDown = useCallback((event) => {
|
|
125
|
-
const d = DIRS[event.key];
|
|
126
|
-
if (d) { event.preventDefault(); move(d[0], d[1]); return; }
|
|
127
|
-
if (event.key.toLowerCase() === "u") { event.preventDefault(); setState((s) => undo(s)); }
|
|
128
|
-
else if (event.key.toLowerCase() === "r") { event.preventDefault(); reset(); }
|
|
129
|
-
else if (event.key === "Enter" && state.wonNew) { event.preventDefault(); nextLevel(); }
|
|
130
|
-
}, [move, state.wonNew, reset, nextLevel]);
|
|
131
|
-
|
|
132
|
-
useGameControls(stageRef, props.paused, onKeyDown);
|
|
133
|
-
|
|
134
|
-
const grid = state.grid;
|
|
135
|
-
const rows = grid.length, cols = grid[0].length;
|
|
136
|
-
// 宽度同时钳制:单元格理想宽、容器可用宽、按关卡长宽比折算的视口可用高——保证等比完整显示不裁切。
|
|
137
|
-
const width = "min(" + (cols * 26) + "px, 100%, calc((100vh - 250px) * " + cols + " / " + rows + "))";
|
|
138
|
-
|
|
139
|
-
return <section className="dgame-game" aria-label="推箱子">
|
|
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>
|
|
141
|
-
<div className="dgame-sokoban-levels" role="tablist" aria-label="选择关卡">
|
|
142
|
-
{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>)}
|
|
143
|
-
<button type="button" onClick={() => setState((s) => undo(s))}>撤销</button>
|
|
144
|
-
<button type="button" onClick={reset}>重置</button>
|
|
145
|
-
</div>
|
|
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 重置">
|
|
147
|
-
{grid.map((row, r) => row.map((ch, c) => {
|
|
148
|
-
let cls = "dgame-soko-cell";
|
|
149
|
-
if (ch === "#") cls += " wall";
|
|
150
|
-
else if (ch === "." || ch === "*" || ch === "+") cls += " target";
|
|
151
|
-
if (ch === "$" || ch === "*") cls += " box";
|
|
152
|
-
if (ch === "@" || ch === "+") cls += " player";
|
|
153
|
-
return <i key={r + "-" + c} className={cls} aria-hidden="true" />;
|
|
154
|
-
}))}
|
|
155
|
-
{state.wonNew ? <div className="dgame-over"><strong>全搞定!第 {level + 1} 关通过</strong><button type="button" onClick={nextLevel}>{level + 1 < LEVELS.length ? "下一关" : "重新开始"}</button></div> : null}
|
|
156
|
-
</div>
|
|
157
|
-
<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>
|
|
158
|
-
</section>;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export function SokobanPreview() {
|
|
162
|
-
// 迷你关卡画面:墙/地/箱/目标/小人色块,与真实游戏元素一致。
|
|
163
|
-
const ROWS = [
|
|
164
|
-
"#######",
|
|
165
|
-
"# #",
|
|
166
|
-
"# .$@ #",
|
|
167
|
-
"# .* #",
|
|
168
|
-
"#######",
|
|
169
|
-
];
|
|
170
|
-
const cls = { "#": "wall", " ": "floor", ".": "target", "$": "box", "*": "boxT", "@": "player", "+": "player" };
|
|
171
|
-
return <span className="dgcov-prev dgcov-prev-soko" aria-hidden="true">
|
|
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>
|
|
175
|
-
</span>;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export const sokobanGame = { Game: SokobanGame, Preview: SokobanPreview, css: `
|
|
179
|
-
.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)}
|
|
180
|
-
` };
|
|
1
|
+
/* 趣味游戏 · 推箱子(经典机台)——纯 Client;把箱子推到目标点上即可过关。 */
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { focusStage, useGameControls, playSfx } from "./shared.jsx";
|
|
4
|
+
|
|
5
|
+
// 关卡:'#' 墙 · ' ' 地板 · '.' 目标 · '$' 箱子 · '@' 人 · '*' 箱子在目标 · '+' 人在目标
|
|
6
|
+
const LEVELS = [
|
|
7
|
+
["######", "# #", "#@$ .#", "# #", "######"],
|
|
8
|
+
["######", "# @ #", "# $ #", "# . #", "######"],
|
|
9
|
+
["#######", "# @ #", "# $$ #", "# .. #", "#######"],
|
|
10
|
+
["######", "#@ #", "# $ #", "# . #", "# #", "######"],
|
|
11
|
+
["#######", "# @ #", "# $ #", "# $ .#", "# . #", "#######"],
|
|
12
|
+
["########", "# #", "# $ $ #", "# . . #", "# @ #", "########"],
|
|
13
|
+
// —— 扩充关:由易到难(已用求解器验证全部可解)——
|
|
14
|
+
["########", "# ## #", "#@ $ . #", "# ## #", "########"],
|
|
15
|
+
["########", "# @ #", "# $$$ #", "# ... #", "# #", "########"],
|
|
16
|
+
["########", "#@ #", "# $ $ #", "# # #", "# . #. #", "########"],
|
|
17
|
+
["########", "# @ #", "# $$ #", "# ..# #", "# #", "########"],
|
|
18
|
+
["########", "#@ #", "# $ $ #", "# #", "# .# . #", "########"],
|
|
19
|
+
["#########", "# #", "# $ $ $ #", "# ... #", "# @ #", "# #", "#########"],
|
|
20
|
+
["#########", "# @ #", "# $#$$ #", "# ... #", "# ## #", "#########"],
|
|
21
|
+
["#########", "#@ # #", "# $ $ #", "# ### #", "# . . #", "#########"],
|
|
22
|
+
["#########", "# ###", "# $$ # #", "# .. #@ #", "# $$ # #", "# .. #", "#########"],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function cloneGrid(grid) {
|
|
26
|
+
return grid.map((row) => row.slice());
|
|
27
|
+
}
|
|
28
|
+
function parse(rows) {
|
|
29
|
+
return rows.map((row) => row.split(""));
|
|
30
|
+
}
|
|
31
|
+
function findPlayer(grid) {
|
|
32
|
+
for (let r = 0; r < grid.length; r += 1) {
|
|
33
|
+
for (let c = 0; c < grid[r].length; c += 1) {
|
|
34
|
+
if (grid[r][c] === "@" || grid[r][c] === "+") return { r, c };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { r: 0, c: 0 };
|
|
38
|
+
}
|
|
39
|
+
function isTarget(ch) {
|
|
40
|
+
return ch === "." || ch === "*" || ch === "+";
|
|
41
|
+
}
|
|
42
|
+
function isBox(ch) {
|
|
43
|
+
return ch === "$" || ch === "*";
|
|
44
|
+
}
|
|
45
|
+
// 获胜:没有箱子错位(不存在 '$')。
|
|
46
|
+
function won(grid) {
|
|
47
|
+
for (const row of grid) for (const ch of row) if (ch === "$") return false;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
function makeInitial(levelIndex) {
|
|
51
|
+
const grid = parse(LEVELS[levelIndex]);
|
|
52
|
+
return { grid, moves: 0, pushes: 0, level: levelIndex, wonNew: false, history: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function step(state, dr, dc) {
|
|
56
|
+
if (state.wonNew) return state;
|
|
57
|
+
const grid = cloneGrid(state.grid);
|
|
58
|
+
const p = findPlayer(grid);
|
|
59
|
+
const nr = p.r + dr, nc = p.c + dc;
|
|
60
|
+
if (nr < 0 || nr >= grid.length || nc < 0 || nc >= grid[0].length) return state;
|
|
61
|
+
const target = grid[nr][nc];
|
|
62
|
+
if (target === "#") return state;
|
|
63
|
+
grid[p.r][p.c] = isTarget(grid[p.r][p.c]) ? "." : " ";
|
|
64
|
+
if (isBox(target)) {
|
|
65
|
+
const br = nr + dr, bc = nc + dc;
|
|
66
|
+
if (br < 0 || br >= grid.length || bc < 0 || bc >= grid[0].length) return state;
|
|
67
|
+
const beyond = grid[br][bc];
|
|
68
|
+
if (beyond === "#" || isBox(beyond)) return state;
|
|
69
|
+
playSfx("push");
|
|
70
|
+
grid[br][bc] = isTarget(beyond) ? "*" : "$";
|
|
71
|
+
grid[nr][nc] = isTarget(target) ? "+" : "@";
|
|
72
|
+
return Object.assign({}, state, {
|
|
73
|
+
grid,
|
|
74
|
+
moves: state.moves + 1,
|
|
75
|
+
pushes: state.pushes + 1,
|
|
76
|
+
wonNew: won(grid),
|
|
77
|
+
history: state.history.concat([{ grid: state.grid, moves: state.moves, pushes: state.pushes }]),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
grid[nr][nc] = isTarget(target) ? "+" : "@";
|
|
81
|
+
playSfx("step");
|
|
82
|
+
return Object.assign({}, state, {
|
|
83
|
+
grid,
|
|
84
|
+
moves: state.moves + 1,
|
|
85
|
+
wonNew: won(grid),
|
|
86
|
+
history: state.history.concat([{ grid: state.grid, moves: state.moves, pushes: state.pushes }]),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function undo(state) {
|
|
90
|
+
if (!state.history.length) return state;
|
|
91
|
+
const prev = state.history[state.history.length - 1];
|
|
92
|
+
return Object.assign({}, state, {
|
|
93
|
+
grid: cloneGrid(prev.grid),
|
|
94
|
+
moves: prev.moves,
|
|
95
|
+
pushes: prev.pushes,
|
|
96
|
+
history: state.history.slice(0, -1),
|
|
97
|
+
wonNew: false,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
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] };
|
|
102
|
+
|
|
103
|
+
export function SokobanGame(props) {
|
|
104
|
+
const [level, setLevel] = useState(0);
|
|
105
|
+
const [state, setState] = useState(() => makeInitial(0));
|
|
106
|
+
const stageRef = useRef(null);
|
|
107
|
+
const levelRef = useRef(level);
|
|
108
|
+
levelRef.current = level;
|
|
109
|
+
|
|
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]);
|
|
116
|
+
const nextLevel = useCallback(() => {
|
|
117
|
+
const n = (levelRef.current + 1) % LEVELS.length;
|
|
118
|
+
setLevel(n);
|
|
119
|
+
setState(makeInitial(n));
|
|
120
|
+
}, []);
|
|
121
|
+
const pickLevel = useCallback((i) => { setLevel(i); setState(makeInitial(i)); }, []);
|
|
122
|
+
const reset = useCallback(() => setState(makeInitial(levelRef.current)), []);
|
|
123
|
+
|
|
124
|
+
const onKeyDown = useCallback((event) => {
|
|
125
|
+
const d = DIRS[event.key];
|
|
126
|
+
if (d) { event.preventDefault(); move(d[0], d[1]); return; }
|
|
127
|
+
if (event.key.toLowerCase() === "u") { event.preventDefault(); setState((s) => undo(s)); }
|
|
128
|
+
else if (event.key.toLowerCase() === "r") { event.preventDefault(); reset(); }
|
|
129
|
+
else if (event.key === "Enter" && state.wonNew) { event.preventDefault(); nextLevel(); }
|
|
130
|
+
}, [move, state.wonNew, reset, nextLevel]);
|
|
131
|
+
|
|
132
|
+
useGameControls(stageRef, props.paused, onKeyDown);
|
|
133
|
+
|
|
134
|
+
const grid = state.grid;
|
|
135
|
+
const rows = grid.length, cols = grid[0].length;
|
|
136
|
+
// 宽度同时钳制:单元格理想宽、容器可用宽、按关卡长宽比折算的视口可用高——保证等比完整显示不裁切。
|
|
137
|
+
const width = "min(" + (cols * 26) + "px, 100%, calc((100vh - 250px) * " + cols + " / " + rows + "))";
|
|
138
|
+
|
|
139
|
+
return <section className="dgame-game" aria-label="推箱子">
|
|
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>
|
|
141
|
+
<div className="dgame-sokoban-levels" role="tablist" aria-label="选择关卡">
|
|
142
|
+
{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>)}
|
|
143
|
+
<button type="button" onClick={() => setState((s) => undo(s))}>撤销</button>
|
|
144
|
+
<button type="button" onClick={reset}>重置</button>
|
|
145
|
+
</div>
|
|
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 重置">
|
|
147
|
+
{grid.map((row, r) => row.map((ch, c) => {
|
|
148
|
+
let cls = "dgame-soko-cell";
|
|
149
|
+
if (ch === "#") cls += " wall";
|
|
150
|
+
else if (ch === "." || ch === "*" || ch === "+") cls += " target";
|
|
151
|
+
if (ch === "$" || ch === "*") cls += " box";
|
|
152
|
+
if (ch === "@" || ch === "+") cls += " player";
|
|
153
|
+
return <i key={r + "-" + c} className={cls} aria-hidden="true" />;
|
|
154
|
+
}))}
|
|
155
|
+
{state.wonNew ? <div className="dgame-over"><strong>全搞定!第 {level + 1} 关通过</strong><button type="button" onClick={nextLevel}>{level + 1 < LEVELS.length ? "下一关" : "重新开始"}</button></div> : null}
|
|
156
|
+
</div>
|
|
157
|
+
<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>
|
|
158
|
+
</section>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function SokobanPreview() {
|
|
162
|
+
// 迷你关卡画面:墙/地/箱/目标/小人色块,与真实游戏元素一致。
|
|
163
|
+
const ROWS = [
|
|
164
|
+
"#######",
|
|
165
|
+
"# #",
|
|
166
|
+
"# .$@ #",
|
|
167
|
+
"# .* #",
|
|
168
|
+
"#######",
|
|
169
|
+
];
|
|
170
|
+
const cls = { "#": "wall", " ": "floor", ".": "target", "$": "box", "*": "boxT", "@": "player", "+": "player" };
|
|
171
|
+
return <span className="dgcov-prev dgcov-prev-soko" aria-hidden="true">
|
|
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>
|
|
175
|
+
</span>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const sokobanGame = { Game: SokobanGame, Preview: SokobanPreview, css: `
|
|
179
|
+
.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)}
|
|
180
|
+
` };
|