心智模型
快照、命令、回执、事件、一拍、批量。理解这六个概念,就理解了为什么接口长这样,以及怎样写才快。
快照:读,零等待
运行时每 50 ms 在游戏线程上把整个世界采一遍,写进共享内存。g.snapshot() 拿到的是一份完整、自洽的世界:
- 16 个玩家槽:金、木、人口、上限、累计采集量、种族;
- 最多 1024 个单位:类型、主人、坐标、血 / 蓝(含上限)、当前订单和订单目标、实际在打谁(任务目标)、英雄等级 / 经验 / 技能点、每个人对它的可见性;
- 最多 256 份单位细节:12 个技能(等级、剩余冷却)、8 个 buff、6 格背包;
- 地上物品、树(每 2 秒刷新)、生产表(训练 / 研究 / 建造 / 升级的进度)、游戏时钟、游戏内时间。
读一份约 0.4 ms(Python 解析),不等游戏线程。所以:随便读。g.units()、g.my_army()、g.cooldown()、g.inventory() 这类接口都是从同一份快照里取,一拍里调多少次都不贵。
发布周期可以调:g.set_publish_period(ms),16 ~ 1000 毫秒。一次采集在游戏线程上约 0.5 ~ 0.9 ms,33 ms 也没问题。全机共用一个值,最后写的算。
命令:写,约一帧
g.move / attack / gather / build / train / cast … 交给游戏线程执行。运行时在游戏线程的事件分发里成批执行客户端提交的命令,所以一条命令约等一帧(落在事件簇里时约 0.1 ms,否则等到下一次分发)。
- 命令可以传一个单位或一个列表,列表里的单位同一帧一起下;
- 加
queue='after'就是 Shift 排队:做完手上这件再做; - 命令里的单位用快照里拿到的对象即可,SDK 用句柄对核对身份(地址会被新单位复用,句柄不会)。
回执:每条命令都有
r = g.build(worker, "hbar", x, y)
if r: # 引擎接下了
...
else:
r.reason # 'rejected(金不够)'
r.verdict # 8
r.exec_us # 这条在游戏线程上执行了几微秒
回执是在同一帧里读回来的:下令前后单位的订单、引擎函数的返回值、可行性检查的原因码。它回答「引擎接没接这条命令、为什么没接」,但不回答「最后做成了没有」—— 做成没有看快照和事件。
全部状态码和原因码见 回执与原因码。
事件:发生了什么
on_event(g, ev) 在每拍 on_tick 之前,把上一拍以来的事件逐条交给你:
| 事件 | 含义 |
|---|---|
unit.appeared / unit.died / unit.removed | 单位出现、死亡、消失(进金矿、被转化、尸体腐烂也算消失,不等于死了) |
unit.damaged / order.changed / owner.changed | 掉血、换订单、换主人 |
hero.levelup | 英雄升级 |
item.appeared / item.removed | 地上的物品出现、被捡走或用掉 |
damage | 引擎级:每一下伤害。来源单位、攻击类型、伤害类型、实际掉血、护甲前伤害 |
killed | 引擎级:这一下把它打死了,带凶手 |
production.done | 训练 / 研究 / 建造 / 升级完成,带四字码和用了多少游戏秒。对手的也有 |
spell.cast | 单位放了技能:技能四字码、等级、冷却秒、施法点 |
message | 屏幕消息框里出了一条:游戏提示(「需要更多的农场」)、聊天(.chat 里有发言人和内容)、系统消息 |
selection.changed / player.left | 本机玩家的选择变了 / 有玩家离开或被判负移除 |
game.started / game.ended | 新的一局开始 / 离开对局 |
画板按钮被点、热键、点地面这些输入事件见 界面与输入。
事件流是全局的:对手的生产完成、野怪的死亡都在里面。按 ev.owner 或单位句柄过滤。
一拍:Bot 的节奏
on_tick 默认每秒调 5 次(墙钟)。一拍的耗时基本就是你自己的计算:快照零等待,命令约一帧。一拍超过周期会自动顺延,不会越积越多。
- 2 倍速下别按墙钟等。 想等 3 游戏秒,就看
g.clock()涨了 3,不要sleep(1.5)。 - 别在
on_tick里sleep。 需要「过一会儿再做」,就记下当前游戏时间,下一拍再判断。
批量:几十条命令只等一次
一拍要下很多条命令时,包在 with g.batch(): 里:
with g.batch():
g.attack(melee, target_a)
g.attack(ranged, target_b)
g.move(wounded, home.x, home.y)
g.cast(hero, "thunderclap")
# 块结束时整批提交:同一帧执行,只等一次游戏线程
- 块里的命令返回
Pending,块结束后变成回执;块结束前读它会抛错; - 块里抛了异常,整批作废(半截命令比不发更危险);
- 实测 8 条移动:逐条 68 ~ 99 ms,一批 6.5 ~ 10 ms。
同样的思路也适用于查询:g.can_do_many([(u, code), ...])、g.tech_many([...]) 一次问很多个。
读自己刚写的
同一拍里,快照还看不到你刚下的命令(下一次发布才追上)。两段逻辑可能会抢同一个工人:一段刚派它去盖农场,另一段看快照以为它还闲着。
g.order_of(u) 解决这个问题:快照追上之前,它以回执里的新订单为准。判断「闲不闲」用 g.order_of(u),不要用 u.order。 g.idle_workers() 已经把「这一拍刚被派了活的」排除掉了。
延迟档位
| 档 | 通道 | 延迟 | 用在 |
|---|---|---|---|
| 0 | 推送快照 + 事件流 | 读一份约 0.4 ms;数据每 50 ms 新一份 | 所有「看」的接口 |
| 1 | 快车道 | 约 1 帧;6 进程并发时中位 0.06 ms | 所有命令和查询(SDK 默认) |
| 2 | 控制通道 | 20 ~ 40 ms | 兜底、少数界面类操作(倍速、气泡、消息) |
| 3 | 网关(WebSocket / JSON) | 档 1 + 约 1 ms | 任何语言、浏览器、大模型、另一台机器上的程序 |
接口目录 里每个接口都标了它走哪一档。