首页
/ 在 tldraw 中用 OverlayUtil 构建塔防游戏:overlay 画布、命中测试与事件路由实战

在 tldraw 中用 OverlayUtil 构建塔防游戏:overlay 画布、命中测试与事件路由实战

2026-09-08 19:30:10作者:龚格成

本示例位于仓库 apps/examples/src/examples/use-cases/tower-defense/,是 tldraw 官方示例中把「游戏循环 + 响应式状态 + Canvas 直绘」完整跑在 overlay 体系上的代表作。敌人、子弹、爆炸、射程环、放置预览与升级按钮全部由自定义 OverlayUtil 在 overlay 画布上绘制,塔身则是编辑器原生的锁定 geo 图形。读完本文你将掌握:如何通过 overlayUtils 注册自定义 overlay、如何让 atom 驱动的 getOverlays() 自动响应游戏状态、如何用 getGeometry()/getCursor()/onPointerDown() 拦截指针事件,以及如何利用编辑器 tick 事件驱动逐帧游戏逻辑。

一、示例总览:把无限画布编辑器变成一张游戏棋盘

打开该示例即可直接游玩,玩法如下(与 README.md 所述一致):

  • 从工具栏选择一座塔(或直接按 123),点击画布放置。每座塔都是一个被锁定(locked)的 geo 图形(三角形 / 矩形 / 椭圆),各自拥有独立的射程、射速、伤害与弹道类型;
  • 敌人沿一条固定折线路径前进,漏进终点会扣生命值;
  • 点击敌人可以手动造成伤害,悬停塔身可以查看射程并出现升级按钮。

它的实现文档(即本示例的 README)用三句话点透了整个架构核心:

  1. 一切会动的东西都是 OverlayUtil 子类,通过 overlayUtils prop 注册:路径、敌人、子弹、爆炸、塔的射程环、放置预览乃至升级按钮,全部绘制在 overlay 画布上;
  2. 游戏状态存放在 atomgetOverlays() 在响应式求值中读取它们,因此游戏循环每次更新状态,所有 overlay 都会自动重绘;
  3. 需要响应用户输入的对象实现 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」 敌人存在才激活 EnemyOverlayUtilplacingTower$ 非空且钱够才激活放置预览
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() 让同一条路径能适配明暗两套主题;EnemyOverlayUtilUpgradeButtonOverlayUtilrender() 还会读取 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):

  1. maybeSpawn —— 按随游戏时间收缩的间隔刷怪;
  2. moveEnemies —— 沿路径推进,漏网的扣生命;
  3. fireTowers —— 遍历页面上的 geo 图形,命中范围内的塔按冷却开火;
  4. moveProjectiles —— 推进子弹、过期回收;
  5. resolveHits —— 计算命中、AOE、减速与赏金;
  6. 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 负责,其中两个细节体现了对编辑器模型的尊重:

  1. computed 缓存候选塔扫描_candidates 只在页面图形/包围盒变化时重算,getOverlays() 内再做廉价的指针过滤,避免 mousemove 风暴触发全量扫描(源码注释原话);
  2. 更新锁定图形必须显式 ignoreShapeLock
this.editor.run(() => {
	this.editor.updateShape({
		id: shapeId, type: 'geo',
		meta: { ...shape.meta, towerLevel: nextLevel },
		props: { fill: 'solid', color: levelColor(nextLevel) },
	})
}, { ignoreShapeLock: true })

同样的特判出现在 restartGamedeleteShapes 清场也必须在 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_LENGTHgetPositionAtDistance(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 }) 把视口直接框到路径所在的页面区域,让游戏开局即有完整视野。

九、把这套模式迁移到你的场景

塔防示例真正示范的是一种可复用的「编辑器即游戏/工具画布」组合模式,可提炼为四步:

  1. 状态层:把所有「不属于编辑器文档、但驱动画布装饰」的动态数据放进 atom(此例的 enemies$/projectiles$ 与编辑器的 shape store 完全解耦);
  2. 视图层:每种视觉元素写一个 OverlayUtilgetOverlays() 从 atom 推导、render() 在页面坐标下的 canvas 直接绘制,尺寸感用 editor.getZoomLevel() 归一化;
  3. 交互层:凡需要被点击/悬停的对象,给出 getGeometry() 的命中几何与光标、用 onPointerDown() 拦截事件,并注意 return true 以阻止默认工具行为;
  4. 时序层:游戏帧由 editor.on('tick') 驱动,务必钳制后台恢复后的大增量;需要跨帧的原子写入集中在循环函数内完成,让 overlay 只做被动重绘。

其中对 locked shape 的写入/删除必须加 ignoreShapeLockoverlay id 全局唯一需自带命名空间命中与绘制共用同一页面坐标语义三条经验,是从源码直接可验证的硬约束。对照 OverlayUtil 基类 的文档注释逐项阅读,即可完整掌握这套 overlay 生命周期,并在自己的 tldraw 应用中复用。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
docsdocs
暂无描述
Markdown
899
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
525
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395