dsh-dock 0.6.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 +6 -4
- package/client.js +620 -71
- 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/mobile-relay/gateway.js +279 -0
- package/features/mobile-relay/host.js +328 -0
- package/features/mobile-relay/view.jsx +207 -0
- package/features/tokenlog/host.js +2 -2
- package/features/tokenlog/view.jsx +12 -21
- package/package.json +3 -2
- package/screenshots/dock-animation.png +0 -0
- package/screenshots/dock-modelconfig.png +0 -0
- package/src/client.jsx +45 -9
- package/src/host-core.js +4 -1
- package/screenshots/dock-robot.png +0 -0
package/features/balance/view.js
CHANGED
|
@@ -19,6 +19,8 @@ const C_GRANTED = '#22d3ee';
|
|
|
19
19
|
const C_TOPUP = '#fbbf24';
|
|
20
20
|
const C_OK = '#34d399';
|
|
21
21
|
const C_ERR = '#f87171';
|
|
22
|
+
// 余额接口有些 Provider 仅返回美元。功能坞统一以人民币展示,汇率与用量模块默认值保持一致。
|
|
23
|
+
const USD_CNY_RATE = 7.2;
|
|
22
24
|
|
|
23
25
|
function accentOf(id) {
|
|
24
26
|
if (CURATED_ACCENTS[id]) return CURATED_ACCENTS[id];
|
|
@@ -28,6 +30,22 @@ function accentOf(id) {
|
|
|
28
30
|
return ACCENT_PALETTE[h % ACCENT_PALETTE.length];
|
|
29
31
|
}
|
|
30
32
|
const fmt = (v) => (v == null || v === '' ? '—' : String(v));
|
|
33
|
+
function cnyBalanceInfo(info) {
|
|
34
|
+
if (!info) return info;
|
|
35
|
+
if (info.currency === 'CNY') return Object.assign({}, info, { currency: '人民币' });
|
|
36
|
+
if (info.currency !== 'USD') return info;
|
|
37
|
+
const convert = (value) => {
|
|
38
|
+
if (value == null) return value;
|
|
39
|
+
const n = Number(value);
|
|
40
|
+
return Number.isFinite(n) ? n * USD_CNY_RATE : value;
|
|
41
|
+
};
|
|
42
|
+
return Object.assign({}, info, {
|
|
43
|
+
currency: '人民币(估算)',
|
|
44
|
+
totalBalance: convert(info.totalBalance),
|
|
45
|
+
grantedBalance: convert(info.grantedBalance),
|
|
46
|
+
toppedUpBalance: convert(info.toppedUpBalance),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
31
49
|
|
|
32
50
|
// ---- 共享快照:弹层/设置页/首页总揽共用一份数据与刷新 ----
|
|
33
51
|
const balanceStore = {
|
|
@@ -123,12 +141,15 @@ function BalanceView(props) {
|
|
|
123
141
|
} else if (b && b.status === "ok") {
|
|
124
142
|
const infos = Array.isArray(b.infos) ? b.infos : [];
|
|
125
143
|
balBody = react.createElement("div", { className: "dkb-mains" },
|
|
126
|
-
infos.map((
|
|
144
|
+
infos.map((rawInfo, idx) => {
|
|
145
|
+
const i = cnyBalanceInfo(rawInfo);
|
|
146
|
+
return react.createElement("div", { key: idx, className: "dkb-main" },
|
|
127
147
|
react.createElement("span", { className: "dkb-main-label", style: { color: accent } }, i.currency),
|
|
128
148
|
react.createElement("span", { className: "dkb-main-value" }, fmt(i.totalBalance)),
|
|
129
149
|
react.createElement("span", { className: "dkb-main-parts" },
|
|
130
150
|
i.grantedBalance != null ? react.createElement("span", { className: "dkb-main-part", style: { color: C_GRANTED } }, "赠送 " + fmt(i.grantedBalance)) : null,
|
|
131
|
-
i.toppedUpBalance != null ? react.createElement("span", { className: "dkb-main-part", style: { color: C_TOPUP } }, "充值 " + fmt(i.toppedUpBalance)) : null))
|
|
151
|
+
i.toppedUpBalance != null ? react.createElement("span", { className: "dkb-main-part", style: { color: C_TOPUP } }, "充值 " + fmt(i.toppedUpBalance)) : null));
|
|
152
|
+
}));
|
|
132
153
|
} else if (b && b.status === "login-required" && b.consoleUrl) {
|
|
133
154
|
balBody = react.createElement("div", { className: "dkb-main" },
|
|
134
155
|
react.createElement("a", { href: b.consoleUrl, target: "_blank", rel: "noreferrer", className: "dkb-link", style: { color: accent } }, "去控制台查看余额 →"));
|
|
@@ -238,9 +259,9 @@ function chipBalanceText(p) {
|
|
|
238
259
|
: fmt(b.remaining);
|
|
239
260
|
return "余额 剩 " + s + (b.unit ? " " + b.unit : "");
|
|
240
261
|
}
|
|
241
|
-
const info = Array.isArray(b.infos) && b.infos[0] ? b.infos[0] : null;
|
|
262
|
+
const info = cnyBalanceInfo(Array.isArray(b.infos) && b.infos[0] ? b.infos[0] : null);
|
|
242
263
|
if (!info) return "余额 …";
|
|
243
|
-
const cur = info.currency === "
|
|
264
|
+
const cur = info.currency === "CNY" || info.currency === "人民币(估算)" ? "¥" : "";
|
|
244
265
|
const v = Number(info.totalBalance);
|
|
245
266
|
const s = Number.isFinite(v) ? (v >= 10000 ? (v / 10000).toFixed(2) + "万" : String(Math.round(v * 100) / 100)) : fmt(info.totalBalance);
|
|
246
267
|
return "余额 " + cur + s;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/* 趣味游戏 · 打砖块(经典机台)——纯 Client;canvas 渲染,方向键/鼠标/触摸控制挡板。 */
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
const W = 320, H = 360;
|
|
5
|
+
const BRICK_COLS = 8, BRICK_ROWS = 5, BRICK_GAP = 4, BRICK_TOP = 22, BRICK_H = 18;
|
|
6
|
+
const PADDLE_W = 60, PADDLE_H = 11, PADDLE_BOTTOM = 20;
|
|
7
|
+
const BALL_R = 5.5;
|
|
8
|
+
const SPEED = 3.4;
|
|
9
|
+
const COLORS = ["#f87171", "#fb923c", "#facc15", "#4ade80", "#38bdf8"];
|
|
10
|
+
|
|
11
|
+
function makeBricks(level) {
|
|
12
|
+
const bricks = [];
|
|
13
|
+
const bw = (W - BRICK_GAP * (BRICK_COLS + 1)) / BRICK_COLS;
|
|
14
|
+
for (let r = 0; r < BRICK_ROWS; r += 1) {
|
|
15
|
+
for (let c = 0; c < BRICK_COLS; c += 1) {
|
|
16
|
+
bricks.push({
|
|
17
|
+
x: BRICK_GAP + c * (bw + BRICK_GAP),
|
|
18
|
+
y: BRICK_TOP + r * (BRICK_H + BRICK_GAP),
|
|
19
|
+
w: bw, h: BRICK_H,
|
|
20
|
+
color: COLORS[(r + level) % COLORS.length],
|
|
21
|
+
alive: true,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return bricks;
|
|
26
|
+
}
|
|
27
|
+
function draw(ctx, g) {
|
|
28
|
+
ctx.clearRect(0, 0, W, H);
|
|
29
|
+
// 星空背景
|
|
30
|
+
ctx.fillStyle = "#0b1024";
|
|
31
|
+
ctx.fillRect(0, 0, W, H);
|
|
32
|
+
ctx.fillStyle = "#1b2bb0";
|
|
33
|
+
for (const s of g.stars) { ctx.globalAlpha = s.a; ctx.fillRect(s.x, s.y, 1.5, 1.5); }
|
|
34
|
+
ctx.globalAlpha = 1;
|
|
35
|
+
// 砖块
|
|
36
|
+
for (const b of g.bricks) {
|
|
37
|
+
if (!b.alive) continue;
|
|
38
|
+
ctx.fillStyle = b.color;
|
|
39
|
+
ctx.beginPath();
|
|
40
|
+
roundRect(ctx, b.x, b.y, b.w, b.h, 4);
|
|
41
|
+
ctx.fill();
|
|
42
|
+
ctx.fillStyle = "rgba(255,255,255,.25)";
|
|
43
|
+
roundRect(ctx, b.x, b.y, b.w, b.h / 2.4, 4);
|
|
44
|
+
ctx.fill();
|
|
45
|
+
}
|
|
46
|
+
// 挡板
|
|
47
|
+
ctx.fillStyle = "#a5f3fc";
|
|
48
|
+
roundRect(ctx, g.paddle.x, g.paddle.y, PADDLE_W, PADDLE_H, 5);
|
|
49
|
+
ctx.fill();
|
|
50
|
+
// 球
|
|
51
|
+
ctx.fillStyle = "#fef3c7";
|
|
52
|
+
ctx.beginPath(); ctx.arc(g.ball.x, g.ball.y, BALL_R, 0, Math.PI * 2); ctx.fill();
|
|
53
|
+
ctx.fillStyle = "rgba(254,243,199,.5)";
|
|
54
|
+
ctx.beginPath(); ctx.arc(g.ball.x, g.ball.y, BALL_R + 2.5, 0, Math.PI * 2); ctx.fill();
|
|
55
|
+
}
|
|
56
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
57
|
+
ctx.beginPath();
|
|
58
|
+
ctx.moveTo(x + r, y);
|
|
59
|
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
60
|
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
61
|
+
ctx.arcTo(x, y + h, x, y, r);
|
|
62
|
+
ctx.arcTo(x, y, x + w, y, r);
|
|
63
|
+
ctx.closePath();
|
|
64
|
+
}
|
|
65
|
+
function collideRect(x, y, r, bx, by, bw, bh) {
|
|
66
|
+
const cx = Math.max(bx, Math.min(x, bx + bw));
|
|
67
|
+
const cy = Math.max(by, Math.min(y, by + bh));
|
|
68
|
+
const dx = x - cx, dy = y - cy;
|
|
69
|
+
return dx * dx + dy * dy < r * r;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function BreakoutGame(props) {
|
|
73
|
+
const canvasRef = useRef(null);
|
|
74
|
+
const [score, setScore] = useState(0);
|
|
75
|
+
const [lives, setLives] = useState(3);
|
|
76
|
+
const [level, setLevel] = useState(1);
|
|
77
|
+
const [status, setStatus] = useState("play"); // play | win | over
|
|
78
|
+
const keys = useRef({});
|
|
79
|
+
const pausedRef = useRef(!!(props && props.paused)); pausedRef.current = !!(props && props.paused);
|
|
80
|
+
const game = useRef(null);
|
|
81
|
+
const scoreRef = useRef(0); scoreRef.current = score;
|
|
82
|
+
const statusRef = useRef("play"); statusRef.current = status;
|
|
83
|
+
const livesRef = useRef(3); livesRef.current = lives;
|
|
84
|
+
|
|
85
|
+
const start = useCallback((lvl) => {
|
|
86
|
+
const bricks = makeBricks(lvl);
|
|
87
|
+
game.current = {
|
|
88
|
+
stars: Array.from({ length: 46 }, () => ({ x: Math.random() * W, y: Math.random() * H, a: .25 + Math.random() * .5 })),
|
|
89
|
+
bricks,
|
|
90
|
+
paddle: { x: (W - PADDLE_W) / 2, y: H - PADDLE_BOTTOM - PADDLE_H },
|
|
91
|
+
ball: { x: W / 2, y: H - PADDLE_BOTTOM - PADDLE_H - BALL_R - 1, vx: (Math.random() > .5 ? 1 : -1) * SPEED, vy: -SPEED },
|
|
92
|
+
speed: SPEED * (1 + (lvl - 1) * .09),
|
|
93
|
+
};
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
start(1); setScore(0); setLives(3); setStatus("play");
|
|
98
|
+
}, [start]);
|
|
99
|
+
|
|
100
|
+
const launchBall = useCallback(() => {
|
|
101
|
+
const g = game.current;
|
|
102
|
+
if (!g) return;
|
|
103
|
+
g.ball.vx = (Math.random() > .5 ? 1 : -1) * SPEED;
|
|
104
|
+
g.ball.vy = -SPEED;
|
|
105
|
+
g.launched = true;
|
|
106
|
+
}, []);
|
|
107
|
+
const resetBall = useCallback(() => {
|
|
108
|
+
const g = game.current;
|
|
109
|
+
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; }
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// rAF 主循环。
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const draw = () => {
|
|
115
|
+
const g = game.current;
|
|
116
|
+
const ctx = canvasRef.current && canvasRef.current.getContext("2d");
|
|
117
|
+
if (!g || !ctx) return;
|
|
118
|
+
if (pausedRef.current || statusRef.current !== "play") { draw(ctx, g); return; }
|
|
119
|
+
// 挡板移动
|
|
120
|
+
if (keys.current.left) g.paddle.x -= 5.5;
|
|
121
|
+
if (keys.current.right) g.paddle.x += 5.5;
|
|
122
|
+
g.paddle.x = Math.max(0, Math.min(W - PADDLE_W, g.paddle.x));
|
|
123
|
+
// 球
|
|
124
|
+
const b = g.ball;
|
|
125
|
+
if (!b.launched) {
|
|
126
|
+
b.x = g.paddle.x + PADDLE_W / 2;
|
|
127
|
+
b.y = g.paddle.y - BALL_R - 1;
|
|
128
|
+
} else {
|
|
129
|
+
b.x += b.vx; b.y += b.vy;
|
|
130
|
+
// 墙
|
|
131
|
+
if (b.x - BALL_R < 0) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
|
|
132
|
+
if (b.x + BALL_R > W) { b.x = W - BALL_R; b.vx = -Math.abs(b.vx); }
|
|
133
|
+
if (b.y - BALL_R < 0) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
|
|
134
|
+
// 挡板
|
|
135
|
+
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) {
|
|
136
|
+
const rel = (b.x - (g.paddle.x + PADDLE_W / 2)) / (PADDLE_W / 2);
|
|
137
|
+
const ang = rel * .6;
|
|
138
|
+
const sp = Math.hypot(b.vx, b.vy);
|
|
139
|
+
b.vx = sp * Math.sin(ang);
|
|
140
|
+
b.vy = -Math.abs(sp * Math.cos(ang));
|
|
141
|
+
b.y = g.paddle.y - BALL_R - 1;
|
|
142
|
+
}
|
|
143
|
+
// 砖块
|
|
144
|
+
const maxSpeed = g.speed * 1.6;
|
|
145
|
+
for (const br of g.bricks) {
|
|
146
|
+
if (!br.alive) continue;
|
|
147
|
+
if (collideRect(b.x, b.y, BALL_R, br.x, br.y, br.w, br.h)) {
|
|
148
|
+
br.alive = false;
|
|
149
|
+
const cx = br.x + br.w / 2, cy = br.y + br.h / 2;
|
|
150
|
+
const dx = b.x - cx, dy = b.y - cy;
|
|
151
|
+
if (Math.abs(dx / br.w) > Math.abs(dy / br.h)) { b.vx = -b.vx; b.x += b.vx * 2; }
|
|
152
|
+
else { b.vy = -b.vy; b.y += b.vy * 2; }
|
|
153
|
+
setScore((s) => s + 10);
|
|
154
|
+
scoreRef.current += 10;
|
|
155
|
+
const remain = g.bricks.some((x) => x.alive);
|
|
156
|
+
if (!remain) {
|
|
157
|
+
setStatus("win"); setLevel((l) => l + 1);
|
|
158
|
+
if (statusRef.current !== "over") statusRef.current = "win";
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// 掉出
|
|
164
|
+
if (b.y - BALL_R > H) {
|
|
165
|
+
const nl = livesRef.current - 1;
|
|
166
|
+
livesRef.current = nl; setLives(nl);
|
|
167
|
+
if (nl <= 0) { setStatus("over"); statusRef.current = "over"; }
|
|
168
|
+
else resetBall();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
draw(ctx, g);
|
|
172
|
+
};
|
|
173
|
+
let raf = requestAnimationFrame(function loop() { draw(); raf = requestAnimationFrame(loop); });
|
|
174
|
+
return () => cancelAnimationFrame(raf);
|
|
175
|
+
}, [resetBall]);
|
|
176
|
+
|
|
177
|
+
const onKeyDown = useCallback((event) => {
|
|
178
|
+
if (pausedRef.current) return;
|
|
179
|
+
if (event.key === "ArrowLeft") { event.preventDefault(); keys.current.left = true; }
|
|
180
|
+
else if (event.key === "ArrowRight") { event.preventDefault(); keys.current.right = true; }
|
|
181
|
+
else if (event.key === " ") { event.preventDefault(); launchBall(); }
|
|
182
|
+
else if (event.key.toLowerCase() === "r") { event.preventDefault(); start(1); setScore(0); setLives(3); setStatus("play"); statusRef.current = "play"; livesRef.current = 3; }
|
|
183
|
+
}, [launchBall, start]);
|
|
184
|
+
const onKeyUp = useCallback((event) => {
|
|
185
|
+
if (event.key === "ArrowLeft") keys.current.left = false;
|
|
186
|
+
else if (event.key === "ArrowRight") keys.current.right = false;
|
|
187
|
+
}, []);
|
|
188
|
+
|
|
189
|
+
const onPointerMove = useCallback((e) => {
|
|
190
|
+
const g = game.current, cv = canvasRef.current;
|
|
191
|
+
if (!g || !cv) return;
|
|
192
|
+
const rect = cv.getBoundingClientRect();
|
|
193
|
+
const scale = rect.width / W;
|
|
194
|
+
g.paddle.x = Math.max(0, Math.min(W - PADDLE_W, (e.clientX - rect.left) / scale - PADDLE_W / 2));
|
|
195
|
+
}, []);
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (pausedRef.current) return;
|
|
198
|
+
if (canvasRef.current) canvasRef.current.focus();
|
|
199
|
+
}, [props.paused]);
|
|
200
|
+
|
|
201
|
+
return <section className="dgame-game" aria-label="打砖块">
|
|
202
|
+
<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>
|
|
203
|
+
<div className="dgame-breakout">
|
|
204
|
+
<canvas ref={canvasRef} width={W} height={H} tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onPointerMove={onPointerMove} className="dgame-breakout-canvas" aria-label="打砖块游戏区域,左右方向键或鼠标移动挡板,空格发球" />
|
|
205
|
+
{status === "win" ? <div className="dgame-over"><strong>全消!进入第 {level} 关</strong><button type="button" onClick={() => { start(level); setStatus("play"); statusRef.current = "play"; }}>下一关</button></div> : null}
|
|
206
|
+
{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}
|
|
207
|
+
</div>
|
|
208
|
+
<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>
|
|
209
|
+
</section>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function BreakoutPreview() {
|
|
213
|
+
const cols = ["#f87171", "#fb923c", "#facc15", "#4ade80", "#38bdf8", "#c084fc"];
|
|
214
|
+
return <span className="dgcov-prev dgcov-prev-breakout" aria-hidden="true">
|
|
215
|
+
{Array.from({ length: 12 }, (_, i) => {
|
|
216
|
+
const row = Math.floor(i / 6), c = i % 6;
|
|
217
|
+
return <i key={i} style={{ left: (5 + c * 15) + "%", top: (10 + row * 12) + "%", background: cols[i % cols.length] }} />;
|
|
218
|
+
})}
|
|
219
|
+
<b />
|
|
220
|
+
</span>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const breakoutGame = { Game: BreakoutGame, Preview: BreakoutPreview, css: `
|
|
224
|
+
.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}
|
|
225
|
+
` };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/* 趣味游戏 · 五子棋(棋盘对战)——纯 Client;玩家执黑先手,AI 执白。 */
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
const SIZE = 15;
|
|
5
|
+
const DIRS = [[0, 1], [1, 0], [1, 1], [1, -1]];
|
|
6
|
+
|
|
7
|
+
function emptyBoard() {
|
|
8
|
+
return Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
|
|
9
|
+
}
|
|
10
|
+
function inBoard(r, c) {
|
|
11
|
+
return r >= 0 && r < SIZE && c >= 0 && c < SIZE;
|
|
12
|
+
}
|
|
13
|
+
// 在 (r,c) 落 color 后,沿四个方向统计连子数、开放端,计分。
|
|
14
|
+
function lineScore(board, r, c, color) {
|
|
15
|
+
let total = 0;
|
|
16
|
+
for (const [dr, dc] of DIRS) {
|
|
17
|
+
let count = 1;
|
|
18
|
+
let rr = r + dr, cc = c + dc;
|
|
19
|
+
let openEnds = 0;
|
|
20
|
+
while (inBoard(rr, cc) && board[rr][cc] === color) { count += 1; rr += dr; cc += dc; }
|
|
21
|
+
if (inBoard(rr, cc) && board[rr][cc] === null) openEnds += 1;
|
|
22
|
+
rr = r - dr; cc = c - dc;
|
|
23
|
+
while (inBoard(rr, cc) && board[rr][cc] === color) { count += 1; rr -= dr; cc -= dc; }
|
|
24
|
+
if (inBoard(rr, cc) && board[rr][cc] === null) openEnds += 1;
|
|
25
|
+
total += patternScore(count, openEnds);
|
|
26
|
+
}
|
|
27
|
+
return total;
|
|
28
|
+
}
|
|
29
|
+
function patternScore(count, openEnds) {
|
|
30
|
+
if (count >= 5) return 10000000;
|
|
31
|
+
if (openEnds === 0) return 0;
|
|
32
|
+
if (count === 4) return openEnds === 2 ? 1000000 : 100000;
|
|
33
|
+
if (count === 3) return openEnds === 2 ? 10000 : 1000;
|
|
34
|
+
if (count === 2) return openEnds === 2 ? 200 : 20;
|
|
35
|
+
if (count === 1) return openEnds === 2 ? 10 : 1;
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
function checkWin(board, r, c, color) {
|
|
39
|
+
for (const [dr, dc] of DIRS) {
|
|
40
|
+
let count = 1;
|
|
41
|
+
let rr = r + dr, cc = c + dc;
|
|
42
|
+
while (inBoard(rr, cc) && board[rr][cc] === color) { count += 1; rr += dr; cc += dc; }
|
|
43
|
+
rr = r - dr; cc = c - dc;
|
|
44
|
+
while (inBoard(rr, cc) && board[rr][cc] === color) { count += 1; rr -= dr; cc -= dc; }
|
|
45
|
+
if (count >= 5) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
function boardFull(board) {
|
|
50
|
+
for (const row of board) for (const cell of row) if (!cell) return false;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
// 取有落子的附近空位(距离 ≤2),否则空盘取中心。
|
|
54
|
+
function candidates(board) {
|
|
55
|
+
const set = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
let hasStone = false;
|
|
58
|
+
for (let r = 0; r < SIZE; r += 1) {
|
|
59
|
+
for (let c = 0; c < SIZE; c += 1) {
|
|
60
|
+
if (!board[r][c]) continue;
|
|
61
|
+
hasStone = true;
|
|
62
|
+
for (let dr = -2; dr <= 2; dr += 1) {
|
|
63
|
+
for (let dc = -2; dc <= 2; dc += 1) {
|
|
64
|
+
const rr = r + dr, cc = c + dc;
|
|
65
|
+
if (inBoard(rr, cc) && !board[rr][cc] && !seen.has(rr * SIZE + cc)) {
|
|
66
|
+
seen.add(rr * SIZE + cc);
|
|
67
|
+
set.push([rr, cc]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!hasStone) return [[7, 7], [7, 8], [8, 7]];
|
|
74
|
+
return set;
|
|
75
|
+
}
|
|
76
|
+
// AI 选点:进攻价值 + 防守价值(防对方五连/活四)。
|
|
77
|
+
function aiMove(board) {
|
|
78
|
+
const cands = candidates(board);
|
|
79
|
+
let best = null, bestScore = -1;
|
|
80
|
+
for (const [r, c] of cands) {
|
|
81
|
+
const attack = lineScore(board, r, c, "w");
|
|
82
|
+
const defend = lineScore(board, r, c, "b");
|
|
83
|
+
const score = attack + defend * 0.92;
|
|
84
|
+
if (score > bestScore) { bestScore = score; best = [r, c]; }
|
|
85
|
+
}
|
|
86
|
+
return best || [7, 7];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function GomokuGame(props) {
|
|
90
|
+
const [board, setBoard] = useState(emptyBoard);
|
|
91
|
+
const [turn, setTurn] = useState("b");
|
|
92
|
+
const [winner, setWinner] = useState(null);
|
|
93
|
+
const [last, setLast] = useState(null);
|
|
94
|
+
const [history, setHistory] = useState([]);
|
|
95
|
+
const [thinking, setThinking] = useState(false);
|
|
96
|
+
const boardRef = useRef(board);
|
|
97
|
+
boardRef.current = board;
|
|
98
|
+
const turnRef = useRef(turn);
|
|
99
|
+
turnRef.current = turn;
|
|
100
|
+
const winRef = useRef(winner);
|
|
101
|
+
winRef.current = winner;
|
|
102
|
+
|
|
103
|
+
const finish = useCallback((b, r, c, color) => {
|
|
104
|
+
if (checkWin(b, r, c, color)) setWinner(color);
|
|
105
|
+
else if (boardFull(b)) setWinner("draw");
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const place = useCallback((r, c, color) => {
|
|
109
|
+
if (boardRef.current[r][c]) return false;
|
|
110
|
+
const next = boardRef.current.map((row) => row.slice());
|
|
111
|
+
next[r][c] = color;
|
|
112
|
+
setBoard(next);
|
|
113
|
+
boardRef.current = next;
|
|
114
|
+
setLast({ r, c });
|
|
115
|
+
setHistory((h) => h.concat([{ r, c, color }]));
|
|
116
|
+
finish(next, r, c, color);
|
|
117
|
+
return true;
|
|
118
|
+
}, [finish]);
|
|
119
|
+
|
|
120
|
+
// AI 回合:思考片刻后落子(执白)。
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (turn !== "w" || winner || props.paused) return;
|
|
123
|
+
setThinking(true);
|
|
124
|
+
const t = setTimeout(() => {
|
|
125
|
+
const [r, c] = aiMove(boardRef.current);
|
|
126
|
+
place(r, c, "w");
|
|
127
|
+
setTurn("b");
|
|
128
|
+
setThinking(false);
|
|
129
|
+
}, 260);
|
|
130
|
+
return () => clearTimeout(t);
|
|
131
|
+
}, [turn, winner, props.paused, place]);
|
|
132
|
+
|
|
133
|
+
const onCell = useCallback((r, c) => {
|
|
134
|
+
if (winRef.current || turnRef.current !== "b" || thinking || boardRef.current[r][c]) return;
|
|
135
|
+
place(r, c, "b");
|
|
136
|
+
setTurn("w");
|
|
137
|
+
}, [thinking, place]);
|
|
138
|
+
|
|
139
|
+
const reset = useCallback(() => {
|
|
140
|
+
setBoard(emptyBoard()); setTurn("b"); setWinner(null); setLast(null); setHistory([]); setThinking(false);
|
|
141
|
+
}, []);
|
|
142
|
+
const undo = useCallback(() => {
|
|
143
|
+
if (winner || thinking || history.length < 2) return;
|
|
144
|
+
// 回退两步:撤掉 AI 与玩家。
|
|
145
|
+
const h = history.slice(0, -2);
|
|
146
|
+
const next = emptyBoard();
|
|
147
|
+
for (const m of h) next[m.r][m.c] = m.color;
|
|
148
|
+
setBoard(next); boardRef.current = next;
|
|
149
|
+
setHistory(h); setTurn("b"); setWinner(null); setLast(h.length ? h[h.length - 1] : null);
|
|
150
|
+
}, [history, winner, thinking]);
|
|
151
|
+
|
|
152
|
+
const statusText = winner === "b" ? "你赢了 · 五子连珠" : winner === "w" ? "AI 赢了 · 再试一次" : winner === "draw" ? "和棋 · 棋盘已满" : (turn === "b" ? "轮到你落子(黑)" : "AI 思考中…(白)");
|
|
153
|
+
|
|
154
|
+
return <section className="dgame-game" aria-label="五子棋">
|
|
155
|
+
<div className="dgame-game-head"><div><h3>五子棋</h3><p>你执黑先手,AI 执白;先在横竖斜任一线连成五子者胜。</p></div><div className="dgame-score"><span>回合</span><strong className="dg-gomoku-status">{statusText}</strong><button type="button" onClick={undo}>撤销</button><button type="button" onClick={reset}>重开</button></div></div>
|
|
156
|
+
<div className="dgame-gomoku-board" role="grid" aria-label="十五路五子棋棋盘">
|
|
157
|
+
{board.map((row, r) => row.map((cell, c) => (
|
|
158
|
+
<button type="button" key={r + "-" + c} role="gridcell" className={"dgame-gomoku-cell" + (cell ? " filled " + cell : "") + (last && last.r === r && last.c === c ? " last" : "")}
|
|
159
|
+
aria-label={"第" + (r + 1) + "行第" + (c + 1) + "列" + (cell === "b" ? " 黑子" : cell === "w" ? " 白子" : " 空格")}
|
|
160
|
+
onClick={() => onCell(r, c)}>{cell ? <i className={"dgame-gomoku-stone " + cell} /> : null}</button>
|
|
161
|
+
)))}
|
|
162
|
+
</div>
|
|
163
|
+
<div className="dgame-controls"><button type="button" onClick={undo}>撤销</button><span>{statusText}</span><button type="button" onClick={reset}>重开</button></div>
|
|
164
|
+
</section>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function GomokuPreview() {
|
|
168
|
+
return <span className="dgcov-prev dgcov-prev-gomoku" aria-hidden="true">
|
|
169
|
+
{[[6, 6], [7, 7], [8, 8], [9, 9], [7, 8], [8, 7]].map(([r, c], i) => <i key={i} style={{ left: (c + 1) * 10 + "%", top: (r + 1) * 9.5 + "%" }} className={i % 3 === 0 || i === 5 ? "black" : "white"} />)}
|
|
170
|
+
</span>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const gomokuGame = { Game: GomokuGame, Preview: GomokuPreview, css: `
|
|
174
|
+
.dgame-gomoku-board{display:grid;grid-template-columns:repeat(15,1fr);grid-template-rows:repeat(15,1fr);align-self:center;width:min(100%,320px);aspect-ratio:1/1;padding:6px;border-radius:8px;background:linear-gradient(135deg,#d9a441,#c08a2c);border:1px solid color-mix(in srgb,#8a5a1c 60%,transparent);box-shadow:0 4px 18px rgb(0 0 0/.35),inset 0 0 0 2px rgba(0,0,0,.18);background-image:repeating-linear-gradient(90deg,transparent 0 calc(100%/14),rgba(0,0,0,.16) calc(100%/14) calc(100%/14 + 1px)),repeating-linear-gradient(0deg,transparent 0 calc(100%/14),rgba(0,0,0,.16) calc(100%/14) calc(100%/14 + 1px))}.dgame-gomoku-cell{position:relative;background:transparent;border:none;cursor:pointer;padding:0;margin:0;outline:none}.dgame-gomoku-cell:focus-visible::after{content:'';position:absolute;inset:8%;border:2px solid rgba(255,255,255,.7);border-radius:50%}.dgame-gomoku-stone{position:absolute;inset:12%;border-radius:50%;display:block}.dgame-gomoku-stone.black{background:radial-gradient(circle at 34% 30%,#4b5563,#111827 68%);box-shadow:0 2px 5px rgb(0 0 0/.5),inset 0 1px 1px rgba(255,255,255,.25)}.dgame-gomoku-stone.white{background:radial-gradient(circle at 34% 30%,#ffffff,#cbd5e1 70%);box-shadow:0 2px 5px rgb(0 0 0/.4),inset 0 1px 1px rgba(255,255,255,.9)}.dgame-gomoku-cell.last .dgame-gomoku-stone::after{content:'';position:absolute;inset:32%;border-radius:50%;background:rgba(255,80,80,.9)}.dg-gomoku-status{font-size:12px;color:var(--dsw-alias-label-tertiary)}
|
|
175
|
+
` };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/* 趣味游戏 · 极速赛车(经典机台)——纯 Client;canvas 渲染,躲避来车、收集金币,越开越快。 */
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
const W = 300, H = 420;
|
|
5
|
+
const ROAD_W = 232, ROAD_X = (W - ROAD_W) / 2;
|
|
6
|
+
const CAR_W = 34, CAR_H = 52;
|
|
7
|
+
const CAR_COLORS = ["#f87171", "#fb923c", "#c084fc", "#38bdf8", "#4ade80", "#fde047"];
|
|
8
|
+
function laneX(lane) { return ROAD_X + ROAD_W * (lane + 0.5) / 3 - CAR_W / 2; }
|
|
9
|
+
function overlap(a, b) {
|
|
10
|
+
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;
|
|
11
|
+
}
|
|
12
|
+
function rect(ctx, x, y, w, h, r, fill) {
|
|
13
|
+
ctx.fillStyle = fill;
|
|
14
|
+
ctx.beginPath();
|
|
15
|
+
ctx.moveTo(x + r, y);
|
|
16
|
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
17
|
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
18
|
+
ctx.arcTo(x, y + h, x, y, r);
|
|
19
|
+
ctx.arcTo(x, y, x + w, y, r);
|
|
20
|
+
ctx.closePath();
|
|
21
|
+
ctx.fill();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function RacerGame(props) {
|
|
25
|
+
const canvasRef = useRef(null);
|
|
26
|
+
const [status, setStatus] = useState("play"); // play | over
|
|
27
|
+
const game = useRef(null);
|
|
28
|
+
const keys = useRef({});
|
|
29
|
+
const statusRef = useRef("play"); statusRef.current = status;
|
|
30
|
+
const pausedRef = useRef(!!(props && props.paused)); pausedRef.current = !!(props && props.paused);
|
|
31
|
+
|
|
32
|
+
const reset = useCallback(() => {
|
|
33
|
+
game.current = {
|
|
34
|
+
stars: Array.from({ length: 50 }, () => ({ x: Math.random() * W, y: Math.random() * H, a: .2 + Math.random() * .5 })),
|
|
35
|
+
player: { x: laneX(1), y: H - CAR_H - 16 },
|
|
36
|
+
enemies: [], coins: [],
|
|
37
|
+
dist: 0, coinsGot: 0, spawn: 0, coinSpawn: 0,
|
|
38
|
+
speed: 3.4, over: false,
|
|
39
|
+
};
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
useEffect(() => { reset(); setStatus("play"); }, [reset]);
|
|
43
|
+
|
|
44
|
+
const launch = useCallback(() => {
|
|
45
|
+
reset(); setStatus("play");
|
|
46
|
+
}, [reset]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const draw = () => {
|
|
50
|
+
const g = game.current, ctx = canvasRef.current && canvasRef.current.getContext("2d");
|
|
51
|
+
if (!g || !ctx) return;
|
|
52
|
+
// 背景
|
|
53
|
+
ctx.fillStyle = "#0b1024"; ctx.fillRect(0, 0, W, H);
|
|
54
|
+
ctx.fillStyle = "#1b2bb0";
|
|
55
|
+
for (const s of g.stars) { ctx.globalAlpha = s.a; ctx.fillRect(s.x, s.y, 1.5, 1.5); }
|
|
56
|
+
ctx.globalAlpha = 1;
|
|
57
|
+
// 路面
|
|
58
|
+
ctx.fillStyle = "#20243a"; ctx.fillRect(ROAD_X, 0, ROAD_W, H);
|
|
59
|
+
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);
|
|
60
|
+
// 车道虚线
|
|
61
|
+
ctx.fillStyle = "rgba(255,255,255,.16)";
|
|
62
|
+
for (let i = 1; i < 3; i += 1) {
|
|
63
|
+
const lx = ROAD_X + ROAD_W * i / 3 - 1;
|
|
64
|
+
for (let yy = (g.dist % 60); yy < H; yy += 60) ctx.fillRect(lx, yy, 2, 26);
|
|
65
|
+
}
|
|
66
|
+
if (pausedRef.current || g.over) { drawHUD(ctx, g); return; }
|
|
67
|
+
// 挡板移动(玩家车)
|
|
68
|
+
if (keys.current.left) g.player.x -= 5;
|
|
69
|
+
if (keys.current.right) g.player.x += 5;
|
|
70
|
+
g.player.x = Math.max(ROAD_X + 2, Math.min(ROAD_X + ROAD_W - CAR_W - 2, g.player.x));
|
|
71
|
+
g.dist += g.speed * .16;
|
|
72
|
+
// 生成来车
|
|
73
|
+
g.spawn += 1;
|
|
74
|
+
if (g.spawn > Math.max(20, 56 - g.dist / 600)) {
|
|
75
|
+
g.spawn = 0;
|
|
76
|
+
const lane = Math.floor(Math.random() * 3);
|
|
77
|
+
const v = g.speed * (1.1 + Math.random() * .6);
|
|
78
|
+
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)] });
|
|
79
|
+
}
|
|
80
|
+
// 生成金币
|
|
81
|
+
g.coinSpawn += 1;
|
|
82
|
+
if (g.coinSpawn > Math.max(30, 70 - g.dist / 500)) {
|
|
83
|
+
g.coinSpawn = 0;
|
|
84
|
+
g.coins.push({ x: laneX(Math.floor(Math.random() * 3)), y: -30, r: 11, got: false });
|
|
85
|
+
}
|
|
86
|
+
// 更新来车/金币
|
|
87
|
+
for (const e of g.enemies) e.y += e.v;
|
|
88
|
+
for (const c of g.coins) c.y += g.speed;
|
|
89
|
+
g.enemies = g.enemies.filter((e) => e.y < H + 20);
|
|
90
|
+
g.coins = g.coins.filter((c) => c.y < H + 20);
|
|
91
|
+
// 碰撞检测
|
|
92
|
+
for (const e of g.enemies) {
|
|
93
|
+
if (overlap(g.player, e)) { g.over = true; setStatus("over"); statusRef.current = "over"; }
|
|
94
|
+
}
|
|
95
|
+
for (const c of g.coins) {
|
|
96
|
+
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) {
|
|
97
|
+
c.got = true; g.coinsGot += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// 画来车
|
|
101
|
+
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); }
|
|
102
|
+
// 画金币
|
|
103
|
+
ctx.fillStyle = "#fde047";
|
|
104
|
+
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"; }
|
|
105
|
+
// 画玩家车
|
|
106
|
+
drawCar(ctx, g.player.x, g.player.y, "#22d3ee");
|
|
107
|
+
drawHUD(ctx, g);
|
|
108
|
+
};
|
|
109
|
+
const drawHUD = (ctx, g) => {
|
|
110
|
+
ctx.fillStyle = "#e7ecff";
|
|
111
|
+
ctx.font = "bold 13px system-ui, sans-serif";
|
|
112
|
+
ctx.textBaseline = "top";
|
|
113
|
+
ctx.fillText("距离 " + Math.floor(g.dist / 60) + " m", 10, 10);
|
|
114
|
+
ctx.fillStyle = "#fde047";
|
|
115
|
+
ctx.fillText("金币 × " + g.coinsGot, W - 70, 10);
|
|
116
|
+
};
|
|
117
|
+
function drawCar(ctx, x, y, c) {
|
|
118
|
+
rect(ctx, x, y, CAR_W, CAR_H, 9, c);
|
|
119
|
+
ctx.fillStyle = "rgba(255,255,255,.3)"; rect(ctx, x + 5, y + 6, CAR_W - 10, 13, 4, "rgba(255,255,255,.3)");
|
|
120
|
+
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)");
|
|
121
|
+
ctx.fillStyle = "rgba(255,255,255,.45)";
|
|
122
|
+
rect(ctx, x + 3, y + CAR_H - 4, 6, 5, 2, "#fde047");
|
|
123
|
+
rect(ctx, x + CAR_W - 9, y + CAR_H - 4, 6, 5, 2, "#fde047");
|
|
124
|
+
}
|
|
125
|
+
let raf = requestAnimationFrame(function loop() { draw(); raf = requestAnimationFrame(loop); });
|
|
126
|
+
return () => cancelAnimationFrame(raf);
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const onKeyDown = useCallback((event) => {
|
|
130
|
+
if (pausedRef.current) return;
|
|
131
|
+
if (event.key === "ArrowLeft") { event.preventDefault(); keys.current.left = true; }
|
|
132
|
+
else if (event.key === "ArrowRight") { event.preventDefault(); keys.current.right = true; }
|
|
133
|
+
else if (event.key.toLowerCase() === "r") { event.preventDefault(); launch(); }
|
|
134
|
+
}, [launch]);
|
|
135
|
+
const onKeyUp = useCallback((event) => {
|
|
136
|
+
if (event.key === "ArrowLeft") keys.current.left = false;
|
|
137
|
+
else if (event.key === "ArrowRight") keys.current.right = false;
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
const onPointerMove = useCallback((e) => {
|
|
141
|
+
const g = game.current, cv = canvasRef.current;
|
|
142
|
+
if (!g || !cv) return;
|
|
143
|
+
const rect0 = cv.getBoundingClientRect();
|
|
144
|
+
const scale = rect0.width / W;
|
|
145
|
+
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));
|
|
146
|
+
}, []);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (pausedRef.current) return;
|
|
149
|
+
if (canvasRef.current) canvasRef.current.focus();
|
|
150
|
+
}, [props.paused]);
|
|
151
|
+
|
|
152
|
+
return <section className="dgame-game" aria-label="极速赛车">
|
|
153
|
+
<div className="dgame-game-head"><div><h3>极速赛车</h3><p>左右移动避开迎面来车、收集金币,越开越快;撞车即结束。距离与金币实时显示在游戏画面左上/右上。</p></div><div className="dgame-score"><span>操作</span><span>←/→ 或鼠标</span></div></div>
|
|
154
|
+
<div className="dgame-racer">
|
|
155
|
+
<canvas ref={canvasRef} width={W} height={H} tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onPointerMove={onPointerMove} className="dgame-racer-canvas" aria-label="极速赛车游戏区域,左右方向键或鼠标控制方向" />
|
|
156
|
+
{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}
|
|
157
|
+
</div>
|
|
158
|
+
<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>
|
|
159
|
+
</section>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function RacerPreview() {
|
|
163
|
+
return <span className="dgcov-prev dgcov-prev-racer" aria-hidden="true">
|
|
164
|
+
<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%" }} />
|
|
165
|
+
<b />
|
|
166
|
+
</span>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const racerGame = { Game: RacerGame, Preview: RacerPreview, css: `
|
|
170
|
+
.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)}
|
|
171
|
+
` };
|