基于 SpacetimeDB 构建实时协作绘图应用(基础版):基本绘制与实时光标全攻略
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
本指南以仓库内 Paint App 基础版组合提示词 为骨架,完整讲解如何用 SpacetimeDB 作为后端,从零构建一个具备基本绘制与实时光标两大核心能力的多人实时协作画板。你将掌握画布/成员/笔画/光标的数据建模思路、reducer 服务端函数设计、React 客户端订阅同步的关键实现,以及一套可直接照搬的验收清单。
一、这个文档在项目中的定位:评测系统的第一个功能关卡
仓库tools/llm-oneshot/apps/paint-app/prompts/下维护着一套模块化提示词体系,用于测试 LLM 使用 SpacetimeDB 构建协作应用的代码生成能力。目录结构如下:
prompts/ ├── README.md # 提示词系统说明 ├── features/ # 16 个功能积木(单一功能提示词) │ ├── 01_basic.md │ └── 02_cursor_indicators.md ├── composed/ # 预拼接的累积式提示词(语言无关) │ └── 01_basic.md # ← 本文的关联文档 ├── language/ # 语言/后端特定的搭建约束(小型文件) │ ├── typescript-spacetime.md │ ├── rust-spacetime.md │ └── csharp-spacetime.md ├── grading_checklist.md # 评分清单 └── grading_rubric.md # 评分规则composed/01_basic.md是 12 个累积关卡中的第 1 关。根据 prompts/README.md 中的说明,每个关卡都是累积式的——01_basic只包含两项功能:Basic Drawing(基本绘制)和Live Cursors(实时光标),后续关卡(形状、选择、图层、权限等)全部建立在这一基础上。
实际使用时,需要把语言文件 + 组合提示词拼接起来:
# 概念上:语言文件头 + 功能提示词 cat language/typescript-spacetime.md composed/01_basic.md > test_prompt.md或者直接以@引用的方式交给 LLM:
@language/typescript-spacetime.md @composed/01_basic.md execute文档开头的See language/*.md for language-specific setup, architecture, and constraints.正是指向这条组合使用路径。以 TypeScript 栈为例,language/typescript-spacetime.md 规定了:后端为 SpacetimeDB TypeScript 模块,客户端为 React + Vite + TypeScript;模块名固定为paint-app;只能在backend/spacetimedb/与client/src/两个目录内创建代码。
二、UI 基线:SpacetimeDB 品牌暗色主题
文档的 UI 要求只有一句话,却是客户端视觉实现的前提:
Use SpacetimeDB brand styling (dark theme).
即整个画板 UI 必须采用SpacetimeDB 品牌风格的深色主题。从仓库中的参考实现看,这一约束在客户端styles.css中以 CSS 变量的形式落地。你可以从以下路径的参考实现中提取具体色板与控件样式作为基准:
- 参考实现 client/src/styles.css
- 参考实现 client/src/App.tsx(组件结构与状态管理)
三、功能需求拆解(一):基本绘制 Basic Drawing
这是整个应用的地基。文档列出的 6 项硬性要求如下,每一项都必须在实现中完整落地:
- 用户可以设置显示名(display name)并挑选头像颜色(avatar color)
- 用户可以创建画布并加入/离开画布
- 基本绘制工具:自由画笔(freehand brush)、橡皮擦(eraser,视觉上移除笔画)、取色器(color picker)
- 可调节笔刷大小(brush size)
- 清空画布选项(带确认弹窗)
- 实时同步——能看到其他用户的笔画实时出现
- 笔画在绘制过程中保持可见(无轮询闪烁)
下面逐一结合仓库源码讲解实现路径。
3.1 显示名与头像颜色:set_display_name/set_avatar_color
后端使用 reducer(服务端函数)来校验并持久化用户资料。参考实现 backend/spacetimedb/src/index.ts 中:
spacetimedb.reducer( 'set_display_name', { displayName: t.string() }, (ctx, { displayName }) => { if (!displayName.trim()) throw new SenderError('Display name cannot be empty'); if (displayName.length > 50) throw new SenderError('Display name too long'); const user = ctx.db.user.identity.find(ctx.sender); if (!user) throw new SenderError('User not found'); ctx.db.user.identity.update({ ...user, displayName: displayName.trim() }); } ); spacetimedb.reducer( 'set_avatar_color', { color: t.string() }, (ctx, { color }) => { if (!color.match(/^#[0-9a-fA-F]{6}$/)) throw new SenderError('Invalid color format'); // ...更新 avatarColor 字段 } );关键点:
- 输入校验发生在服务端:显示名非空、长度 ≤ 50,颜色必须是
#RRGGBB格式,不合法直接SenderError抛错回客户端; ctx.sender是调用者的身份标识(Identity),服务端凭此定位当前用户行;- 用户首次连接时由生命周期钩子
spacetimedb.clientConnected自动建行(见index.ts第 15–25 行),默认显示名为User-<identity前6位>,并分配一个随机头像色。
3.2 画布的创建、加入与离开:create_canvas/join_canvas/leave_canvas
画布与成员关系的建模在 schema.ts 中由两张公开表承担:
export const Canvas = table( { name: 'canvas', public: true }, { id: t.u64().primaryKey().autoInc(), ownerIdentity: t.identity(), name: t.string(), isPrivate: t.bool(), // shareLinkToken / shareLinkPermission / keepForever / lastActivityAt / createdAt ... } ); export const CanvasMember = table( { name: 'canvas_member', public: true, indexes: [ { name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'] }, { name: 'canvas_member_user_identity', algorithm: 'btree', columns: ['userIdentity'] }, ], }, { id: t.u64().primaryKey().autoInc(), canvasId: t.u64(), userIdentity: t.identity(), role: t.string(), // 'owner' | 'editor' | 'viewer' invitedAt: t.timestamp(), } );create_canvasreducer 的核心逻辑(index.ts 第 119–157 行)做了三件事:
- 插入
Canvas行,ownerIdentity记为调用者; - 把创建者写入
CanvasMember,角色为owner; - 同时创建默认图层
Layer 1(orderIndex: 0, visible: true, opacity: 1.0),保证画布创建即有可绘制层。
join_canvas(第 159–217 行)则处理:
- 通过
canvasId查画布,不存在即抛Canvas not found; - 画布为私有(
isPrivate: true)且用户还不是成员时拒绝加入; - 首次加入时创建一条
canvas_presence在线状态记录(status: 'active'、默认工具brush、视口viewportX/Y = 0、viewportZoom = 1.0),并写活动日志joined;重复加入则把状态刷新为 active。
leave_canvas(第 219–252 行)负责清理该用户在本画布的 presence、光标(cursor)与选中(selection)记录,并记录left活动。断线场景则由clientDisconnected钩子兜底清理(见index.ts第 27–80 行)。
3.3 笔画数据模型:Stroke表与add_strokereducer
自由画笔的核心在于**笔画(Stroke)**的建模。参考实现将其设计为一行一条笔画,点的序列以 JSON 字符串存储:
export const Stroke = table( { name: 'stroke', public: true, indexes: [ { name: 'stroke_canvas_id', algorithm: 'btree', columns: ['canvasId'] }, { name: 'stroke_layer_id', algorithm: 'btree', columns: ['layerId'] }, ], }, { id: t.u64().primaryKey().autoInc(), canvasId: t.u64(), layerId: t.u64(), creatorIdentity: t.identity(), tool: t.string(), // 'brush' | 'eraser' color: t.string(), brushSize: t.u32(), points: t.string(), // JSON array of {x, y} points createdAt: t.timestamp(), } );schema.ts中Stroke表的tool注释明确限定为'brush' | 'eraser'。橡皮擦不是像素级删除,而是"视觉上移除笔画"——这一点由delete_strokereducer(index.ts 第 650–662 行)实现:根据strokeId直接删除整行。因此"擦除"一个笔画就是让该笔画在所有人屏幕上消失。
add_strokereducer(第 594–648 行)的入参覆盖了全部绘制要素:
spacetimedb.reducer( 'add_stroke', { canvasId: t.u64(), layerId: t.u64(), tool: t.string(), color: t.string(), brushSize: t.u32(), points: t.string(), // 形如 JSON 数组 "[{\"x\":1,\"y\":2}, ...]" }, (ctx, { canvasId, layerId, tool, color, brushSize, points }) => { requireEditor(ctx, canvasId); requireLayerEditable(ctx, layerId); // 插入 Stroke 行、写 Undo 记录、记活动日志、刷新画布 lastActivityAt } );其中requireEditor/requireLayerEditable是权限与图层锁定守卫,是后续关卡(图层锁定、权限)的地基,在基础版中已内建。
3.4 清空画布:带确认 + 清空前自动存版本
文档要求清空操作必须有确认弹窗(前端交互),而服务端clear_canvas(index.ts 第 1573–1596 行)的设计更稳妥——清空之前先自动保存一份版本快照:
spacetimedb.reducer( 'clear_canvas', { canvasId: t.u64() }, (ctx, { canvasId }) => { requireEditor(ctx, canvasId); // Save version before clearing const snapshotData = createSnapshot(ctx, canvasId); ctx.db.version.insert({ id: 0n, canvasId, name: 'Before clear', snapshotData, isAutoSave: true, createdBy: ctx.sender, createdAt: ctx.timestamp, }); deleteCanvasElements(ctx, canvasId); logActivity(ctx, canvasId, ctx.sender, 'cleared_canvas'); touchCanvas(ctx, canvasId); } );这一设计为后续第 7 关(Version History)提前铺路,也让"误清空可恢复"成为可能。
3.5 无闪烁实时同步:订阅机制而非轮询
"笔画在绘制过程中保持可见(no flicker from polling)"是文档对同步机制的明确约束。SpacetimeDB 的解法是客户端订阅 + 服务端事务广播:客户端建立连接后调用subscriptionBuilder().subscribeToAllTables()(见 App.tsx 第 61–71 行),服务端每次 reducer 事务提交后,订阅了该表的所有客户端会增量收到变更,无需轮询,因此不会出现"刷新闪烁"。
客户端通过@spacetimedb/react的useTable钩子把表直接映射为 React 响应式状态:
const [strokes] = useTable(tables.stroke); const [cursors] = useTable(tables.cursor); const [canvasPresences] = useTable(tables.canvasPresence); // ...其余 16 张表同理(App.tsx 第 155–171 行)useTable返回[readonly rows[], isLoading],表内任何行的插入/更新/删除都会触发重渲染——这就是"别人的笔画实时出现在我屏幕上"的客户端机制。连接配置见 config.ts:
export const MODULE_NAME = 'paint-app-20260112-154500'; export const SPACETIMEDB_URI = 'ws://localhost:3000';四、功能需求拆解(二):实时光标 Live Cursors
文档对实时光标的 5 项要求:
- 实时展示所有协作者的鼠标位置
- 每个光标显示用户名与头像颜色
- 光标图标反映其当前工具(brush、eraser、select 等)
- 光标旁展示其当前所选颜色的小预览
- 光标平滑动画,用户不活跃时淡出
4.1 数据模型:Cursor表 +CanvasPresence表
export const Cursor = table( { name: 'cursor', public: true, indexes: [{ name: 'cursor_canvas_id', algorithm: 'btree', columns: ['canvasId'] }], }, { id: t.u64().primaryKey().autoInc(), canvasId: t.u64(), userIdentity: t.identity(), x: t.f64(), y: t.f64(), tool: t.string(), color: t.string(), lastUpdatedAt: t.timestamp(), } );光标是一行一条的高频率更新记录,坐标用f64浮点保存(schema.ts 第 222–240 行)。而"用户名/头像色"这类相对静态的信息不必冗余进Cursor表——前端拿到userIdentity后再从user表关联出显示名与头像色即可。
CanvasPresence表(schema.ts第 21–45 行)则负责"在线/闲置/离开"状态与当前工具、视口、跟随目标的记录:
{ id: t.u64().primaryKey().autoInc(), canvasId: t.u64(), userIdentity: t.identity(), status: t.string(), // 'active' | 'idle' | 'away' currentTool: t.string(), lastActivityAt: t.timestamp(), viewportX / viewportY / viewportZoom: t.f64(), followingUser: t.identity().optional(), }4.2 位置同步:update_cursorreducer
spacetimedb.reducer( 'update_cursor', { canvasId: t.u64(), x: t.f64(), y: t.f64(), tool: t.string(), color: t.string() }, (ctx, { canvasId, x, y, tool, color }) => { // 已存在 → 更新 x/y/tool/color/lastUpdatedAt;不存在 → 插入新行 // 同时把 CanvasPresence 刷新为 status:'active'、currentTool: tool } );要点:该 reducer 是幂等的 upsert 语义——光标行不存在就插入,存在就更新,天然适合鼠标高频 move 事件的节流上报。它同时把用户的 presence 状态刷新为active并同步当前工具,一举覆盖了文档"光标图标反映当前工具"的需求。
4.3 平滑动画与淡出:前端渲染策略
"光标平滑动画"和"不活跃时淡出"是纯前端表现层的工作(参考 App.tsx 的渲染逻辑):
- 平滑动画:订阅到新的光标坐标后,使用
requestAnimationFrame或 CSS transition 插值移动光标 DOM 元素,避免跳变; - 淡出:依据
Cursor.lastUpdatedAt或CanvasPresence.lastActivityAt判断不活跃时长,超过阈值即降低透明度直至隐藏; - 工具图标 + 颜色预览:
tool字段驱动光标 SVG 图标(画笔/橡皮/选择等),color字段渲染成光标旁的小色块; - 名字与头像色:按
userIdentityjoinuser表的displayName与avatarColor。
断线/离开的兜底清理已在clientDisconnected与leave_canvas中实现,保证不会出现"幽灵光标"。
五、可运行的参考实现与验收清单
仓库中保留了两份完整的参考实现(均由 LLM 生成并落盘),可作为对照基准:
- paint-app-20260109-164112(第一版)
- paint-app-20260112-154500(完整版)(本文引用即此版)
验收时直接对照 grading_checklist.md 中第 1、2 关的勾选清单:
1. Basic Drawing
- Set display name + avatar color
- Create/join canvases
- Brush draws, eraser erases
- Color picker works
- Strokes sync in real-time⭐
2. Live Cursors
- See others' cursors
- Cursors show name + color
- Cursor shows tool icon
- Smooth cursor movement⭐
- Fade on inactive
其中标 ⭐ 的项(实时同步笔画、平滑光标移动)是评分清单明确标注的"关键差异化指标",用于验证 SpacetimeDB 订阅式实时能力相比传统轮询方案的优势。每项按 0–3 分打分,两个关卡共 10 项,满分 30。
六、后续演进:从 01_basic 走向 12_full
01_basic是整个累积关卡的入口,后续关卡在上面的数据模型上做增量扩展,参考实现中已经预留了大量伏笔:
| 关卡 | 新增能力 | 基础版中的伏笔 |
|---|---|---|
| 02 shapes | 矩形/椭圆/直线/箭头 | Shape表已建好(schema.ts 第 149–174 行) |
| 04 layers | 图层 + 锁定 | Layer表 +lock_layer已内建 |
| 05 presence | 在线/闲置/离开 | CanvasPresence.status字段已内建 |
| 07 versions | 版本历史 | 清空画布自动存快照已内建 |
| 08 permissions | 查看/编辑角色 | CanvasMember.role与requireEditor已内建 |
| 12 full | 画布聊天/自动清理/便签/快捷键 | ChatMessage、AutoSaveJob等表与定时 reducer 已就位 |
每个功能积木的独立提示词位于 prompts/features/,组合后即形成composed/02_shapes.md直至composed/12_full.md的累积提示词。换言之,把本关做扎实,后面的关卡就是在同一套 schema 与 reducer 体系上"添砖加瓦"。
七、总结
通过01_basic这一关,你可以完整掌握 SpacetimeDB 构建实时协作应用的最小闭环:
- 建模:
user(身份资料)、canvas/canvas_member(画布与成员)、stroke(笔画)、cursor/canvas_presence(光标与在线状态)四类核心表; - 服务端逻辑:一切写入都收敛为 reducer,校验、权限、日志、定时任务都在事务内完成;
- 实时同步:客户端
subscribeToAllTables+useTable订阅式刷新,天然无轮询、无闪烁; - 交互打磨:确认弹窗、光标平滑动画与不活跃淡出等体验细节决定完成度。
以 prompts/README.md 中@language/... @composed/01_basic.md execute的方式组合提示词,即可复现本文所述全部能力;再配合 grading_checklist.md 逐项验收,就能交付一个真正"实时、无闪烁、可多人协作"的基础版绘图应用。
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考