基于 SpacetimeDB 构建实时协作绘图应用(基础版):基本绘制与实时光标全攻略

发布时间:2026/9/13 19:01:54
基于 SpacetimeDB 构建实时协作绘图应用(基础版):基本绘制与实时光标全攻略 基于 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或者直接以引用的方式交给 LLMlanguage/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 1orderIndex: 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_strokereducerindex.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_canvasindex.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.tsexport 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_cursorreducerspacetimedb.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 DrawingSet display name avatar colorCreate/join canvasesBrush draws, eraser erasesColor picker worksStrokes sync in real-time⭐2. Live CursorsSee others cursorsCursors show name colorCursor shows tool iconSmooth cursor movement⭐Fade on inactive其中标 ⭐ 的项实时同步笔画、平滑光标移动是评分清单明确标注的关键差异化指标用于验证 SpacetimeDB 订阅式实时能力相比传统轮询方案的优势。每项按 0–3 分打分两个关卡共 10 项满分 30。六、后续演进从 01_basic 走向 12_full01_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校验、权限、日志、定时任务都在事务内完成实时同步客户端subscribeToAllTablesuseTable订阅式刷新天然无轮询、无闪烁交互打磨确认弹窗、光标平滑动画与不活跃淡出等体验细节决定完成度。以 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),仅供参考