界面与输入
画板上的按钮、选项卡能点,悬停自动高亮;登记热键、点地面选位置、读鼠标指着哪儿、知道本机玩家选中了谁。点击、热键、放技能、聊天全文、玩家离开,都进事件流。
画板 画出来的东西,现在能点了。运行时接管游戏窗口的输入,外部程序可以:
| 能力 | 一句话 | 游戏收得到吗 |
|---|---|---|
| 可点的画板项 | 按钮、选项卡、面板:点中发 ui.click,悬停自动高亮 | 点在按钮上的这一下收不到 |
| 热键 | 登记 F5、ctrl+shift+Q 这样的组合,按下发 hotkey | 可选吞掉(连同它生成的字符) |
| 点地面 | 点在世界上发 mouse.world,带地面坐标 | 可选吞掉(「点一个位置放塔」) |
| 鼠标位置 | 每帧更新:屏幕像素、指着的地面点、悬停的画板项 | —— |
| 选择 | 本机玩家选中了谁;一变就发 selection.changed | —— |
全部是本机输入 + 本机绘制:不进指令流,多人局也安全。但回调里要是改了世界(刷单位、改属性),那还是只能单人局。
Python:g.ui
ui = g.ui # 第一次用时运行时接管窗口输入
ui.button("shop", "买一瓶药(50 金)", screen=(40, 300), on_click=lambda g, ev: buy(g))
c = ui.choice("升级了!选一个奖励", [("力量 +5", "更能扛"), ("攻速 +20%", "更能打"), ("召唤狼", "多一个帮手")],
pause=True, on_pick=lambda g, i: give(g, i)) # 屏幕中间一排卡片;pause=True 选的时候游戏暂停
i = c.wait(timeout=30) # 也可以阻塞等(期间照样泵事件,不丢)
ui.hotkey("F5", lambda g, ev: g.say(hero, "收到!")) # 默认吞掉
ui.hotkey("ctrl+shift+Q", on_press=..., swallow=False)
ui.mouse(on_click, capture=True, buttons=("left", "right")) # 抓地面点击:左键、右键都报,并吞掉
xy = ui.pick_point("点地面:塔建在哪?") # 阻塞版:下一次左键点地面 -> (x, y);Esc 或超时 -> None
ui.cursor() # {'screen': (x, y), 'world': (x, y, z) 或 None, 'hover': 'shop'}
ui.toast("第 3 波来了!", seconds=3)
ui.close() # 收起自己的控件和热键;没有别的程序在用输入时才交还窗口输入
g.close() # 或者整个断开(也可以写 with Game(...) as g:)
回调的参数是 (g, ev),在你调 g.events() 时触发 —— Bot 和玩法模组的运行器每拍都会调。没给回调的点击进 ui.clicks。回调里抛了异常只记日志,不影响别的回调和事件。
画板底层也能直接用:g.canvas.text(..., clickable=True, hover=颜色),点击从事件流里收,ev.key 就是画的时候给的 key。画可点的元素会自动打开输入,不用先碰 g.ui。
热键写法:F1 ~ F24、A ~ Z、0 ~ 9、numpad0 ~ numpad9、space enter esc tab backspace insert delete home end pageup pagedown left up right down,前面可以加 ctrl+、shift+、alt+。
不带修饰键的字母和数字会和聊天输入、游戏快捷键打架。优先用 F5 ~ F8 这类游戏没占的键,或者组合键。
新事件
g.events() 里多了这些(完整字段见 W3P 协议):
| kind | 什么时候 | 便捷字段 |
|---|---|---|
ui.click | 可交互的画板项被点 | .key 画板 key、.button('left' / 'right')、.mods 修饰键 |
ui.hover | 鼠标移进 / 移出画板项 | .key(移出时是 None) |
hotkey | 登记的热键按下 | .key 热键写法、.mods |
mouse.world | 开了地面点击时,点在世界上 | .x .y 地面坐标、.button、.value(1 = 被吞了) |
selection.changed | 本机玩家的选择变了 | 用 g.selection() 取单位 |
spell.cast | 单位放了技能(技能开始冷却) | .spell 四字码、.b 等级、.value 冷却秒、.x .y 施法点 |
message | 屏幕消息框里出了一条 | .text 全文、.frame 哪个框、.chat(是聊天时) |
player.left | 玩家离开或被判负移除 | .player |
game.ended | 离开对局 | —— |
聊天和屏幕消息
玩家在聊天框打的字,直接从 message 事件的 .chat 读:
for ev in g.events():
if ev.kind == "message" and ev.chat and ev.chat["text"] == "-follow":
... # ev.chat = {'channel': '所有人', 'sender': '玩家名', 'text': '-follow'}
g.messages() 另有一份独立的游标:游戏提示(「需要更多的农场」「不能在那里建造」)也在里面。写 Bot 时用它就知道一条命令为什么没做成。
从别的语言用
- 网关:
ui.button、ui.choice、ui.hotkey、ui.mouse、ui.cursor这些方法在 网关 上同名可用。远端给不了回调函数,点击和热键从事件推送里收(ui.click事件带key)。 - 直接写共享内存:先发语义命令
input_enable(W3P 操作码 74),运行时开始接管输入;在输入块Local\War3Input_<pid>里你写热键表和鼠标开关,它回写鼠标位置、指着的地面点和悬停项。画板项的标志位0x40表示「可交互」。布局见 W3P 协议。
好几个程序同时用
模组、远见、MCP、网关的每个会话可能同时往一局游戏里放按钮、登记热键,互不干扰:
- 每个程序登记自己的热键和地面点击开关,SDK 把大家的合并成一张表交给运行时。同一个键只留一条,事件发给所有人,各自按键认自己的热键;
ui.close()只撤自己的,最后一个程序走了才交还窗口输入;- 程序被强行结束、没来得及收尾:运行时每 2 秒检查一次,登记过的程序全都退出了,就清掉它们留下的热键和地面点击拦截,它们画的按钮也不再拦点击。
实测
2026-09-25,演练实例上的实机核对 16/16:
- 点按钮 →
ui.click+ 回调,运行时的拦截计数 +1(游戏没收到这一下);点按钮外面不触发; - F6 →
hotkey;点地面 →mouse.world(被吞掉); - 刷一个圣骑士、选中它 →
selection.changed,g.selection()对得上;放神圣护甲 →spell.cast('AHds', 1, 35.0); - 地图文字 →
message;聊天 →message,.chat解析出发言人和内容; - 判负电脑 →
player.left;结束对局 →game.ended。
真人鼠标点按钮、悬停高亮也逐一看过。
边界和注意
- 位置用的是真鼠标:游戏自己按系统光标读位置,所以悬停、
cursor()反映的是真鼠标。拦截只管按键。 - 画在鼠标指针下面:魔兽把指针当作画面的一部分每帧画进去。画板和头顶气泡都在游戏画指针的那一步之前画:盖住游戏界面,被指针盖住。这一帧没画指针(藏起来或过场)时才退回最后一步画。
- 系统缩放:自己写测试、用窗口消息投递点击时,不感知 DPI 的进程投过去的坐标会被系统放大(150% 缩放时实测 ×1.5)。测试程序先声明感知 DPI。真人点击不受影响。
- 第一次要预热字体,约 1 秒。这期间按钮还没画出来,点不中。
- 不在对局里时不报地面点击:主菜单和结算页上
mouse.world不发,也不吞。 - 按下时被吞掉的那一下,松开前切到别的程序或鼠标拖出了窗口,也会复位,不会连下一次松开也吞掉。
- 1.27 没有新建游戏界面框的函数(1.31 才有):这里的按钮、卡片都是运行时画的,样式随意,但不会出现在游戏自己的菜单层级里。