diff --git a/Docs/Addressables-Bootstrap.md b/Docs/Addressables-Bootstrap.md new file mode 100644 index 0000000000..2ca3acfa75 --- /dev/null +++ b/Docs/Addressables-Bootstrap.md @@ -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//`. Để 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/`** 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)` (1–100). + +### 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).* diff --git a/Docs/ServerTime_SunMoon_MiniMap.md b/Docs/ServerTime_SunMoon_MiniMap.md new file mode 100644 index 0000000000..125d719d9a --- /dev/null +++ b/Docs/ServerTime_SunMoon_MiniMap.md @@ -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.* diff --git a/Docs/chat-channel-whisper-dropdown-summary.md b/Docs/chat-channel-whisper-dropdown-summary.md new file mode 100644 index 0000000000..073e1bf1b3 --- /dev/null +++ b/Docs/chat-channel-whisper-dropdown-summary.md @@ -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` diff --git a/Docs/chat-emoji-progress-summary.md b/Docs/chat-emoji-progress-summary.md new file mode 100644 index 0000000000..ad8adff359 --- /dev/null +++ b/Docs/chat-emoji-progress-summary.md @@ -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 `` neu resolve duoc, + - truoc khi gui convert `` -> 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 ``. + +- 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 `` (hoac ``), 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: + - `` + - hoac `` + - hoac ``. + - `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. diff --git a/Docs/chat-emotion-refactor-summary.md b/Docs/chat-emotion-refactor-summary.md new file mode 100644 index 0000000000..2374aafb67 --- /dev/null +++ b/Docs/chat-emotion-refactor-summary.md @@ -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 (``, ``, ``) vào `TMP_InputField` khi người chơi chọn emoji. + +**Đã làm:** + +- `Assets/Scripts/ChatInputHandler.cs` + - Thêm `using BrewMonster.Scripts.Chat` và `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): ``. + - 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 `<…>`) khi **gửi**; **ô nhập** chỉ hiển thị bản **TMP** (``). 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 ``, 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>` & 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>`. 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` là `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()) { ... }` và `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).* diff --git a/Docs/combo-skill-quickbar-ui-summary.md b/Docs/combo-skill-quickbar-ui-summary.md new file mode 100644 index 0000000000..63cc7d2950 --- /dev/null +++ b/Docs/combo-skill-quickbar-ui-summary.md @@ -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 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` và `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. diff --git a/Docs/name-plate-height/EC_Player_RenderName_vs_UIPlayer.md b/Docs/name-plate-height/EC_Player_RenderName_vs_UIPlayer.md new file mode 100644 index 0000000000..2241fba2ac --- /dev/null +++ b/Docs/name-plate-height/EC_Player_RenderName_vs_UIPlayer.md @@ -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` ~5929–6274 | 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` ~6391–6456 | 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`. diff --git a/Docs/name-plate-height/Unity_WorldNameplate_Summary.md b/Docs/name-plate-height/Unity_WorldNameplate_Summary.md new file mode 100644 index 0000000000..c3f40d0e62 --- /dev/null +++ b/Docs/name-plate-height/Unity_WorldNameplate_Summary.md @@ -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)`**: + - Có **`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`. + - Có **`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`). + - 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` và `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)` diff --git a/Docs/portrait-capture-summary.md b/Docs/portrait-capture-summary.md new file mode 100644 index 0000000000..99b207ce1a --- /dev/null +++ b/Docs/portrait-capture-summary.md @@ -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** và **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. (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 camera–mặt (~0.5–1.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 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 | diff --git a/claude.md b/claude.md index 653f000289..d19e0ce7a9 100644 --- a/claude.md +++ b/claude.md @@ -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 ``) + `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` | +| `Chat/EmotionData/EmotionLibrarySpriteMap.cs` | **SO** implement `IEmotionSpriteMap`: tham chiếu `EmotionLibrarySO` + **`TMP_SpriteAsset`**; 1 frame có thể ``; 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) để `` 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.