dsh-dock 0.7.1 → 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.
- package/client.js +3571 -118
- package/features/games/games/breakout.jsx +10 -8
- package/features/games/games/gomoku.jsx +89 -20
- package/features/games/games/racer.jsx +3 -5
- package/features/games/games/shared.jsx +118 -0
- package/features/games/games/snake.jsx +58 -17
- package/features/games/games/sokoban.jsx +35 -8
- package/features/games/games/tank.jsx +15 -3
- package/features/games/games/tetris.jsx +90 -23
- package/features/games/games/xiangqi.jsx +91 -14
- package/features/games/view.jsx +32 -25
- package/features/games/xiangqi-core.js +48 -19
- package/package.json +1 -1
- package/src/client.jsx +3 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* 趣味游戏 · 打砖块(经典机台)——纯 Client;canvas 渲染,方向键/鼠标/触摸控制挡板。 */
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { focusStage, useGameControls, playSfx } from "./shared.jsx";
|
|
3
4
|
|
|
4
5
|
const W = 320, H = 360;
|
|
5
6
|
const BRICK_COLS = 8, BRICK_ROWS = 5, BRICK_GAP = 4, BRICK_TOP = 22, BRICK_H = 18;
|
|
@@ -109,9 +110,9 @@ export function BreakoutGame(props) {
|
|
|
109
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; }
|
|
110
111
|
}, []);
|
|
111
112
|
|
|
112
|
-
// rAF
|
|
113
|
+
// rAF 主循环。注意:本地函数不能叫 draw,否则会遮蔽模块级渲染函数导致递归自调用。
|
|
113
114
|
useEffect(() => {
|
|
114
|
-
const
|
|
115
|
+
const frame = () => {
|
|
115
116
|
const g = game.current;
|
|
116
117
|
const ctx = canvasRef.current && canvasRef.current.getContext("2d");
|
|
117
118
|
if (!g || !ctx) return;
|
|
@@ -133,6 +134,7 @@ export function BreakoutGame(props) {
|
|
|
133
134
|
if (b.y - BALL_R < 0) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
|
|
134
135
|
// 挡板
|
|
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");
|
|
136
138
|
const rel = (b.x - (g.paddle.x + PADDLE_W / 2)) / (PADDLE_W / 2);
|
|
137
139
|
const ang = rel * .6;
|
|
138
140
|
const sp = Math.hypot(b.vx, b.vy);
|
|
@@ -146,6 +148,7 @@ export function BreakoutGame(props) {
|
|
|
146
148
|
if (!br.alive) continue;
|
|
147
149
|
if (collideRect(b.x, b.y, BALL_R, br.x, br.y, br.w, br.h)) {
|
|
148
150
|
br.alive = false;
|
|
151
|
+
playSfx("brick");
|
|
149
152
|
const cx = br.x + br.w / 2, cy = br.y + br.h / 2;
|
|
150
153
|
const dx = b.x - cx, dy = b.y - cy;
|
|
151
154
|
if (Math.abs(dx / br.w) > Math.abs(dy / br.h)) { b.vx = -b.vx; b.x += b.vx * 2; }
|
|
@@ -154,6 +157,7 @@ export function BreakoutGame(props) {
|
|
|
154
157
|
scoreRef.current += 10;
|
|
155
158
|
const remain = g.bricks.some((x) => x.alive);
|
|
156
159
|
if (!remain) {
|
|
160
|
+
playSfx("win");
|
|
157
161
|
setStatus("win"); setLevel((l) => l + 1);
|
|
158
162
|
if (statusRef.current !== "over") statusRef.current = "win";
|
|
159
163
|
}
|
|
@@ -164,13 +168,14 @@ export function BreakoutGame(props) {
|
|
|
164
168
|
if (b.y - BALL_R > H) {
|
|
165
169
|
const nl = livesRef.current - 1;
|
|
166
170
|
livesRef.current = nl; setLives(nl);
|
|
171
|
+
playSfx(nl <= 0 ? "over" : "step");
|
|
167
172
|
if (nl <= 0) { setStatus("over"); statusRef.current = "over"; }
|
|
168
173
|
else resetBall();
|
|
169
174
|
}
|
|
170
175
|
}
|
|
171
176
|
draw(ctx, g);
|
|
172
177
|
};
|
|
173
|
-
let raf = requestAnimationFrame(function loop() {
|
|
178
|
+
let raf = requestAnimationFrame(function loop() { frame(); raf = requestAnimationFrame(loop); });
|
|
174
179
|
return () => cancelAnimationFrame(raf);
|
|
175
180
|
}, [resetBall]);
|
|
176
181
|
|
|
@@ -193,15 +198,12 @@ export function BreakoutGame(props) {
|
|
|
193
198
|
const scale = rect.width / W;
|
|
194
199
|
g.paddle.x = Math.max(0, Math.min(W - PADDLE_W, (e.clientX - rect.left) / scale - PADDLE_W / 2));
|
|
195
200
|
}, []);
|
|
196
|
-
|
|
197
|
-
if (pausedRef.current) return;
|
|
198
|
-
if (canvasRef.current) canvasRef.current.focus();
|
|
199
|
-
}, [props.paused]);
|
|
201
|
+
useGameControls(canvasRef, pausedRef.current, onKeyDown, onKeyUp);
|
|
200
202
|
|
|
201
203
|
return <section className="dgame-game" aria-label="打砖块">
|
|
202
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>
|
|
203
205
|
<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="打砖块游戏区域,左右方向键或鼠标移动挡板,空格发球" />
|
|
206
|
+
<canvas ref={canvasRef} width={W} height={H} tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onPointerMove={onPointerMove} onClick={() => focusStage(canvasRef)} className="dgame-breakout-canvas" aria-label="打砖块游戏区域,左右方向键或鼠标移动挡板,空格发球" />
|
|
205
207
|
{status === "win" ? <div className="dgame-over"><strong>全消!进入第 {level} 关</strong><button type="button" onClick={() => { start(level); setStatus("play"); statusRef.current = "play"; }}>下一关</button></div> : null}
|
|
206
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}
|
|
207
209
|
</div>
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
/* 趣味游戏 · 五子棋(棋盘对战)——纯 Client;玩家执黑先手,AI 执白。 */
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { focusStage, useGameControls, playSfx } from "./shared.jsx";
|
|
4
|
+
|
|
5
|
+
// 正规棋盘:SVG 精确绘制(viewBox 1500×1500,交点对齐各格中心)。外框加粗,五个星位点。
|
|
6
|
+
const GOMOKU_LINES = (() => {
|
|
7
|
+
const P = (n) => n * 100 + 50;
|
|
8
|
+
const parts = [<rect key="frame" x={P(0)} y={P(0)} width={1400} height={1400} strokeWidth={6.5} fill="none" />];
|
|
9
|
+
let k = 0;
|
|
10
|
+
for (let i = 1; i <= 13; i += 1) {
|
|
11
|
+
parts.push(<line key={k++} x1={P(i)} y1={P(0)} x2={P(i)} y2={P(14)} />);
|
|
12
|
+
parts.push(<line key={k++} x1={P(0)} y1={P(i)} x2={P(14)} y2={P(i)} />);
|
|
13
|
+
}
|
|
14
|
+
for (const [r, c] of [[3, 3], [3, 11], [7, 7], [11, 3], [11, 11]]) {
|
|
15
|
+
parts.push(<circle key={k++} className="dgame-gomoku-star" cx={P(c)} cy={P(r)} r={15} />);
|
|
16
|
+
}
|
|
17
|
+
return parts;
|
|
18
|
+
})();
|
|
3
19
|
|
|
4
20
|
const SIZE = 15;
|
|
5
21
|
const DIRS = [[0, 1], [1, 0], [1, 1], [1, -1]];
|
|
@@ -35,16 +51,17 @@ function patternScore(count, openEnds) {
|
|
|
35
51
|
if (count === 1) return openEnds === 2 ? 10 : 1;
|
|
36
52
|
return 0;
|
|
37
53
|
}
|
|
38
|
-
|
|
54
|
+
// 返回连成五子(含以上)的坐标数组;未连成返回 null。
|
|
55
|
+
function winLine(board, r, c, color) {
|
|
39
56
|
for (const [dr, dc] of DIRS) {
|
|
40
|
-
|
|
57
|
+
const cells = [[r, c]];
|
|
41
58
|
let rr = r + dr, cc = c + dc;
|
|
42
|
-
while (inBoard(rr, cc) && board[rr][cc] === color) {
|
|
59
|
+
while (inBoard(rr, cc) && board[rr][cc] === color) { cells.push([rr, cc]); rr += dr; cc += dc; }
|
|
43
60
|
rr = r - dr; cc = c - dc;
|
|
44
|
-
while (inBoard(rr, cc) && board[rr][cc] === color) {
|
|
45
|
-
if (
|
|
61
|
+
while (inBoard(rr, cc) && board[rr][cc] === color) { cells.push([rr, cc]); rr -= dr; cc -= dc; }
|
|
62
|
+
if (cells.length >= 5) return cells;
|
|
46
63
|
}
|
|
47
|
-
return
|
|
64
|
+
return null;
|
|
48
65
|
}
|
|
49
66
|
function boardFull(board) {
|
|
50
67
|
for (const row of board) for (const cell of row) if (!cell) return false;
|
|
@@ -90,10 +107,13 @@ export function GomokuGame(props) {
|
|
|
90
107
|
const [board, setBoard] = useState(emptyBoard);
|
|
91
108
|
const [turn, setTurn] = useState("b");
|
|
92
109
|
const [winner, setWinner] = useState(null);
|
|
110
|
+
const [winCells, setWinCells] = useState(null);
|
|
111
|
+
const [showResult, setShowResult] = useState(false);
|
|
93
112
|
const [last, setLast] = useState(null);
|
|
94
113
|
const [history, setHistory] = useState([]);
|
|
95
114
|
const [thinking, setThinking] = useState(false);
|
|
96
115
|
const boardRef = useRef(board);
|
|
116
|
+
const stageRef = useRef(null);
|
|
97
117
|
boardRef.current = board;
|
|
98
118
|
const turnRef = useRef(turn);
|
|
99
119
|
turnRef.current = turn;
|
|
@@ -101,8 +121,10 @@ export function GomokuGame(props) {
|
|
|
101
121
|
winRef.current = winner;
|
|
102
122
|
|
|
103
123
|
const finish = useCallback((b, r, c, color) => {
|
|
104
|
-
|
|
105
|
-
|
|
124
|
+
const line = winLine(b, r, c, color);
|
|
125
|
+
if (line) { setWinner(color); setWinCells(line); setShowResult(true); playSfx(color === "b" ? "win" : "over"); }
|
|
126
|
+
else if (boardFull(b)) { setWinner("draw"); setWinCells(null); setShowResult(true); playSfx("clear"); }
|
|
127
|
+
else playSfx("stone");
|
|
106
128
|
}, []);
|
|
107
129
|
|
|
108
130
|
const place = useCallback((r, c, color) => {
|
|
@@ -137,39 +159,86 @@ export function GomokuGame(props) {
|
|
|
137
159
|
}, [thinking, place]);
|
|
138
160
|
|
|
139
161
|
const reset = useCallback(() => {
|
|
140
|
-
setBoard(emptyBoard()); setTurn("b"); setWinner(null); setLast(null); setHistory([]); setThinking(false);
|
|
162
|
+
setBoard(emptyBoard()); setTurn("b"); setWinner(null); setWinCells(null); setShowResult(false); setLast(null); setHistory([]); setThinking(false);
|
|
141
163
|
}, []);
|
|
142
164
|
const undo = useCallback(() => {
|
|
143
|
-
if (
|
|
144
|
-
// 回退两步:撤掉 AI
|
|
165
|
+
if (thinking || history.length < 2) return;
|
|
166
|
+
// 回退两步:撤掉 AI 与玩家(对局结束后也允许撤回继续)。
|
|
145
167
|
const h = history.slice(0, -2);
|
|
146
168
|
const next = emptyBoard();
|
|
147
169
|
for (const m of h) next[m.r][m.c] = m.color;
|
|
148
170
|
setBoard(next); boardRef.current = next;
|
|
149
|
-
setHistory(h); setTurn("b"); setWinner(null);
|
|
150
|
-
|
|
171
|
+
setHistory(h); setTurn("b"); setWinner(null); setWinCells(null); setShowResult(false);
|
|
172
|
+
setLast(h.length ? h[h.length - 1] : null);
|
|
173
|
+
}, [history, thinking]);
|
|
174
|
+
|
|
175
|
+
const onKeyDown = useCallback((event) => {
|
|
176
|
+
if (event.key.toLowerCase() === "r") { event.preventDefault(); reset(); }
|
|
177
|
+
else if (event.key.toLowerCase() === "u") { event.preventDefault(); undo(); }
|
|
178
|
+
}, [reset, undo]);
|
|
179
|
+
useGameControls(stageRef, props.paused, onKeyDown);
|
|
151
180
|
|
|
152
181
|
const statusText = winner === "b" ? "你赢了 · 五子连珠" : winner === "w" ? "AI 赢了 · 再试一次" : winner === "draw" ? "和棋 · 棋盘已满" : (turn === "b" ? "轮到你落子(黑)" : "AI 思考中…(白)");
|
|
153
182
|
|
|
154
183
|
return <section className="dgame-game" aria-label="五子棋">
|
|
155
184
|
<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
|
-
|
|
158
|
-
|
|
185
|
+
<div className="dgame-gomoku-board" ref={stageRef} tabIndex={0} onKeyDown={onKeyDown} onClick={() => focusStage(stageRef)} role="grid" aria-label="十五路五子棋棋盘">
|
|
186
|
+
<svg className="dgame-gomoku-lines" viewBox="0 0 1500 1500" preserveAspectRatio="none" aria-hidden="true">{GOMOKU_LINES}</svg>
|
|
187
|
+
{board.map((row, r) => row.map((cell, c) => {
|
|
188
|
+
const isWin = winCells && winCells.some(([wr, wc]) => wr === r && wc === c);
|
|
189
|
+
return <button type="button" key={r + "-" + c} role="gridcell" className={"dgame-gomoku-cell" + (cell ? " filled " + cell : "") + (last && last.r === r && last.c === c ? " last" : "") + (isWin ? " wincell" : "")}
|
|
159
190
|
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
|
-
))
|
|
191
|
+
onClick={() => { focusStage(stageRef); onCell(r, c); }}>{cell ? <i className={"dgame-gomoku-stone " + (cell === "b" ? "black" : "white")} /> : null}</button>;
|
|
192
|
+
}))}
|
|
193
|
+
{winner && showResult ? (
|
|
194
|
+
<div className="dgame-gomoku-result" role="status">
|
|
195
|
+
<div className={"dgame-gomoku-result-card " + winner}>
|
|
196
|
+
<span className="dgame-gomoku-result-icon" aria-hidden="true">{winner === "b" ? "🎉" : winner === "w" ? "🤖" : "🤝"}</span>
|
|
197
|
+
<strong>{winner === "b" ? "你赢了!" : winner === "w" ? "AI 赢了" : "和棋"}</strong>
|
|
198
|
+
<p>{winner === "b" ? "五子连珠,漂亮!" : winner === "w" ? "再接再厉,卷土重来。" : "棋盘已满,平分秋色。"}</p>
|
|
199
|
+
<div className="dgame-gomoku-result-actions">
|
|
200
|
+
<button type="button" onClick={(e) => { e.stopPropagation(); setShowResult(false); }}>查看棋盘</button>
|
|
201
|
+
<button type="button" onClick={(e) => { e.stopPropagation(); reset(); }}>再来一局</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
) : null}
|
|
162
206
|
</div>
|
|
163
207
|
<div className="dgame-controls"><button type="button" onClick={undo}>撤销</button><span>{statusText}</span><button type="button" onClick={reset}>重开</button></div>
|
|
164
208
|
</section>;
|
|
165
209
|
}
|
|
166
210
|
|
|
167
211
|
export function GomokuPreview() {
|
|
212
|
+
// 迷你 9 路棋盘:木纹 + 加粗外框 + 星位 + 黑白对弈石子,与真实棋盘同款画法。
|
|
213
|
+
const P = (n) => n * 100 + 50;
|
|
214
|
+
const stones = [[3, 3, "b"], [6, 3, "w"], [4, 4, "w"], [5, 5, "b"], [2, 6, "w"], [6, 6, "b"]];
|
|
168
215
|
return <span className="dgcov-prev dgcov-prev-gomoku" aria-hidden="true">
|
|
169
|
-
|
|
216
|
+
<svg viewBox="0 0 900 900" preserveAspectRatio="xMidYMid slice">
|
|
217
|
+
<rect x={P(0)} y={P(0)} width={800} height={800} fill="none" strokeWidth={11} />
|
|
218
|
+
{[1, 2, 3, 4, 5, 6, 7].map((i) => [
|
|
219
|
+
<line key={"v" + i} x1={P(i)} y1={P(0)} x2={P(i)} y2={P(8)} />,
|
|
220
|
+
<line key={"h" + i} x1={P(0)} y1={P(i)} x2={P(8)} y2={P(i)} />,
|
|
221
|
+
])}
|
|
222
|
+
<circle className="dgp-star" cx={P(4)} cy={P(4)} r={16} />
|
|
223
|
+
{stones.map(([r, c, col], i) => <circle key={i} className={"dgp-stone-" + col} cx={P(c)} cy={P(r)} r={40} />)}
|
|
224
|
+
</svg>
|
|
170
225
|
</span>;
|
|
171
226
|
}
|
|
172
227
|
|
|
173
228
|
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%,
|
|
229
|
+
.dgame-gomoku-board{position:relative;display:grid;grid-template-columns:repeat(15,1fr);grid-template-rows:repeat(15,1fr);align-self:center;width:min(100%,min(90vw,330px));aspect-ratio:1/1;padding:12px;border-radius:12px;border:1px solid color-mix(in srgb,#7c4f16 65%,transparent);box-shadow:0 12px 32px rgb(0 0 0/.5),inset 0 1px 0 rgba(255,236,200,.55),inset 0 -3px 9px rgba(90,50,10,.35),inset 3px 0 8px rgba(255,224,170,.28),inset -3px 0 8px rgba(120,70,20,.25);outline:none;background:repeating-linear-gradient(94deg,rgba(124,79,22,.05) 0 2px,transparent 2px 11px),repeating-linear-gradient(87deg,rgba(255,238,196,.05) 0 2px,transparent 2px 14px),radial-gradient(120% 110% at 30% 10%,#edc97f,#dcae62 52%,#cc9a52 80%,#bb8844)}.dgame-gomoku-lines{position:absolute;inset:12px;pointer-events:none}.dgame-gomoku-lines line{stroke:#66431a;stroke-width:3;fill:none}.dgame-gomoku-star{fill:#66431a}.dgame-gomoku-cell{position:relative;z-index:1;background:transparent;border:none;cursor:pointer;padding:0;margin:0;outline:none;min-width:0;min-height:0}.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:10%;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)}
|
|
230
|
+
.dgame-gomoku-cell.wincell .dgame-gomoku-stone{animation:dg-gomoku-win 1s ease-in-out infinite;z-index:1}
|
|
231
|
+
@keyframes dg-gomoku-win{0%,100%{box-shadow:0 2px 5px rgb(0 0 0/.5),0 0 0 0 rgba(255,214,102,.85)}50%{box-shadow:0 2px 5px rgb(0 0 0/.5),0 0 0 6px rgba(255,214,102,0)}}
|
|
232
|
+
.dgame-gomoku-result{position:absolute;inset:-1px;z-index:5;display:grid;place-items:center;background:rgba(10,10,20,.55);backdrop-filter:blur(2px);border-radius:8px;animation:dg-gomoku-fade .25s ease-out}
|
|
233
|
+
.dgame-gomoku-result-card{display:flex;flex-direction:column;align-items:center;gap:6px;padding:18px 26px;border-radius:14px;background:linear-gradient(160deg,#241f38,#171427);border:1px solid color-mix(in srgb,var(--dgame-accent,#a78bfa) 45%,transparent);box-shadow:0 12px 40px rgb(0 0 0/.55),0 0 0 4px rgba(167,139,250,.08);animation:dg-gomoku-pop .35s cubic-bezier(.22,1.6,.36,1);text-align:center}
|
|
234
|
+
.dgame-gomoku-result-card strong{font-size:18px;color:#fff}
|
|
235
|
+
.dgame-gomoku-result-card.b strong{color:#fbbf24}.dgame-gomoku-result-card.w strong{color:#e5e7eb}.dgame-gomoku-result-card.draw strong{color:#cbd5e1}
|
|
236
|
+
.dgame-gomoku-result-icon{font-size:28px;line-height:1;animation:dg-gomoku-bounce 1.2s ease-in-out infinite}
|
|
237
|
+
.dgame-gomoku-result-card p{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary,#9ca3af)}
|
|
238
|
+
.dgame-gomoku-result-actions{display:flex;gap:10px;margin-top:6px}
|
|
239
|
+
.dgame-gomoku-result-actions button{padding:6px 14px;border-radius:8px;border:1px solid color-mix(in srgb,var(--dgame-accent,#a78bfa) 40%,transparent);background:rgba(167,139,250,.14);color:#ede9fe;font-size:13px;cursor:pointer}
|
|
240
|
+
.dgame-gomoku-result-actions button:hover{background:rgba(167,139,250,.28)}
|
|
241
|
+
@keyframes dg-gomoku-fade{from{opacity:0}to{opacity:1}}
|
|
242
|
+
@keyframes dg-gomoku-pop{from{opacity:0;transform:scale(.6)}to{opacity:1;transform:scale(1)}}
|
|
243
|
+
@keyframes dg-gomoku-bounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}
|
|
175
244
|
` };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* 趣味游戏 · 极速赛车(经典机台)——纯 Client;canvas 渲染,躲避来车、收集金币,越开越快。 */
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { focusStage, useGameControls } from "./shared.jsx";
|
|
3
4
|
|
|
4
5
|
const W = 300, H = 420;
|
|
5
6
|
const ROAD_W = 232, ROAD_X = (W - ROAD_W) / 2;
|
|
@@ -144,15 +145,12 @@ export function RacerGame(props) {
|
|
|
144
145
|
const scale = rect0.width / W;
|
|
145
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));
|
|
146
147
|
}, []);
|
|
147
|
-
|
|
148
|
-
if (pausedRef.current) return;
|
|
149
|
-
if (canvasRef.current) canvasRef.current.focus();
|
|
150
|
-
}, [props.paused]);
|
|
148
|
+
useGameControls(canvasRef, pausedRef.current, onKeyDown, onKeyUp);
|
|
151
149
|
|
|
152
150
|
return <section className="dgame-game" aria-label="极速赛车">
|
|
153
151
|
<div className="dgame-game-head"><div><h3>极速赛车</h3><p>左右移动避开迎面来车、收集金币,越开越快;撞车即结束。距离与金币实时显示在游戏画面左上/右上。</p></div><div className="dgame-score"><span>操作</span><span>←/→ 或鼠标</span></div></div>
|
|
154
152
|
<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="极速赛车游戏区域,左右方向键或鼠标控制方向" />
|
|
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="极速赛车游戏区域,左右方向键或鼠标控制方向" />
|
|
156
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}
|
|
157
155
|
</div>
|
|
158
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>
|
|
@@ -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
|
+
}
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
/* 趣味游戏 · 贪吃蛇(经典机台)——纯 Client;吃到食物变长,撞墙或咬到自身结束。 */
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { focusStage, useGameControls, playSfx } from "./shared.jsx";
|
|
3
4
|
|
|
4
5
|
const SIZE = 16;
|
|
5
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] };
|
|
6
|
-
const OPPOSITE = { "-1,0": [1, 0], "1,0": [-1, 0], "0
|
|
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
|
+
}
|
|
7
17
|
|
|
8
18
|
function randFood(snake) {
|
|
9
19
|
while (true) {
|
|
@@ -13,7 +23,7 @@ function randFood(snake) {
|
|
|
13
23
|
}
|
|
14
24
|
function initial() {
|
|
15
25
|
const snake = [{ r: 7, c: 5 }, { r: 7, c: 4 }, { r: 7, c: 3 }];
|
|
16
|
-
return { snake, dir: [0, 1], food: randFood(snake), score: 0, over: false };
|
|
26
|
+
return { snake, dir: [0, 1], food: randFood(snake), score: 0, over: false, started: false };
|
|
17
27
|
}
|
|
18
28
|
function step(state, dir) {
|
|
19
29
|
const head = state.snake[0];
|
|
@@ -25,6 +35,7 @@ function step(state, dir) {
|
|
|
25
35
|
const ate = state.food.r === nr && state.food.c === nc;
|
|
26
36
|
let snake = [{ r: nr, c: nc }, ...state.snake];
|
|
27
37
|
if (ate) {
|
|
38
|
+
playSfx("eat");
|
|
28
39
|
const food = randFood(snake);
|
|
29
40
|
return Object.assign({}, state, { snake, food, score: state.score + 1 });
|
|
30
41
|
}
|
|
@@ -35,38 +46,56 @@ function step(state, dir) {
|
|
|
35
46
|
export function SnakeGame(props) {
|
|
36
47
|
const [state, setState] = useState(initial);
|
|
37
48
|
const [paused, setPaused] = useState(false);
|
|
49
|
+
// 速度档位:默认慢速,可随时切换(重开不重置)。
|
|
50
|
+
const [speedMode, setSpeedMode] = useState(loadSpeedMode);
|
|
38
51
|
const stageRef = useRef(null);
|
|
39
52
|
const dirRef = useRef(state.dir);
|
|
40
53
|
const overRef = useRef(state.over); overRef.current = state.over;
|
|
41
54
|
const pausedRef = useRef(!!(props && props.paused) || paused); pausedRef.current = !!(props && props.paused) || paused;
|
|
42
55
|
|
|
43
|
-
const start = useCallback(() => { const init = initial(); dirRef.current = init.dir; setState(init); setPaused(false); }, []);
|
|
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 }))); }, []);
|
|
44
59
|
const changeDir = useCallback((nv) => {
|
|
45
60
|
if (overRef.current || pausedRef.current) return;
|
|
46
61
|
const cur = dirRef.current;
|
|
47
62
|
const opp = OPPOSITE[cur[0] + "," + cur[1]];
|
|
48
63
|
if (opp && opp[0] === nv[0] && opp[1] === nv[1]) return;
|
|
49
64
|
dirRef.current = nv;
|
|
50
|
-
|
|
65
|
+
begin();
|
|
66
|
+
}, [begin]);
|
|
51
67
|
|
|
52
68
|
useEffect(() => {
|
|
53
|
-
if (state.over
|
|
54
|
-
|
|
55
|
-
const timer = setInterval(() => setState((s) => (s.over || pausedRef.current ? s : step(s, dirRef.current))), speed);
|
|
56
|
-
return () => clearInterval(timer);
|
|
57
|
-
}, [state.over, state.score, props.paused, paused]);
|
|
69
|
+
if (state.over) playSfx("over");
|
|
70
|
+
}, [state.over]);
|
|
58
71
|
|
|
59
72
|
useEffect(() => {
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}, []);
|
|
63
84
|
|
|
64
85
|
const onKeyDown = useCallback((event) => {
|
|
65
86
|
const d = DIRS[event.key];
|
|
66
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
|
+
}
|
|
67
94
|
if (event.key.toLowerCase() === "p") { event.preventDefault(); setPaused((p) => !p); }
|
|
68
95
|
else if (event.key.toLowerCase() === "r") { event.preventDefault(); start(); }
|
|
69
|
-
}, [changeDir, start]);
|
|
96
|
+
}, [changeDir, start, state.started]);
|
|
97
|
+
|
|
98
|
+
useGameControls(stageRef, pausedRef.current, onKeyDown);
|
|
70
99
|
|
|
71
100
|
const cells = [];
|
|
72
101
|
for (let r = 0; r < SIZE; r += 1) {
|
|
@@ -81,13 +110,20 @@ export function SnakeGame(props) {
|
|
|
81
110
|
}
|
|
82
111
|
|
|
83
112
|
return <section className="dgame-game" aria-label="贪吃蛇">
|
|
84
|
-
<div className="dgame-game-head"><div><h3>贪吃蛇</h3><p
|
|
85
|
-
<div className="dgame-snake-
|
|
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="贪吃蛇游戏区域,使用方向键控制移动,回车开始">
|
|
86
121
|
<div className="dgame-snake-grid">{cells}</div>
|
|
87
122
|
{state.over ? <div className="dgame-over"><strong>撞上了 · 得 {state.score} 分</strong><button type="button" onClick={start}>重新开始</button></div> : null}
|
|
88
|
-
{!state.
|
|
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}
|
|
89
125
|
</div>
|
|
90
|
-
<div className="dgame-controls"><button type="button" aria-label="向上" onClick={() => changeDir([-1, 0])}>↑</button><span>方向键 / WASD ·
|
|
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>
|
|
91
127
|
</section>;
|
|
92
128
|
}
|
|
93
129
|
|
|
@@ -99,5 +135,10 @@ export function SnakePreview() {
|
|
|
99
135
|
}
|
|
100
136
|
|
|
101
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)}
|
|
102
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)}
|
|
103
144
|
` };
|