文档 核心概念

心智模型

快照、命令、回执、事件、一拍、批量。理解这六个概念,就理解了为什么接口长这样,以及怎样写才快。

快照:读,零等待

运行时每 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任何语言、浏览器、大模型、另一台机器上的程序

接口目录 里每个接口都标了它走哪一档。