dsh-dock 0.11.2 → 0.11.5
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 +255 -396
- package/client.js +596 -98
- package/cordis.patch.yml +2 -2
- package/features/animation/host.js +119 -119
- package/features/animation/view.jsx +1662 -1662
- 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 -472
- package/features/mobile-relay/host.js +622 -570
- package/features/mobile-relay/view.jsx +380 -300
- package/features/modelconfig/host.js +406 -406
- package/features/modelconfig/view.js +490 -490
- package/features/notify/host.js +374 -374
- package/features/notify/view.jsx +751 -751
- package/features/theme/view.js +61 -61
- package/features/tokenlog/host.js +882 -883
- package/features/tokenlog/view.jsx +1369 -932
- package/features/visionproxy/host.js +315 -315
- package/index.js +195 -195
- package/package.json +66 -64
- package/screenshots/dock-tokenlog-charts-2.png +0 -0
- package/screenshots/dock-tokenlog-charts-3.png +0 -0
- package/screenshots/dock-tokenlog-charts.png +0 -0
- package/src/client.jsx +677 -677
- package/src/host-core.js +207 -207
- package/src/shared.js +192 -192
- package/src/task-track.js +354 -359
|
@@ -1,227 +1,227 @@
|
|
|
1
|
-
/* 趣味游戏 · 打砖块(经典机台)——纯 Client;canvas 渲染,方向键/鼠标/触摸控制挡板。 */
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { focusStage, useGameControls, playSfx } from "./shared.jsx";
|
|
4
|
-
|
|
5
|
-
const W = 320, H = 360;
|
|
6
|
-
const BRICK_COLS = 8, BRICK_ROWS = 5, BRICK_GAP = 4, BRICK_TOP = 22, BRICK_H = 18;
|
|
7
|
-
const PADDLE_W = 60, PADDLE_H = 11, PADDLE_BOTTOM = 20;
|
|
8
|
-
const BALL_R = 5.5;
|
|
9
|
-
const SPEED = 3.4;
|
|
10
|
-
const COLORS = ["#f87171", "#fb923c", "#facc15", "#4ade80", "#38bdf8"];
|
|
11
|
-
|
|
12
|
-
function makeBricks(level) {
|
|
13
|
-
const bricks = [];
|
|
14
|
-
const bw = (W - BRICK_GAP * (BRICK_COLS + 1)) / BRICK_COLS;
|
|
15
|
-
for (let r = 0; r < BRICK_ROWS; r += 1) {
|
|
16
|
-
for (let c = 0; c < BRICK_COLS; c += 1) {
|
|
17
|
-
bricks.push({
|
|
18
|
-
x: BRICK_GAP + c * (bw + BRICK_GAP),
|
|
19
|
-
y: BRICK_TOP + r * (BRICK_H + BRICK_GAP),
|
|
20
|
-
w: bw, h: BRICK_H,
|
|
21
|
-
color: COLORS[(r + level) % COLORS.length],
|
|
22
|
-
alive: true,
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return bricks;
|
|
27
|
-
}
|
|
28
|
-
function draw(ctx, g) {
|
|
29
|
-
ctx.clearRect(0, 0, W, H);
|
|
30
|
-
// 星空背景
|
|
31
|
-
ctx.fillStyle = "#0b1024";
|
|
32
|
-
ctx.fillRect(0, 0, W, H);
|
|
33
|
-
ctx.fillStyle = "#1b2bb0";
|
|
34
|
-
for (const s of g.stars) { ctx.globalAlpha = s.a; ctx.fillRect(s.x, s.y, 1.5, 1.5); }
|
|
35
|
-
ctx.globalAlpha = 1;
|
|
36
|
-
// 砖块
|
|
37
|
-
for (const b of g.bricks) {
|
|
38
|
-
if (!b.alive) continue;
|
|
39
|
-
ctx.fillStyle = b.color;
|
|
40
|
-
ctx.beginPath();
|
|
41
|
-
roundRect(ctx, b.x, b.y, b.w, b.h, 4);
|
|
42
|
-
ctx.fill();
|
|
43
|
-
ctx.fillStyle = "rgba(255,255,255,.25)";
|
|
44
|
-
roundRect(ctx, b.x, b.y, b.w, b.h / 2.4, 4);
|
|
45
|
-
ctx.fill();
|
|
46
|
-
}
|
|
47
|
-
// 挡板
|
|
48
|
-
ctx.fillStyle = "#a5f3fc";
|
|
49
|
-
roundRect(ctx, g.paddle.x, g.paddle.y, PADDLE_W, PADDLE_H, 5);
|
|
50
|
-
ctx.fill();
|
|
51
|
-
// 球
|
|
52
|
-
ctx.fillStyle = "#fef3c7";
|
|
53
|
-
ctx.beginPath(); ctx.arc(g.ball.x, g.ball.y, BALL_R, 0, Math.PI * 2); ctx.fill();
|
|
54
|
-
ctx.fillStyle = "rgba(254,243,199,.5)";
|
|
55
|
-
ctx.beginPath(); ctx.arc(g.ball.x, g.ball.y, BALL_R + 2.5, 0, Math.PI * 2); ctx.fill();
|
|
56
|
-
}
|
|
57
|
-
function roundRect(ctx, x, y, w, h, r) {
|
|
58
|
-
ctx.beginPath();
|
|
59
|
-
ctx.moveTo(x + r, y);
|
|
60
|
-
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
61
|
-
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
62
|
-
ctx.arcTo(x, y + h, x, y, r);
|
|
63
|
-
ctx.arcTo(x, y, x + w, y, r);
|
|
64
|
-
ctx.closePath();
|
|
65
|
-
}
|
|
66
|
-
function collideRect(x, y, r, bx, by, bw, bh) {
|
|
67
|
-
const cx = Math.max(bx, Math.min(x, bx + bw));
|
|
68
|
-
const cy = Math.max(by, Math.min(y, by + bh));
|
|
69
|
-
const dx = x - cx, dy = y - cy;
|
|
70
|
-
return dx * dx + dy * dy < r * r;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function BreakoutGame(props) {
|
|
74
|
-
const canvasRef = useRef(null);
|
|
75
|
-
const [score, setScore] = useState(0);
|
|
76
|
-
const [lives, setLives] = useState(3);
|
|
77
|
-
const [level, setLevel] = useState(1);
|
|
78
|
-
const [status, setStatus] = useState("play"); // play | win | over
|
|
79
|
-
const keys = useRef({});
|
|
80
|
-
const pausedRef = useRef(!!(props && props.paused)); pausedRef.current = !!(props && props.paused);
|
|
81
|
-
const game = useRef(null);
|
|
82
|
-
const scoreRef = useRef(0); scoreRef.current = score;
|
|
83
|
-
const statusRef = useRef("play"); statusRef.current = status;
|
|
84
|
-
const livesRef = useRef(3); livesRef.current = lives;
|
|
85
|
-
|
|
86
|
-
const start = useCallback((lvl) => {
|
|
87
|
-
const bricks = makeBricks(lvl);
|
|
88
|
-
game.current = {
|
|
89
|
-
stars: Array.from({ length: 46 }, () => ({ x: Math.random() * W, y: Math.random() * H, a: .25 + Math.random() * .5 })),
|
|
90
|
-
bricks,
|
|
91
|
-
paddle: { x: (W - PADDLE_W) / 2, y: H - PADDLE_BOTTOM - PADDLE_H },
|
|
92
|
-
ball: { x: W / 2, y: H - PADDLE_BOTTOM - PADDLE_H - BALL_R - 1, vx: (Math.random() > .5 ? 1 : -1) * SPEED, vy: -SPEED },
|
|
93
|
-
speed: SPEED * (1 + (lvl - 1) * .09),
|
|
94
|
-
};
|
|
95
|
-
}, []);
|
|
96
|
-
|
|
97
|
-
useEffect(() => {
|
|
98
|
-
start(1); setScore(0); setLives(3); setStatus("play");
|
|
99
|
-
}, [start]);
|
|
100
|
-
|
|
101
|
-
const launchBall = useCallback(() => {
|
|
102
|
-
const g = game.current;
|
|
103
|
-
if (!g) return;
|
|
104
|
-
g.ball.vx = (Math.random() > .5 ? 1 : -1) * SPEED;
|
|
105
|
-
g.ball.vy = -SPEED;
|
|
106
|
-
g.launched = true;
|
|
107
|
-
}, []);
|
|
108
|
-
const resetBall = useCallback(() => {
|
|
109
|
-
const g = game.current;
|
|
110
|
-
if (g) { g.ball.x = W / 2; g.ball.y = H - PADDLE_BOTTOM - PADDLE_H - BALL_R - 1; g.ball.vx = 0; g.ball.vy = 0; g.launched = false; }
|
|
111
|
-
}, []);
|
|
112
|
-
|
|
113
|
-
// rAF 主循环。注意:本地函数不能叫 draw,否则会遮蔽模块级渲染函数导致递归自调用。
|
|
114
|
-
useEffect(() => {
|
|
115
|
-
const frame = () => {
|
|
116
|
-
const g = game.current;
|
|
117
|
-
const ctx = canvasRef.current && canvasRef.current.getContext("2d");
|
|
118
|
-
if (!g || !ctx) return;
|
|
119
|
-
if (pausedRef.current || statusRef.current !== "play") { draw(ctx, g); return; }
|
|
120
|
-
// 挡板移动
|
|
121
|
-
if (keys.current.left) g.paddle.x -= 5.5;
|
|
122
|
-
if (keys.current.right) g.paddle.x += 5.5;
|
|
123
|
-
g.paddle.x = Math.max(0, Math.min(W - PADDLE_W, g.paddle.x));
|
|
124
|
-
// 球
|
|
125
|
-
const b = g.ball;
|
|
126
|
-
if (!g.launched) {
|
|
127
|
-
b.x = g.paddle.x + PADDLE_W / 2;
|
|
128
|
-
b.y = g.paddle.y - BALL_R - 1;
|
|
129
|
-
} else {
|
|
130
|
-
b.x += b.vx; b.y += b.vy;
|
|
131
|
-
// 墙
|
|
132
|
-
if (b.x - BALL_R < 0) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
|
|
133
|
-
if (b.x + BALL_R > W) { b.x = W - BALL_R; b.vx = -Math.abs(b.vx); }
|
|
134
|
-
if (b.y - BALL_R < 0) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
|
|
135
|
-
// 挡板
|
|
136
|
-
if (b.vy > 0 && b.y + BALL_R >= g.paddle.y && b.y - BALL_R <= g.paddle.y + PADDLE_H && b.x >= g.paddle.x - BALL_R && b.x <= g.paddle.x + PADDLE_W + BALL_R) {
|
|
137
|
-
playSfx("bounce");
|
|
138
|
-
const rel = (b.x - (g.paddle.x + PADDLE_W / 2)) / (PADDLE_W / 2);
|
|
139
|
-
const ang = rel * .6;
|
|
140
|
-
const sp = Math.hypot(b.vx, b.vy);
|
|
141
|
-
b.vx = sp * Math.sin(ang);
|
|
142
|
-
b.vy = -Math.abs(sp * Math.cos(ang));
|
|
143
|
-
b.y = g.paddle.y - BALL_R - 1;
|
|
144
|
-
}
|
|
145
|
-
// 砖块
|
|
146
|
-
const maxSpeed = g.speed * 1.6;
|
|
147
|
-
for (const br of g.bricks) {
|
|
148
|
-
if (!br.alive) continue;
|
|
149
|
-
if (collideRect(b.x, b.y, BALL_R, br.x, br.y, br.w, br.h)) {
|
|
150
|
-
br.alive = false;
|
|
151
|
-
playSfx("brick");
|
|
152
|
-
const cx = br.x + br.w / 2, cy = br.y + br.h / 2;
|
|
153
|
-
const dx = b.x - cx, dy = b.y - cy;
|
|
154
|
-
if (Math.abs(dx / br.w) > Math.abs(dy / br.h)) { b.vx = -b.vx; b.x += b.vx * 2; }
|
|
155
|
-
else { b.vy = -b.vy; b.y += b.vy * 2; }
|
|
156
|
-
setScore((s) => s + 10);
|
|
157
|
-
scoreRef.current += 10;
|
|
158
|
-
const remain = g.bricks.some((x) => x.alive);
|
|
159
|
-
if (!remain) {
|
|
160
|
-
playSfx("win");
|
|
161
|
-
setStatus("win"); setLevel((l) => l + 1);
|
|
162
|
-
if (statusRef.current !== "over") statusRef.current = "win";
|
|
163
|
-
}
|
|
164
|
-
break;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
// 掉出
|
|
168
|
-
if (b.y - BALL_R > H) {
|
|
169
|
-
const nl = livesRef.current - 1;
|
|
170
|
-
livesRef.current = nl; setLives(nl);
|
|
171
|
-
playSfx(nl <= 0 ? "over" : "step");
|
|
172
|
-
if (nl <= 0) { setStatus("over"); statusRef.current = "over"; }
|
|
173
|
-
else resetBall();
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
draw(ctx, g);
|
|
177
|
-
};
|
|
178
|
-
let raf = requestAnimationFrame(function loop() { frame(); raf = requestAnimationFrame(loop); });
|
|
179
|
-
return () => cancelAnimationFrame(raf);
|
|
180
|
-
}, [resetBall]);
|
|
181
|
-
|
|
182
|
-
const onKeyDown = useCallback((event) => {
|
|
183
|
-
if (pausedRef.current) return;
|
|
184
|
-
if (event.key === "ArrowLeft") { event.preventDefault(); keys.current.left = true; }
|
|
185
|
-
else if (event.key === "ArrowRight") { event.preventDefault(); keys.current.right = true; }
|
|
186
|
-
else if (event.key === " ") { event.preventDefault(); launchBall(); }
|
|
187
|
-
else if (event.key.toLowerCase() === "r") { event.preventDefault(); start(1); setScore(0); setLives(3); setStatus("play"); statusRef.current = "play"; livesRef.current = 3; }
|
|
188
|
-
}, [launchBall, start]);
|
|
189
|
-
const onKeyUp = useCallback((event) => {
|
|
190
|
-
if (event.key === "ArrowLeft") keys.current.left = false;
|
|
191
|
-
else if (event.key === "ArrowRight") keys.current.right = false;
|
|
192
|
-
}, []);
|
|
193
|
-
|
|
194
|
-
const onPointerMove = useCallback((e) => {
|
|
195
|
-
const g = game.current, cv = canvasRef.current;
|
|
196
|
-
if (!g || !cv) return;
|
|
197
|
-
const rect = cv.getBoundingClientRect();
|
|
198
|
-
const scale = rect.width / W;
|
|
199
|
-
g.paddle.x = Math.max(0, Math.min(W - PADDLE_W, (e.clientX - rect.left) / scale - PADDLE_W / 2));
|
|
200
|
-
}, []);
|
|
201
|
-
useGameControls(canvasRef, pausedRef.current, onKeyDown, onKeyUp);
|
|
202
|
-
|
|
203
|
-
return <section className="dgame-game" aria-label="打砖块">
|
|
204
|
-
<div className="dgame-game-head"><div><h3>打砖块</h3><p>移动挡板反弹小球,击碎所有砖块;球落到底部扣一条命。</p></div><div className="dgame-score"><span>得分 <strong>{score}</strong></span><span>命 {lives} · 第 {level} 关</span></div></div>
|
|
205
|
-
<div className="dgame-breakout">
|
|
206
|
-
<canvas ref={canvasRef} width={W} height={H} tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onPointerMove={onPointerMove} onClick={(e) => { focusStage(canvasRef); launchBall(); }} className="dgame-breakout-canvas" aria-label="打砖块游戏区域,左右方向键或鼠标移动挡板,空格或点击发球" />
|
|
207
|
-
{status === "win" ? <div className="dgame-over"><strong>全消!进入第 {level} 关</strong><button type="button" onClick={() => { start(level); setStatus("play"); statusRef.current = "play"; }}>下一关</button></div> : null}
|
|
208
|
-
{status === "over" ? <div className="dgame-over"><strong>球掉光了 · 得 {score} 分</strong><button type="button" onClick={() => { start(1); setScore(0); setLives(3); setStatus("play"); statusRef.current = "play"; livesRef.current = 3; }}>重新开始</button></div> : null}
|
|
209
|
-
</div>
|
|
210
|
-
<div className="dgame-controls"><button type="button" aria-label="向左移动" onClick={() => { keys.current.left = false; game.current && (game.current.paddle.x = Math.max(0, game.current.paddle.x - 12)); }}>← 左移</button><span>←/→ 或鼠标移动 · 空格/点击发球</span><button type="button" aria-label="向右移动" onClick={() => { keys.current.right = false; game.current && (game.current.paddle.x = Math.min(W - PADDLE_W, game.current.paddle.x + 12)); }}>右移 {'->'}</button></div>
|
|
211
|
-
</section>;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export function BreakoutPreview() {
|
|
215
|
-
const cols = ["#f87171", "#fb923c", "#facc15", "#4ade80", "#38bdf8", "#c084fc"];
|
|
216
|
-
return <span className="dgcov-prev dgcov-prev-breakout" aria-hidden="true">
|
|
217
|
-
{Array.from({ length: 12 }, (_, i) => {
|
|
218
|
-
const row = Math.floor(i / 6), c = i % 6;
|
|
219
|
-
return <i key={i} style={{ left: (5 + c * 15) + "%", top: (10 + row * 12) + "%", background: cols[i % cols.length] }} />;
|
|
220
|
-
})}
|
|
221
|
-
<b />
|
|
222
|
-
</span>;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export const breakoutGame = { Game: BreakoutGame, Preview: BreakoutPreview, css: `
|
|
226
|
-
.dgame-breakout{position:relative;align-self:center;width:min(100%,320px);margin:0 auto}.dgame-breakout-canvas{display:block;width:100%;aspect-ratio:320/360;border:1px solid color-mix(in srgb,rgb(56 189 248) 32%,var(--dsw-alias-border-l1));border-radius:10px;outline:none;background:#0b1024;box-shadow:0 4px 18px rgb(0 0 0/.4)}.dgame-breakout-canvas:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,rgb(56 189 248) 35%,transparent)}.dgame-breakout .dgame-over{position:absolute;inset:0;border-radius:10px}
|
|
227
|
-
` };
|
|
1
|
+
/* 趣味游戏 · 打砖块(经典机台)——纯 Client;canvas 渲染,方向键/鼠标/触摸控制挡板。 */
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { focusStage, useGameControls, playSfx } from "./shared.jsx";
|
|
4
|
+
|
|
5
|
+
const W = 320, H = 360;
|
|
6
|
+
const BRICK_COLS = 8, BRICK_ROWS = 5, BRICK_GAP = 4, BRICK_TOP = 22, BRICK_H = 18;
|
|
7
|
+
const PADDLE_W = 60, PADDLE_H = 11, PADDLE_BOTTOM = 20;
|
|
8
|
+
const BALL_R = 5.5;
|
|
9
|
+
const SPEED = 3.4;
|
|
10
|
+
const COLORS = ["#f87171", "#fb923c", "#facc15", "#4ade80", "#38bdf8"];
|
|
11
|
+
|
|
12
|
+
function makeBricks(level) {
|
|
13
|
+
const bricks = [];
|
|
14
|
+
const bw = (W - BRICK_GAP * (BRICK_COLS + 1)) / BRICK_COLS;
|
|
15
|
+
for (let r = 0; r < BRICK_ROWS; r += 1) {
|
|
16
|
+
for (let c = 0; c < BRICK_COLS; c += 1) {
|
|
17
|
+
bricks.push({
|
|
18
|
+
x: BRICK_GAP + c * (bw + BRICK_GAP),
|
|
19
|
+
y: BRICK_TOP + r * (BRICK_H + BRICK_GAP),
|
|
20
|
+
w: bw, h: BRICK_H,
|
|
21
|
+
color: COLORS[(r + level) % COLORS.length],
|
|
22
|
+
alive: true,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return bricks;
|
|
27
|
+
}
|
|
28
|
+
function draw(ctx, g) {
|
|
29
|
+
ctx.clearRect(0, 0, W, H);
|
|
30
|
+
// 星空背景
|
|
31
|
+
ctx.fillStyle = "#0b1024";
|
|
32
|
+
ctx.fillRect(0, 0, W, H);
|
|
33
|
+
ctx.fillStyle = "#1b2bb0";
|
|
34
|
+
for (const s of g.stars) { ctx.globalAlpha = s.a; ctx.fillRect(s.x, s.y, 1.5, 1.5); }
|
|
35
|
+
ctx.globalAlpha = 1;
|
|
36
|
+
// 砖块
|
|
37
|
+
for (const b of g.bricks) {
|
|
38
|
+
if (!b.alive) continue;
|
|
39
|
+
ctx.fillStyle = b.color;
|
|
40
|
+
ctx.beginPath();
|
|
41
|
+
roundRect(ctx, b.x, b.y, b.w, b.h, 4);
|
|
42
|
+
ctx.fill();
|
|
43
|
+
ctx.fillStyle = "rgba(255,255,255,.25)";
|
|
44
|
+
roundRect(ctx, b.x, b.y, b.w, b.h / 2.4, 4);
|
|
45
|
+
ctx.fill();
|
|
46
|
+
}
|
|
47
|
+
// 挡板
|
|
48
|
+
ctx.fillStyle = "#a5f3fc";
|
|
49
|
+
roundRect(ctx, g.paddle.x, g.paddle.y, PADDLE_W, PADDLE_H, 5);
|
|
50
|
+
ctx.fill();
|
|
51
|
+
// 球
|
|
52
|
+
ctx.fillStyle = "#fef3c7";
|
|
53
|
+
ctx.beginPath(); ctx.arc(g.ball.x, g.ball.y, BALL_R, 0, Math.PI * 2); ctx.fill();
|
|
54
|
+
ctx.fillStyle = "rgba(254,243,199,.5)";
|
|
55
|
+
ctx.beginPath(); ctx.arc(g.ball.x, g.ball.y, BALL_R + 2.5, 0, Math.PI * 2); ctx.fill();
|
|
56
|
+
}
|
|
57
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
58
|
+
ctx.beginPath();
|
|
59
|
+
ctx.moveTo(x + r, y);
|
|
60
|
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
61
|
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
62
|
+
ctx.arcTo(x, y + h, x, y, r);
|
|
63
|
+
ctx.arcTo(x, y, x + w, y, r);
|
|
64
|
+
ctx.closePath();
|
|
65
|
+
}
|
|
66
|
+
function collideRect(x, y, r, bx, by, bw, bh) {
|
|
67
|
+
const cx = Math.max(bx, Math.min(x, bx + bw));
|
|
68
|
+
const cy = Math.max(by, Math.min(y, by + bh));
|
|
69
|
+
const dx = x - cx, dy = y - cy;
|
|
70
|
+
return dx * dx + dy * dy < r * r;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function BreakoutGame(props) {
|
|
74
|
+
const canvasRef = useRef(null);
|
|
75
|
+
const [score, setScore] = useState(0);
|
|
76
|
+
const [lives, setLives] = useState(3);
|
|
77
|
+
const [level, setLevel] = useState(1);
|
|
78
|
+
const [status, setStatus] = useState("play"); // play | win | over
|
|
79
|
+
const keys = useRef({});
|
|
80
|
+
const pausedRef = useRef(!!(props && props.paused)); pausedRef.current = !!(props && props.paused);
|
|
81
|
+
const game = useRef(null);
|
|
82
|
+
const scoreRef = useRef(0); scoreRef.current = score;
|
|
83
|
+
const statusRef = useRef("play"); statusRef.current = status;
|
|
84
|
+
const livesRef = useRef(3); livesRef.current = lives;
|
|
85
|
+
|
|
86
|
+
const start = useCallback((lvl) => {
|
|
87
|
+
const bricks = makeBricks(lvl);
|
|
88
|
+
game.current = {
|
|
89
|
+
stars: Array.from({ length: 46 }, () => ({ x: Math.random() * W, y: Math.random() * H, a: .25 + Math.random() * .5 })),
|
|
90
|
+
bricks,
|
|
91
|
+
paddle: { x: (W - PADDLE_W) / 2, y: H - PADDLE_BOTTOM - PADDLE_H },
|
|
92
|
+
ball: { x: W / 2, y: H - PADDLE_BOTTOM - PADDLE_H - BALL_R - 1, vx: (Math.random() > .5 ? 1 : -1) * SPEED, vy: -SPEED },
|
|
93
|
+
speed: SPEED * (1 + (lvl - 1) * .09),
|
|
94
|
+
};
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
start(1); setScore(0); setLives(3); setStatus("play");
|
|
99
|
+
}, [start]);
|
|
100
|
+
|
|
101
|
+
const launchBall = useCallback(() => {
|
|
102
|
+
const g = game.current;
|
|
103
|
+
if (!g) return;
|
|
104
|
+
g.ball.vx = (Math.random() > .5 ? 1 : -1) * SPEED;
|
|
105
|
+
g.ball.vy = -SPEED;
|
|
106
|
+
g.launched = true;
|
|
107
|
+
}, []);
|
|
108
|
+
const resetBall = useCallback(() => {
|
|
109
|
+
const g = game.current;
|
|
110
|
+
if (g) { g.ball.x = W / 2; g.ball.y = H - PADDLE_BOTTOM - PADDLE_H - BALL_R - 1; g.ball.vx = 0; g.ball.vy = 0; g.launched = false; }
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
// rAF 主循环。注意:本地函数不能叫 draw,否则会遮蔽模块级渲染函数导致递归自调用。
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const frame = () => {
|
|
116
|
+
const g = game.current;
|
|
117
|
+
const ctx = canvasRef.current && canvasRef.current.getContext("2d");
|
|
118
|
+
if (!g || !ctx) return;
|
|
119
|
+
if (pausedRef.current || statusRef.current !== "play") { draw(ctx, g); return; }
|
|
120
|
+
// 挡板移动
|
|
121
|
+
if (keys.current.left) g.paddle.x -= 5.5;
|
|
122
|
+
if (keys.current.right) g.paddle.x += 5.5;
|
|
123
|
+
g.paddle.x = Math.max(0, Math.min(W - PADDLE_W, g.paddle.x));
|
|
124
|
+
// 球
|
|
125
|
+
const b = g.ball;
|
|
126
|
+
if (!g.launched) {
|
|
127
|
+
b.x = g.paddle.x + PADDLE_W / 2;
|
|
128
|
+
b.y = g.paddle.y - BALL_R - 1;
|
|
129
|
+
} else {
|
|
130
|
+
b.x += b.vx; b.y += b.vy;
|
|
131
|
+
// 墙
|
|
132
|
+
if (b.x - BALL_R < 0) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
|
|
133
|
+
if (b.x + BALL_R > W) { b.x = W - BALL_R; b.vx = -Math.abs(b.vx); }
|
|
134
|
+
if (b.y - BALL_R < 0) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
|
|
135
|
+
// 挡板
|
|
136
|
+
if (b.vy > 0 && b.y + BALL_R >= g.paddle.y && b.y - BALL_R <= g.paddle.y + PADDLE_H && b.x >= g.paddle.x - BALL_R && b.x <= g.paddle.x + PADDLE_W + BALL_R) {
|
|
137
|
+
playSfx("bounce");
|
|
138
|
+
const rel = (b.x - (g.paddle.x + PADDLE_W / 2)) / (PADDLE_W / 2);
|
|
139
|
+
const ang = rel * .6;
|
|
140
|
+
const sp = Math.hypot(b.vx, b.vy);
|
|
141
|
+
b.vx = sp * Math.sin(ang);
|
|
142
|
+
b.vy = -Math.abs(sp * Math.cos(ang));
|
|
143
|
+
b.y = g.paddle.y - BALL_R - 1;
|
|
144
|
+
}
|
|
145
|
+
// 砖块
|
|
146
|
+
const maxSpeed = g.speed * 1.6;
|
|
147
|
+
for (const br of g.bricks) {
|
|
148
|
+
if (!br.alive) continue;
|
|
149
|
+
if (collideRect(b.x, b.y, BALL_R, br.x, br.y, br.w, br.h)) {
|
|
150
|
+
br.alive = false;
|
|
151
|
+
playSfx("brick");
|
|
152
|
+
const cx = br.x + br.w / 2, cy = br.y + br.h / 2;
|
|
153
|
+
const dx = b.x - cx, dy = b.y - cy;
|
|
154
|
+
if (Math.abs(dx / br.w) > Math.abs(dy / br.h)) { b.vx = -b.vx; b.x += b.vx * 2; }
|
|
155
|
+
else { b.vy = -b.vy; b.y += b.vy * 2; }
|
|
156
|
+
setScore((s) => s + 10);
|
|
157
|
+
scoreRef.current += 10;
|
|
158
|
+
const remain = g.bricks.some((x) => x.alive);
|
|
159
|
+
if (!remain) {
|
|
160
|
+
playSfx("win");
|
|
161
|
+
setStatus("win"); setLevel((l) => l + 1);
|
|
162
|
+
if (statusRef.current !== "over") statusRef.current = "win";
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// 掉出
|
|
168
|
+
if (b.y - BALL_R > H) {
|
|
169
|
+
const nl = livesRef.current - 1;
|
|
170
|
+
livesRef.current = nl; setLives(nl);
|
|
171
|
+
playSfx(nl <= 0 ? "over" : "step");
|
|
172
|
+
if (nl <= 0) { setStatus("over"); statusRef.current = "over"; }
|
|
173
|
+
else resetBall();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
draw(ctx, g);
|
|
177
|
+
};
|
|
178
|
+
let raf = requestAnimationFrame(function loop() { frame(); raf = requestAnimationFrame(loop); });
|
|
179
|
+
return () => cancelAnimationFrame(raf);
|
|
180
|
+
}, [resetBall]);
|
|
181
|
+
|
|
182
|
+
const onKeyDown = useCallback((event) => {
|
|
183
|
+
if (pausedRef.current) return;
|
|
184
|
+
if (event.key === "ArrowLeft") { event.preventDefault(); keys.current.left = true; }
|
|
185
|
+
else if (event.key === "ArrowRight") { event.preventDefault(); keys.current.right = true; }
|
|
186
|
+
else if (event.key === " ") { event.preventDefault(); launchBall(); }
|
|
187
|
+
else if (event.key.toLowerCase() === "r") { event.preventDefault(); start(1); setScore(0); setLives(3); setStatus("play"); statusRef.current = "play"; livesRef.current = 3; }
|
|
188
|
+
}, [launchBall, start]);
|
|
189
|
+
const onKeyUp = useCallback((event) => {
|
|
190
|
+
if (event.key === "ArrowLeft") keys.current.left = false;
|
|
191
|
+
else if (event.key === "ArrowRight") keys.current.right = false;
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
const onPointerMove = useCallback((e) => {
|
|
195
|
+
const g = game.current, cv = canvasRef.current;
|
|
196
|
+
if (!g || !cv) return;
|
|
197
|
+
const rect = cv.getBoundingClientRect();
|
|
198
|
+
const scale = rect.width / W;
|
|
199
|
+
g.paddle.x = Math.max(0, Math.min(W - PADDLE_W, (e.clientX - rect.left) / scale - PADDLE_W / 2));
|
|
200
|
+
}, []);
|
|
201
|
+
useGameControls(canvasRef, pausedRef.current, onKeyDown, onKeyUp);
|
|
202
|
+
|
|
203
|
+
return <section className="dgame-game" aria-label="打砖块">
|
|
204
|
+
<div className="dgame-game-head"><div><h3>打砖块</h3><p>移动挡板反弹小球,击碎所有砖块;球落到底部扣一条命。</p></div><div className="dgame-score"><span>得分 <strong>{score}</strong></span><span>命 {lives} · 第 {level} 关</span></div></div>
|
|
205
|
+
<div className="dgame-breakout">
|
|
206
|
+
<canvas ref={canvasRef} width={W} height={H} tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onPointerMove={onPointerMove} onClick={(e) => { focusStage(canvasRef); launchBall(); }} className="dgame-breakout-canvas" aria-label="打砖块游戏区域,左右方向键或鼠标移动挡板,空格或点击发球" />
|
|
207
|
+
{status === "win" ? <div className="dgame-over"><strong>全消!进入第 {level} 关</strong><button type="button" onClick={() => { start(level); setStatus("play"); statusRef.current = "play"; }}>下一关</button></div> : null}
|
|
208
|
+
{status === "over" ? <div className="dgame-over"><strong>球掉光了 · 得 {score} 分</strong><button type="button" onClick={() => { start(1); setScore(0); setLives(3); setStatus("play"); statusRef.current = "play"; livesRef.current = 3; }}>重新开始</button></div> : null}
|
|
209
|
+
</div>
|
|
210
|
+
<div className="dgame-controls"><button type="button" aria-label="向左移动" onClick={() => { keys.current.left = false; game.current && (game.current.paddle.x = Math.max(0, game.current.paddle.x - 12)); }}>← 左移</button><span>←/→ 或鼠标移动 · 空格/点击发球</span><button type="button" aria-label="向右移动" onClick={() => { keys.current.right = false; game.current && (game.current.paddle.x = Math.min(W - PADDLE_W, game.current.paddle.x + 12)); }}>右移 {'->'}</button></div>
|
|
211
|
+
</section>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function BreakoutPreview() {
|
|
215
|
+
const cols = ["#f87171", "#fb923c", "#facc15", "#4ade80", "#38bdf8", "#c084fc"];
|
|
216
|
+
return <span className="dgcov-prev dgcov-prev-breakout" aria-hidden="true">
|
|
217
|
+
{Array.from({ length: 12 }, (_, i) => {
|
|
218
|
+
const row = Math.floor(i / 6), c = i % 6;
|
|
219
|
+
return <i key={i} style={{ left: (5 + c * 15) + "%", top: (10 + row * 12) + "%", background: cols[i % cols.length] }} />;
|
|
220
|
+
})}
|
|
221
|
+
<b />
|
|
222
|
+
</span>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const breakoutGame = { Game: BreakoutGame, Preview: BreakoutPreview, css: `
|
|
226
|
+
.dgame-breakout{position:relative;align-self:center;width:min(100%,320px);margin:0 auto}.dgame-breakout-canvas{display:block;width:100%;aspect-ratio:320/360;border:1px solid color-mix(in srgb,rgb(56 189 248) 32%,var(--dsw-alias-border-l1));border-radius:10px;outline:none;background:#0b1024;box-shadow:0 4px 18px rgb(0 0 0/.4)}.dgame-breakout-canvas:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,rgb(56 189 248) 35%,transparent)}.dgame-breakout .dgame-over{position:absolute;inset:0;border-radius:10px}
|
|
227
|
+
` };
|