# 界面与输入

> 画板上的按钮、选项卡能点，悬停自动高亮；登记热键、点地面选位置、读鼠标指着哪儿、知道本机玩家选中了谁。点击、热键、放技能、聊天全文、玩家离开，都进事件流。

来源: https://war3ai.com/docs/ui-input/

[画板](https://war3ai.com/docs/canvas/) 画出来的东西，现在**能点了**。运行时接管游戏窗口的输入，外部程序可以：

| 能力 | 一句话 | 游戏收得到吗 |
|---|---|---|
| **可点的画板项** | 按钮、选项卡、面板：点中发 `ui.click`，悬停自动高亮 | 点在按钮上的这一下**收不到** |
| **热键** | 登记 `F5`、`ctrl+shift+Q` 这样的组合，按下发 `hotkey` | 可选吞掉（连同它生成的字符） |
| **点地面** | 点在世界上发 `mouse.world`，带地面坐标 | 可选吞掉（「点一个位置放塔」） |
| **鼠标位置** | 每帧更新：屏幕像素、指着的地面点、悬停的画板项 | —— |
| **选择** | 本机玩家选中了谁；一变就发 `selection.changed` | —— |

全部是**本机输入 + 本机绘制**：不进指令流，多人局也安全。但回调里要是改了世界（刷单位、改属性），那还是只能单人局。

## Python：g.ui

```python
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 和[玩法模组](https://war3ai.com/docs/mods/)的运行器每拍都会调。没给回调的点击进 `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 协议](https://war3ai.com/docs/protocol/)）：

| 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` 读：

```python
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` 这些方法在 [网关](https://war3ai.com/docs/gateway/) 上同名可用。远端给不了回调函数，点击和热键从事件推送里收（`ui.click` 事件带 `key`）。
- **直接写共享内存**：先发语义命令 `input_enable`（W3P 操作码 74），运行时开始接管输入；在输入块 `Local\War3Input_<pid>` 里你写热键表和鼠标开关，它回写鼠标位置、指着的地面点和悬停项。画板项的标志位 `0x40` 表示「可交互」。布局见 [W3P 协议](https://war3ai.com/docs/protocol/)。

## 好几个程序同时用

模组、远见、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 才有）：这里的按钮、卡片都是运行时画的，样式随意，但不会出现在游戏自己的菜单层级里。
