dsh-dock 0.9.1 → 0.9.3
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 +5 -3
- package/client.js +284 -70
- package/features/animation/host.js +19 -1
- package/features/animation/view.jsx +171 -28
- package/features/games/games/breakout.jsx +3 -3
- package/features/games/games/racer.jsx +1 -1
- package/features/games/games/tetris.jsx +12 -6
- package/features/games/games/xiangqi.jsx +98 -26
- package/features/games/view.jsx +1 -1
- package/features/mobile-relay/host.js +53 -0
- package/package.json +2 -2
- package/src/client.jsx +1 -1
- package/src/host-core.js +2 -0
|
@@ -37,6 +37,21 @@ const XIANGQI_LINES = (() => {
|
|
|
37
37
|
return parts;
|
|
38
38
|
})();
|
|
39
39
|
|
|
40
|
+
// 传统棋谱着法(如"炮二平五"/"馬8进7"):红方用中文数字、黑方用阿拉伯数字,各自从己方右侧数起。
|
|
41
|
+
const CN_NUMS = ["一", "二", "三", "四", "五", "六", "七", "八", "九"];
|
|
42
|
+
function moveNotation(board, fr, fc, tr, tc) {
|
|
43
|
+
const p = board[fr][fc];
|
|
44
|
+
if (!p) return "";
|
|
45
|
+
const red = p.c === "r";
|
|
46
|
+
const num = (n) => (red ? CN_NUMS[n - 1] : String(n));
|
|
47
|
+
const file = (c) => (red ? 9 - c : c + 1);
|
|
48
|
+
const forward = red ? tr < fr : tr > fr;
|
|
49
|
+
const verb = fr === tr ? "平" : (forward ? "进" : "退");
|
|
50
|
+
// 直行棋子进/退记步数;斜行棋子(马/象/士)进/退记目标线位。
|
|
51
|
+
const to = fr === tr || p.t === "h" || p.t === "e" || p.t === "a" ? file(tc) : Math.abs(tr - fr);
|
|
52
|
+
return pieceChar(p) + num(file(fc)) + verb + num(to);
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
export function XiangqiGame(props) {
|
|
41
56
|
const [board, setBoard] = useState(initialBoard);
|
|
42
57
|
const [turn, setTurn] = useState("r");
|
|
@@ -45,6 +60,11 @@ export function XiangqiGame(props) {
|
|
|
45
60
|
const [over, setOver] = useState(null); // 'r' | 'b' | 'draw'
|
|
46
61
|
const [check, setCheck] = useState(false);
|
|
47
62
|
const [last, setLast] = useState(null);
|
|
63
|
+
// 上一手的起点/终点/棋谱文本:起点画虚线圈、终点画红点,棋谱行让玩家看清对方走了什么。
|
|
64
|
+
const [lastMove, setLastMove] = useState(null); // { fr, fc, tr, tc, text }
|
|
65
|
+
const [moveLog, setMoveLog] = useState([]); // [{ side, text }] 双方着法流水
|
|
66
|
+
// 跳跃动画:moving = { fr, fc, tr, tc, piece, capture },动画结束后落定为普通棋子。
|
|
67
|
+
const [moving, setMoving] = useState(null);
|
|
48
68
|
const [thinking, setThinking] = useState(false);
|
|
49
69
|
const [history, setHistory] = useState([]);
|
|
50
70
|
// 已出现过的局面指纹:AI 搜索时排除重复局面,杜绝"反复将军无限循环"。
|
|
@@ -57,44 +77,78 @@ export function XiangqiGame(props) {
|
|
|
57
77
|
const overRef = useRef(over); overRef.current = over;
|
|
58
78
|
const thinkingRef = useRef(thinking); thinkingRef.current = thinking;
|
|
59
79
|
const historyRef = useRef(history); historyRef.current = history;
|
|
80
|
+
// 动画期间锁输入与 AI 回合,结束后调用 settle 落定真正的棋盘状态。
|
|
81
|
+
const movingRef = useRef(false);
|
|
82
|
+
const pendingRef = useRef(null);
|
|
83
|
+
|
|
84
|
+
// 起跳动画:先在起点渲染飞行棋子,260ms 后落地并把棋盘推进到新局面。
|
|
85
|
+
const animTimerRef = useRef(null);
|
|
86
|
+
const animateMove = useCallback((b, fr, fc, tr, tc, after) => {
|
|
87
|
+
// 被移动/被吃的棋子须从旧盘(boardRef)读取:b 已是落子后的新盘。
|
|
88
|
+
const piece = boardRef.current[fr][fc];
|
|
89
|
+
const capture = !!boardRef.current[tr][tc];
|
|
90
|
+
pendingRef.current = { board: b, after };
|
|
91
|
+
movingRef.current = true;
|
|
92
|
+
setSel(null); setTargets([]);
|
|
93
|
+
setMoving({ fr, fc, tr, tc, piece, capture });
|
|
94
|
+
playSfx(capture ? "capture" : "step");
|
|
95
|
+
if (animTimerRef.current) clearTimeout(animTimerRef.current);
|
|
96
|
+
animTimerRef.current = setTimeout(() => {
|
|
97
|
+
animTimerRef.current = null;
|
|
98
|
+
const pend = pendingRef.current;
|
|
99
|
+
pendingRef.current = null;
|
|
100
|
+
movingRef.current = false;
|
|
101
|
+
setMoving(null);
|
|
102
|
+
if (pend) {
|
|
103
|
+
const text = moveNotation(boardRef.current, fr, fc, tr, tc);
|
|
104
|
+
boardRef.current = pend.board;
|
|
105
|
+
setBoard(pend.board);
|
|
106
|
+
setLastMove({ fr, fc, tr, tc, text });
|
|
107
|
+
setMoveLog((log) => log.concat({ side: piece.c, text }));
|
|
108
|
+
setLast([tr, tc]);
|
|
109
|
+
if (pend.after) pend.after();
|
|
110
|
+
}
|
|
111
|
+
}, 260);
|
|
112
|
+
}, []);
|
|
60
113
|
|
|
61
114
|
const restart = useCallback(() => {
|
|
115
|
+
if (animTimerRef.current) { clearTimeout(animTimerRef.current); animTimerRef.current = null; }
|
|
116
|
+
pendingRef.current = null; movingRef.current = false;
|
|
62
117
|
const b = initialBoard();
|
|
63
118
|
boardRef.current = b;
|
|
64
119
|
setBoard(b); setTurn("r"); setSel(null); setTargets([]); setOver(null); setCheck(false); setLast(null); setThinking(false); setHistory([]);
|
|
65
|
-
setRepKeys([posKey(b)]);
|
|
120
|
+
setLastMove(null); setMoveLog([]); setMoving(null); setRepKeys([posKey(b)]);
|
|
66
121
|
}, []);
|
|
67
122
|
|
|
68
123
|
const undo = useCallback(() => {
|
|
69
|
-
if (thinkingRef.current || overRef.current || turnRef.current !== "r") return;
|
|
124
|
+
if (thinkingRef.current || overRef.current || turnRef.current !== "r" || movingRef.current) return;
|
|
70
125
|
const h = historyRef.current;
|
|
71
126
|
if (h.length < 2) return;
|
|
72
127
|
const hist = h.slice(0, -2);
|
|
73
128
|
const b = hist.length ? cloneBoard(hist[hist.length - 1]) : initialBoard();
|
|
74
129
|
boardRef.current = b;
|
|
75
|
-
setBoard(b); setHistory(hist); setSel(null); setTargets([]); setLast(null); setOver(null);
|
|
130
|
+
setBoard(b); setHistory(hist); setSel(null); setTargets([]); setLast(null); setLastMove(null); setMoveLog((prevLog) => prevLog.slice(0, -2)); setOver(null);
|
|
76
131
|
setCheck(inCheck(b, "r"));
|
|
77
132
|
// 重建局面指纹(历史各局面 + 当前)。
|
|
78
133
|
setRepKeys(hist.map(posKey).concat(posKey(b)));
|
|
79
134
|
}, []);
|
|
80
135
|
|
|
81
136
|
const moveRed = useCallback((tr, tc) => {
|
|
82
|
-
if (turnRef.current !== "r" || overRef.current || !sel) return;
|
|
137
|
+
if (turnRef.current !== "r" || overRef.current || !sel || movingRef.current) return;
|
|
83
138
|
const fr = sel[0], fc = sel[1];
|
|
84
|
-
if (boardRef.current[tr][tc]) playSfx("capture"); else playSfx("step");
|
|
85
139
|
const nb = applyMove(boardRef.current, fr, fc, tr, tc);
|
|
86
140
|
setHistory((h) => [...h, boardRef.current]);
|
|
87
|
-
boardRef.current = nb;
|
|
88
141
|
pushKey(nb);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
142
|
+
animateMove(nb, fr, fc, tr, tc, () => {
|
|
143
|
+
// 动画落地后再移交回合:让 AI 的 useEffect 依赖到最新 turn 才启动搜索。
|
|
144
|
+
setTurn("b");
|
|
145
|
+
if (!hasLegalMoves(nb, "b")) { setOver("r"); setCheck(false); }
|
|
146
|
+
else setCheck(inCheck(nb, "b"));
|
|
147
|
+
});
|
|
148
|
+
}, [sel, pushKey, animateMove]);
|
|
95
149
|
|
|
96
150
|
const select = useCallback((r, c) => {
|
|
97
|
-
if (overRef.current || turnRef.current !== "r" || thinkingRef.current) return;
|
|
151
|
+
if (overRef.current || turnRef.current !== "r" || thinkingRef.current || movingRef.current) return;
|
|
98
152
|
const p = boardRef.current[r][c];
|
|
99
153
|
if (p && p.c === "r") {
|
|
100
154
|
setSel([r, c]);
|
|
@@ -106,9 +160,9 @@ export function XiangqiGame(props) {
|
|
|
106
160
|
setSel(null); setTargets([]);
|
|
107
161
|
}, [sel, targets, moveRed]);
|
|
108
162
|
|
|
109
|
-
// AI
|
|
163
|
+
// AI 回合(执黑):先想棋(thinking 期间玩家能看到"AI 思考中"),落子走同款跳跃动画。
|
|
110
164
|
useEffect(() => {
|
|
111
|
-
if (turn !== "b" || over || props.paused) return;
|
|
165
|
+
if (turn !== "b" || over || props.paused || movingRef.current) return;
|
|
112
166
|
const t = setTimeout(() => {
|
|
113
167
|
// 局面出现次数表:AI 优先走新局面;全部重复时也倾向去往出现最少的局面。
|
|
114
168
|
const seenCounts = new Map();
|
|
@@ -116,20 +170,20 @@ export function XiangqiGame(props) {
|
|
|
116
170
|
const mv = bestMove(boardRef.current, "b", 3, seenCounts);
|
|
117
171
|
if (mv) {
|
|
118
172
|
const [fr, fc, tr, tc] = mv;
|
|
119
|
-
if (boardRef.current[tr][tc]) playSfx("capture"); else playSfx("step");
|
|
120
173
|
const nb = applyMove(boardRef.current, fr, fc, tr, tc);
|
|
121
174
|
setHistory((h) => [...h, boardRef.current]);
|
|
122
|
-
boardRef.current = nb;
|
|
123
175
|
pushKey(nb);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
176
|
+
animateMove(nb, fr, fc, tr, tc, () => {
|
|
177
|
+
setTurn("r");
|
|
178
|
+
if (!hasLegalMoves(nb, "r")) { setOver("b"); setCheck(false); }
|
|
179
|
+
else setCheck(inCheck(nb, "r"));
|
|
180
|
+
});
|
|
127
181
|
} else setOver("r");
|
|
128
182
|
setThinking(false);
|
|
129
|
-
},
|
|
183
|
+
}, 420);
|
|
130
184
|
setThinking(true);
|
|
131
185
|
return () => clearTimeout(t);
|
|
132
|
-
}, [turn, over, props.paused, repKeys, pushKey]);
|
|
186
|
+
}, [turn, over, props.paused, repKeys, pushKey, animateMove]);
|
|
133
187
|
|
|
134
188
|
const onKeyDown = useCallback((event) => {
|
|
135
189
|
if (event.key.toLowerCase() === "r") { event.preventDefault(); restart(); }
|
|
@@ -137,7 +191,7 @@ export function XiangqiGame(props) {
|
|
|
137
191
|
}, [restart, undo]);
|
|
138
192
|
useGameControls(stageRef, props.paused, onKeyDown);
|
|
139
193
|
|
|
140
|
-
const status = over === "r" ? "红方胜 · 恭喜" : over === "b" ? "黑方胜 · AI 赢" : (thinking ? "AI 思考中…" : (check ? "将军!" : (turn === "r" ? "轮到你(红方)" : "轮到 AI(黑方)")));
|
|
194
|
+
const status = over === "r" ? "红方胜 · 恭喜" : over === "b" ? "黑方胜 · AI 赢" : (moving ? (moving.piece.c === "r" ? "红方走子…" : "AI 走子…") : (thinking ? "AI 思考中…" : (check ? "将军!" : (turn === "r" ? "轮到你(红方)" : "轮到 AI(黑方)"))));
|
|
141
195
|
const prevCheck = useRef(false);
|
|
142
196
|
useEffect(() => {
|
|
143
197
|
if (over === "r") playSfx("win");
|
|
@@ -145,6 +199,8 @@ export function XiangqiGame(props) {
|
|
|
145
199
|
else if (!thinking && check && !prevCheck.current) playSfx("check");
|
|
146
200
|
prevCheck.current = !thinking && check;
|
|
147
201
|
}, [check, thinking, over]);
|
|
202
|
+
// 组件卸载时清掉未落地的动画定时器,避免对已卸载组件 setState。
|
|
203
|
+
useEffect(() => () => { if (animTimerRef.current) clearTimeout(animTimerRef.current); }, []);
|
|
148
204
|
|
|
149
205
|
return <section className="dgame-game" aria-label="象棋">
|
|
150
206
|
<div className="dgame-game-head"><div><h3>中国象棋</h3><p>你执红先行,AI 执黑;吃掉对方将/帅即胜。点击选中红子,再点高亮落点走棋。</p></div><div className="dgame-score"><span>当前</span><strong className="dg-gomoku-status">{status}</strong><button type="button" onClick={undo}>撤销</button><button type="button" onClick={restart}>重开</button></div></div>
|
|
@@ -155,17 +211,33 @@ export function XiangqiGame(props) {
|
|
|
155
211
|
<text className="dgame-xiangqi-rivertext" x={670} y={505}>漢 界</text>
|
|
156
212
|
</svg>
|
|
157
213
|
{board.map((row, r) => row.map((p, c) => {
|
|
214
|
+
const mv = moving;
|
|
215
|
+
// 动画期间:飞行棋子由独立层渲染(起点格不再画它);被吃棋子原地淡出。
|
|
216
|
+
const isFlyFrom = mv && mv.fr === r && mv.fc === c;
|
|
217
|
+
const isDying = mv && mv.capture && mv.tr === r && mv.tc === c;
|
|
158
218
|
const isSel = sel && sel[0] === r && sel[1] === c;
|
|
159
219
|
const isTargetMark = targets.some(([tr, tc]) => tr === r && tc === c);
|
|
220
|
+
const isLastMoveFrom = lastMove && lastMove.fr === r && lastMove.fc === c;
|
|
221
|
+
const isLand = !mv && lastMove && lastMove.tr === r && lastMove.tc === c;
|
|
160
222
|
const isLast = last && last[0] === r && last[1] === c;
|
|
161
223
|
return <button type="button" key={r + "-" + c} role="gridcell"
|
|
162
|
-
className={"dgame-xiangqi-cell" + (isSel ? " sel" : "") + (isTargetMark ? " target" : "") + (isLast ? " last" : "")}
|
|
224
|
+
className={"dgame-xiangqi-cell" + (isSel ? " sel" : "") + (isTargetMark ? " target" : "") + (isLastMoveFrom ? " from" : "") + (isLand ? " land" : "") + (isLast ? " last" : "")}
|
|
163
225
|
aria-label={"第" + (r + 1) + "行第" + (c + 1) + "列" + (p ? pieceChar(p) : " 空")}
|
|
164
226
|
onClick={() => { focusStage(stageRef); select(r, c); }}>
|
|
165
|
-
{p ? <span className={"dgame-xiangqi-pc " + p.c}>{pieceChar(p)}</span> : (isTargetMark ? <span className="dgame-xiangqi-dot" aria-hidden="true" /> : null)}
|
|
227
|
+
{p && !isFlyFrom ? <span className={"dgame-xiangqi-pc " + p.c + (isDying ? " dying" : "")}>{pieceChar(p)}</span> : (!p && isTargetMark ? <span className="dgame-xiangqi-dot" aria-hidden="true" /> : null)}
|
|
166
228
|
</button>;
|
|
167
229
|
}))}
|
|
230
|
+
{moving ? <span key={moving.fr + "-" + moving.fc + "-" + moving.tr + "-" + moving.tc + "-" + moving.piece.c}
|
|
231
|
+
className={"dgame-xiangqi-pc dgame-xiangqi-fly " + moving.piece.c}
|
|
232
|
+
style={{
|
|
233
|
+
"--fx0": (moving.fc + 0.5) / 9, "--fy0": (moving.fr + 0.5) / 10,
|
|
234
|
+
"--fx1": (moving.tc + 0.5) / 9, "--fy1": (moving.tr + 0.5) / 10,
|
|
235
|
+
}}
|
|
236
|
+
aria-hidden="true">{pieceChar(moving.piece)}</span> : null}
|
|
168
237
|
</div>
|
|
238
|
+
{moveLog.length ? <div className="dgame-xiangqi-log" role="status" aria-live="polite">
|
|
239
|
+
{moveLog.slice(-4).map((m, i) => <span key={moveLog.length - 4 + i} className={m.side}>{m.text}</span>)}
|
|
240
|
+
</div> : null}
|
|
169
241
|
<div className="dgame-controls"><button type="button" onClick={undo}>撤销</button><span>{status} · R 重开 · U 撤销</span><button type="button" onClick={restart}>重开</button></div>
|
|
170
242
|
</section>;
|
|
171
243
|
}
|
|
@@ -201,5 +273,5 @@ export function XiangqiPreview() {
|
|
|
201
273
|
}
|
|
202
274
|
|
|
203
275
|
export const xiangqiGame = { Game: XiangqiGame, Preview: XiangqiPreview, css: `
|
|
204
|
-
.dgame-xiangqi-board{position:relative;display:grid;grid-template-columns:repeat(9,1fr);grid-template-rows:repeat(10,1fr);align-self:center;width:min(100%,min(90vw,340px));aspect-ratio:9/10;padding:13px;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(130% 105% at 26% 8%,#f0cd86,#ddb067 48%,#cb9a51 78%,#bb8844)}.dgame-xiangqi-lines{position:absolute;inset:13px;pointer-events:none}.dgame-xiangqi-lines line,.dgame-xiangqi-lines path{stroke:#66431a;stroke-width:3.4;fill:none;stroke-linecap:square}.dgame-xiangqi-lines .dgame-xiangqi-mark{stroke-width:2.6}.dgame-xiangqi-rivertext{fill:rgba(102,67,26,.7);font-family:KaiTi,"Kaiti SC","STKaiti","FangSong",serif;font-size:46px;letter-spacing:7px;text-anchor:middle;dominant-baseline:central}.dgame-xiangqi-board:focus-visible{box-shadow:0 0 0 3px rgba(0,0,0,.25)}.dgame-xiangqi-cell{position:relative;z-index:1;background:transparent;border:none;cursor:pointer;padding:0;margin:0;outline:none;display:grid;place-items:center;min-width:0;min-height:0}.dgame-xiangqi-cell.target .dgame-xiangqi-dot{width:12px;height:12px;border-radius:50%;background:rgba(34,211,238,.6);box-shadow:0 0 10px rgba(34,211,238,.65),0 0 0 2px rgba(255,255,255,.28)}.dgame-xiangqi-cell.target .dgame-xiangqi-pc{box-shadow:0 0 0 2px rgba(248,113,113,.85),0 0 10px rgba(248,113,113,.5),0 2px 4px rgb(0 0 0/.4),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4)}.dgame-xiangqi-cell.sel .dgame-xiangqi-pc::before{content:'';position:absolute;inset:-3px;border-radius:50%;border:2.5px solid rgba(251,191,36,.95);box-shadow:0 0 12px rgba(251,191,36,.6)}.dgame-xiangqi-cell.last .dgame-xiangqi-pc::after{content:'';position:absolute;right:5%;bottom:5%;width:5px;height:5px;border-radius:50%;background:#ef4444;box-shadow:0 0 4px rgba(239,68,68,.8)}.dgame-xiangqi-pc{position:relative;display:grid;place-items:center;z-index:1;width:min(80%,30px);aspect-ratio:1/1;border-radius:50%;box-sizing:border-box;font-family:KaiTi,"Kaiti SC","STKaiti","FangSong","SimSun",serif;font-size:clamp(12px,1.4vw,15px);font-weight:700;line-height:1;background:radial-gradient(circle at 34% 28%,#fff3d4,#eedaa6 46%,#d8b675 72%,#bd9650);box-shadow:0 2px 4px rgb(0 0 0/.45),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4)}.dgame-xiangqi-pc.r{color:#b91c1c;text-shadow:0 1px 0 rgba(255,255,255,.5)}.dgame-xiangqi-
|
|
276
|
+
.dgame-xiangqi-board{position:relative;display:grid;grid-template-columns:repeat(9,1fr);grid-template-rows:repeat(10,1fr);align-self:center;width:min(100%,min(90vw,340px));aspect-ratio:9/10;padding:13px;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(130% 105% at 26% 8%,#f0cd86,#ddb067 48%,#cb9a51 78%,#bb8844)}.dgame-xiangqi-lines{position:absolute;inset:13px;pointer-events:none}.dgame-xiangqi-lines line,.dgame-xiangqi-lines path,.dgame-xiangqi-lines rect{stroke:#66431a;stroke-width:3.4;fill:none;stroke-linecap:square}.dgame-xiangqi-lines rect{stroke-width:7}.dgame-xiangqi-lines .dgame-xiangqi-mark{stroke-width:2.6}.dgame-xiangqi-rivertext{fill:rgba(102,67,26,.7);font-family:KaiTi,"Kaiti SC","STKaiti","FangSong",serif;font-size:46px;letter-spacing:7px;text-anchor:middle;dominant-baseline:central}.dgame-xiangqi-board:focus-visible{box-shadow:0 0 0 3px rgba(0,0,0,.25)}.dgame-xiangqi-cell{position:relative;z-index:1;background:transparent;border:none;cursor:pointer;padding:0;margin:0;outline:none;display:grid;place-items:center;min-width:0;min-height:0}.dgame-xiangqi-cell.target .dgame-xiangqi-dot{width:12px;height:12px;border-radius:50%;background:rgba(34,211,238,.6);box-shadow:0 0 10px rgba(34,211,238,.65),0 0 0 2px rgba(255,255,255,.28)}.dgame-xiangqi-cell.target .dgame-xiangqi-pc{box-shadow:0 0 0 2px rgba(248,113,113,.85),0 0 10px rgba(248,113,113,.5),0 2px 4px rgb(0 0 0/.4),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4)}.dgame-xiangqi-cell.sel .dgame-xiangqi-pc::before{content:'';position:absolute;inset:-3px;border-radius:50%;border:2.5px solid rgba(251,191,36,.95);box-shadow:0 0 12px rgba(251,191,36,.6)}.dgame-xiangqi-cell.from::before{content:'';position:absolute;inset:50% auto auto 50%;width:34%;height:34%;transform:translate(-50%,-50%);border-radius:50%;border:2.5px dashed rgba(102,67,26,.75);pointer-events:none}.dgame-xiangqi-cell.land .dgame-xiangqi-pc{animation:dgame-xq-land .28s cubic-bezier(.2,.85,.3,1.25)}.dgame-xiangqi-cell.last .dgame-xiangqi-pc::after{content:'';position:absolute;right:5%;bottom:5%;width:5px;height:5px;border-radius:50%;background:#ef4444;box-shadow:0 0 4px rgba(239,68,68,.8)}.dgame-xiangqi-pc{position:relative;display:grid;place-items:center;z-index:1;width:min(80%,30px);aspect-ratio:1/1;border-radius:50%;box-sizing:border-box;font-family:KaiTi,"Kaiti SC","STKaiti","FangSong","SimSun",serif;font-size:clamp(12px,1.4vw,15px);font-weight:700;line-height:1;background:radial-gradient(circle at 34% 28%,#fff3d4,#eedaa6 46%,#d8b675 72%,#bd9650);box-shadow:0 2px 4px rgb(0 0 0/.45),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4)}.dgame-xiangqi-pc.dying{animation:dgame-xq-die .26s ease forwards}.dgame-xiangqi-fly{position:absolute;z-index:5;width:calc((100% - 26px)*.0889);aspect-ratio:1/1;border-radius:50%;display:grid;place-items:center;box-sizing:border-box;font-family:KaiTi,"Kaiti SC","STKaiti","FangSong","SimSun",serif;font-size:clamp(12px,1.4vw,15px);font-weight:700;line-height:1;pointer-events:none;background:radial-gradient(circle at 34% 28%,#fff3d4,#eedaa6 46%,#d8b675 72%,#bd9650);box-shadow:0 6px 14px rgb(0 0 0/.5),0 0 0 1.5px rgba(96,62,18,.35),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4);animation:dgame-xq-hop .26s cubic-bezier(.3,.7,.4,1) forwards}.dgame-xiangqi-fly.r{color:#b91c1c;text-shadow:0 1px 0 rgba(255,255,255,.5)}.dgame-xiangqi-fly.b{color:#1f2937;text-shadow:0 1px 0 rgba(255,255,255,.35)}@keyframes dgame-xq-hop{0%{left:calc(13px + var(--fx0)*(100% - 26px));top:calc(13px + var(--fy0)*(100% - 26px));transform:translate(-50%,-50%) scale(.9);opacity:.85}55%{transform:translate(-50%,-64%) scale(1.16);opacity:1}100%{left:calc(13px + var(--fx1)*(100% - 26px));top:calc(13px + var(--fy1)*(100% - 26px));transform:translate(-50%,-50%) scale(1)}}@keyframes dgame-xq-land{0%{transform:scale(1.18)}100%{transform:scale(1)}}@keyframes dgame-xq-die{to{opacity:0;transform:scale(.55)}}.dgame-xiangqi-log{display:flex;flex-wrap:wrap;justify-content:center;gap:5px 12px;align-self:center;min-height:22px;color:var(--dsw-alias-label-secondary);font-family:KaiTi,"Kaiti SC","STKaiti","FangSong",serif;font-size:13px}.dgame-xiangqi-log span{opacity:.65}.dgame-xiangqi-log span:last-child{opacity:1;font-weight:700}.dgame-xiangqi-log .r{color:#c03434}.dgame-xiangqi-log .b{color:var(--dsw-alias-label-primary)}
|
|
205
277
|
` };
|
package/features/games/view.jsx
CHANGED
|
@@ -210,7 +210,7 @@ const GAMES = [
|
|
|
210
210
|
{
|
|
211
211
|
id: "breakout", name: "打砖块", cat: "classic", accent: "#38bdf8",
|
|
212
212
|
desc: "移动挡板反弹小球,击碎全部砖块;球掉到底部丢一条命。",
|
|
213
|
-
tip: "←/→ 或鼠标移动 ·
|
|
213
|
+
tip: "←/→ 或鼠标移动 · 空格/点击发球",
|
|
214
214
|
Game: breakoutGame.Game, Preview: breakoutGame.Preview,
|
|
215
215
|
},
|
|
216
216
|
{
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// 配对状态、在线设备与接力备注只存内存;会话数据始终只有 ~/.dsh 这一份。
|
|
17
17
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
18
18
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { stat } from 'node:fs/promises'
|
|
19
20
|
import { homedir } from 'node:os'
|
|
20
21
|
import { dirname, join } from 'node:path'
|
|
21
22
|
import yaml from 'js-yaml'
|
|
@@ -193,6 +194,58 @@ export const feature = {
|
|
|
193
194
|
|
|
194
195
|
disposers.push(ctx.inject(['settings'], (sctx) => { settingsCtx = sctx }))
|
|
195
196
|
|
|
197
|
+
// ── 浏览模式目录选择器的 Windows 跨盘补全 ──
|
|
198
|
+
// 官方 browse 后端从主目录起步,只能列出当前盘内的子目录,面包屑在主目录之上
|
|
199
|
+
// 全部折叠——Windows 开着远程访问时,选工作区根本到不了其他磁盘。
|
|
200
|
+
// 这里在运行时包装 browse 能力的 list():主目录层级注入全部盘符入口,盘根目录
|
|
201
|
+
// 注入其他盘符入口,远程浏览即可像资源管理器一样跨盘选工作区。非 Windows 或
|
|
202
|
+
// 非浏览模式(原生对话框本身可跨盘)零改动。
|
|
203
|
+
const DRIVE_SCAN_TTL = 5000
|
|
204
|
+
let driveScan = { at: 0, letters: null }
|
|
205
|
+
function sameWinPath(a, b) {
|
|
206
|
+
return a.replace(/[\\/]+/g, '\\').toLowerCase() === b.replace(/[\\/]+/g, '\\').toLowerCase()
|
|
207
|
+
}
|
|
208
|
+
function winDriveLetterOf(p) {
|
|
209
|
+
return /^[A-Za-z]:[\\/]?$/.test(p) ? p.slice(0, 1).toUpperCase() : null
|
|
210
|
+
}
|
|
211
|
+
async function windowsDrivePaths() {
|
|
212
|
+
if (driveScan.letters && Date.now() - driveScan.at < DRIVE_SCAN_TTL) return driveScan.letters
|
|
213
|
+
const probes = []
|
|
214
|
+
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
215
|
+
probes.push(stat(letter + ':\\').then(() => letter, () => null))
|
|
216
|
+
}
|
|
217
|
+
const letters = (await Promise.all(probes)).filter(Boolean).sort()
|
|
218
|
+
driveScan = { at: Date.now(), letters }
|
|
219
|
+
return letters
|
|
220
|
+
}
|
|
221
|
+
disposers.push(ctx.inject(['directoryPicker'], (dpCtx) => {
|
|
222
|
+
if (process.platform !== 'win32') return
|
|
223
|
+
const picker = dpCtx.directoryPicker
|
|
224
|
+
if (!picker || typeof picker.capability !== 'function') return
|
|
225
|
+
let cap
|
|
226
|
+
try { cap = picker.capability() } catch { return }
|
|
227
|
+
if (!cap || cap.kind !== 'browse' || typeof cap.list !== 'function' || cap.__dshDockDrives) return
|
|
228
|
+
cap.__dshDockDrives = true
|
|
229
|
+
const origList = cap.list
|
|
230
|
+
cap.list = async (path, signal) => {
|
|
231
|
+
const listing = await origList(path, signal)
|
|
232
|
+
try {
|
|
233
|
+
const target = String((listing && listing.path) || '')
|
|
234
|
+
const atHome = sameWinPath(target, String(listing.home || '\u0000'))
|
|
235
|
+
const driveRoot = winDriveLetterOf(target)
|
|
236
|
+
// 只在主目录与盘根两级注入:这两级是跨盘的必经之地,其余层级保持官方原样
|
|
237
|
+
if (atHome || driveRoot) {
|
|
238
|
+
const letters = await windowsDrivePaths()
|
|
239
|
+
const rows = letters
|
|
240
|
+
.filter((letter) => !driveRoot || letter !== driveRoot)
|
|
241
|
+
.map((letter) => ({ name: letter + ':\\', path: letter + ':\\', hidden: false }))
|
|
242
|
+
if (rows.length) listing.entries = atHome ? rows.concat(listing.entries) : listing.entries.concat(rows)
|
|
243
|
+
}
|
|
244
|
+
} catch { /* 盘符探测失败保持原列表,不让补全拖垮选目录 */ }
|
|
245
|
+
return listing
|
|
246
|
+
}
|
|
247
|
+
}))
|
|
248
|
+
|
|
196
249
|
// ── 远程访问账号(settings 持久化;密码只存加盐哈希) ──
|
|
197
250
|
function hashPassword(salt, password) {
|
|
198
251
|
return createHash('sha256').update(String(salt) + '\u0000' + String(password), 'utf8').digest('hex')
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dock",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(
|
|
3
|
+
"version": "0.9.3",
|
|
4
|
+
"description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景、完成/需确认通知与提示音、钉钉/飞书推送,开关独立、配置持久化);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|
package/src/client.jsx
CHANGED
|
@@ -28,7 +28,7 @@ import { feature as fGames } from "../features/games/view.jsx";
|
|
|
28
28
|
import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
|
|
29
29
|
|
|
30
30
|
const name = "dsh-dock";
|
|
31
|
-
const DOCK_VERSION = "0.9.
|
|
31
|
+
const DOCK_VERSION = "0.9.3";
|
|
32
32
|
|
|
33
33
|
// ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
|
|
34
34
|
const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fGames, fMobileRelay];
|
package/src/host-core.js
CHANGED
|
@@ -27,6 +27,8 @@ export const DockConfig = z.object({
|
|
|
27
27
|
notifyEnabled: z.boolean().default(true),
|
|
28
28
|
notifyOnComplete: z.boolean().default(true),
|
|
29
29
|
notifyOnError: z.boolean().default(true),
|
|
30
|
+
// 工具等待用户确认/批准时提醒(dsh 会话流 approval/asked → decided)
|
|
31
|
+
notifyOnConfirm: z.boolean().default(true),
|
|
30
32
|
// 通知停留毫秒数(0 = 常驻直到手动关闭)
|
|
31
33
|
notifyStayMs: z.number().default(8000),
|
|
32
34
|
// 浏览器系统通知(页面后台时推送)
|