# 말풍선과 로컬 모델

> 게임 속 어떤 유닛이든 원하는 신분으로 머리 위에 대화 말풍선을 띄웁니다. 로컬 LLM을 연결하면 한 마디가 들어가고 답변 한 마디가 유닛 머리 위에 나타납니다.

출처: https://war3ai.com/ko/docs/speech/

말풍선은 관전용 레이어입니다. 승패에 영향을 주지 않으며 방송, 해설, 디버깅에 적합합니다.

- 어떤 유닛이든, 어떤 신분으로든 말할 수 있고, 여러 유닛이 동시에 말할 수도 있습니다.
- 말풍선마다 글자 크기, 색상, 너비, 꼬리, 투명도, 타이핑 속도를 따로 설정할 수 있습니다.
- 로컬 LLM(LM Studio)에 바로 연결할 수 있고 스트리밍 출력을 지원합니다. 생성되는 대로 말풍선이 갱신됩니다.

## Bot에서 사용하기

가장 간단한 방법은 SDK에 내장된 `say`입니다.

```python
g.say(hero, "나를 따르라!", seconds=4)
```

## 시작과 화면

**가장 간단한 방법은 Farsight 첫 화면 "컨트롤 센터"입니다.** 먼저 "로컬 LLM → 시작하고 모델 로드"를 누르고(LM Studio 로컬 서버 + 설정해 둔 모델을 VRAM에 로드), 그다음 "머리 위 채팅 말풍선 → 시작"을 누르세요. 카드에서 로그 보기, 중지, 재시작도 할 수 있습니다.

화면은 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": "첫 번째 오크 부대가 30초 후 도착합니다.",
      "style": { "tail": false, "type_ms": 0 } },
    { "world": [-4684, 2644], "key": 2, "text": "집결 지점", "style": { "font_px": 16 } }
  ]
}
```

| 필드 | 설명 |
|---|---|
| `unit` / `world` / `screen` | 셋 중 하나: 유닛을 따라다님(체력 바가 있으면 체력 바 바로 위에 붙음) / 맵 좌표 / 화면 픽셀(내레이션용) |
| `name` | 첫 줄에 표시되는 화자. 자유롭게 쓰면 되며 해당 유닛일 필요는 없음 |
| `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개까지 띄울 수 있으며, 프레임당 오버헤드는 평균 약 0.1 ~ 0.2 ms입니다.

### 로컬 모델과 대화하기 `POST /api/chat`

```json
{
  "inst": 16, "unit": "0x14A12614", "name": "마운틴 킹",
  "persona": "너는 워크래프트의 마운틴 킹 무라딘을 연기한다. 호탕하고 술을 좋아한다. 구어체로 한두 문장, 40자 이내.",
  "message": "앞에 오우거 무리가 있는데, 돌격할까?",
  "stream": true
}
```

`{"reply": "...", "first_token_ms": 283, "total_ms": 342}`를 반환하며, 그와 동시에 답변이 이미 해당 유닛 머리 위에 나타나 있습니다. 같은 유닛은 최근 6턴의 대화를 기억합니다.

### 기타

| API | 설명 |
|---|---|
| `GET /api/instances` | 실행 중인 게임 |
| `GET /api/units?inst=16&mine=true&heroes=true` | 유닛 목록(중국어 이름, 좌표, 체력 포함) |
| `POST /api/clear` | 말풍선 하나 또는 전부 지우기 |
| `GET /api/llm`, `POST /api/llm` | 모델 설정 보기 / 변경(`base_url`, `model`, `max_tokens`, `temperature`) |
| `POST /api/banter` | 일꾼 수다 모임: 본진의 일꾼들이 캐릭터 설정에 따라 돌아가며 투덜거리고, 게임 시작 때 소개 멘트를 함(전황은 모두 실제 데이터) |
| `POST /api/camtalk` | 카메라 대화: 화면에 잡힌 영웅과 수행원이 신분에 맞게 대화 |
| `POST /api/events` | 전황 트리거: 교전 시작, 교전 종료, 영웅 사망, 티어 업, 건물 파괴…… 일이 생겼을 때만 말함 |

## 로컬 모델 고르기

RTX 5090 한 장에서 실측한 결과입니다(게임 대사 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배 빠릅니다.
- **"사고(thinking)"는 반드시 꺼야 합니다**: 끄지 않으면 토큰을 모두 사고에 써 버려 답변이 한 글자도 나오지 않습니다.
- 말풍선은 초당 약 22자씩 한 글자씩 찍히므로 생성 속도는 더 이상 병목이 아닙니다. 체감에 실제로 영향을 주는 것은 **첫 토큰 지연**입니다.

> **대사를 “진짜처럼” 만들기**
>
> 모델에 주는 전황은 모두 실제 데이터(경기 수, 승패, 병력, 보유 자원)로 채우고, "이 사실만 사용할 것"을 명시하세요. 실측해 보니 이 제한을 두지 않으면 모델이 일어나지도 않은 전투를 지어냈습니다.
