add docs for ai agent

This commit is contained in:
CuongNV
2026-05-25 10:33:40 +07:00
parent 8cbc777764
commit 9aee39c47f
10 changed files with 1364 additions and 0 deletions
+296
View File
@@ -0,0 +1,296 @@
# Addressables — Bootstrap & tóm tắt nghiệp vụ
Tài liệu này tóm tắt ghi chép **Addressables** (catalog / cache / remote) và các script runtime trong project **`perfect-world-unity`**: `AddressablesCatalogUpdater`, `AddressablesRuntimeUrlRewriter`, `GameContentBootstrap`, cùng chỉnh sửa `AddressableManager`.
Tham chiếu thêm: bản ghi chép tổng quan ban đầu (`Addressables-Tong-hop.md` nếu có trong repo hoặc bản copy ngoài project).
---
## 0. Hai scene — Content trước, Bootstrap sau (khuyến nghị)
Tách **tải Addressables / catalog / bulk** khỏi scene game để `Bootstrap.unity` (UI, `CECUIManager`, `AddressableManager`, …) không bị ảnh hưởng.
```mermaid
flowchart LR
A[GameContentBootstrap.unity index 0] -->|sync OK LoadScene Single| B[Bootstrap.unity]
B -->|BootstrapSceneController ~1s| C[LoginScene]
```
| Scene | Build index | Chứa gì |
|-------|-------------|---------|
| **GameContentBootstrap** | **0** | Chỉ `GameContentBootstrap` (+ UI loading tuỳ chọn) |
| **Bootstrap** | 1 | `BootstrapSceneController`, `AddressableManager`, `CECUIManager`, … **không** gắn `GameContentBootstrap` |
| **LoginScene** | … | Load từ `BootstrapSceneController._nextSceneName` như cũ |
**Setup một lần trong Editor:** menu **Perfect World → Addressables → Setup Two-Scene Bootstrap**
- Tạo `Assets/PerfectWorld/Scene/GameContentBootstrap.unity` (nếu chưa có)
- Gỡ `GameContentBootstrap` khỏi `Bootstrap.unity`
- Đưa scene Content lên **index 0** Build Settings
**Trên component `GameContentBootstrap` (scene Content):**
| Field | Gợi ý |
|--------|--------|
| `_loadNextSceneAfterSuccess` | **Bật** |
| `_nextSceneName` | `Bootstrap` (tên scene trong Build Settings) |
| `_stayOnSceneWhenSyncFails` | **Bật** — lỗi thì không vào game |
Sau khi sync thành công: `GameContentBootstrapSession.IsContentReady = true`, Addressables đã init → scene **Bootstrap** chỉ cần `AddressableManager` / `AUIManager` gọi `AddressablesInitService` (không chờ gate).
---
### Cấu hình component (`GameContentBootstrap`)
### Hai chế độ
| Chế độ | `_useServerForVersionInfo` | Bạn làm gì |
|--------|---------------------------|------------|
| **Test / không API** | **Tắt** | Chỉ cần hardcode (bảng dưới). Không cần `_versionEndpointUrl`. |
| **Production** | **Bật** | Điền URL API + (tuỳ chọn) POST body. Server **sau khi kiểm tra** dữ liệu trả JSON có `contentVersion` và (nếu cần) `assetsBaseUrl`. |
**Editor:** bật `_skipRemoteCallInEditor` nếu muốn **không gọi mạng** trong Editor; lúc đó luôn dùng hardcode / `_editorFakeContentVersion` như checklist cuối tài liệu.
### Các biến cần biết (theo thứ tự ưu tiên đọc)
| Biến | Khi nào cần | Điền gì (ngắn gọn) |
|------|-------------|---------------------|
| `_useServerForVersionInfo` | Luôn quyết định trước | Test → **tắt**. Live → **bật**. |
| `_hardcodedContentVersion` | Server tắt hoặc Editor skip | Chuỗi version (vd `1`, `20250514`). Đổi số này = game coi như có content mới → catalog + bulk download. |
| `_hardcodedAssetsBaseUrl` | Test CDN / không dùng server | **Prefix URL** thư mục remote Addressables trên CDN (vd `https://my-cdn.com/PW/Android/`). Phải khớp cấu trúc upload `ServerData/<Platform>/`. Để trống nếu bundle đã trỏ đúng URL lúc build, không cần đổi host. |
| `_versionEndpointUrl` | **Chỉ khi** server **bật** | URL đầy đủ một endpoint do backend cung cấp, vd `https://api.game.com/v1/addressables/resolve`. |
| `_usePostForVersionRequest` | Server bật | **Bật** nếu luồng của bạn là: client gửi JSON lên → server validate → trả link/prefix. **Tắt** = GET (không body). |
| `_versionPostBody` | POST bật | JSON một dòng hoặc nhiều dòng (vd `{"platform":"Android","token":"..."}`). Để trống → client gửi `{}`. |
| `_bakedRemoteUrlPrefixForRewrite` | Khi dùng `assetsBaseUrl` (từ server **hoặc** hardcode) | **Đúng** phần đầu URL đã **bake** trong catalog (trùng `Remote.LoadPath` lúc build), vd `https://old-build-cdn.example.com/Android/`. Runtime thay prefix này bằng `assetsBaseUrl`. Để trống nếu **không** rewrite. Phải khớp đầu mọi `InternalId` remote cần đổi host. |
| `_remoteBulkDownloadLabel` | Gần như luôn | Trùng **label** trên group Addressables remote cần tải full (mặc định `RemoteContent`). |
| `_holdAddressablesInitUntilVersionChecked` | Khuyến nghị | Giữ **bật** để Addressables init sau khi có version + rewrite. |
| `_skipRemoteCallInEditor` / `_editorFakeContentVersion` | Editor | Bật skip + fake version khi không muốn HTTP trong Editor. |
### `_versionEndpointUrl` vs `_bakedRemoteUrlPrefixForRewrite` — ví dụ một dòng
- **`_versionEndpointUrl`**: không phải link tải từng bundle; là **URL gọi API** (GET hoặc POST) để lấy **metadata**: `contentVersion` + tuỳ chọn `assetsBaseUrl`.
- **`_bakedRemoteUrlPrefixForRewrite`**: không gọi trực tiếp; là **chuỗi copy từ build** (prefix URL trong catalog). Ví dụ build profile `Remote.LoadPath` = `https://cdn-a.com/Android` → điền y hệt vào field này; server trả `assetsBaseUrl` = `https://cdn-b.com/Android` → mọi bundle path đổi từ `cdn-a` sang `cdn-b`.
### Luồng bạn muốn (POST → server kiểm tra → trả “link”)
1. Bật `_useServerForVersionInfo`, điền `_versionEndpointUrl`, bật `_usePostForVersionRequest`, điền `_versionPostBody` (vd token, platform, build channel).
2. Client **POST** `Content-Type: application/json` tới URL đó.
3. Server validate; nếu OK trả **cùng format** như trước (để `JsonUtility` parse được):
```json
{
"contentVersion": "12",
"assetsBaseUrl": "https://cdn.example.com/PW/Android/"
}
```
`assetsBaseUrl` ở đây là **base / prefix** cho remote Addressables (nơi có `catalog_*.json` và bundle), không nhất thiết là một URL file đơn lẻ — Addressables vẫn dùng catalog + download theo label như cũ.
### Option test: luôn dùng link hardcode để download
1. Tắt `_useServerForVersionInfo`.
2. Điền `_hardcodedContentVersion` (vd `test1`).
3. Nếu cần trỏ CDN test khác với URL đã bake trong catalog: điền `_hardcodedAssetsBaseUrl` **và** `_bakedRemoteUrlPrefixForRewrite` như bảng trên.
4. Editor: có thể bật `_skipRemoteCallInEditor` để khỏi gọi server khi test trong Editor.
---
## 1. Khái niệm nhanh (catalog / bundle / cache)
| Thành phần | Vai trò |
|------------|---------|
| **Catalog** (`catalog_*.json` + `.hash`) | Danh mục: address → bundle, URL, hash/CRC… Catalog mới = tín hiệu “có nội dung mới”. |
| **AssetBundle** | Chứa dữ liệu asset thực tế. |
| **Nhóm Local / Remote** | Local: kèm build / `StreamingAssets`. Remote: tải qua HTTP(S). |
| **Player / Editor** | Bundle **theo platform** — không dùng bundle Windows cho Android. |
**Cập nhật trên máy người chơi**
- Catalog trỏ tới bundle/hash/CRC **khác** lần trước **và** luồng runtime thực hiện **tải catalog mới** / **cập nhật catalog** / **load bundle** thành công.
- **Không** tự cập nhật chỉ vì file trên server đổi nếu game **không** tải catalog mới hoặc không load đúng key.
- **`Release`** (RAM): asset có thể hết trong bộ nhớ; lần load sau có thể **đọc lại từ bundle đã cache trên đĩa****không** đồng nghĩa “tải lại toàn bộ từ CDN”.
**Cache bundle**
- Nếu bundle đã cache **hợp lệ** theo catalog (CRC/hash đúng cấu hình), hệ thống **ưu tiên dùng bản local**, không tải lại cả file từ server.
**Runtime đổi CDN / base URL**
- `Remote.LoadPath` trong profile là **lúc build** (URL ghi vào catalog).
- Đổi host/path **lúc chạy**: dùng **`Addressables.InternalIdTransformFunc`** (rewrite `InternalId`) — **trước** `InitializeAsync` / load remote nhiều nhất có thể.
**HTTP / HTTPS**
- Player Settings: **Allow downloads over HTTP** nếu dùng `http://` (thường chỉ dev).
- Production: **HTTPS**; Android/iOS có thể chặn cleartext.
---
## 2. Script trong project
### 2.1. `AddressablesCatalogUpdater.cs`
Bọc API Addressables (package ~2.7.x):
- `EnsureInitializedAsync`
- `CheckForCatalogUpdatesAsync` / `UpdateCatalogsAsync` / `CheckAndUpdateCatalogsIfNeededAsync`
- `GetDownloadSizeBytesAsync`
- `DownloadDependenciesAsync`
- `CleanBundleCacheAsync`
### 2.2. `AddressablesRuntimeUrlRewriter.cs`
- `InstallPrefixRewrite(fromPrefix, toPrefix)` — thay prefix URL trong `InternalId` (CDN mới).
- `ClearInstalledRewrite`
- `ChainPreviousTransform` — gọi transform cũ trước (nếu có).
### 2.3. `GameContentVersionServerClient.cs`
- Struct **`GameContentVersionFetchResult`** (`Ok`, `ContentVersion`, `AssetsBaseUrl`, `Error`).
- **`GameContentVersionServerClient.FetchAsync(url, timeoutSeconds, usePost, postJsonBody)`** — GET **hoặc** POST JSON (`Content-Type: application/json`), parse JSON phẳng (`JsonUtility`).
### 2.4. `GameContentBootstrap.cs` (scene bootstrap)
**Nguồn phiên bản / URL**
- **`_useServerForVersionInfo`**: tắt = không gọi server, dùng **`_hardcodedContentVersion`** / **`_hardcodedAssetsBaseUrl`** (test, API chưa chốt).
- Bật = gọi **`GameContentVersionServerClient.FetchAsync`** tới **`_versionEndpointUrl`** (GET hoặc POST theo **`_usePostForVersionRequest`** + **`_versionPostBody`**) (trừ khi Editor bật skip bên dưới).
- **`_skipRemoteCallInEditor`**: Editor không HTTP; ưu tiên hardcode nếu có version, không thì **`_editorFakeContentVersion`**.
- Bật server nhưng URL trống → fallback hardcode (cảnh báo log).
**Luồng**
1. Resolve version (server hoặc hardcode theo trên).
2. Nếu có `assetsBaseUrl` **và** **`_bakedRemoteUrlPrefixForRewrite`** → `InstallPrefixRewrite`.
3. **Mở gate** → cho phép `AddressableManager` gọi `Addressables.InitializeAsync`.
4. `EnsureInitializedAsync`, so sánh version với `PlayerPrefs`:
- **Lần đầu** hoặc **`contentVersion` khác** bản đã lưu → `CheckAndUpdateCatalogsIfNeededAsync` + `DownloadDependenciesAsync` theo **`_remoteBulkDownloadLabel`**.
- **Trùng version** (và không cần làm content work) → **không** catalog/bulk download.
**PlayerPrefs**
- `PW_GameContent_FirstRemoteSyncDone`
- `PW_GameContent_LastContentVersion`
**Event / API**
- `Finished` (`BootstrapResult`: `Success`, `DidContentWork`, `ErrorMessage`, `ServerContentVersion`).
- `RunInternalAsync()` — gọi từ code khác nếu cần.
**JSON mẫu server (phẳng, đúng `JsonUtility`)**
```json
{
"contentVersion": "12",
"assetsBaseUrl": "https://cdn.example.com/Android"
}
```
### 2.5. `AddressableManager.cs`
- Trước `Addressables.InitializeAsync()` **await** `GameContentBootstrap.WaitForPreAddressablesSetupIfAnyAsync()` để không init Addressables trước khi bootstrap xong HTTP + rewrite (khi gate được tạo).
---
## 3. Bảng liên hệ “MD nghiệp vụ ↔ code”
| Nghiệp vụ | Code |
|-----------|------|
| Kiểm tra / cập nhật catalog | `AddressablesCatalogUpdater` + gọi trong `GameContentBootstrap` |
| Đổi CDN runtime | `AddressablesRuntimeUrlRewriter` + `assetsBaseUrl` từ API |
| First run / mỗi lần mở + so version (server hoặc hardcode) | `GameContentBootstrap` + `GameContentVersionServerClient` + `PlayerPrefs` |
| Init Addressables sau rewrite | Gate + `AddressablesInitService` + `AddressableManager` chờ gate |
| Init tập trung (tránh init sớm) | `AddressablesInitService`**không** gọi `Addressables.InitializeAsync()` trực tiếp; `AUIManager` đã chuyển sang service |
---
## 4. Checklist cấu hình (team)
- [ ] Scene bootstrap có **`GameContentBootstrap`**: tắt **`_useServerForVersionInfo`** + gán hardcode khi test; production bật server + **`_versionEndpointUrl`**.
- [ ] **`_bakedRemoteUrlPrefixForRewrite`** trùng prefix URL **đã bake trong bản build đang chạy** (không chỉ `Remote.LoadPath` hiện tại trong Editor). Ví dụ build cũ trỏ `https://prefect-world-asset....wcsapi.com/` → phải điền đúng prefix đó; **`_hardcodedAssetsBaseUrl`** / server `assetsBaseUrl` = CDN mới (vd `https://pw-assets.brewmonster.vn/Android/`).
- [ ] Mọi entry remote cần “tải full” lần đầu / khi đổi version đều có **cùng label** với **`_remoteBulkDownloadLabel`** (mặc định `RemoteContent`).
- [ ] Build Addressables đúng platform → upload đúng **`ServerData/<Platform>`** lên host (nếu dùng remote).
- [ ] Có luồng **catalog** khi hot-update (bootstrap đã gọi khi version đổi / first run).
- [ ] Production dùng **HTTPS**; mọi chỗ load Addressables dùng **`AddressablesInitService`** (hoặc `AddressableManager.WaitUntilInitializedAsync`), không `InitializeAsync().WaitForCompletion()` trực tiếp.
- [ ] **Hai scene:** index 0 = `GameContentBootstrap`, **không** gắn `GameContentBootstrap` trên `Bootstrap.unity` (chạy menu Setup Two-Scene Bootstrap).
- [ ] `_nextSceneName` = `Bootstrap`; `BootstrapSceneController._nextSceneName` vẫn trỏ `LoginScene` (hoặc scene in-game) như cũ.
---
## 5. Mobile: `SSL CA certificate error` / catalog URL CDN cũ
**Triệu chứng:** `CheckForCatalogUpdates` / tải `catalog_*.hash` lỗi `ConnectionError : SSL CA certificate error`, URL vẫn là host cũ (vd `prefect-world-asset....wcsapi.com`) dù profile Editor đã đổi sang CDN khác.
**Nguyên nhân thường gặp:**
1. **Init sớm:** UI (`AUIManager` / `CECUIManager.Awake`) gọi `Addressables.InitializeAsync()` **trước** `GameContentBootstrap` gắn `InternalIdTransformFunc` → request vẫn tới CDN bake trong catalog build.
2. **Thiếu rewrite:** `assetsBaseUrl` có nhưng `_bakedRemoteUrlPrefixForRewrite` trống → không đổi host.
3. **Chứng chỉ CDN:** Chuỗi SSL/intermediate trên host đích không tin cậy được trên Android (sửa phía CDN / Let's Encrypt full chain).
**Đã xử lý trong code:** `AddressablesInitService` chờ gate bootstrap; `AUIManager` dùng service thay vì init trực tiếp.
**Bạn cần kiểm tra trên scene mobile:**
| Field | Gợi ý |
|--------|--------|
| `_bakedRemoteUrlPrefixForRewrite` | Prefix y hệt URL trong catalog bản APK (vd `https://prefect-world-asset.wcscdn51.v1.wcsapi.com/`) |
| `_hardcodedAssetsBaseUrl` | CDN đích HTTPS hợp lệ (vd `https://pw-assets.brewmonster.vn/Android/`) |
Log mong đợi trước init: `[Cuong] GameContentBootstrap: URL rewrite | from=... → to=...`
Các file khác vẫn có thể gọi `InitializeAsync().WaitForCompletion()` (`EC_Game`, `EC_HPWorkNavigate`, …) — nên chuyển dần sang `AddressablesInitService`.
---
## 6. Ghi chú Editor
- Bật **`_skipRemoteCallInEditor`** để không gọi mạng; dùng **`_hardcodedContentVersion`** / **`_hardcodedAssetsBaseUrl`** hoặc **`_editorFakeContentVersion`** (khi hardcode version trống).
---
## 7. Debug log runtime (`[Cuong]`)
Trong Console Unity, lọc `[Cuong]` để theo dõi bootstrap / tải remote.
### Tiến độ tải (% + MB)
`AddressablesCatalogUpdater.DownloadDependenciesAsync` log **mỗi 5%** (mặc định) và lúc **bắt đầu / kết thúc 100%**, kèm dung lượng khi Addressables báo được `TotalBytes`:
```text
[Cuong] AddressablesCatalogUpdater: Bắt đầu tải dependencies (key=RemoteContent)...
[Cuong] AddressablesCatalogUpdater: 0% (0.0/512.3 MB) — key=RemoteContent
[Cuong] AddressablesCatalogUpdater: 5% (25.6/512.3 MB) — key=RemoteContent
[Cuong] AddressablesCatalogUpdater: 10% (51.2/512.3 MB) — key=RemoteContent
...
[Cuong] AddressablesCatalogUpdater: 100% (512.3/512.3 MB) — key=RemoteContent
[Cuong] AddressablesCatalogUpdater: Tải xong dependencies (key=RemoteContent).
```
Trước khi bulk download, `GameContentBootstrap` gọi `GetDownloadSizeBytesAsync` và log **dung lượng cần tải** (ước lượng theo catalog + cache):
```text
[Cuong] GameContentBootstrap: Đang tải remote content (label=RemoteContent), dung lượng cần tải ~512.3 MB...
```
Nếu đã cache đủ: `dung lượng cần tải ~0 MB` hoặc log “đã có trong cache”.
Đổi bước log %: gọi `DownloadDependenciesAsync(key, progressLogStepPercent: 10)` (1100).
### Bảng log theo giai đoạn
| Giai đoạn | Ví dụ log |
|-----------|-----------|
| Bắt đầu bootstrap | `GameContentBootstrap: Bắt đầu bootstrap Addressables...` |
| Đang lấy version | `Đang lấy contentVersion / assetsBaseUrl...` |
| Đang init / catalog | `Đang khởi tạo Addressables...`, `Đang kiểm tra / cập nhật catalog...` |
| Trước bulk | `dung lượng cần tải ~N MB` (label=...) |
| Đang tải bulk | `AddressablesCatalogUpdater: N% (x/y MB) — key=...` (mỗi ~5%) |
| Xong (không cần tải) | `Hoàn tất — không cần tải catalog/bulk (version không đổi).` |
| Xong (đã tải hết) | `Bootstrap hoàn tất — đã tải xong hết nội dung` |
| AddressableManager | `Bootstrap gate xong`, `InitializeAsync xong — sẵn sàng load asset` |
Lỗi dùng `Debug.LogError` cùng tiền tố `[Cuong]`.
---
*Tài liệu mô tả hành vi và cấu hình trong repo; chi tiết API theo đúng phiên bản `com.unity.addressables` nên đối chiếu [Unity Manual: Addressables](https://docs.unity3d.com/Packages/com.unity.addressables@latest).*
+85
View File
@@ -0,0 +1,85 @@
# Server time, SunMoon phase, và đồng hồ minimap (PC → Unity)
Tài liệu tóm tắt luồng thời gian server, `SetTimeOfTheDay`, đồng bộ main thread, và hiển thị canh giờ trên minimap — đối chiếu `CElementClient` (C++) với `perfect-world-unity` (C#).
---
## 1. `CECGame::SetServerTime` (PC — `EC_Game.cpp`)
- **`m_iTimeError = iSevTime - time(NULL)`** — lệch giây giữa Unix server và máy client.
- **`m_iTimeZoneBias`** — bias múi giờ theo **phút** (ví dụ Bắc Kinh thường **-480**).
- **`GetServerLocalTime()`** (không tham số):
`serverTime = GetServerGMTTime()` (= `time(NULL) + m_iTimeError`), rồi
`serverTime -= m_iTimeZoneBias * 60`, sau đó `gmtime` — tức “giờ địa phương server” dùng để lấy `tm_hour` / `tm_min` / `tm_sec`.
- **SunMoon:**
`nTimeInDay = hour*3600 + min*60 + sec`,
`SetTimeOfTheDay(nTimeInDay / (4.0f * 3600.0f))` — cùng công thức đã port sang Unity.
- **Lưu ý thiết kế PC:** chia `(4*3600)` + chuẩn hóa `[0,1)` làm **nhiều mốc giờ thật** có thể trùng cùng một phase (đã phân tích trong phiên làm việc); phần **UI canh giờ** trên PC lại dùng **`GetTimeOfTheDay()`** (không dùng trực tiếp `tm_hour` cho chuỗi minimap).
---
## 2. Unity — `EC_Game.Time.cs` (partial `EC_Game`)
- **`SetServerTime(int iSevTime, int iTimeZoneBias)`**
- Đồng bộ offset, tính phase giống PC (`iSevTime - bias*60` → giờ trong ngày → `/ (4f*3600f)`), gọi **`SetTimeOfTheDay`**.
- **`SetTimeOfTheDay(float vTime)`** — forward tới `CECSunMoon.Instance.SetTimeOfTheDay`.
- **`SyncSunMoonTimeOfDayFromServerClock()`** — giống cuối `CECGameRun::CreateWorld` / `CECWorld::InitNatureObjects`: lấy **`GetServerGMTTime()`** rồi áp dụng cùng công thức phase (helper nội bộ).
- Log debug có thể dùng tiền tố **`[Cuong]`** theo convention dự án.
---
## 3. Nơi gọi (đối chiếu PC)
| Nguồn (PC) | Unity đã nối |
|------------|----------------|
| Gói **`SERVER_TIME`** → `PostMessage(MSG_SERVERTIME)``SetServerTime` | `GameSession.cs`**`CommandID.SERVER_TIME`**: parse `cmd_server_time`, **`PostToUnityContext`** → `EC_Game.SetServerTime` + `EC_ManMessage.PostMessage` (queue không thread-safe). |
| Cuối **`CECGameRun::CreateWorld`** — set lại phase theo `GetServerLocalTime` | `LoginScreenUI.OnEnterWorldComplete` — gọi **`EC_Game.SyncSunMoonTimeOfDayFromServerClock()`**. |
**Lý do `PostToUnityContext`:** `NetworkManager` nhận socket trong **`Task.Run`**; gọi trực tiếp `SetServerTime` / `CECSunMoon` / `FindFirstObjectByType` trên luồng đó là **không an toàn** với Unity API và **race** với `EC_ManMessage` queue.
---
## 4. `CECSunMoon` (Unity)
- **`SetTimeOfTheDay`**: chuẩn hóa `[0,1)`, gán `m_vTimeOfTheDay`, gọi **`RefreshDayNightFactorsFromPhase()`**.
- **`Update`**: tăng phase theo `TIME_SCALE` (giống hướng PC), sau đó **`RefreshDayNightFactorsFromPhase()`**.
- **`RefreshDayNightFactorsFromPhase`**: port khối **`m_fDNFactor` / `m_fDNFactorDest`** từ `EC_SunMoon::UpdateWithTime` (PC), phục vụ logic minimap (ngày / sáng / hoàng hôn / đêm).
- Getter: **`GetTimeOfTheDay()`**, **`GetDNFactor()`**, **`GetDNFactorDest()`**.
---
## 5. Minimap — chuỗi canh giờ giống PC (`DlgMiniMap.cpp`)
Trên PC (trong `Render`), hint đồng hồ:
- `nTimeIndex = (int)(12 * GetTimeOfTheDay() + 0.5) % 12`
- `GetStringFromTable(1330 + nTimeIndex)` — tên **canh địa** (mười nhị canh).
- `int(GetTimeOfTheDay() * 24)` — số “giờ” hiển thị (theo phase game, không phải `tm_hour` server).
- `Format(GetStringFromTable(604), ...)` — template chuỗi (thường dạng `%s` + `%d` / tương đương).
- **`FixFrame(nTimeItem)`** — icon theo `fDNFactor` / `fDNFactorDest`: **TIME_DAY (0), TIME_MORNING (1), TIME_DUSK (2), TIME_NIGHT (3)**.
**Unity — `CDlgMiniMap.cs`:**
- **`UpdateSystemClockFromPcMiniMapLogic()`** mỗi `Update`, cùng công thức trên.
- SerializeField: **`_txtSystemTime`** (TMP), **`_imgSystemTime`** + **`_systemTimeSprites`** (4 sprite đúng thứ tự enum PC).
- Nếu chưa có `CECGameUIMan` / thiếu string: fallback tên **子…亥** và format **`{0}{1}时)`**.
**Editor:** gán TMP / Image / sprites trên prefab minimap để thấy chữ và icon; không gán thì không crash, chỉ không cập nhật UI tương ứng.
---
## 6. File chính liên quan
| Vai trò | Đường dẫn (Unity) |
|--------|---------------------|
| Thời gian server + phase | `Assets/PerfectWorld/Scripts/MainFiles/EC_Game.Time.cs` |
| SunMoon + DN factor | `Assets/PerfectWorld/Scripts/World/CECSunMoon.cs` |
| Nhận `SERVER_TIME` (main thread) | `Assets/PerfectWorld/Scripts/Network/CSNetwork/GameSession.cs` |
| Vào world — sync lại phase | `Assets/PerfectWorld/Scripts/UI/Login/LoginScreenUI.cs` (`OnEnterWorldComplete`) |
| UI canh giờ minimap | `Assets/PerfectWorld/Scripts/UI/MiniMap/CDlgMiniMap.cs` |
**PC tham chiếu:** `EC_Game.cpp` (`SetServerTime`, `GetServerLocalTime`), `EC_GameRun.cpp` (`MSG_SERVERTIME`, cuối `CreateWorld`), `Network/EC_GameDataPrtc.cpp` (`SERVER_TIME`), `DlgMiniMap.cpp` (đoạn `GetTimeOfTheDay` / string 604 / 1330), `EC_SunMoon.cpp` (`UpdateWithTime` — phần DN factor).
---
*Tài liệu được tạo để handoff nhanh; cập nhật khi đổi protocol bias (phút vs giây) hoặc khi marshal toàn bộ `HandleServerDataSend` lên main thread.*
@@ -0,0 +1,54 @@
# Chat: TMP_Dropdown kênh + MRU Whisper
Tóm tắt hành vi dropdown gắn với `ChatInputHandler` (`Assets/Scripts/ChatInputHandler.cs`).
## Vị trí cấu hình
- **Component:** `ChatInputHandler` trên GameObject chat (cùng `ChatSystemlUI` nếu có).
- **Field Inspector:** `recentWhisperDropdown` — kéo **TMP_Dropdown** từ Hierarchy vào.
- **Giới hạn MRU:** `maxRecentWhisperTargets` (mặc định **5**, tối thiểu áp dụng **1** qua `MaxRecentWhisperTargets`).
## Hai chế độ nội dung dropdown
### 1. Không ở kênh Whisper
Khi `m_currentChannel != GP_CHAT_WHISPER`, dropdown liệt kê **4 kênh** (thứ tự cố định):
| Thứ tự | `ChatChannel` | Nhãn hiển thị |
|--------|----------------------|---------------|
| 0 | `GP_CHAT_LOCAL` | Local |
| 1 | `GP_CHAT_TEAM` | Team |
| 2 | `GP_CHAT_FACTION` | Faction |
| 3 | `GP_CHAT_FARCRY` | World |
- Chọn một dòng → gọi `OnCommand_speakmode(kênh tương ứng)` (tương tự `channelButtons`).
- Đồng bộ selection: nếu kênh hiện tại **nằm** trong bốn kênh trên thì highlight đúng; nếu **không** (ví dụ Trade), caption mặc định về **Local** (index 0) cho đến khi người chọn lại.
### 2. Kênh Whisper (`GP_CHAT_WHISPER`)
- Dòng đầu: ký tự **—** (placeholder): không ép chọn MRU / giữ target đang gõ.
- Các dòng sau: tên người **MRU** (mới chat gần nhất lên trên).
- MRU chỉ **tăng/cập nhật** sau khi gửi whisper thành công (`SendPrivateChat``RecordRecentWhisper`).
- Khi đang ở kênh khác, MRU vẫn được cập nhật trong bộ nhớ; chuyển sang Whisper sẽ thấy danh sách trong dropdown.
## Tương tác & hệ thống
- **Kênh System:** `inputField` không nhập được; dropdown **không interactable** (`interactable = false`).
- **Các kênh khác (không System):** dropdown **interactable** (kể cả khi đang 4 kênh public hoặc Whisper).
- **Sự kiện:** `WhisperPlayerEvent` / `SetWhisperTarget` vẫn chuyển sang Whisper và đồng bộ dropdown (MRU).
## Hằng số / cấu trúc code (tham chiếu)
- `ChatDropdownChannelOrder`: mảng tĩnh 4 kênh public.
- `GetChannelDropdownLabel(ChatChannel)`: nhãn dropdown cho từng kênh.
- `RebuildChatDropdownOptions()`: rebuild theo chế độ hiện tại (public vs whisper).
- `RecordRecentWhisper`: cập nhật list MRU; chỉ `RebuildChatDropdownOptions()` khi đang ở Whisper.
## Lưu ý
- MRU **không** persist (PlayerPrefs / file) — chỉ trong phiên chơi.
- Tên field SerializeField vẫn là `recentWhisperDropdown` để tránh gãy reference prefab cũ.
## Đường dẫn file
- Logic: `Assets/Scripts/ChatInputHandler.cs`
+118
View File
@@ -0,0 +1,118 @@
# Chat Emoji Progress Summary
## Scope
- Muc tieu: hoan thien luong chat co emoji (input -> send -> render TMP) cho Unity client.
- Ngu canh: du an dang chuyen doi tu C++ PC sang C# Unity, uu tien giong logic goc.
## Bugs Da Tim Thay Va Da Sua
- `AUICommon.FilterEmotionSet` bi crash `NullReferenceException`.
- Nguyen nhan: dung `Dictionary` enumerator sai (doc `Current` truoc `MoveNext`).
- Da sua: doi sang `while (it.MoveNext())`, them check null item.
- `UnmarshalEditBoxText` khong extract item nao.
- Nguyen nhan: `itemMask = 0`.
- Da sua: dung `ITEMMASK_ALL = ~0`.
- `EditBoxItemsSet` dung range item-code sai (`\u0001..\u0010`) gay vo toan bo parse/lookup.
- Nguyen nhan: constants local khong dong bo voi `AUICommon.AUICOMMON_ITEM_CODE_START/END` (`\uE000..\uE3FF`).
- Da sua:
- bo constants sai trong `EditBoxItemsSet`,
- dung range cua `AUICommon` cho check/next-char.
- Hien thi raw wire text trong TMP: `` + `<0><0:41>`.
- Nguyen nhan: wire format khong phai TMP rich text.
- Da sua:
- input chen `<sprite name="...">` neu resolve duoc,
- truoc khi gui convert `<sprite>` -> wire.
## Thay Doi Ve Architecture Emoji/TMP
- Ho tro nhieu bo emoji (9 bo) bang mapping theo set:
- `EmotionSetSpriteAssetEntry` (EmotionSetIndex -> TMP_SpriteAsset).
- `EmotionLibrarySpriteMap.SpriteAssetsPerEmotionSet`.
- `EmotionLibrarySpriteMap` da cap nhat:
- tra dung sprite asset theo `emotionSet`,
- fallback ve `TmpSpriteAsset` de tuong thich cau hinh cu,
- fallback ten `cell_XXXX` cho asset cu.
- Converter atlas dat ten sprite theo namespace:
- tu `cell_XXXX` -> `s{N}_cell_XXXX` (N = EmotionSetIndex),
- tranh trung ten khi gom nhieu pack vao TMP.
## Cac File Da Duoc Them/Sua
- Sua:
- `Assets/PerfectWorld/Scripts/Network/CSNetwork/AUICommon.cs`
- `Assets/Scripts/ChatInputHandler.cs`
- `Assets/Scripts/CECUIManager.cs`
- `Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySpriteMap.cs`
- `Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterCore.cs`
- `Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverterWindow.cs`
- Them moi:
- `Assets/PerfectWorld/Scripts/Chat/ChatInputTmpSpriteConverter.cs`
- `Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionSetSpriteAssetEntry.cs`
- `Assets/PerfectWorld/Scripts/Editor/EmotionLibrarySpriteMapEditor.cs`
- `Assets/Scripts/ChatEmojiQuickTest.cs`
## Class Test Nhanh
- `ChatEmojiQuickTest` da duoc tao de test local:
- nhap text + chen emoji,
- send va hien thi len `TextMeshProUGUI`,
- mo phong luong: TMP tag -> wire -> TMP display.
## Viec Can Setup Trong Unity (Bat Buoc)
- `EmotionLibrarySpriteMap`:
- gan `Library`,
- sync va gan du `SpriteAssetsPerEmotionSet` cho 9 bo.
- TMP asset:
- dam bao sprite naming khop (`s{N}_cell_XXXX`),
- setup fallback sprite assets de resolve du tat ca bo khi dung `<sprite name>`.
- Input/Output TMP:
- co sprite asset va fallback phu hop.
## Ket Qua Ky Vong
- Khong con crash tai `FilterEmotionSet`.
- Khong con hien raw wire text khi da qua conversion dung luong.
- Co the nhap emoji, gui len, va hien thi icon tren `TextMeshProUGUI` (neu setup asset dung).
## Cap nhat bug nghiem trong: chon set khac van ra emoji set 1 (2026-04-09)
- Hien tuong:
- Co 9 bo emoji.
- Chon emoji o set 1 thi dung.
- Chon set 2..8 van render hinh cua set 1.
- Nguyen nhan goc:
- Tag TMP dang tao dang `<sprite name="cell_XXXX">` (hoac `<sprite index=...>`), khong chi dinh ro `TMP_SpriteAsset`.
- Khi nhieu sprite asset deu co cung ten `cell_XXXX`, TMP uu tien asset mac dinh/fallback dau tien => thuong la set 1.
- Cach sua da ap dung:
- Them `SpriteAssetName` vao `EmotionSpriteInfo`.
- `EmotionLibrarySpriteMap.TryGetSprite(...)` tra them ten asset theo tung `emotionSet` (`set.TmpSpriteAsset.name`).
- `EmotionTMPTagBuilder.BuildSpriteTag(...)` tao tag co kem asset:
- `<sprite="Emotions N" name="cell_XXXX">`
- hoac `<sprite="Emotions N" anim="start,end,fps">`
- hoac `<sprite="Emotions N" index=N>`.
- `AUICommon.ConvertEmotionsToTMP(...)` dung chung `EmotionTMPTagBuilder.BuildSpriteTag(info)` de tat ca luong render dong nhat.
- File da sua cho fix nay:
- `Assets/PerfectWorld/Scripts/Network/CSNetwork/AUICommon.cs`
- `Assets/PerfectWorld/Scripts/Chat/EmotionTMPTagBuilder.cs`
- `Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySpriteMap.cs`
- Dieu kien setup de hoat dong dung:
- Trong `EmotionLibrarySO`, moi `EmotionSetSnapshot` phai gan dung `TmpSpriteAsset` tuong ung tung set.
- Ten asset can on dinh, vi du `Emotions 0`, `Emotions 1`, ...; tag su dung chinh ten nay.
- Ket qua sau fix:
- Emoji duoc resolve dung theo `emotionSet`.
- Khong con tinh trang chon set khac nhung hien thi hinh cua set 1.
+144
View File
@@ -0,0 +1,144 @@
# Tóm tắt refactor Chat / Emotion (session)
Tài liệu này ghi lại các thay đổi code liên quan **Emotion → TMP**, **ChatInput / Emoji picker**, và **EmotionLibrarySpriteMap** trong kho `perfect-world-unity`.
---
## 1. `EmotionTMPTagBuilder` — nhập liệu chat
**Mục đích:** Chèn tag TMP (`<sprite name=…>`, `<sprite index=…>`, `<sprite anim=…>`) vào `TMP_InputField` khi người chơi chọn emoji.
**Đã làm:**
- `Assets/Scripts/ChatInputHandler.cs`
- Thêm `using BrewMonster.Scripts.Chat``BrewMonster.Scripts.Chat.EmotionData`.
- Thêm `[SerializeField] EmotionLibrarySpriteMap _spriteMap`.
- `InsertEmoji` → gọi `AppendEmotionWire` (buffer wire + refresh TMP) — xem **mục 7**.
- `Assets/PerfectWorld/Scripts/Chat/UI/EmojiPickerUI.cs`
- Bỏ Reflection gọi `InsertEmoji` qua `MethodInfo`.
- Gọi `ChatInputHandler.InsertEmoji` (nội bộ dùng wire + TMP display).
**Ghi chú Inspector:** Gán `EmotionLibrarySpriteMap` vào `ChatInputHandler` nếu dùng `InsertEmoji`; picker vẫn dùng `_emotionSpriteMap` riêng trên `EmojiPickerUI`.
---
## 2. `ChatEmotionDisplayPipeline` — cảnh báo spriteMap null
**Vấn đề:** Constructor luôn log warning dù `spriteMap` có giá trị (vì `Debug.LogWarning` nằm ngoài nhánh `else`).
**Đã làm:**
- Chỉ log khi `spriteMap == null` (dùng `StubEmotionSpriteMap`).
- `SetSpriteMap(null)` ghi log rõ: cần gán `EmotionLibrarySpriteMap` trên `CECUIManager`.
**Luồng đúng:** `CECUIManager.Awake``gameUI.SetEmotionSpriteMap(_emotionLibrarySpriteMap)` — nếu field **không gán trên Inspector** thì map vẫn null và fallback stub.
---
## 3. `EmotionLibrarySpriteMap` — nhiều bộ atlas / một TMP mỗi bộ
**Vấn đề ban đầu:** Chỉ có một `TMP_SpriteAsset` → tra index sai khi có nhiều bộ (ví dụ 9 atlas).
**Tiến hóa thiết kế:**
1. **Tạm thời:** Thêm list `PerSetTmpSpriteAssets` (SetIndex → TMP_SpriteAsset) + fallback.
2. **Chốt:** Di chuyển `TMP_SpriteAsset` vào **`EmotionSetSnapshot`** trong `EmotionLibrarySO` — mỗi snapshot một atlas + một `TmpSpriteAsset` cùng chỗ dữ liệu.
3. Xóa list trung gian trên `EmotionLibrarySpriteMap`; chỉ còn `Library`, `PreferSpriteNameTag`, `DefaultAnimFps`.
**File:** `Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySO.cs` — field `TmpSpriteAsset` trên `EmotionSetSnapshot`.
---
## 4. Index sprite: không còn tra `spriteCharacterTable` theo tên
**Yêu cầu:** Atlas cắt **trái → phải, trên → dưới**; sub-sprite đặt tên `cell_XXXX`.
**Đã làm:**
- Thay `FindSpriteIndex(TMP_SpriteAsset, name)` (duyệt `spriteCharacterTable`) bằng **`ParseCellIndex(string)`** — parse số sau prefix `cell_` → index tuyến tính khớp thứ tự cắt.
- Áp dụng cho:
- `PreferSpriteNameTag == false` (một frame): `<sprite index=N>`.
- Emoji động (nhiều frame): `start` / `end` từ frame đầu và frame cuối.
**Hệ quả:** Không phụ thuộc thứ tự entry trong `TMP_SpriteAsset.spriteCharacterTable` để khớp index; vẫn cần gán đúng **TMP Sprite Asset** trên `TextMeshProUGUI` chat để hiển thị.
---
## 5. Đường dẫn file chính
| File | Vai trò |
|------|--------|
| `Assets/PerfectWorld/Scripts/Chat/EmotionTMPTagBuilder.cs` | Build tag (dùng để khớp tag khi TMP → wire) |
| `Assets/Scripts/ChatInputHandler.cs` | `_chatWireBody`, `AppendEmotionWire` / `InsertEmoji`, đồng bộ input |
| `Assets/PerfectWorld/Scripts/Chat/UI/EmojiPickerUI.cs` | Gọi `InsertEmoji` khi chọn emoji |
| `Assets/PerfectWorld/Scripts/Chat/ChatEmotionDisplayPipeline.cs` | Cảnh báo / `SetSpriteMap` / `ConvertWireBodyToTmpDisplay` |
| `Assets/PerfectWorld/Scripts/Chat/ChatWireTmpCodec.cs` | Wire ↔ TMP body: marshal emotion, `TmpBodyToWire` |
| `Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySO.cs` | `EmotionSetSnapshot.TmpSpriteAsset` |
| `Assets/PerfectWorld/Scripts/Chat/EmotionData/EmotionLibrarySpriteMap.cs` | `TryGetSprite`, `ParseCellIndex` |
| `Assets/PerfectWorld/Scripts/Network/CSNetwork/AUICommon.cs` | `EditBoxItemBase.Serialize` (port C++), `EditBoxItemsSet` + PUA |
| `Assets/PerfectWorld/Scripts/UI/GamePlay/EC_GameUIMan.cs` | `ConvertWireChatBodyForDisplay` |
| `Assets/PerfectWorld/Scripts/Network/CSNetwork/GameSession.cs` | `ConvertWireBodyForChatPanel` (EventBus system) |
---
## 7. Chat wire (raw) vs TMP — gửi server & hiển thị (cập nhật)
**Mục đích:** Giữ **chuỗi wire** đúng protocol PC (`MarshalEditBoxText` / PUA + payload `<type><…>`) khi **gửi**; **ô nhập** chỉ hiển thị bản **TMP** (`<sprite>`). Khi **nhận**, tin từ server vẫn là wire; chuyển sang TMP ở pipeline hiển thị (và bổ sung chỗ chỉ đi EventBus).
**Đã làm:**
1. **`EditBoxItemBase.Serialize()`** (`AUICommon.cs`) — port từ `AUICommon.cpp` (coord / image / default gồm emotion). Trước đó trả rỗng → `MarshalEditBoxText` không nối payload sau ký tự PUA.
2. **`EditBoxItemsSet.IsEditboxItemCode`** — dùng `AUICommon.IsEditboxItemCode` (PUA `\uE000…`) thay khoảng `\u0001…\u0010` lệch với `AppendItem`.
3. **`ChatWireTmpCodec.cs`** (mới)
- `BuildMarshaledEmotionWire(set, index)` — một đoạn emotion đã marshal.
- `TmpBodyToWire` — parse `<sprite…>`, khớp tag với output của `EmotionTMPTagBuilder` (brute-force set/index trong giới hạn).
- `WireBodyToTmpForDisplay` — ủy quyền `ChatEmotionDisplayPipeline.ConvertWireBodyToTmpDisplay`.
4. **`ChatEmotionDisplayPipeline.ConvertWireBodyToTmpDisplay`** (static) — `FilterEmotionSet` + `ConvertInlineItemsToTmp` (dùng cho input và helper).
5. **`ChatInputHandler`**
- Field **`_chatWireBody`**: nội dung thân tin nhắn ở dạng wire gửi server (sau `FilterBadWords` vẫn cập nhật lại).
- `onValueChanged``TmpBodyToWire` trên phần thân (sau prefix kênh / whisper).
- `ParseAndSendMessage` / whisper → `SendChat` / `SendPrivateChat` dùng **`_chatWireBody`**, không lấy từ chuỗi TMP.
- Emoji: `AppendEmotionWire` / `InsertEmoji` — nối wire + `RefreshInputDisplayFromWire()` (prefix + `WireBodyToTmpForDisplay`, SuperFarCry dùng `GameSession.SUPER_FAR_CRY_EMOTION_SET`).
6. **`CECGameUIMan.ConvertWireChatBodyForDisplay`** — wire → TMP tái sử dụng cho UI.
7. **`GameSession`**
- **`ConvertWireBodyForChatPanel`**: wire → TMP cho các nhánh **chỉ** `EventBus.Publish(ChatMessageEvent)` (system/broadcast), trước đây có thể đẩy raw wire lên panel.
- **`OnPrtcWorldChat` / `OnPrtcPrivateChat` / player `OnPrtcChatMessage`**: tin người chơi vẫn qua **`AddChatMessage`** — wire→TMP đã có trong pipeline; **không** gọi thêm convert trên body (tránh double conversion). Comment trong code ghi rõ.
**Ghi chú:** Cần gán **`EmotionLibrarySpriteMap`** trên `ChatInputHandler` để TMP ↔ wire khớp emoji; không gán thì phần thân gần như text thường (emoji marshal không đầy đủ).
---
## 6. Việc cần làm trên Editor (một lần)
- Gán `EmotionLibrarySpriteMap` (hoặc `EmotionLibrarySO`) đúng vào `CECUIManager` và các chỗ SerializeField liên quan.
- Trong `EmotionLibrary` asset: mỗi `EmotionSetSnapshot` trong `Sets` → gán **`Tmp Sprite Asset`** tương ứng từng atlas (nếu dùng cho tooling / tài liệu; logic index hiện tại dựa trên tên `cell_XXXX`).
- Trên `TextMeshProUGUI` chat: gán **Sprite Asset** khớp atlas đang dùng.
---
## 8. Hiển thị wire `<0><set:index>` & crash khi nhận tin (cập nhật phiên tháng 4/2026)
### 8.1 Vấn đề hiển thị literal `<0><0:23>` thay vì TMP
- **Nguyên nhân:** Chuỗi wire đúng chuẩn PC dùng ký tự **PUA** (`\uE000``\uE3FF`) + payload `Serialize()` dạng `<0><set:index>`. Nếu thiếu PUA (server/encoding, hoặc chỉ còn phần serialize), `UnmarshalEditBoxText` không tạo item → `ConvertInlineItemsToTmp` trả về nguyên chuỗi → TMP in literal.
- **Đã làm:** Trong `ChatEmotionDisplayPipeline.ConvertInlineItemsToTmp`, sau bước unmarshal/TMP thông thường, thêm **fallback regex** `<0><(\d+):(\d+)>`: thay bằng tag TMP qua `IEmotionSpriteMap.TryGetSprite` + `EmotionTMPTagBuilder.BuildSpriteTag` (cùng logic với emotion đã marshal đầy đủ).
- **Vị trí file:** `Assets/PerfectWorld/Scripts/Chat/ChatEmotionDisplayPipeline.cs` (`LooseMarshaledEmotionRegex`, `ReplaceLooseMarshaledEmotionTags`).
- **Phương án kiến trúc:** Ưu tiên sửa **một chỗ** trong pipeline (`ChatEmotionDisplayPipeline` / `CECGameUIMan.ConvertWireChatBodyForDisplay`), không chỉ trong `ChatMessageView.Bind`, để mini chat và panel chung một luồng.
### 8.2 Trùng `ChatMessageEvent` nhánh NPC
- **Vấn đề:** `OnPrtcChatMessage` (NPC) gọi `AddChatMessage` (đã publish bản wire→TMP), sau đó còn `EventBus.Publish(new ChatMessageEvent(message))` với `message` vẫn chứa **thân wire** trong format string → trùng tin / tin thứ hai chưa convert.
- **Đã làm:** Xóa publish thứ hai; giữ comment giải thích.
- **Vị trí file:** `Assets/PerfectWorld/Scripts/Network/CSNetwork/GameSession.cs` (nhánh `ISNPCID`).
### 8.3 Crash khi nhận tin có emoji (NullReferenceException)
- **Nguyên nhân:** Trong `AUICommon.FilterEmotionSet`, vòng lặp dùng `Dictionary` enumerator truy cập `it.Current.Value` **trước** `it.MoveNext()` — trước lần `MoveNext()` đầu tiên, `Current.Value``null``pItem.GetType()` crash. Tin không có emoji (`GetItemCount() == 0`) không vào vòng lặp nên bug ẩn; tin nhận từ server **có** PUA emotion thì kích hoạt ngay.
- **Đã làm:** Đổi sang `while (it.MoveNext()) { ... }``if (pItem == null) continue;` (cùng pattern với `AUI_FilterEditboxItem`).
- **Vị trí file:** `Assets/PerfectWorld/Scripts/Network/CSNetwork/AUICommon.cs` — hàm `FilterEmotionSet`.
---
*Cập nhật: tháng 4/2026 — mục 7 (chat wire vs TMP); mục 8 (fallback loose tag, NPC event, fix FilterEmotionSet).*
*Last update: April 2026 — added section 8 (loose tag fallback, NPC duplicate event, FilterEmotionSet iterator fix).*
+120
View File
@@ -0,0 +1,120 @@
# Combo Skill Quickbar UI Summary (2026-04-17)
## Mục tiêu
Đồng bộ toàn bộ luồng combo skill giữa:
- danh sách chọn combo trong assign UI,
- assign slot (`AUIToggleAssignSlot`),
- quickbar runtime (`CDlgQuickBar`),
- icon/cooldown overlay (`AUIImagePicture`, `AUIClockIcon`),
để `SCT_SKILLGRP` hiển thị đúng icon, giữ đúng metadata group, và assign được từ UI.
## 1) Nâng cấp model hiển thị `SKILLGRP` trên slot
### File: `Assets/PerfectWorld/Scripts/UI/GamePlay/AUIImagePicture.cs`
- Thêm lưu trạng thái group:
- `_skillGroupIndex` (group hiện hành),
- `_skillGroupIndexes` (danh sách group khả dụng trên slot),
- `SkillGroupIndex`, `SkillGroupIndexes`.
- Thêm API:
- `SetSkillGroupIcons(IReadOnlyList<int> groupIndexes, int preferredGroupIndex = -1)`.
- Giữ API cũ `SetSkillGroupIcon(...)` nhưng chuyển sang gọi API mới.
- Chuẩn hóa resolve icon theo thứ tự:
1) `(nIcon + 1).ToString()`,
2) `nIcon.ToString()`,
3) `DefaultComboIcon = "爱你"`.
- `Clear()` reset toàn bộ metadata combo + reset cooldown state.
### File: `Assets/PerfectWorld/Scripts/UI/GamePlay/AUIToggleAssignSlot.cs`
- Thêm:
- `_skillGroupIndex`,
- `_skillGroupIndexes`,
- `GetSkillGroupIndexForAssign()`,
- `GetSkillGroupIndexesForAssign()`,
- `SetSkillGroupIconsForAssign(...)`.
- `SetSkillGroupIconForAssign(...)` map sang API mới (backward compatible).
- Có check nullable cho `GetVideoSettings()` (`EC_VIDEO_SETTING?`) để tránh lỗi CS1061.
## 2) Clock/icon state cho combo group
### File: `Assets/PerfectWorld/Scripts/UI/GamePlay/SkillUI/AUIClockIcon.cs`
- Bổ sung metadata bind:
- `BindSkillGroup(int groupIndex)`,
- `GetBoundSkillGroup()`,
- `ClearSkillGroupBinding()`.
- `UpdateClockIcon()` thêm guard `null``range <= 0` để an toàn runtime.
## 3) Update logic `SCT_SKILLGRP` khi render shortcut
### File: `Assets/PerfectWorld/Scripts/UI/GamePlay/CdlgQuickBar.cs`
- Nhánh `SCT_SKILLGRP`:
- lấy `groupIndex` từ `CECSCSkillGrp`,
- gom danh sách `groupIndexes`:
- ưu tiên group hiện tại từ shortcut,
- cộng thêm các combo có `nIcon > 0`,
- gọi `pCell.SetSkillGroupIcons(groupIndexes, groupIndex)`.
- Mỗi vòng slot reset:
- `pSkill = null`,
- `pClock.ClearSkillGroupBinding()`,
để tránh bleed state giữa các slot.
### File: `Assets/PerfectWorld/Scripts/UI/SkillUI/DlgAssignSlots.cs`
- Nhánh `SCT_SKILLGRP` cũng dùng danh sách nhiều group và gọi:
- `assignSlot.SetSkillGroupIconsForAssign(groupIndexes, groupIndex)`.
- Bỏ placeholder `"unknown"`; fallback đúng icon combo group.
## 4) Fix assign combo từ `AUIToggleSkillAssign` -> `AUIToggleAssignSlot`
### Vấn đề
- `DlgAssignSub` chỉ hook toggle ở `ptSkillSlotList` (skill thường), chưa hook `psSkillSlotList` (combo list).
- Combo trong `ShowSkillGrp()` chưa có id dùng cho assign event nên không tạo được shortcut group.
### Sửa
#### File: `Assets/PerfectWorld/Scripts/UI/SkillUI/DlgAssignSub.cs`
- Hook/Unhook sự kiện toggle cho cả `psSkillSlotList`.
- `ShowSkillGrp()`:
- reset `_otherSkillIndex = 0` mỗi lần render,
- set icon combo group,
- encode `groupIndex` thành `skillID` âm: `-(groupIndex + 1)` để tái sử dụng `AssignSkillSelectionChangedEvent`.
#### File: `Assets/PerfectWorld/Scripts/UI/SkillUI/DlgAssignSlots.cs`
- Thêm pending combo state:
- `_pendingComboGroupIndex`,
- `IsPendingComboAssign()`,
- `ClearPendingComboAssign()`.
- Thêm `CreateSkillGroupShortcut()` dùng:
- `a_pSCS[currentListIndex].CreateSkillGroupShortcut(currentSelectedSlotIndex, _pendingComboGroupIndex)`.
- `OnAssignSkillSelectionChanged(...)`:
- nếu `skillID < 0` -> decode thành combo group và assign dạng `SCT_SKILLGRP`.
- `OnClickedAssignSlot(...)` xử lý pending combo giống skill/action.
- `OnAssignSkillCommitted(...)`:
- nếu event `skillID < 0`, set lại icon combo group cho slot vừa assign và uncheck toggle.
## 5) Nullable/build fixes đã xử lý
- `Assets/PerfectWorld/Scripts/UI/GamePlay/AUIToggleAssignSlot.cs`
- `Assets/PerfectWorld/Scripts/UI/GamePlay/AUIImagePicture.cs`
Đã đổi các truy cập `setting.comboSkill` sang mẫu an toàn:
- `EC_VIDEO_SETTING? setting = ...;`
- `if (!setting.HasValue || setting.Value.comboSkill == null) return ...;`
- dùng local `comboSkills = setting.Value.comboSkill`.
## 6) Kết quả hiện tại
- Assign combo từ danh sách assign UI sang quickbar slot hoạt động lại.
- `SCT_SKILLGRP` hiển thị icon đúng (không còn placeholder sai).
- Slot có thể giữ metadata 1-nhiều group để phục vụ logic mở rộng.
- Luồng code không còn lỗi nullable CS1061 đã báo.
@@ -0,0 +1,164 @@
# Name plate height: `EC_Player::RenderName` (PC) vs Unity `UIPlayer`
This note explains how the **vertical anchor** for the player name / pate ("名牌") was derived from the original **C++** client and what the **Unity C#** port does instead.
## Source locations (PC)
| Piece | File | Approx. lines | Role |
|--------|------|----------------|------|
| Screen-space draw & layout | `CElementClient/EC_Player.cpp` | `RenderName` ~59296274 | Uses `m_PateContent` (2D base X/Y, depth Z), scales with `fScale`, stacks title/name/icons in **pixels**. |
| **World-space anchor → screen** | Same file | `FillPateContent` ~63916456 | Computes **3D point `vPos`** above the character, projects to screen, fills `m_PateContent`. |
So: **height above the character is decided in `FillPateContent`, not inside the name-drawing math at line 5937.** `RenderName` starts with `y = m_PateContent.iCurY - 20*fScale` and horizontal centering from `iBaseX`.
---
## C++: `FillPateContent` — 3D anchor `vPos`
Logic summary (in order):
1. **Booth** (`m_iBoothState == 2` + booth model AABB):
`vPos = m_aabb.Center + Y * boothCHAABB.Extents.y * 1.15f`
2. **Chariot** (`IsInChariot()`):
`vPos = m_aabb.Center + Y * dummyModelAABB.Extents.y * 2.f`
3. **Default** (most cases):
`vPos = m_aabb.Center + g_vAxisY * m_aabb.Extents.y`
→ top of the **logical** player AABB (`m_aabb`), same idea as "center + full height half-extent" on Y.
4. **Male** (`GetGender() == GENDER_MALE`):
`vPos.y += 0.1f`
Comment in source: 男模型比较高,拉近时名字容易嵌到身体里面 — male model reads taller; when zooming in the name tends to clip into the body, so lift slightly.
5. **Riding pet** (`IsRidingOnPet() && m_pPetModel`):
Replace with pick AABB top:
`vPos = pickAABB.Center + (0, pickAABB.Extents.y, 0)`
(`GetPlayerPickAABB()` — skin model AABB when available, else `m_aabb`.)
6. **Sitting** + race `RACE_GHOST` or `RACE_OBORO`:
Extra `vPos.y += m_aabb.Extents.y * scaleRatio[race][gender]` (table in source).
7. **Flying** + `RACE_OBORO`:
`vPos.y += m_aabb.Extents.y * 0.5f`
Then **`Transform(vPos → vScrPos)`** (world to screen). If depth out of range, pate is hidden. Otherwise:
- `iBaseX = (int)vScrPos.x`
- `iBaseY = (int)vScrPos.y - 10`
- `iCurY = iBaseY`
- `z = vScrPos.z` (used as depth for draw order)
## C++: `RenderName` — 2D layout
- Uses `m_PateContent.iBaseX`, `iCurY`, `z`.
- Example: `y = int(m_PateContent.iCurY - 20*fScale)` then `RegisterRender(..., y+2, ..., z)`.
- Name/title/team icons are laid out in **screen space** (centered on `iBaseX`), not in world space.
---
## Unity C#: `UIPlayer` + `PlayerVisual` (Player)
**Files**
- `Assets/PerfectWorld/Scripts/UI/UIPlayer.cs``LateUpdate` sets **`canvasRoot.position`** every frame via `NameplateWorldAnchor.TryGetWorldPosition`.
- `Assets/Scripts/PlayerVisual.cs``TryGetNamePlateAnchorWorld` — combined `SkinnedMeshRenderer` bounds top.
**Important**: reads `sharedMesh.bounds` (mesh local space) + `TransformPoint` + `lossyScale` to build world bounds manually — NOT `renderer.bounds` which may be stale right after `SetActive(true)`.
**Anchor priority for Player (world space)**
1. Skeleton hook: `CECPlayer.GetHook(headHookName)` (default `"HH_Head"`) + `headHookWorldOffset`.
2. Else if `preferRendererBounds` and `PlayerVisual.TryGetNamePlateAnchorWorld` succeeds: **top of combined SMR bounds** `(center.x, bounds.max.y, center.z)`.
3. Else: **logical AABB top**`m_aabb.Center` + `m_aabb.Extents.y` on Y (same structural idea as PC default `vPos`).
4. If AABB branch + `applyMaleHeadWorldOffsetForAabbOnly` + male: **`y += maleHeadWorldOffset`** (default `0.1f`, aligned with PC's +0.1 for male).
5. Always add **`extraWorldYOffset`** for tuning in the Editor.
**Billboard / facing camera** is intentionally **not** in `UIPlayer`; use `LookAtCamera` on the Canvas / parent, matching the split on PC (world point → then screen/UI drawing).
---
## Unity C#: `UINPC` + `NPCVisual` (NPC / Monster)
**Files**
- `Assets/PerfectWorld/Scripts/UI/GamePlay/UINPC.cs` — coroutine retry at init + **`LateUpdate`** per frame (after coroutine resolves).
- `Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs``TryGetNamePlateAnchorWorld` (SMR merged bounds) + `CacheTopBone` / `TryGetTopBoneWorld` (highest skeleton bone).
**Anchor priority for NPC/Monster (world space)**
1. **Skeleton hook** (`useNpcHeadHook = true`): `hostNpc.GetHook("HH_Head")` + `headHookWorldOffset` — follows animation every frame via `LateUpdate`. Best for models with a head hook.
2. **Cached top bone**: `NPCVisual.TryGetTopBoneWorld()` — bone with highest world-Y found when model is ready, cached by `CacheTopBone()`. Follows animation per-frame. Solves floating/flying monsters (e.g. 小星星) that have no `HH_Head`.
3. **SMR merged bounds**: `NPCVisual.TryGetNamePlateAnchorWorld()` — static rest-pose bounds.
4. **Root fallback**: `hostNpc.transform.position + up * fallbackHeightAboveRoot`.
**`CacheTopBone` flow**
```
UINPC coroutine → SMR/hook ready → _nameplateAnchor.RefreshNpcTopBone()
→ NPCVisual.CacheTopBone()
→ scan all SkinnedMeshRenderer.bones (HashSet dedup)
→ find bone with highest world.y → store as _cachedTopBone
LateUpdate every frame → TryGetTopBoneWorld() → _cachedTopBone.position + extraWorldYOffset
```
**Why `LateUpdate` was added to `UINPC`**
Without it, canvas position is set **once at init** (rest-pose height). When animation lifts the body (e.g. 小星星 flying +1.5 units above pivot), the nameplate stays at the init position → overlap. `LateUpdate` re-reads the cached top bone's current world position every frame → nameplate follows animation.
---
## Unity C#: `CECMatter` (Item / Mine drop)
**File**: `Assets/PerfectWorld/Scripts/Objet/CECMatter.cs`
Matter objects use a **self-contained bounds helper** — no `NameplateWorldAnchor` — because they use `MeshRenderer` (static mesh), not `SkinnedMeshRenderer`.
**Root cause of old bug**: `textObject.localPosition = new Vector3(0, 0.6f, 0)` — fixed offset, wrong for multi-mesh models. Example: 噬人花 has mesh `_0` (tall) + mesh `_1` (short) → text appeared at small-mesh height, clipped by the tall mesh.
**Fix**: `TryGetCombinedRendererBounds` gathers all child `Renderer` components:
- `MeshRenderer` → mesh from `MeshFilter.sharedMesh`
- `SkinnedMeshRenderer``smr.sharedMesh`
- Same `sharedMesh.bounds + TransformPoint + lossyScale` approach as `PlayerVisual` / `NPCVisual`
**Anchor**: `(combinedBounds.center.x, combinedBounds.max.y + 0.05f, combinedBounds.center.z)``InverseTransformPoint``localPosition`.
Fallback (no renderer): `y = 0.6f` + `Debug.LogWarning` `[Cuong]`.
**PC equivalent** (`EC_Matter::RenderName` ~908): `vPos = aabb.Center + Y * (aabb.Extents.y * 1.3f)` — full model AABB, same intent as combined bounds.
---
## Mapping: PC vs Unity
| Topic | PC (`FillPateContent` / `RenderName`) | Unity |
|--------|--------------------------------------|-------|
| Coordinate space | World point → **screen** (2D UI pate) | **World** position on `canvasRoot` (World Space Canvas) |
| Player default height | `m_aabb.Center + Y * m_aabb.Extents.y` (+ male +0.1) | Same AABB top when hook + mesh fail; male +0.1 **only on AABB fallback** |
| Player skin / mesh | Used indirectly via `GetPlayerPickAABB()` when **riding pet** | Optional **SMR bounds** path (`PlayerVisual`) before falling back to `m_aabb` |
| Player skeleton head | **Not** used in `FillPateContent` | **`HH_Head` hook** preferred when available |
| NPC/Monster height | `EC_NPC::FillPateContent``GetCHAABB` or AABB | Priority: hook → **cached top bone** → SMR bounds → root offset |
| NPC follows animation | N/A (PC projected each frame) | `LateUpdate` reads top bone per frame |
| Matter height | `EC_Matter::RenderName` — full model AABB * 1.3 | Combined `Renderer.bounds` (all meshes), `max.y + 0.05` |
| Booth / chariot | Special `vPos` branches | **Not** ported |
| Sitting / Oboro flying | Extra Y from race/gender tables | **Not** ported |
| Male offset | Always after default `vPos` | Only on **AABB fallback** |
| Visibility | Depth test via projected `z` | `SetVisible`, camera culling, layer |
| Fine vertical nudge | `iBaseY = vScrPos.y - 10`, `y = iCurY - 20*fScale` | `extraWorldYOffset`, `headHookWorldOffset` |
---
## Practical notes for designers / programmers
- Unity places the **whole** world Canvas at one world point; **name/chat/HP** offsets are **local** under `canvasRoot` (prefab layout).
- PC stacks multiple pate rows in **pixels** from `iCurY`; that is replaced by RectTransform hierarchy + `LookAtCamera`.
- If behaviour should match PC **exactly** for booth/chariot/mount/sitting/flying races, those branches from `FillPateContent` would need explicit ports.
- For NPC/Monster with no `HH_Head` hook, ensure `QueueLoadNPCModel` triggers `RefreshWorldNameplatePosition()` so the top-bone scan runs after the first animation frame.
---
## References (code)
- PC: `CElementClient/EC_Player.cpp``CECPlayer::RenderName` (~5929), `CECPlayer::FillPateContent` (~6391).
- PC: `CElementClient/EC_NPC.cpp``CECNpc::RenderName`, `CECNpc::FillPateContent`.
- PC: `CElementClient/EC_Matter.cpp``CECMatter::RenderName` (~889).
- Unity Player: `Assets/PerfectWorld/Scripts/UI/UIPlayer.cs`, `Assets/Scripts/PlayerVisual.cs`.
- Unity NPC/Monster: `Assets/PerfectWorld/Scripts/UI/GamePlay/UINPC.cs`, `Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs`, `Assets/PerfectWorld/Scripts/UI/GamePlay/NameplateWorldAnchor.cs`.
- Unity Matter: `Assets/PerfectWorld/Scripts/Objet/CECMatter.cs`.
@@ -0,0 +1,138 @@
# Tóm tắt nameplate world-space — các file liên quan
Tài liệu tóm tắt **toàn bộ công việc** cho neo tên / HUD trên đầu (world-space Canvas), tập trung vào **`NameplateWorldAnchor`** (MonoBehaviour) làm một chỗ chỉnh độ cao / neo 3D.
---
## Danh sách file chính
| # | Đường dẫn | Vai trò |
|---|-----------|---------|
| 1 | `Assets/PerfectWorld/Scripts/UI/GamePlay/NameplateWorldAnchor.cs` | **MonoBehaviour**: toàn bộ logic neo 3D (player + NPC/Monster); `TryGetWorldPosition`, `CacheRefs`, `RefreshNpcTopBone`. |
| 2 | `Assets/PerfectWorld/Scripts/UI/UIPlayer.cs` | Player UI: `LateUpdate` gán `canvasRoot.position` mỗi frame; gọi `NameplateWorldAnchor.TryGetWorldPosition`. `[RequireComponent(typeof(NameplateWorldAnchor))]`. |
| 3 | `Assets/PerfectWorld/Scripts/UI/GamePlay/UINPC.cs` | NPC/Monster UI: coroutine retry ở `Start` + **`LateUpdate`** mỗi frame sau khi coroutine hoàn tất; gọi `NameplateWorldAnchor.TryGetWorldPosition`. `[RequireComponent(typeof(NameplateWorldAnchor))]`. |
| 4 | `Assets/Scripts/PlayerVisual.cs` | Player: `TryGetNamePlateAnchorWorld` — gộp bounds `SkinnedMeshRenderer` (dùng `sharedMesh.bounds`). |
| 5 | `Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs` | NPC/Monster: `TryGetNamePlateAnchorWorld` (SMR gộp) + `CacheTopBone` / `TryGetTopBoneWorld` (bone cao nhất). |
| 6 | `Assets/PerfectWorld/Scripts/Objet/CECMatter.cs` | Matter (item/mine): neo tên bằng `TryGetCombinedRendererBounds` (gộp mọi `Renderer`). Không dùng `NameplateWorldAnchor`. |
**Prefab:** GameObject "UIPlayer" bên trong `PlayerPrefab`, `MonsterPrefab`, `NPCServer.prefab` đã có cả `UIPlayer.cs` / `UINPC.cs` lẫn `NameplateWorldAnchor` được serialize sẵn.
---
## 1. `NameplateWorldAnchor.cs` (MonoBehaviour)
Gắn trên object chứa `UIPlayer` hoặc `UINPC` (cùng transform hoặc cha).
- **`CacheRefs`**: tự tìm `CECPlayer` / `CECNPC` / `PlayerVisual` / `NPCVisual` từ hierarchy nếu chưa gán.
- **`RefreshNpcTopBone`**: gọi `npcVisual.CacheTopBone()` — trigger scan xương sau khi model sẵn sàng.
- **`TryGetWorldPosition(out Vector3 worldPos)`**:
-**`CECPlayer`** → nhánh **player**:
1. Hook xương **`HH_Head`** (`useHeadSkeletonHook`, `headHookName`, `headHookWorldOffset`).
2. `PlayerVisual.TryGetNamePlateAnchorWorld` (SMR bounds).
3. Fallback đỉnh **`m_aabb`** + male offset + `extraWorldYOffset`.
-**`CECNPC`** → nhánh **NPC/Monster** (ưu tiên theo thứ tự):
1. **Hook xương** (`useNpcHeadHook`): `hostNpc.GetHook("HH_Head")` — theo animation mỗi frame.
2. **Bone cao nhất** (`NPCVisual.TryGetTopBoneWorld`): bone đã cache từ `CacheTopBone`. Dùng khi không có `HH_Head`, giải quyết NPC/Monster bay/nổi (vd: 小星星).
3. **SMR merged bounds** (`NPCVisual.TryGetNamePlateAnchorWorld`): rest-pose bounds.
4. **Root fallback**: `hostNpc.transform.position + up * fallbackHeightAboveRoot`.
- Luôn cộng `extraWorldYOffset`.
→ Một class duy nhất chỉnh độ cao / neo; UI chỉ đọc điểm world và đặt Canvas.
---
## 2. `UIPlayer.cs`
- Giữ reference **`NameplateWorldAnchor`** (auto-find nếu null).
- **`LateUpdate`** mỗi frame: `nameplateAnchor.TryGetWorldPosition``canvasRoot.position`.
- Các field hook / SMR / AABB / offset đã chuyển sang **`NameplateWorldAnchor`** trên cùng prefab.
---
## 3. `UINPC.cs`
- Giữ **`_canvasRoot`** cho world HUD; mọi offset / fallback NPC nằm trên **`NameplateWorldAnchor`**.
- **`LateUpdate`** mỗi frame *(thêm mới)*: chỉ chạy sau khi `_initialNameplateRoutine == null` (coroutine kết thúc); gọi `TryGetWorldPosition``ApplyCanvasRootLocalPosition`.
→ Cho phép nameplate theo animation (vd: xương đầu di chuyển khi nhân vật bay/nổi).
- `Start()` dùng coroutine defer/retry để chờ model/SMR load xong rồi set vị trí lần đầu.
- Khi model load xong từ `CECNPC.QueueLoadNPCModel`, gọi `RefreshWorldNameplatePosition()` để restart coroutine.
- Khi coroutine xác nhận model sẵn sàng (SMR/hook/top-bone ready), gọi thêm `_nameplateAnchor.RefreshNpcTopBone()` để cache xương cao nhất.
- Set theo **local position** (convert từ world bằng `parent.InverseTransformPoint`).
---
## 4. `PlayerVisual.cs`
- **`TryGetNamePlateAnchorWorld`**: gộp bounds tất cả `SkinnedMeshRenderer` con.
- Đọc `sharedMesh.bounds` (mesh local space) + `TransformPoint(center)` + `lossyScale` → world bounds thủ công.
**Không** dùng `renderer.bounds` (có thể stale ngay sau `SetActive(true)` trong cùng frame).
- Kết quả: `worldPos = (combinedBounds.center.x, combinedBounds.max.y, combinedBounds.center.z)`.
- `debugNamePlateBounds` log tiền tố **`[Cuong]`**.
---
## 5. `NPCVisual.cs`
- **`TryGetNamePlateAnchorWorld`**: cùng thuật toán SMR gộp như `PlayerVisual` (dùng `sharedMesh.bounds`).
- **`CacheTopBone()`** *(thêm mới)*:
- Lấy tất cả `SkinnedMeshRenderer` con → duyệt `smr.bones` (dedup bằng `HashSet<Transform>`).
- Tìm bone có `world.y` cao nhất → lưu vào `_cachedTopBone`.
- Log `[Cuong] [NPCVisual] CacheTopBone` khi `debugNamePlateBounds = true`.
- **`TryGetTopBoneWorld(out Vector3)`** *(thêm mới)*: trả `_cachedTopBone.position`; `false` nếu chưa cache.
- `debugNamePlateBounds` log tiền tố **`[Cuong]`**.
---
## 6. `CECMatter.cs`
Nameplate cho item/mine drop. **Không** dùng `NameplateWorldAnchor` (Matter là mesh tĩnh, không có skeleton).
- **`TryGetCombinedRendererBounds(matterRoot, excludeSubtree)`**:
- Duyệt tất cả `Renderer` con (gồm cả `MeshRenderer``SkinnedMeshRenderer`).
- Với `MeshRenderer`: lấy mesh từ `MeshFilter.sharedMesh`.
- Với `SkinnedMeshRenderer`: lấy `smr.sharedMesh`.
- Cùng cách tính world bounds như `PlayerVisual` / `NPCVisual`.
- **`TryGetItemNameAnchorLocal`**: gọi `TryGetCombinedRendererBounds` → anchor tại `(center.x, max.y + 0.05, center.z)``InverseTransformPoint``localPosition`.
- Fallback nếu không có renderer: `y = 0.6f` + `LogWarning [Cuong]`.
- BoxCollider khi spawn cũng dùng `combinedBounds.size` + `combinedBounds.center` (thay vì chỉ lấy renderer đầu tiên).
---
## Cập nhật phiên hiện tại (2026-05)
### CECMatter — fix multi-mesh nameplate height
- **Vấn đề**: offset cố định `0.6f` sai với model nhiều mesh (vd: 噬人花 có mesh `_0` cao + `_1` thấp → tên đè lên cây thấp).
- **Nguyên nhân sâu hơn**: `renderer.bounds` stale sau `SetActive(true)` cùng frame.
- **Fix**: `TryGetCombinedRendererBounds` đọc `sharedMesh.bounds` + transform thủ công, gộp mọi `Renderer` (cả `MeshRenderer`). Anchor tại `combinedBounds.max.y`.
### NameplateWorldAnchor — thêm NPC hook + cached top bone
- Thêm **Priority 1 NPC**: `hostNpc.GetHook("HH_Head")` — dùng chung `headHookName` với player.
- Thêm **Priority 2 NPC**: `NPCVisual.TryGetTopBoneWorld()` (bone cao nhất đã cache).
- Thêm `RefreshNpcTopBone()` để trigger scan từ `UINPC`.
- Field mới: `useNpcHeadHook` (default `true`).
### NPCVisual — thêm CacheTopBone
- `CacheTopBone()`: scan tất cả SMR bones, dedup `HashSet`, lưu bone `world.y` cao nhất.
- `TryGetTopBoneWorld()`: trả vị trí bone đã cache.
### UINPC — thêm LateUpdate
- **Vấn đề**: 小星星 và các monster có animation nổi/bay bị đè tên vì canvas set vị trí **một lần** từ rest-pose.
- **Fix**: `LateUpdate` re-read `TryGetWorldPosition` mỗi frame sau khi coroutine kết thúc → nameplate theo bone animation.
- Coroutine gọi `RefreshNpcTopBone()` ở cả early-exit lẫn timeout để đảm bảo top bone được cache.
---
## Đối chiếu nhanh với PC (tham khảo)
- PC: **`FillPateContent`** quyết định điểm 3D; **`RenderName`** chỉ layout pixel.
- Unity world Canvas: bắt chước **bước neo 3D**; player có hook + SMR + AABB; NPC/Monster có hook + **top bone** + SMR + root fallback.
Chi tiết player & matter PC vs Unity: [EC_Player_RenderName_vs_UIPlayer.md](./EC_Player_RenderName_vs_UIPlayer.md).
---
## PC gốc (tham khảo)
- `CElementClient/EC_Player.cpp``FillPateContent`, `RenderName`
- `CElementClient/EC_NPC.cpp``FillPateContent`, `RenderName`
- `CElementClient/EC_Matter.cpp``RenderName` (~889): `vPos = modelAABB.Center + Y * (Extents.y * 1.3f)`
+186
View File
@@ -0,0 +1,186 @@
# Portrait Capture — Tóm tắt hệ thống
**Cập nhật:** 07/05/2026
**Liên quan plan:** \elseplayer_avatar_hudnpc_ff835033
---
## Mục tiêu
- **HUD NPC (else player):** hiển thị avatar 3D khi chọn player làm target — ổn định khi target di chuyển.
- **HUD host player:** hiển thị avatar host trên màn chọn nhân vật — tận dụng \PlayerModelPreview\ + camera gắn xương \Bip01 Head\, nhìn thẳng mặt.
---
## Vấn đề gốc (ElsePlayer)
Camera bám trực tiếp \ ransform\ nhân vật thật trong world mỗi frame → khi chạy/nhảy: **giật, lệch, render không ổn**.
---
## Giải pháp ElsePlayer: clone + camera kiểu host
Thay vì bám live player:
1. Tìm root model hiển thị (\FindVisualModelRoot\).
2. \Instantiate\ clone, đặt tại vị trí cố định xa gameplay (ví dụ \(9999, 0, 0)\).
3. \CleanupCloneComponents\: bỏ Collider/Rigidbody/MonoBehaviour thừa, **giữ Animator** (idle).
4. Gán layer **Portrait** cho toàn hierarchy clone.
5. Camera được **parent trực tiếp vào xương đầu** của clone (cùng style host), đặt theo `faceAxis + cameraDistance`.
6. Trong `LateUpdate`, chỉ `Slerp` rotation để nhìn về mặt (`faceUpLift`) — không bám `transform` player thật ngoài world.
**File:** \Assets/PerfectWorld/Scripts/UI/ElsePlayerPortraitCapture.cs
**API (giữ tương thích CECUIManager):**
- \ElsePlayerPortraitCapture.Instance?.SetTarget(elsePlayer.transform);- \ElsePlayerPortraitCapture.Instance?.ClearTarget();- RawImage: \ElsePlayerPortraitCapture.Instance?.OutputTexture
---
## Tiện ích dùng chung
**File:** \Assets/PerfectWorld/Scripts/UI/PortraitCaptureUtils.cs
| Nội dung | Mô tả |
|----------|--------|
| \FindVisualModelRoot\ | Tìm root có \SkinnedMeshRenderer\ trong hierarchy player |
| \FindHeadBone\ / \FindChildByName\ | Tìm xương đầu / tìm con theo tên |
| \CleanupCloneComponents\ | Dọn clone, giữ Animator |
| \SetLayerRecursive\ | Gán layer cho cả cây GameObject |
| \FindUrpUnlitShader\ | Tìm shader `Universal Render Pipeline/Unlit` |
| \ApplyClonedUrpUnlitMaterials\ | Clone từng material slot của mọi renderer và đổi clone sang URP/Unlit |
| \ConvertMaterialToUrpUnlit\ | Giữ lại texture/màu chính (`_BaseMap`/`_MainTex`, `_BaseColor`/`_Color`, `_Cutoff`) |
| \CreatePortraitCamera\ / \CreatePortraitRT\ | Tạo camera (culling theo layer) + RT 256xARGB32 |
---
## Material portrait: clone + URP/Unlit
Áp dụng cho cả **ElsePlayerPortraitCapture****HostPlayerPortraitCapture**:
1. Không sửa material gốc đang dùng trong gameplay.
2. Clone từng `sharedMaterial` theo từng renderer/slot (`new Material(src)`).
3. Đổi clone sang shader `Universal Render Pipeline/Unlit`.
4. Copy lại texture/màu chính để giữ ngoại hình gần giống model gốc.
### ElsePlayer (model clone)
- Chạy `ApplyClonedUrpUnlitMaterials` trực tiếp trên `_portraitClone`.
- Khi `ClearTarget()`: destroy toàn bộ material instance đã tạo.
### HostPlayer (model từ `PlayerModelPreview`)
- Trước khi đổi material: lưu snapshot `renderer.sharedMaterials` của model preview.
- Áp clone URP/Unlit để render portrait.
- Khi `ClearPortrait()` / đổi target mới: restore lại `sharedMaterials` gốc + destroy material clone.
- Mục tiêu: không để model preview ngoài UI bị “kẹt” shader Unlit sau khi đóng portrait.
### Inspector
- `usePortraitUnlitMaterials` (Else/Host): bật để dùng pipeline clone material + URP/Unlit.
- Nếu project không tìm thấy `Universal Render Pipeline/Unlit`, hệ thống log warning và giữ shader clone gốc.
---
## Host player: \PlayerModelPreview\ + camera trên \Bip01 Head
**File:** \Assets/PerfectWorld/Scripts/UI/HostPlayerPortraitCapture.cs
Không clone host từ world; dùng model đã load trong **\PlayerModelPreview\** (đủ trang bị, vị trí preview cố định).
### Camera gắn vào \Bip01 Head
Camera được **parent trực tiếp vào xương đầu** thay vì Spine2 — mọi chuyển động đầu từ idle animation được camera theo tự động.
**Công thức:**
\faceDir = headBone.<FaceAxis> (cấu hình qua Inspector: Forward/Back/Up/Down)
camWorldPos = headBone.position + faceDir * cameraDistance
camera.SetParent(Bip01 Head)
camera.position = camWorldPos → Unity lưu localOffset đúng
\
**LateUpdate:** chỉ Slerp rotation về phía mặt — không ghi đè position:
\\csharp
Vector3 toFace = (headBone.position + Vector3.up * faceUpLift) - camera.position;
targetRot = Quaternion.LookRotation(toFace);
camera.rotation = Quaternion.Slerp(camera.rotation, targetRot, dt * lookSmoothSpeed);
\
### Inspector
| Field | Ý nghĩa |
|-------|---------|
| \headFaceAxis\ | Trục nào của \Bip01 Head\ chỉ ra phía mặt (Forward/Back/Up/Down/Left/Right) |
| \ lipFaceAxis\ | Lật nhanh nếu chọn sai trục |
| \portraitLayer\ | Layer chỉ camera portrait render (vd. 29) |
| \cameraDistance\ | Khoảng cách cameramặt (~0.51.0m) |
| \ aceUpLift\ | Nhích điểm nhìn lên (tránh nhìn cằm thay vì mắt) |
| \lookSmoothSpeed\ | Cao = snappy, thấp = smooth |
---
## Kết nối với \PlayerModelPreview\ — xử lý async load
\ShowAllPlayerModels()\ là \sync void\ — model load từng cái một. Nếu gọi \AttachToPlayerModelPreview\ quá sớm thì list còn rỗng.
### Giải pháp: event \OnModelReady\ + call tại \ShowSelectedModelWhenReady
**\PlayerModelPreview.cs\**: thêm event báo khi model lần đầu được \SetActive(true)\:
\\csharp
public event Action<int> OnModelReady;
// trong ApplyRequestedModelVisibility():
go.SetActive(shouldBeActive);
if (shouldBeActive && !wasActive)
OnModelReady?.Invoke(playerModelIds[i]);
\
**\SelecScreenCharacter.cs\**: gọi \AttachToPlayerModelPreview\ ngay sau \ShowPlayerModel\:
\\csharp
PlayerModelPreview.Instance.ShowPlayerModel(roleId);
HostPlayerPortraitCapture.Instance?.AttachToPlayerModelPreview(roleId);
\
Tại đây coroutine đã poll đảm bảo model load xong → \TryAttachFromPreview\ thành công ngay (Path 1), không cần đợi event.
### Hai path trong \AttachToPlayerModelPreview
\AttachToPlayerModelPreview(roleId)
├─ TryAttachFromPreview() → model có rồi → SetupPortrait() ✔ (Path 1)
└─ không tìm thấy
→ _pendingRoleId = roleId
→ subscribe PlayerModelPreview.OnModelReady (Path 2)
→ OnModelReady fires
→ OnPreviewModelReady() → TryAttachFromPreview() ✔
\
---
## API đầy đủ
\\csharp
HostPlayerPortraitCapture.Instance?.AttachToPlayerModelPreview(roleId);
HostPlayerPortraitCapture.Instance?.Refresh(roleId); // sau reload đồ / model preview
HostPlayerPortraitCapture.Instance?.ClearPortrait();
HostPlayerPortraitCapture.Instance?.SetHostPlayer(root); // fallback
// RawImage:
rawImage.texture = HostPlayerPortraitCapture.Instance?.OutputTexture;
\
---
## Checklist Unity Editor
- [ ] Tạo layer **Portrait** (Tags and Layers).
- [ ] Main Camera: **bỏ** layer Portrait khỏi Culling Mask.
- [ ] Gán \portraitLayer\ khớp trên \ElsePlayerPortraitCapture\ và \HostPlayerPortraitCapture\.
- [ ] Scene: có GameObject gắn \ElsePlayerPortraitCapture\ và \HostPlayerPortraitCapture\.
- [ ] ElsePlayer: \CECUIManager\ gọi \SetTarget\ / \ClearTarget\ — không đổi API.
- [ ] Host: \SelecScreenCharacter\ gọi \AttachToPlayerModelPreview\ sau \ShowPlayerModel\; wire \OutputTexture\ vào RawImage HUD.
- [ ] Inspector: chỉnh \headFaceAxis\ cho đúng rig (Forward là mặc định, bật \ lipFaceAxis\ nếu portrait quay ngược).
- [ ] Bật `usePortraitUnlitMaterials` nếu muốn portrait dùng material clone URP/Unlit ổn định ánh sáng.
---
## Tại sao không dùng \CECClonePlayer\ cho portrait
\CECClonePlayer\ là player đầy đủ (AI, di chuyển, combat, equip async) — quá nặng. Portrait chỉ cần **mesh + skeleton + Animator** (else) hoặc **model preview có sẵn** (host).
---
## So sánh nhanh
| | ElsePlayer | Host |
|---|------------|------|
| Nguồn model | Clone từ world player | \PlayerModelPreview\ theo oleId\ |
| Camera | Con của head bone trên clone + Slerp LookAt mỗi frame | Con của \Bip01 Head\ + Slerp LookAt mỗi frame |
| \LateUpdate\ | Có (chỉ Slerp rotation) | Có (chỉ Slerp rotation) |
| Subscribe async | Không cần | \OnModelReady\ event từ \PlayerModelPreview\ |
| Material portrait | Clone material trên clone và đổi URP/Unlit | Clone material trên preview + restore material gốc khi clear |
+59
View File
@@ -77,3 +77,62 @@
- Đề xuất giải pháp khắc phục cụ thể.
- Không tự động sửa mà chờ xác nhận từ người dùng (theo quy trình 5 bước).
- **Lưu ý:** Luôn kiểm tra các directive như `#if UNITY_EDITOR`, `#if UNITY_STANDALONE`, `#if UNITY_ANDROID`, `#if UNITY_IOS`, v.v. khi scan code.
## 8. Bối Cảnh Làm Việc Hiện Tại (Current Workspace)
- **Mục tiêu:** Xây dựng hệ thống Chat (Chat System) cho phiên bản Mobile.
- **Thành phần dự kiến:**
- Logic Gửi/Nhận gói tin chat (Protocol).
- Xử lý các kênh chat (Local, World, Faction, Private...).
- UI Input Handler (vd: `ChatInputHandler.cs`).
- Phân tích mã nguồn gốc C++ (`EC_GameSession.cpp`, v.v.) định tuyến sang C#.
## 9. Chat / Emotion — Bản lưu tiến trình (handoff, cập nhật 2026-04)
Mục đích: đọc mục này khi mở **chat mới** để nối lại ngữ cảnh (PC → Unity, emoji/TMP, tool, SO).
### C++ gốc (chỉ tham chiếu)
- **`FilterEmotionSet`:** `AUInterface2/AUICommon.cpp` — `UnmarshalEditBoxText` → với mỗi `enumEIEmotion` sửa info `"set:index"` → `MarshalEditBoxText`.
- **Atlas + txt:** `AUInterface2/AUIManager.cpp` `Init`: `Surfaces\InGame\Emotions{l}.dds`, lưới **32×32**, `Surfaces\InGame\Emotions{l}.txt` — mỗi dòng: `nStartPos`, `nNumFrames`, hint, `nFrameTick[]` (max 20). **`index` trong protocol** = thứ tự dòng trong `.txt`, không phải tọa độ tùy ý.
- **`Emotions{N}.txt` không có trong repo source** — lấy từ client PC: `Surfaces\InGame\`.
### Unity — luồng chat → TMP (đã nối trong code)
1. **`EC_GameUIMan.AddChatMessage`** (`Assets/PerfectWorld/Scripts/UI/GamePlay/EC_GameUIMan.cs`): king bit (Country) → **`AUICommon.FilterEmotionSet`** → FilterBadWords → **`UnmarshalEditBoxText`** + **`ConvertEmotionsToTMP` / `ConvertCoordsToTMP` / `ConvertIvtrItemsToTMP` / `StripRemainingItemCodes`** → format màu/kênh → **`GameSession.ChatMessageEvent`**.
2. **`ChatPanelUI`** nhận event → **`ChatMessageView.Bind`** → TMP (`TextOutlet`).
3. **`AUICommon.cs` (`CSNetwork`):** toàn bộ logic rich-text item + `EmotionSpriteInfo` (**có thêm** `UseSpriteName`, `SpriteName` cho tag `<sprite name="…">`) + `IEmotionSpriteMap`.
4. **`CECGameUIMan`:** field **`_emotionLibrarySpriteMap`** (SerializeField, type `EmotionLibrarySpriteMap`). Trong **`Init()`**, nếu gán SO thì **`_spriteMap` = SO đó**, không thì **`StubEmotionSpriteMap`**.
### Dữ liệu & SO
| File | Vai trò |
|------|--------|
| `Chat/EmotionData/EmotionEntryData.cs` | Một emotion: StartPos, NumFrames, Hint, FrameTicks, FrameSprites |
| `Chat/EmotionData/EmotionSetDataSO.cs` | Một bộ `EmotionsN` |
| `Chat/EmotionData/EmotionLibrarySO.cs` | Nhiều bộ: `List<EmotionSetSnapshot>` |
| `Chat/EmotionData/EmotionLibrarySpriteMap.cs` | **SO** implement `IEmotionSpriteMap`: tham chiếu `EmotionLibrarySO` + **`TMP_SpriteAsset`**; 1 frame có thể `<sprite name>`; nhiều frame cần tra index trong `spriteInfoList` theo tên `cell_XXXX` |
| `Chat/StubEmotionSpriteMap.cs` | Fallback khi chưa gán `EmotionLibrarySpriteMap` |
### Tool Editor (atlas + txt → Multiple + SO)
- Menu: **Perfect World → Chat → Emotion Atlas Converter…**
- **`EmotionAtlasConverterCore.cs`:** mỗi bộ xuất **`Emotions{N}_atlas.png`** (hoặc slice **in-place** trên asset gốc), **Sprite Mode Multiple**, tên sub-sprite **`cell_0000` …**
- **`EmotionAtlasConverterWindow.cs`:** single set; **batch** danh sách slot tùy số dòng (**Set N**, Atlas, TXT), trùng `N` lỗi; nút tạo **`EmotionLibrary.asset`**.
### Việc cần làm trên Editor (một lần)
1. Import PNG + `Emotions{N}.txt` → chạy converter → có **`EmotionLibrary.asset`** (và/hoặc từng `EmotionSetData_*.asset`).
2. Tạo **TMP Sprite Asset** khớp atlas (tên `cell_XXXX` giống tool).
3. Tạo asset **Create → Perfect World → Chat → Emotion Library Sprite Map**: gán **Library** + **Tmp Sprite Asset**; tùy chọn **Prefer Sprite Name Tag** (emoji 1 frame).
4. Gán **`EmotionLibrarySpriteMap`** vào prefab/scene **`CECGameUIMan`**.
5. Trên **TextMeshProUGUI** chat: gán **Sprite Asset** (cùng TMP asset) để `<sprite>` render được.
### Việc còn mở / tùy chọn
- Test end-to-end tin nhắn có emoji từ server.
- Link **`coord:`** / **`item:`** trong TMP: mở rộng `ChatMessageView.OnPointerClick` nếu khác logic whisper.
- Tinh chỉnh FPS animation từ `FrameTicks` nếu cần khớp PC hơn.
### Đường dẫn nhanh
- `Assets/PerfectWorld/Scripts/Network/CSNetwork/AUICommon.cs`
- `Assets/PerfectWorld/Scripts/UI/GamePlay/EC_GameUIMan.cs`
- `Assets/PerfectWorld/Scripts/Chat/UI/ChatPanelUI.cs`, `ChatMessageView.cs`
- `Assets/PerfectWorld/Scripts/Chat/EmotionData/*`
- `Assets/PerfectWorld/Scripts/Editor/EmotionAtlasConverter*.cs`
## 10. Rule debug cá nhân (Cuong)
- Nếu là debug/log do Cuong tạo, luôn thêm tiền tố `[Cuong]` ở đầu nội dung log để dễ lọc và truy vết.