在 tldraw 中用 OverlayUtil 构建塔防游戏:overlay 画布、命中测试与事件路由实战
本示例位于仓库
apps/examples/src/examples/use-cases/tower-defense/,是 tldraw 官方示例中把「游戏循环 + 响应式状态 + Canvas 直绘」完整跑在 overlay 体系上的代表作。敌人、子弹、爆炸、射程环、放置预览与升级按钮全部由自定义OverlayUtil在 overlay 画布上绘制,塔身则是编辑器原生的锁定 geo 图形。读完本文你将掌握:如何通过overlayUtils注册自定义 overlay、如何让atom驱动的getOverlays()自动响应游戏状态、如何用getGeometry()/getCursor()/onPointerDown()拦截指针事件,以及如何利用编辑器tick事件驱动逐帧游戏逻辑。
一、示例总览:把无限画布编辑器变成一张游戏棋盘
打开该示例即可直接游玩,玩法如下(与 README.md 所述一致):
- 从工具栏选择一座塔(或直接按
1、2、3),点击画布放置。每座塔都是一个被锁定(locked)的 geo 图形(三角形 / 矩形 / 椭圆),各自拥有独立的射程、射速、伤害与弹道类型; - 敌人沿一条固定折线路径前进,漏进终点会扣生命值;
- 点击敌人可以手动造成伤害,悬停塔身可以查看射程并出现升级按钮。
它的实现文档(即本示例的 README)用三句话点透了整个架构核心:
- 一切会动的东西都是
OverlayUtil子类,通过overlayUtilsprop 注册:路径、敌人、子弹、爆炸、塔的射程环、放置预览乃至升级按钮,全部绘制在 overlay 画布上; - 游戏状态存放在
atom中,getOverlays()在响应式求值中读取它们,因此游戏循环每次更新状态,所有 overlay 都会自动重绘; - 需要响应用户输入的对象实现
getGeometry()(命中测试)、getCursor()(悬停光标)、onPointerDown()(点击),编辑器会在默认 canvas 处理之前把这些指针事件路由给 overlay。
示例的 front-matter 元数据(title: Tower defense、component: ./TowerDefenseExample.tsx、keywords: overlay, overlayutil, canvas, animation, game, hit testing, raf, tick)也直接佐证了它的教学重心:tldraw SDK 的 overlay 绘制 + 事件路由能力。
源码目录速览
apps/examples/src/examples/use-cases/tower-defense/
├── README.md # 示例说明(本文主体)
├── TowerDefenseExample.tsx # 组件入口:注册 overlays、替换 UI、挂 GameRunner
├── game-state.ts # 全部游戏状态(atom 集合)与伤害结算工具函数
├── game-loop.ts # 每帧逻辑:刷怪/移动/开火/子弹/爆炸/胜负
├── tower-config.ts # 三种塔的数据表、升级成本与属性成长曲线
├── enemy-config.ts # 三种敌人的乘数配置与随时间的刷怪权重
├── path.ts # 硬编码敌人路径(折线)与“距离→坐标”换算
├── sounds.ts # Web Audio 合成音效(无需音频资源)
├── tower-defense.css # 工具栏 / HUD 样式
└── overlays/ # 7 个自定义 OverlayUtil 实现
├── PathOverlayUtil.ts # 路径轨道
├── EnemyOverlayUtil.ts # 敌人(可点选、带血条)
├── ProjectileOverlayUtil.ts # 子弹
├── ExplosionOverlayUtil.ts # 爆炸
├── TowerRangeOverlayUtil.ts # 塔的射程环
├── PlacementPreviewOverlayUtil.ts # 放置预览(跟随指针)
└── UpgradeButtonOverlayUtil.ts # 升级按钮
该示例属于 apps/examples(一个 Vite 应用)下的 use-cases 分类,启动该应用后即可按标题 “Tower defense” 找到并试玩。
二、OverlayUtil:editor overlay 体系的设计意图
在深入游戏细节前,先看这套机制在 SDK 中的定义。OverlayUtil 基类 注释把它定义为「叠加在画布上的临时 UI 元素」(选择手柄、旋转角点、形状 handle 等同属此列),每个 OverlayUtil 负责一种 overlay 类型,并声明五个能力:
| 抽象/虚方法 | 职责 | 本示例中对应的实现 |
|---|---|---|
isActive() |
响应式判定「当前是否需要产生此类 overlay」 | 敌人存在才激活 EnemyOverlayUtil;placingTower$ 非空且钱够才激活放置预览 |
getOverlays() |
依据编辑器当前状态返回 overlay 实例数组 | 逐只敌人映射 { id, type, props };被悬停塔才返回升级按钮 |
getGeometry() |
返回页面坐标下的命中测试几何,null 表示不可交互 | 敌人/按钮/预览均为 Circle2d |
getCursor() |
悬停时的光标样式(cross/pointer) |
敌人 'cross'、升级按钮 'pointer'、放置预览 'cross' |
render(ctx, overlays) |
把一个 util 当前激活的全部 overlay 绘制进 Canvas 2D context(context 已应用相机变换到页面空间) | 按 editor.getZoomLevel() 缩放线宽并绘制 |
另有 static type: string 作为 overlay 的全局唯一类型名,options.zIndex 控制绘制与命中测试的顺序(数值大的先被命中、绘制在上层),以及静态方法 configure() 用于在不继承的情况下覆写内置 util 的选项。
TLOverlay 接口则要求每个实例提供 id(在全部 util 间全局唯一,因此官方建议用命名空间前缀如 td-enemy:${e.id})、type 与任意 props。
注册:overlayUtils prop + defaultOverlayUtils
在入口组件中,游戏把内置 util 与 7 个自定义类拼成一个数组传给 <Tldraw overlayUtils={...}>:
const overlayUtils: TLAnyOverlayUtilConstructor[] = [
...defaultOverlayUtils, // 保留选择框、手柄等编辑器原生 overlay
PathOverlayUtil,
TowerRangeOverlayUtil,
PlacementPreviewOverlayUtil,
EnemyOverlayUtil,
ProjectileOverlayUtil,
ExplosionOverlayUtil,
UpgradeButtonOverlayUtil,
]
见 TowerDefenseExample.tsx。展开 defaultOverlayUtils 是关键细节——塔虽是「游戏单位」,但仍是可被选择/平移的原生图形,原生 overlay 不能丢;而新增 util 通过各自的 zIndex(例如路径 50、放置预览 150、敌人 200、升级按钮 260)在两者之间插空,控制覆盖层级。
用 render() 直接作画:Canvas API 即游戏渲染器
游戏没有引入任何贴图与 DOM 元素,动画完全靠 overlay 的 Canvas 绘制。以 PathOverlayUtil 为例:它在 render() 中先用 38px 宽的半透明描边画出「路基」,再叠加一圈 setLineDash 的虚线中线;线宽与虚线长度均除以 zoom,保证任意缩放级别下视觉一致:
const zoom = this.editor.getZoomLevel()
const isDark = this.editor.getColorMode() === 'dark'
ctx.save()
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
// 宽软质轨道
ctx.lineWidth = 38
ctx.strokeStyle = isDark ? 'rgba(80, 80, 110, 0.45)' : 'rgba(160, 170, 200, 0.45)'
ctx.beginPath()
ctx.moveTo(PATH[0].x, PATH[0].y)
for (let i = 1; i < PATH.length; i++) ctx.lineTo(PATH[i].x, PATH[i].y)
ctx.stroke()
// 居中虚线
ctx.lineWidth = 2 / zoom
ctx.setLineDash([10 / zoom, 10 / zoom])
...
getColorMode() 让同一条路径能适配明暗两套主题;EnemyOverlayUtil、UpgradeButtonOverlayUtil 的 render() 还会读取 editor.getCurrentTheme() / isDark 来绘制与主题协调的 HUD 色块。
三、状态先行:用 atom 驱动 overlay 的响应式重绘
游戏没有任何外部状态库,全部状态收敛在 game-state.ts 的一组 tldraw atom 中:
| atom | 含义 | 初始值 |
|---|---|---|
enemies$ |
存活敌人数组 | [] |
projectiles$ |
飞行中的子弹数组 | [] |
explosions$ |
正在播放的爆炸效果数组 | [] |
placingTower$ |
当前「拿在手上」待放置的塔类型(null 表示未选) |
null |
score$ / gold$ / lives$ |
分数 / 金币 / 生命 | 0 / 100 / 20 |
gameOver$ |
是否结束 | false |
elapsedMs$ |
累计游戏毫秒(供难度曲线使用) | 0 |
这正对应 README 中「getOverlays() 读取 atom,游戏循环更新后 overlay 自动重绘」的机制:overlay 的 isActive()/getOverlays() 是在 tldraw 的响应式系统里求值的,任何被读取的 atom 变化都会触达脏标记,进而只重绘受影响的 util。
例如 EnemyOverlayUtil.isActive() 读取 enemies$.get().length,敌人数组从空变为非空的瞬间该 util 即被激活;getOverlays() 再把每个敌人从「路径距离」换算成页面坐标并补齐血量、是否减速等渲染所需 props。换句话说 overlay 只是「视图」:每帧真正修改状态的只有游戏循环,overlay 永远是被动重绘的。
另有两个非 atom 状态值得注意:
towerCooldowns是一个普通Map<shapeId, {lastFiredAt}>(game-state.ts),因为冷却只有游戏循环读写、不需要驱动任何 overlay;- 敌人/子弹/爆炸的 id 由模块内计数器
nextEnemyId()等函数分配,保证 overlay id 在游戏期间稳定唯一。
四、游戏主循环:挂在 editor 的 tick 事件上
README 明确指出:游戏循环跑在编辑器的 tick 事件上,该事件每帧触发一次并携带已流逝毫秒数。挂载逻辑在 GameRunner 组件中(TowerDefenseExample.tsx):
const onTick = (elapsedMs: number) => {
// 后台标签页恢复时 tick 会上报一个很大的增量,
// 直接使用会把所有敌人瞬移到路径末端
const dt = Math.min(60, elapsedMs)
runGameTick(editor, dt)
}
editor.on('tick', onTick)
...
return () => {
editor.off('tick', onTick)
window.removeEventListener('keydown', onKeyDown)
placingTower$.set(null)
}
一个非常值得借鉴的工程细节是 60ms 增量钳制:浏览器切后台再回来时,tick 上报的增量可能高达数秒,若不钳制,所有敌人会在一帧内走完全程。同样的做法也解释了示例为何选用 tick 而非 requestAnimationFrame(keywords 中的 raf/tick 正指此事)——编辑器已经管理好了每帧回调与清理时机,游戏循环无需自建 RAF。
每帧的流水线 runGameTick 顺序如下(见 game-loop.ts):
maybeSpawn—— 按随游戏时间收缩的间隔刷怪;moveEnemies—— 沿路径推进,漏网的扣生命;fireTowers—— 遍历页面上的 geo 图形,命中范围内的塔按冷却开火;moveProjectiles—— 推进子弹、过期回收;resolveHits—— 计算命中、AOE、减速与赏金;tickExplosions/checkGameOver—— 推进爆炸动画并判定胜负。
刷怪与难度曲线
常量全部集中定义在 game-loop.ts:基础刷怪间隔 1400ms,随游戏时间以 0.00004/s 的速率缩短,下限 350ms;敌人基础 HP 50、随时间线性放大 0.025/s;基础移速约 65 页面单位/秒(叠加 ±25 随机抖动)。而 enemy-config.ts 定义了三类兵种的乘数模版:
| 兵种 | HP 乘数 | 移速乘数 | 半径 | 赏金乘数 | 定位 |
|---|---|---|---|---|---|
| Grunt | 1.0 | 1.0 | 18 | 1.0 | 标准步兵 |
| Runner | 0.55 | 1.9 | 13 | 1.4 | 高速脆皮 |
| Brute | 3.2 | 0.62 | 26 | 2.6 | 重装慢速 |
pickEnemyType(elapsedMs) 用「随时间滑动的三色权重」采样:前 60 秒内 grunt 权重从 1 线性衰减到 0.2,runner 与 brute 逐步抬高,从而让后期波次自然混合更硬/更快的敌人,同时让 overlay 层渲染的内容比统一圆圈丰富。
五、命中测试与指针路由:overlay 先于画布拿到事件
这是 README 中最具 SDK 价值的机制:可交互 overlay 通过 getGeometry() 参与命中测试;编辑器会把 pointer 事件优先路由给命中的 overlay,再进入默认工具逻辑。基类注释对此的表述是 onPointerDown 像一次「中断」——一旦覆写该方法,除非显式返回 false 把事件交还默认行为,否则你就接管了这个事件(OverlayUtil.ts)。
EnemyOverlayUtil 是完整的教科书示例:
override getGeometry(overlay: TLEnemyOverlay): Geometry2d {
const { x, y, radius } = overlay.props
// Circle2d 的 x/y 是包围盒左上角,所以圆心 = (x+radius, y+radius)
return new Circle2d({ x: x - radius, y: y - radius, radius, isFilled: true })
}
override getCursor(): TLCursorType {
return 'cross'
}
override onPointerDown(overlay: TLEnemyOverlay): boolean {
applyDamage(overlay.props.enemyId, CLICK_DAMAGE) // CLICK_DAMAGE = 5
// 返回 truthy 以阻止编辑器对“游戏点击”启动框选/拖选
return true
}
命中几何是页面坐标下的 Circle2d;敌人绘制时又以自身坐标为中心画圆(ctx.arc),二者共用同一套坐标语义,命中自然准确。onPointerDown 里手动扣 5 点血后返回 true,从而把「点击敌人」与「在画布上框选」彻底区分开——这正是 README「editor routes pointer events to them before the canvas」的落地形态。
getCursor() 同时负责悬停反馈:敌人上出现十字光标,升级按钮上则是 pointer。
六、放置塔也走 overlay:预览跟随指针、点击即建图形
玩法中「选择塔 → 点击放置」同样没有写任何自定义事件监听,而是由 PlacementPreviewOverlayUtil 独立完成,README 称之为「placement itself goes through an overlay too」。
关键手法是 预览几何始终跟着指针走:getOverlays() 每次都用 editor.inputs.getCurrentPagePoint() 取当前指针页面坐标作为圆心,而 getGeometry() 返回一个恰好覆盖指针的半径 30 的圆:
override getOverlays(): TLPlacementPreviewOverlay[] {
...
const { x, y } = this.editor.inputs.getCurrentPagePoint()
return [{ id: 'td-placement-preview:main', type: 'td-placement-preview',
props: { geo, x, y, range: stats.range } }]
}
override getGeometry(overlay): Geometry2d {
const r = TOWER_PLACEMENT_SIZE / 2 // 60 / 2 = 30
return new Circle2d({ x: x - r, y: y - r, radius: r, isFilled: true })
}
源码注释说明了设计取舍:既然预览圆永远「套住」光标,点击必然命中它,于是放置逻辑可以直接落在 onPointerDown() 里,无需在 canvas 上附加 capture 阶段的额外监听器来劫持事件:
override onPointerDown(overlay): boolean {
if (gold$.get() < stats.cost) return true
this.editor.createShape({
id: createShapeId(),
type: 'geo',
x: x - TOWER_PLACEMENT_SIZE / 2,
y: y - TOWER_PLACEMENT_SIZE / 2,
isLocked: true, // 塔是锁定图形
props: { geo, w: TOWER_PLACEMENT_SIZE, h: TOWER_PLACEMENT_SIZE },
})
gold$.update((g) => g - stats.cost)
placingTower$.set(null)
return true
}
而 render() 则负责画半透明的射程环与幽灵轮廓(矩形/椭圆/三角形的路径逐类型手绘),并用 colors.selectionFill/selectionStroke 保持与主题一致。
这里出现了一个贯穿全例的设计选择——塔身是编辑器原生 geo 图形而非 overlay:为什么「会动的东西」都是 overlay,而静止的塔是图形?因为塔需要被游戏循环与升级逻辑按 shape id 定位、需要走 updateShape 升级、需要享受编辑器原生选中交互;overlay 则被设计为临时性、每帧从状态重建的装饰层。两者分工清晰。
塔数据、升级与“锁定图形”特判
TOWER_STATS_BY_GEO 是三种塔唯一的数值来源:
| 塔(geo) | 名称 | 射程 | 射速(ms) | 伤害 | 弹速 | 弹种 | 造价 |
|---|---|---|---|---|---|---|---|
| triangle | Archer(弓手) | 220 | 350 | 8 | 700 | arrow | 50 |
| rectangle | Cannon(火炮) | 160 | 1100 | 30 | 380 | rock | 120 |
| ellipse | Magic(法师) | 190 | 600 | 14 | 520 | orb | 80 |
源码注释说明造价大体正比于综合强度(range × damage / fireRate),而初始 100 金币恰够立刻造一座 Archer、靠击杀慢慢攒出其余两种。只有这三种 geo 会被游戏循环当塔处理,玩家随手画的其它 geo 一律忽略,编辑器因此保持可用。
升级采用「shape.meta 持久化等级」的模式(tower-config.ts):等级上限 MAX_TOWER_LEVEL = 4;升级花费 ceil(baseCost × 0.6 × currentLevel)(即 1→2 花 60%、2→3 花 120%…);每升一级伤害 ×1.4、射程 ×1.06、射速 ×0.9;levelColor 还会把图形颜色从橙色逐步变成蓝/紫/红,作为等级可视反馈。
悬停塔身浮现的升级按钮由 UpgradeButtonOverlayUtil 负责,其中两个细节体现了对编辑器模型的尊重:
- 用
computed缓存候选塔扫描:_candidates只在页面图形/包围盒变化时重算,getOverlays()内再做廉价的指针过滤,避免 mousemove 风暴触发全量扫描(源码注释原话); - 更新锁定图形必须显式
ignoreShapeLock:
this.editor.run(() => {
this.editor.updateShape({
id: shapeId, type: 'geo',
meta: { ...shape.meta, towerLevel: nextLevel },
props: { fill: 'solid', color: levelColor(nextLevel) },
})
}, { ignoreShapeLock: true })
同样的特判出现在 restartGame:deleteShapes 清场也必须在 editor.run(..., { ignoreShapeLock: true }) 下执行,否则锁定形状不会被删除。
七、开火、AOE 与子弹结算:纯页面坐标下的几何运算
游戏循环在模拟层完全不接触渲染,只做纯数学运算(game-loop.ts):
fireTowers通过editor.getCurrentPageShapes()拿到全部图形,仅挑出三种「塔型 geo」;用editor.getShapePageBounds(shape.id)的中心作为炮口,在射程内寻找沿路径走得最远的敌人作为目标(放走最先到达终点的敌人);- 子弹
{ x, y, vx, vy, damage, kind, targetEnemyId }沿单位方向向量以stats.projectileSpeed飞出; resolveHits先以「子弹实时位置 vs 目标当前坐标」判命中,目标阵亡则回退到「就近命中任意敌人」以保证手感;orb(法师弹)命中时会spawnExplosion并结算一个MAGIC_AOE_RADIUS = 60的圆形范围伤害,同时把范围内敌人标记减速(减速持续 1500ms、速度 ×0.4,多次命中只刷新时间不叠加);- 伤害与减速汇总后一次性
enemies$.set(next),避免一帧内多次 set 引发额外重绘。
路径系统 path.ts 用 8 个页面坐标折点描述一条「Z」字形路线,模块加载时预计算各段长度与总长 PATH_LENGTH;getPositionAtDistance(distance) 依据敌人走过的累计距离做线性插值并返回 { x, y, angle }——它是敌人 overlay、开火瞄准、命中判定三方共用的唯一换算函数。
音效同样零资源依赖:sounds.ts 用 Web Audio API 合成三种弹种与升级提示音,AudioContext 在第一次真正开火时才惰性创建(规避浏览器自动播放限制,此时用户必然已点击过画布),并用 MIN_INTERVAL_MS = 25 做限流防止多塔齐射时爆音。
八、HUD 与输入:用 UI 组件替换和全局键盘接管
示例通过 <Tldraw components={...}> 深度替换了编辑器外壳(TowerDefenseExample.tsx):TopPanel 换成 HUD(金币/分数/生命/在场敌人数/在场子弹数 + Restart),Toolbar 换成 GameToolbar,其余菜单、MiniMap、样式面板等约 20 个面板统一置为 null,得到一张干净的游戏界面。
HUD 的数据订阅使用 tldraw 的 React 绑定 useValue:
const gold = useValue('gold', () => gold$.get(), [])
const placingGeo = useValue('placingGeo', () => placingTower$.get(), [])
工具栏会在 gold 变化时即时把买不起的塔按钮置灰,因为 pickTower 本身就做了门槛校验:「钱不够就不把塔『拿到手上』,预览自然也不会出现」。
键盘输入则由 GameRunner 挂载的全局 keydown 兜底(TowerDefenseExample.tsx):1/2/3 选塔、Escape 回到选择、空格 重启;事件先过滤 meta/ctrl/alt 组合键与输入框焦点,避免与无障碍/文本输入冲突。另外通过 <Tldraw options={{ createTextOnCanvasDoubleClick: false }}> 关闭双击创建文本——否则快速连点敌人会误生成文本框(源码注释点明这是连点事件的真实坑位)。
onMount 中还用 editor.zoomToBounds(new Box(-300, 0, 1700, 700), { immediate: true }) 把视口直接框到路径所在的页面区域,让游戏开局即有完整视野。
九、把这套模式迁移到你的场景
塔防示例真正示范的是一种可复用的「编辑器即游戏/工具画布」组合模式,可提炼为四步:
- 状态层:把所有「不属于编辑器文档、但驱动画布装饰」的动态数据放进
atom(此例的enemies$/projectiles$与编辑器的 shape store 完全解耦); - 视图层:每种视觉元素写一个
OverlayUtil,getOverlays()从 atom 推导、render()在页面坐标下的 canvas 直接绘制,尺寸感用editor.getZoomLevel()归一化; - 交互层:凡需要被点击/悬停的对象,给出
getGeometry()的命中几何与光标、用onPointerDown()拦截事件,并注意return true以阻止默认工具行为; - 时序层:游戏帧由
editor.on('tick')驱动,务必钳制后台恢复后的大增量;需要跨帧的原子写入集中在循环函数内完成,让 overlay 只做被动重绘。
其中对 locked shape 的写入/删除必须加 ignoreShapeLock、overlay id 全局唯一需自带命名空间、命中与绘制共用同一页面坐标语义三条经验,是从源码直接可验证的硬约束。对照 OverlayUtil 基类 的文档注释逐项阅读,即可完整掌握这套 overlay 生命周期,并在自己的 tldraw 应用中复用。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00