Per user request: split the development process into per-topic docs under docs/dev-log/. Each entry follows the same structure: What was asked → Problems → How solved Files: - README.md index - 01-initial-scope-and-design.md - 02-ui-primitives-api-design.md - 03-monorepo-scaffold.md - 04-ui-primitive-impl.md - 05-drag-and-drop.md - 06-three-games.md - 07-bugfixes-round-1.md - lessons-learned.md all gotchas consolidated - decisions.md locked decisions with dates Total: 1071 lines across 10 files.
110 lines
3.3 KiB
Markdown
110 lines
3.3 KiB
Markdown
# 05 · 拖拽 + 落点判定
|
||
|
||
## 做什么
|
||
|
||
实现 Card 拖出、Zone 接收、onDrop 触发游戏 move。验证端到端:
|
||
- 拖单张牌
|
||
- 多选(Shift+click)后拖一组
|
||
- 跨 zone 拖动
|
||
|
||
## 遇到什么问题
|
||
|
||
### 问题 1:Playwright 拖拽总 timeout — "subtree intercepts pointer events"
|
||
|
||
`dragstart` 不会从被遮挡的 SVG 子元素触发。`<rect>` / `<text>` 等会"吃掉"事件。
|
||
|
||
**解决**:把 SVG 包在 `<div style="pointer-events:none">` 里再 `dangerouslySetInnerHTML`:
|
||
```tsx
|
||
dangerouslySetInnerHTML={{
|
||
__html: `<div style="pointer-events:none;width:100%;height:100%;">${svg}</div>`,
|
||
}}
|
||
```
|
||
|
||
### 问题 2:React StrictMode + Client 双重挂载 → state 翻倍 + React duplicate key
|
||
|
||
`Client(...)` 内部用了全局 store;StrictMode 双重挂载让 store 注册两次,state 出现两次(桌上有 4 张牌而非 2 张)。
|
||
|
||
**解决**:
|
||
- `Client(...)` 用 `useMemo(() => Client({...}), [game])` 包裹,避免每次 render 重新创建
|
||
- `main.tsx` 暂时移除 `<StrictMode>`(在 boardgame.io 的 React client 升级前)
|
||
|
||
### 问题 3:多选拖拽只拖了一张
|
||
|
||
Zone 没把 `selectedIds` 传给 Card,Card dragstart 退化为只带自己的 id。
|
||
|
||
**解决**:Zone 渲染 Card 时传 `cardIds={selected ? Array.from(selectedIds) : undefined}`,Card 内部 `cardIds ?? [card.id]` 兜底。
|
||
|
||
### 问题 4:拖拽的多张牌在桌面上重叠
|
||
|
||
DragTest 手牌 fan 默认 spacing=30 < 卡宽 63,点击被上层卡遮挡。
|
||
|
||
**解决**:DragTest 显式设 `layout: { fan: 0, spacing: 70 }`。
|
||
|
||
### 问题 5:拖拽到非 drop target zone 时被误判
|
||
|
||
例如拖到 `hand` zone(没配 dropMove)会被当作落到自身。
|
||
|
||
**解决**:Zone 拖拽前先 `if (!isDropTarget) return;` 检查。
|
||
|
||
## 怎么解决
|
||
|
||
### HTML5 拖拽 + 自定义 MIME
|
||
|
||
```ts
|
||
const DRAG_MIME = 'application/x-tts-cards';
|
||
|
||
e.dataTransfer.setData(DRAG_MIME, JSON.stringify({
|
||
cardIds,
|
||
fromZoneId: props.fromZoneId,
|
||
}));
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
```
|
||
|
||
### Zone 接收流程
|
||
|
||
```ts
|
||
const handleDragOver = (e) => {
|
||
if (!isDropTarget) return;
|
||
if (!e.dataTransfer.types.includes(DRAG_MIME)) return;
|
||
e.preventDefault(); // 关键:允许 drop
|
||
e.dataTransfer.dropEffect = 'move';
|
||
};
|
||
|
||
const handleDrop = (e) => {
|
||
e.preventDefault();
|
||
const raw = e.dataTransfer.getData(DRAG_MIME);
|
||
const payload = JSON.parse(raw);
|
||
onDrop?.(payload.cardIds, payload.fromZoneId, def.id);
|
||
};
|
||
```
|
||
|
||
### Board 协调
|
||
|
||
```ts
|
||
const handleDrop = (cardIds, fromZoneId, toZoneId) => {
|
||
const target = ui.zones.find(z => z.id === toZoneId);
|
||
if (!target?.dropMove || !onMove) return;
|
||
const args = target.dropArgs
|
||
? target.dropArgs(cardIds, fromZoneId, toZoneId)
|
||
: [cardIds, fromZoneId, toZoneId];
|
||
onMove(target.dropMove, args);
|
||
setSelectedIds(new Set()); // 拖完清选择
|
||
};
|
||
```
|
||
|
||
### 验证
|
||
|
||
- 拖 6♠ → 桌(hand=4, table=1)✓
|
||
- Shift+click 选 3 张(2♠/4♠/6♠)+ 拖 2♠ → 全部移到桌(hand=3♠/5♠, table=2♠/4♠/6♠)✓
|
||
|
||
## 成果
|
||
|
||
- Commit `2b492d2 feat(ui): HTML5 drag-and-drop + multi-select`
|
||
- Commit `8953554 fix(ui): drag pointer-events, StrictMode compat, multi-drag`(后续 bug 修复)
|
||
|
||
## 关联
|
||
|
||
- [06-three-games.md](./06-three-games.md) — 用 DragTest 验证拖拽
|
||
- [07-bugfixes-round-1.md](./07-bugfixes-round-1.md) — 拖拽 bug 修复
|
||
- [lessons-learned.md §7-12](./lessons-learned.md) — 拖拽相关踩坑
|