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.
@@ -0,0 +1,169 @@
1
+ /* 趣味游戏 · 极速赛车(经典机台)——纯 Client;canvas 渲染,躲避来车、收集金币,越开越快。 */
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { focusStage, useGameControls } from "./shared.jsx";
4
+
5
+ const W = 300, H = 420;
6
+ const ROAD_W = 232, ROAD_X = (W - ROAD_W) / 2;
7
+ const CAR_W = 34, CAR_H = 52;
8
+ const CAR_COLORS = ["#f87171", "#fb923c", "#c084fc", "#38bdf8", "#4ade80", "#fde047"];
9
+ function laneX(lane) { return ROAD_X + ROAD_W * (lane + 0.5) / 3 - CAR_W / 2; }
10
+ function overlap(a, b) {
11
+ return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
12
+ }
13
+ function rect(ctx, x, y, w, h, r, fill) {
14
+ ctx.fillStyle = fill;
15
+ ctx.beginPath();
16
+ ctx.moveTo(x + r, y);
17
+ ctx.arcTo(x + w, y, x + w, y + h, r);
18
+ ctx.arcTo(x + w, y + h, x, y + h, r);
19
+ ctx.arcTo(x, y + h, x, y, r);
20
+ ctx.arcTo(x, y, x + w, y, r);
21
+ ctx.closePath();
22
+ ctx.fill();
23
+ }
24
+
25
+ export function RacerGame(props) {
26
+ const canvasRef = useRef(null);
27
+ const [status, setStatus] = useState("play"); // play | over
28
+ const game = useRef(null);
29
+ const keys = useRef({});
30
+ const statusRef = useRef("play"); statusRef.current = status;
31
+ const pausedRef = useRef(!!(props && props.paused)); pausedRef.current = !!(props && props.paused);
32
+
33
+ const reset = useCallback(() => {
34
+ game.current = {
35
+ stars: Array.from({ length: 50 }, () => ({ x: Math.random() * W, y: Math.random() * H, a: .2 + Math.random() * .5 })),
36
+ player: { x: laneX(1), y: H - CAR_H - 16 },
37
+ enemies: [], coins: [],
38
+ dist: 0, coinsGot: 0, spawn: 0, coinSpawn: 0,
39
+ speed: 3.4, over: false,
40
+ };
41
+ }, []);
42
+
43
+ useEffect(() => { reset(); setStatus("play"); }, [reset]);
44
+
45
+ const launch = useCallback(() => {
46
+ reset(); setStatus("play");
47
+ }, [reset]);
48
+
49
+ useEffect(() => {
50
+ const draw = () => {
51
+ const g = game.current, ctx = canvasRef.current && canvasRef.current.getContext("2d");
52
+ if (!g || !ctx) return;
53
+ // 背景
54
+ ctx.fillStyle = "#0b1024"; ctx.fillRect(0, 0, W, H);
55
+ ctx.fillStyle = "#1b2bb0";
56
+ for (const s of g.stars) { ctx.globalAlpha = s.a; ctx.fillRect(s.x, s.y, 1.5, 1.5); }
57
+ ctx.globalAlpha = 1;
58
+ // 路面
59
+ ctx.fillStyle = "#20243a"; ctx.fillRect(ROAD_X, 0, ROAD_W, H);
60
+ ctx.fillStyle = "rgba(255,255,255,.09)"; ctx.fillRect(ROAD_X + 6, 0, 4, H); ctx.fillRect(ROAD_X + ROAD_W - 10, 0, 4, H);
61
+ // 车道虚线
62
+ ctx.fillStyle = "rgba(255,255,255,.16)";
63
+ for (let i = 1; i < 3; i += 1) {
64
+ const lx = ROAD_X + ROAD_W * i / 3 - 1;
65
+ for (let yy = (g.dist % 60); yy < H; yy += 60) ctx.fillRect(lx, yy, 2, 26);
66
+ }
67
+ if (pausedRef.current || g.over) { drawHUD(ctx, g); return; }
68
+ // 挡板移动(玩家车)
69
+ if (keys.current.left) g.player.x -= 5;
70
+ if (keys.current.right) g.player.x += 5;
71
+ g.player.x = Math.max(ROAD_X + 2, Math.min(ROAD_X + ROAD_W - CAR_W - 2, g.player.x));
72
+ g.dist += g.speed * .16;
73
+ // 生成来车
74
+ g.spawn += 1;
75
+ if (g.spawn > Math.max(20, 56 - g.dist / 600)) {
76
+ g.spawn = 0;
77
+ const lane = Math.floor(Math.random() * 3);
78
+ const v = g.speed * (1.1 + Math.random() * .6);
79
+ g.enemies.push({ x: laneX(lane), y: -CAR_H - Math.random() * 60, w: CAR_W, h: CAR_H, v, c: CAR_COLORS[Math.floor(Math.random() * CAR_COLORS.length)] });
80
+ }
81
+ // 生成金币
82
+ g.coinSpawn += 1;
83
+ if (g.coinSpawn > Math.max(30, 70 - g.dist / 500)) {
84
+ g.coinSpawn = 0;
85
+ g.coins.push({ x: laneX(Math.floor(Math.random() * 3)), y: -30, r: 11, got: false });
86
+ }
87
+ // 更新来车/金币
88
+ for (const e of g.enemies) e.y += e.v;
89
+ for (const c of g.coins) c.y += g.speed;
90
+ g.enemies = g.enemies.filter((e) => e.y < H + 20);
91
+ g.coins = g.coins.filter((c) => c.y < H + 20);
92
+ // 碰撞检测
93
+ for (const e of g.enemies) {
94
+ if (overlap(g.player, e)) { g.over = true; setStatus("over"); statusRef.current = "over"; }
95
+ }
96
+ for (const c of g.coins) {
97
+ if (!c.got && Math.abs(c.x + c.r - (g.player.x + CAR_W / 2)) < CAR_W / 2 + 8 && Math.abs(c.y - (g.player.y + CAR_H / 2)) < CAR_H / 2 + 10) {
98
+ c.got = true; g.coinsGot += 1;
99
+ }
100
+ }
101
+ // 画来车
102
+ for (const e of g.enemies) { rect(ctx, e.x, e.y, e.w, e.h, 7, e.c); ctx.fillStyle = "rgba(255,255,255,.25)"; ctx.fillRect(e.x + 6, e.y + 7, e.w - 12, 12); }
103
+ // 画金币
104
+ ctx.fillStyle = "#fde047";
105
+ for (const c of g.coins) { ctx.beginPath(); ctx.arc(c.x + c.r, c.y, c.r, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "rgba(120,53,15,.4)"; ctx.beginPath(); ctx.arc(c.x + c.r, c.y, c.r * .5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#fde047"; }
106
+ // 画玩家车
107
+ drawCar(ctx, g.player.x, g.player.y, "#22d3ee");
108
+ drawHUD(ctx, g);
109
+ };
110
+ const drawHUD = (ctx, g) => {
111
+ ctx.fillStyle = "#e7ecff";
112
+ ctx.font = "bold 13px system-ui, sans-serif";
113
+ ctx.textBaseline = "top";
114
+ ctx.fillText("距离 " + Math.floor(g.dist / 60) + " m", 10, 10);
115
+ ctx.fillStyle = "#fde047";
116
+ ctx.fillText("金币 × " + g.coinsGot, W - 70, 10);
117
+ };
118
+ function drawCar(ctx, x, y, c) {
119
+ rect(ctx, x, y, CAR_W, CAR_H, 9, c);
120
+ ctx.fillStyle = "rgba(255,255,255,.3)"; rect(ctx, x + 5, y + 6, CAR_W - 10, 13, 4, "rgba(255,255,255,.3)");
121
+ ctx.fillStyle = "rgba(15,23,42,.5)"; rect(ctx, x + 5, y + CAR_H - 16, CAR_W - 10, 9, 3, "rgba(15,23,42,.5)");
122
+ ctx.fillStyle = "rgba(255,255,255,.45)";
123
+ rect(ctx, x + 3, y + CAR_H - 4, 6, 5, 2, "#fde047");
124
+ rect(ctx, x + CAR_W - 9, y + CAR_H - 4, 6, 5, 2, "#fde047");
125
+ }
126
+ let raf = requestAnimationFrame(function loop() { draw(); raf = requestAnimationFrame(loop); });
127
+ return () => cancelAnimationFrame(raf);
128
+ }, []);
129
+
130
+ const onKeyDown = useCallback((event) => {
131
+ if (pausedRef.current) return;
132
+ if (event.key === "ArrowLeft") { event.preventDefault(); keys.current.left = true; }
133
+ else if (event.key === "ArrowRight") { event.preventDefault(); keys.current.right = true; }
134
+ else if (event.key.toLowerCase() === "r") { event.preventDefault(); launch(); }
135
+ }, [launch]);
136
+ const onKeyUp = useCallback((event) => {
137
+ if (event.key === "ArrowLeft") keys.current.left = false;
138
+ else if (event.key === "ArrowRight") keys.current.right = false;
139
+ }, []);
140
+
141
+ const onPointerMove = useCallback((e) => {
142
+ const g = game.current, cv = canvasRef.current;
143
+ if (!g || !cv) return;
144
+ const rect0 = cv.getBoundingClientRect();
145
+ const scale = rect0.width / W;
146
+ g.player.x = Math.max(ROAD_X + 2, Math.min(ROAD_X + ROAD_W - CAR_W - 2, (e.clientX - rect0.left) / scale - CAR_W / 2));
147
+ }, []);
148
+ useGameControls(canvasRef, pausedRef.current, onKeyDown, onKeyUp);
149
+
150
+ return <section className="dgame-game" aria-label="极速赛车">
151
+ <div className="dgame-game-head"><div><h3>极速赛车</h3><p>左右移动避开迎面来车、收集金币,越开越快;撞车即结束。距离与金币实时显示在游戏画面左上/右上。</p></div><div className="dgame-score"><span>操作</span><span>←/→ 或鼠标</span></div></div>
152
+ <div className="dgame-racer">
153
+ <canvas ref={canvasRef} width={W} height={H} tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onPointerMove={onPointerMove} onClick={() => focusStage(canvasRef)} className="dgame-racer-canvas" aria-label="极速赛车游戏区域,左右方向键或鼠标控制方向" />
154
+ {status === "over" ? <div className="dgame-over"><strong>撞车了 · 行驶 {(game.current && Math.floor(game.current.dist / 60)) || 0} 米</strong><button type="button" onClick={launch}>重新出发</button></div> : null}
155
+ </div>
156
+ <div className="dgame-controls"><button type="button" aria-label="向左" onClick={() => { keys.current.left = false; if (game.current) game.current.player.x = Math.max(ROAD_X + 2, game.current.player.x - 14); }}>← 左移</button><span>←/→ 或鼠标移动 · R 重开</span><button type="button" aria-label="向右" onClick={() => { keys.current.right = false; if (game.current) game.current.player.x = Math.min(ROAD_X + ROAD_W - CAR_W - 2, game.current.player.x + 14); }}>右移 {'->'}</button></div>
157
+ </section>;
158
+ }
159
+
160
+ export function RacerPreview() {
161
+ return <span className="dgcov-prev dgcov-prev-racer" aria-hidden="true">
162
+ <i style={{ left: "20%", top: "8%" }} /><i style={{ left: "55%", top: "22%" }} /><i style={{ left: "20%", top: "42%" }} /><i style={{ left: "64%", top: "58%" }} /><i style={{ left: "38%", top: "74%" }} />
163
+ <b />
164
+ </span>;
165
+ }
166
+
167
+ export const racerGame = { Game: RacerGame, Preview: RacerPreview, css: `
168
+ .dgame-racer{position:relative;align-self:center;width:min(100%,300px);margin:0 auto}.dgame-racer-canvas{display:block;width:100%;aspect-ratio:300/420;border:1px solid color-mix(in srgb,rgb(34 211 238) 32%,var(--dsw-alias-border-l1));border-radius:10px;outline:none;background:#0b1024;box-shadow:0 4px 18px rgb(0 0 0/.4)}.dgame-racer-canvas:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,rgb(34 211 238) 35%,transparent)}.dgame-racer .dgame-over{position:absolute;inset:0;border-radius:10px}.dgame-racer-stat{color:var(--dsw-alias-label-tertiary)}
169
+ ` };
@@ -0,0 +1,118 @@
1
+ /* 趣味游戏 · 游戏公共工具:键盘焦点与全局按键监听 */
2
+ import { useEffect, useRef } from "react";
3
+
4
+ function isTypingTarget(event) {
5
+ const tag = (event.target && event.target.tagName) || "";
6
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
7
+ if (event.target && event.target.isContentEditable) return true;
8
+ return false;
9
+ }
10
+
11
+ /**
12
+ * 让游戏舞台自动聚焦并在点击时重新夺回焦点;
13
+ * 同时注册 window 级 keydown,确保方向键/WASD 等即使舞台因弹窗/按钮暂时失焦也能响应。
14
+ * paused:来自 GameWindow 的暂停/最小化/选择层状态;暂停时不监听按键、不抢焦点。
15
+ */
16
+ export function useGameControls(stageRef, paused, onKeyDown, onKeyUp) {
17
+ const pausedRef = useRef(!!paused);
18
+ pausedRef.current = !!paused;
19
+
20
+ // 自动聚焦舞台(延时确保 DOM 已渲染且窗口已显示)
21
+ useEffect(() => {
22
+ if (pausedRef.current) return;
23
+ const el = stageRef && stageRef.current;
24
+ if (!el) return;
25
+ const t = setTimeout(() => { try { el.focus({ preventScroll: true }); } catch { /* ignore */ } }, 60);
26
+ return () => clearTimeout(t);
27
+ }, [paused]);
28
+
29
+ // 全局按键监听:只在窗口打开、未暂停、且不在输入框中时响应。
30
+ // 事件目标落在游戏舞台(或其子元素,如棋盘格按钮)内时跳过——
31
+ // 舞台自身的 onKeyDown 会通过冒泡处理该键,避免双重触发。
32
+ useEffect(() => {
33
+ const inStage = (event) => {
34
+ const el = stageRef && stageRef.current;
35
+ return !!el && (el === event.target || el.contains(event.target));
36
+ };
37
+ const handleDown = (event) => {
38
+ if (pausedRef.current) return;
39
+ if (isTypingTarget(event)) return;
40
+ if (inStage(event)) return;
41
+ if (typeof onKeyDown === "function") onKeyDown(event);
42
+ };
43
+ const handleUp = (event) => {
44
+ if (pausedRef.current) return;
45
+ if (isTypingTarget(event)) return;
46
+ if (inStage(event)) return;
47
+ if (typeof onKeyUp === "function") onKeyUp(event);
48
+ };
49
+ window.addEventListener("keydown", handleDown);
50
+ window.addEventListener("keyup", handleUp);
51
+ return () => {
52
+ window.removeEventListener("keydown", handleDown);
53
+ window.removeEventListener("keyup", handleUp);
54
+ };
55
+ }, [onKeyDown, onKeyUp]);
56
+ }
57
+
58
+ /** 点击舞台时重新聚焦(配合 onClick) */
59
+ export function focusStage(stageRef) {
60
+ if (stageRef && stageRef.current) {
61
+ try { stageRef.current.focus({ preventScroll: true }); } catch { /* ignore */ }
62
+ }
63
+ }
64
+
65
+ /* ---------- 游戏音效:WebAudio 现场合成,零素材文件;首次交互后惰性初始化 ---------- */
66
+ let _ctx = null;
67
+ function audioCtx() {
68
+ if (typeof window === "undefined") return null;
69
+ const AC = window.AudioContext || window.webkitAudioContext;
70
+ if (!AC) return null;
71
+ try {
72
+ if (!_ctx) _ctx = new AC();
73
+ if (_ctx.state === "suspended") _ctx.resume().catch(() => {});
74
+ return _ctx;
75
+ } catch { return null; }
76
+ }
77
+ // name → 音色参数。t 波形 · g 音量 · d 单音时长 · f2 滑向频率 · seq 连奏序列 [频率, 延迟秒]
78
+ const SFX_DEFS = {
79
+ move: { f: 240, t: "square", d: 0.035, g: 0.07 },
80
+ rotate: { f: 340, t: "square", d: 0.05, g: 0.08 },
81
+ lock: { f: 150, t: "triangle", d: 0.08, g: 0.15 },
82
+ step: { f: 190, t: "triangle", d: 0.03, g: 0.06 },
83
+ push: { f: 115, t: "triangle", d: 0.07, g: 0.14 },
84
+ stone: { f: 520, t: "triangle", d: 0.05, g: 0.13, f2: 430 },
85
+ capture: { f: 175, t: "square", d: 0.09, g: 0.15, f2: 105 },
86
+ check: { t: "square", g: 0.12, d: 0.24, seq: [[660, 0], [660, 0.14]] },
87
+ eat: { f: 620, t: "square", d: 0.06, g: 0.11, f2: 920 },
88
+ bounce: { f: 420, t: "square", d: 0.03, g: 0.07 },
89
+ brick: { f: 740, t: "square", d: 0.05, g: 0.1, f2: 500 },
90
+ clear: { t: "sine", g: 0.15, d: 0.28, seq: [[523, 0], [659, 0.09], [784, 0.18], [1047, 0.27]] },
91
+ win: { t: "sine", g: 0.14, d: 0.34, seq: [[523, 0], [659, 0.11], [784, 0.22], [1047, 0.33], [784, 0.47], [1047, 0.56]] },
92
+ over: { t: "sawtooth", g: 0.11, d: 0.38, seq: [[392, 0], [311, 0.13], [233, 0.27], [185, 0.44]] },
93
+ };
94
+ export function playSfx(name) {
95
+ const def = SFX_DEFS[name];
96
+ const ac = def && audioCtx();
97
+ if (!ac) return;
98
+ const t0 = ac.currentTime;
99
+ const events = def.seq || [[def.f, 0]];
100
+ for (const [freq, dt] of events) {
101
+ let osc, gain;
102
+ try {
103
+ osc = ac.createOscillator();
104
+ gain = ac.createGain();
105
+ osc.type = def.t || "square";
106
+ osc.frequency.setValueAtTime(freq, t0 + dt);
107
+ if (def.f2) osc.frequency.exponentialRampToValueAtTime(Math.max(30, def.f2), t0 + dt + def.d);
108
+ gain.gain.setValueAtTime(0.0001, t0 + dt);
109
+ gain.gain.exponentialRampToValueAtTime(def.g, t0 + dt + 0.008);
110
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dt + def.d);
111
+ osc.connect(gain).connect(ac.destination);
112
+ osc.start(t0 + dt);
113
+ osc.stop(t0 + dt + def.d + 0.03);
114
+ } catch {
115
+ if (osc) try { osc.stop(); } catch { /* ignore */ }
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,144 @@
1
+ /* 趣味游戏 · 贪吃蛇(经典机台)——纯 Client;吃到食物变长,撞墙或咬到自身结束。 */
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { focusStage, useGameControls, playSfx } from "./shared.jsx";
4
+
5
+ const SIZE = 16;
6
+ 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] };
7
+ const OPPOSITE = { "-1,0": [1, 0], "1,0": [-1, 0], "0,1": [0, -1], "0,-1": [0, 1] };
8
+ // 三档速度:随得分缓慢提速,下限保证可玩性。
9
+ const SPEED_MODES = {
10
+ slow: { label: "慢速", stepMs: 4, base: 320, min: 180 },
11
+ normal: { label: "中速", stepMs: 3, base: 220, min: 120 },
12
+ fast: { label: "快速", stepMs: 2, base: 140, min: 80 },
13
+ };
14
+ function loadSpeedMode() {
15
+ try { return SPEED_MODES[localStorage.getItem("dsh-dock.snake.speed")] ? localStorage.getItem("dsh-dock.snake.speed") : "slow"; } catch { return "slow"; }
16
+ }
17
+
18
+ function randFood(snake) {
19
+ while (true) {
20
+ const r = Math.floor(Math.random() * SIZE), c = Math.floor(Math.random() * SIZE);
21
+ if (!snake.some((s) => s.r === r && s.c === c)) return { r, c };
22
+ }
23
+ }
24
+ function initial() {
25
+ const snake = [{ r: 7, c: 5 }, { r: 7, c: 4 }, { r: 7, c: 3 }];
26
+ return { snake, dir: [0, 1], food: randFood(snake), score: 0, over: false, started: false };
27
+ }
28
+ function step(state, dir) {
29
+ const head = state.snake[0];
30
+ const nr = head.r + dir[0], nc = head.c + dir[1];
31
+ const body = state.snake.slice(0, -1);
32
+ if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE || body.some((s) => s.r === nr && s.c === nc)) {
33
+ return Object.assign({}, state, { over: true });
34
+ }
35
+ const ate = state.food.r === nr && state.food.c === nc;
36
+ let snake = [{ r: nr, c: nc }, ...state.snake];
37
+ if (ate) {
38
+ playSfx("eat");
39
+ const food = randFood(snake);
40
+ return Object.assign({}, state, { snake, food, score: state.score + 1 });
41
+ }
42
+ snake.pop();
43
+ return Object.assign({}, state, { snake });
44
+ }
45
+
46
+ export function SnakeGame(props) {
47
+ const [state, setState] = useState(initial);
48
+ const [paused, setPaused] = useState(false);
49
+ // 速度档位:默认慢速,可随时切换(重开不重置)。
50
+ const [speedMode, setSpeedMode] = useState(loadSpeedMode);
51
+ const stageRef = useRef(null);
52
+ const dirRef = useRef(state.dir);
53
+ const overRef = useRef(state.over); overRef.current = state.over;
54
+ const pausedRef = useRef(!!(props && props.paused) || paused); pausedRef.current = !!(props && props.paused) || paused;
55
+
56
+ const start = useCallback(() => { const init = initial(); init.started = true; dirRef.current = init.dir; setState(init); setPaused(false); }, []);
57
+ // 待机 → 开始:按回车/空格,或直接按方向键起步。
58
+ const begin = useCallback(() => { setState((s) => (s.started || s.over ? s : Object.assign({}, s, { started: true }))); }, []);
59
+ const changeDir = useCallback((nv) => {
60
+ if (overRef.current || pausedRef.current) return;
61
+ const cur = dirRef.current;
62
+ const opp = OPPOSITE[cur[0] + "," + cur[1]];
63
+ if (opp && opp[0] === nv[0] && opp[1] === nv[1]) return;
64
+ dirRef.current = nv;
65
+ begin();
66
+ }, [begin]);
67
+
68
+ useEffect(() => {
69
+ if (state.over) playSfx("over");
70
+ }, [state.over]);
71
+
72
+ useEffect(() => {
73
+ if (!state.started || state.over || pausedRef.current) return;
74
+ const mode = SPEED_MODES[speedMode] || SPEED_MODES.slow;
75
+ const speed = Math.max(mode.min, mode.base - state.score * mode.stepMs);
76
+ const timer = setInterval(() => setState((s) => (s.over || !s.started || pausedRef.current ? s : step(s, dirRef.current))), speed);
77
+ return () => clearInterval(timer);
78
+ }, [state.started, state.over, state.score, props.paused, paused, speedMode]);
79
+
80
+ const pickSpeed = useCallback((m) => {
81
+ setSpeedMode(m);
82
+ try { localStorage.setItem("dsh-dock.snake.speed", m); } catch { /* ignore */ }
83
+ }, []);
84
+
85
+ const onKeyDown = useCallback((event) => {
86
+ const d = DIRS[event.key];
87
+ if (d) { event.preventDefault(); changeDir(d); return; }
88
+ if (event.key === "Enter" || event.key === " ") {
89
+ event.preventDefault();
90
+ if (overRef.current || !state.started || pausedRef.current) start();
91
+ else setPaused((p) => !p);
92
+ return;
93
+ }
94
+ if (event.key.toLowerCase() === "p") { event.preventDefault(); setPaused((p) => !p); }
95
+ else if (event.key.toLowerCase() === "r") { event.preventDefault(); start(); }
96
+ }, [changeDir, start, state.started]);
97
+
98
+ useGameControls(stageRef, pausedRef.current, onKeyDown);
99
+
100
+ const cells = [];
101
+ for (let r = 0; r < SIZE; r += 1) {
102
+ for (let c = 0; c < SIZE; c += 1) {
103
+ const idx = state.snake.findIndex((s) => s.r === r && s.c === c);
104
+ const isFood = state.food.r === r && state.food.c === c;
105
+ let cls = "dgame-snake-cell";
106
+ if (idx >= 0) cls += " bob" + (idx === 0 ? " head" : idx < 4 ? " body2" : "");
107
+ if (isFood) cls += " food";
108
+ cells.push(<i key={r + "-" + c} className={cls} aria-hidden="true" />);
109
+ }
110
+ }
111
+
112
+ return <section className="dgame-game" aria-label="贪吃蛇">
113
+ <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>
114
+ <div className="dgame-snake-speeds" role="radiogroup" aria-label="游戏速度">
115
+ <span>速度</span>
116
+ {Object.entries(SPEED_MODES).map(([m, cfg]) => (
117
+ <button key={m} type="button" role="radio" aria-checked={speedMode === m} className={speedMode === m ? "on" : ""} onClick={() => pickSpeed(m)}>{cfg.label}</button>
118
+ ))}
119
+ </div>
120
+ <div className="dgame-snake-stage" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} onClick={() => focusStage(stageRef)} aria-label="贪吃蛇游戏区域,使用方向键控制移动,回车开始">
121
+ <div className="dgame-snake-grid">{cells}</div>
122
+ {state.over ? <div className="dgame-over"><strong>撞上了 · 得 {state.score} 分</strong><button type="button" onClick={start}>重新开始</button></div> : null}
123
+ {!state.started && !state.over ? <div className="dgame-over"><strong>按 Enter 或方向键开始</strong><button type="button" onClick={start}>开始游戏</button></div> : null}
124
+ {state.started && !state.over && pausedRef.current ? <div className="dgame-over"><strong>已暂停</strong><button type="button" onClick={() => setPaused(false)}>继续(P)</button></div> : null}
125
+ </div>
126
+ <div className="dgame-controls"><button type="button" aria-label="向上" onClick={() => changeDir([-1, 0])}>↑</button><span>方向键 / WASD · 回车 开始/暂停 · R 重开</span><button type="button" aria-label="向下" onClick={() => changeDir([1, 0])}>↓</button></div>
127
+ </section>;
128
+ }
129
+
130
+ export function SnakePreview() {
131
+ return <span className="dgcov-prev dgcov-prev-snake" aria-hidden="true">
132
+ <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%" }} />
133
+ <b />
134
+ </span>;
135
+ }
136
+
137
+ export const snakeGame = { Game: SnakeGame, Preview: SnakePreview, css: `
138
+ .dgame-snake-speeds{display:flex;gap:6px;align-items:center;align-self:center;justify-content:center}
139
+ .dgame-snake-speeds span{font-size:11px;color:var(--dsw-alias-label-tertiary)}
140
+ .dgame-snake-speeds button{min-width:44px;height:26px;padding:0 8px;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}
141
+ .dgame-snake-speeds button.on{color:#bbf7d0;border-color:color-mix(in srgb,#4ade80 55%,transparent);background:color-mix(in srgb,#4ade80 16%,transparent)}
142
+ .dgame-snake-speeds button:hover{border-color:#4ade80;color:var(--dsw-alias-label-primary)}
143
+ .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)}
144
+ ` };
@@ -0,0 +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
+ ` };