# 頭上の吹き出しとローカルモデル

> ゲーム内の任意のユニットの頭上に、任意の役柄で吹き出しを表示します。ローカル LLM につなげば、1 文を入力すると 1 文の返答がユニットの頭上に表示されます。

出典: https://war3ai.com/ja/docs/speech/

吹き出しは観賞用のレイヤーです。勝敗には影響せず、配信、実況、デバッグに向いています。

- 任意のユニットが任意の役柄で話せます。複数のユニットが同時に話すこともできます。
- 吹き出しごとに、フォントサイズ、色、幅、しっぽ、透明度、タイピング速度を個別にカスタマイズできます。
- ローカル LLM（LM Studio）に直接接続でき、ストリーミング出力に対応します。生成しながら吹き出しを更新します。

## Bot から使う

最も簡単なのは SDK 組み込みの `say` です。

```python
g.say(hero, "俺に続け！", seconds=4)
```

## 起動と UI

**いちばん手軽なのは Farsight のトップページ「コントロールセンター」です**。まず「ローカル LLM → 起動してモデルを読み込む」（LM Studio のローカルサーバー + 設定済みのモデルを VRAM に読み込む）をクリックし、次に「頭上のチャット吹き出し → 起動」をクリックします。カードではログの確認、停止、再起動ができます。

UI は Farsight 左側の「頭上の吹き出し」ページにあります。ユニットに話させる（ユニットを選ぶ、テキストを書く、スタイルを調整する、モデルと会話する）、農民の井戸端会議、カメラ会話、戦況トリガー、モデル設定があり、上部バーで選んだインスタンスに対して操作します。

コマンドラインでも起動できます。

```bash
python speech/speak_launch.py              # ローカルモデルサーバーを起動 + モデルを読み込んでウォームアップ + 吹き出し API を起動
python speech/speak_launch.py --restart    # コードを変更したあとに API を再起動
python speech/speak_launch.py --stop       # API を停止し、モデルを VRAM からアンロード
```

各ステップは「すでにあればスキップ」するので、何度実行しても副作用はありません。

## HTTP API

デフォルトは `http://127.0.0.1:8872/`（ポートは `openwar3.json` の `ports.speech`）で、どのプログラムからでも呼び出せます。

### ユニットに話させる `POST /api/say`

```json
{
  "inst": 16,
  "bubbles": [
    { "unit": "0x14A12614", "name": "マウンテンキング", "text": "俺に続け！" },
    { "unit": "0x14A12924", "name": "アークメイジ", "text": "ブリザードは任せろ。",
      "style": { "font_px": 26, "text_color": "#FFE080", "bg_color": "#C0102040" } },
    { "screen": [960, 110], "key": 1, "name": "ナレーション", "text": "オークの第 1 波到着まであと 30 秒。",
      "style": { "tail": false, "type_ms": 0 } },
    { "world": [-4684, 2644], "key": 2, "text": "集合地点", "style": { "font_px": 16 } }
  ]
}
```

| フィールド | 説明 |
|---|---|
| `unit` / `world` / `screen` | いずれか 1 つ：ユニットに追従（HP バーがあればその真上に表示）/ マップ座標 / 画面ピクセル（ナレーション用） |
| `name` | 1 行目に表示する話者名。自由に書けて、そのユニットである必要はありません |
| `text` | 本文。自動で折り返します |
| `duration_ms` | 表示時間。0 = 自動で 3 〜 5 秒 |
| `key` | ワールド / 画面の吹き出しの番号。同じ key の新しいメッセージが古いものを置き換えます |
| `update` | 同じ吹き出しがすでにある場合、テキストだけを差し替え、タイマーはリセットしません（ストリーミング出力用） |
| `style` | `font_px`、`max_width_px`、`text_color`、`bg_color`、`border_color`、`tail`、`side_px`、`opacity`、`type_ms`、`font`…… |

同時に表示できる吹き出しは最大 32 個です。1 フレームあたりのコストは平均約 0.1 〜 0.2 ms です。

### ローカルモデルと会話する `POST /api/chat`

```json
{
  "inst": 16, "unit": "0x14A12614", "name": "マウンテンキング",
  "persona": "あなたは Warcraft のマウンテンキング、ムラディンを演じます。豪快で酒好き。口語で 1〜2 文、40 文字以内。",
  "message": "この先にオーガの群れがいる。突っ込むか？",
  "stream": true
}
```

返り値は `{"reply": "...", "first_token_ms": 283, "total_ms": 342}` で、この返答はすでにそのユニットの頭上に表示されています。同じユニットは直近 6 往復の会話を記憶します。

### その他

| エンドポイント | 説明 |
|---|---|
| `GET /api/instances` | 実行中のゲーム |
| `GET /api/units?inst=16&mine=true&heroes=true` | ユニット一覧（中国語名、座標、HP 付き） |
| `POST /api/clear` | 吹き出しを 1 つ、またはすべて消去 |
| `GET /api/llm`、`POST /api/llm` | モデル設定の確認 / 変更（`base_url`、`model`、`max_tokens`、`temperature`） |
| `POST /api/banter` | 農民の井戸端会議：拠点のワーカーがキャラ設定に沿って順番にぼやき、開始時に口上を述べます（戦況はすべて実データ） |
| `POST /api/camtalk` | カメラ会話：画面に映っているヒーローと従者が役柄に応じて会話します |
| `POST /api/events` | 戦況トリガー：開戦、戦闘終了、ヒーロー戦死、ティアアップ、建物破壊……何かが起きたときだけ話します |

## ローカルモデルの選び方

RTX 5090 1 枚での実測（ゲームのセリフ 5 本）：

| モデル | VRAM | 速度 | 1 回の返答 | 結論 |
|---|---|---|---|---|
| **Qwen3.6-35B-A3B**（MoE、毎回 3B のみアクティブ）、Q4、思考オフ | 20.6 GB | 約 142 token/s | **約 0.3 秒**（最初のトークンまで約 0.27 秒） | 推奨：速く、中国語のロールプレイが自然 |
| gpt-oss-20b（MXFP4）、推論 low | 11.3 GB | 約 280 token/s | 0.3 〜 0.8 秒 | VRAM が厳しいとき向け。中国語はやや平板 |
| Qwen3.6-27B（dense）、Q4 | 17.2 GB | 約 39 token/s | 5.5 秒経ってもまだ思考中 | リアルタイム会話には不向き |

- **速度は「毎回アクティブになるパラメータ数」で決まり、総パラメータ数ではありません**：35B の MoE は 3B しかアクティブにならず、27B の dense モデルより 3 〜 4 倍速くなります。
- **「思考」は必ずオフにしてください**：オフにしないと、トークンがすべて思考に使われ、1 文字も返答しません。
- 吹き出しは 1 秒あたり約 22 文字の速さで 1 文字ずつ表示されるため、生成速度はもはやボトルネックではありません。体験を本当に左右するのは**最初のトークンまでの遅延**です。

> **セリフを「本物らしく」する**
>
> モデルに渡す戦況は必ず実データ（試合数、勝敗、兵力、備蓄）にし、「これらの事実だけを使うこと」と明示してください。実測では、この制約がないとモデルは起きていない戦闘をでっち上げました。
