# Hướng dẫn sử dụng VKL VFX Studio

VKL VFX Studio là công cụ tạo và chỉnh sửa hiệu ứng cho VKL Game. Preview trong Studio sử dụng trực tiếp VKL VFX Engine, vì vậy hiệu ứng nhìn thấy trong Studio cũng là hiệu ứng được Web Renderer sử dụng. File JSON xuất từ Studio có thể dùng chung cho Web và Flutter.

## 1. Mở Studio

Studio nằm tại `web_client/vfx/studio.html`.

Studio cần được mở thông qua web server của dự án để trình duyệt tải JSON và texture atlas. Không nên mở trực tiếp bằng địa chỉ bắt đầu với `file://`.

Ví dụ, từ thư mục `web_client` chạy một web server tại cổng 8765, sau đó truy cập:

```text
http://127.0.0.1:8765/vfx/studio.html
```

Khi tải thành công, góc trên vùng preview sẽ hiển thị `VKL webgl2` hoặc `VKL canvas2d` và số particle của effect hiện tại.

## 2. Các khu vực chính

### Thư viện effect

Cột bên trái chứa toàn bộ effect trong thư viện hiện tại.

- Ô **Tìm effect** lọc danh sách theo ID.
- Nhấn tên effect để chọn và preview.
- **Lưu bản nháp** lưu trạng thái đang chỉnh vào trình duyệt.
- **Khôi phục** xóa bản nháp và tải lại dữ liệu gốc của dự án.

### Vùng preview

Khu vực giữa hiển thị effect bằng VKL VFX Engine.

- Nút `Ⅱ` tạm dừng.
- Nút `▶` tiếp tục phát.
- Nút `↻` phát lại từ đầu.
- Thanh timeline cho phép kéo thủ công đến một thời điểm chính xác. Studio tự pause ngay khi bắt đầu kéo.
- Ô số cạnh timeline cho phép nhập trực tiếp thời gian theo mili giây. Có thể dùng phím mũi tên trái/phải để lùi hoặc tiến 16 ms mỗi bước.
- **Phát tại đây** phát lại effect ở tâm vùng preview.
- **Màu nền** chuyển giữa nền tối, nền sáng và lưới alpha.
- **Core** đặt màu chính được tham chiếu bằng `$primary`.
- **Glow** đặt màu phụ được tham chiếu bằng `$accent`.

Core và Glow chỉ thay đổi màu runtime của preview. Muốn đặt màu cố định trong JSON, nhập mã màu tại trường **Màu particle**.

### Bảng chỉnh sửa

Cột bên phải có năm thẻ:

- **Effect**: thuộc tính chung và danh sách emitter.
- **Emitter**: thuộc tính particle của emitter đang chọn.
- **Atlas**: gán texture hoặc chuỗi frame.
- **JSON**: chỉnh toàn bộ effect bằng JSON.

## 3. Ý nghĩa tham số Effect

| Tham số | Ý nghĩa | Gợi ý |
|---|---|---|
| `id` | Tên định danh duy nhất của effect | Chỉ dùng chữ, số, `_` hoặc `-`; ví dụ `gem_fire_orbit` |
| `durationMs` | Tổng thời gian effect tồn tại | Effect chọn Gemz thường khoảng 700–1600 ms |
| `maxParticles` | Số particle tối đa cho một instance | Phải lớn hơn hoặc bằng tổng `burst` của các emitter |
| `blendMode` | Cách trộn màu effect với nền | `additive` cho ánh sáng; `normal` cho hình màu thông thường |
| `emitters` | Danh sách các nguồn phát particle | Một effect cần ít nhất một emitter |
| `layers` | Lớp màu toàn màn hình, chỉnh trong JSON | Dùng cho flash, tint, vignette hoặc screen effect |

### Blend mode

- `additive`: cộng ánh sáng, phù hợp glow, tia sáng, phép thuật và neon.
- `normal`: vẽ theo alpha thông thường, phù hợp hình có màu hoặc texture không phát sáng.

### Hiện, ẩn và xóa Emitter trong Effect

Mỗi dòng trong danh sách **Emitters** của thẻ **Effect** có hai thao tác nhanh:

- Nút `👁` ẩn emitter. Studio lưu `"enabled": false`; emitter vẫn giữ nguyên cấu hình nhưng không tạo particle trong preview, Web hoặc Flutter.
- Nút `⊘` hiện lại emitter và xóa thuộc tính `enabled: false` khỏi JSON.
- Nút `×` xóa hoàn toàn emitter khỏi effect.

Emitter bị ẩn được làm mờ và gạch tên trong danh sách. Có thể chọn, chỉnh sửa hoặc kéo đổi thứ tự như bình thường. Đây là cách phù hợp để so sánh các lớp VFX mà không mất cấu hình.

Một effect luôn cần ít nhất một emitter, vì vậy Studio không cho xóa dòng cuối cùng. Khi tính `maxParticles`, emitter đang ẩn không được tính vào tổng burst hoạt động.

## 4. Ý nghĩa tham số Emitter

### Effect, Emitter, Particle và Primitive khác nhau thế nào?

Có thể hình dung cấu trúc của một hiệu ứng như sau:

```text
Effect
├── Emitter: vòng sáng
│   ├── Particle 1: ring
│   └── Particle 2: ring
├── Emitter: tia năng lượng
│   ├── Particle 1: ray
│   ├── Particle 2: ray
│   └── ...
└── Emitter: tinh thể quay
    ├── Particle 1: neon_diamond
    └── ...
```

- **Effect** là toàn bộ hiệu ứng, có tổng thời lượng và nhiều emitter.
- **Emitter** là một nguồn hoặc một lớp phát. Nó mô tả khi nào phát, phát bao nhiêu particle, particle sống bao lâu, chuyển động thế nào và có màu gì.
- **Particle** là một phần tử cụ thể được emitter tạo ra. Một emitter có `burst: 12` sẽ tạo 12 particle.
- **Primitive** là hình học dùng để vẽ một particle, ví dụ chấm tròn, vòng sáng, tia hoặc tinh thể.

Một emitter không phải là một particle. Emitter là **công thức sinh particle**. Khi effect được phát, engine đọc công thức này và tạo ra các particle thật.

### Vòng đời của một emitter

Trình tự thời gian:

```text
Effect bắt đầu
      │
      ├── chờ startMs
      │
      ├── tạo số particle theo burst
      │
      ├── mỗi particle chạy trong khoảng lifeMs
      │      ├── di chuyển theo speed, angle và gravity
      │      ├── thay đổi độ sáng theo opacity
      │      └── thay đổi kích thước theo scale
      │
      └── particle kết thúc
```

Ví dụ:

```json
{
  "id": "delayed_sparks",
  "burst": 10,
  "startMs": 300,
  "lifeMs": [500, 800]
}
```

Emitter này chờ 300 ms sau khi effect bắt đầu, sau đó tạo 10 tia lửa. Mỗi tia tồn tại ngẫu nhiên từ 500 đến 800 ms.

Nếu `startMs + lifeMs` lớn hơn `durationMs`, phần cuối của particle sẽ bị cắt khi effect kết thúc.

### Vì sao nên chia một effect thành nhiều emitter?

Mỗi emitter nên đảm nhiệm một vai trò thị giác rõ ràng. Ví dụ một effect Gemz có thể gồm:

| Emitter | Vai trò | Primitive phù hợp |
|---|---|---|
| `core_flash` | Chớp sáng ở tâm | `dot`, `star` hoặc sprite |
| `energy_ring` | Vòng năng lượng mở rộng | `ring` |
| `radial_rays` | Tia phát 360 độ | `ray` |
| `orbit_crystals` | Tinh thể quay quanh Gemz | `neon_diamond` |
| `spark_dust` | Hạt bụi nhỏ tạo chiều sâu | `dot`, `star` |
| `final_burst` | Điểm nhấn kết thúc | `star` hoặc sprite |

Tách thành nhiều emitter giúp:

- Điều khiển thời điểm xuất hiện của từng lớp bằng `startMs`.
- Cho mỗi lớp tuổi thọ, màu và chuyển động riêng.
- Dễ tắt hoặc giảm một lớp khi tối ưu Mobile.
- Dễ đọc và sửa JSON hơn.

### Thuộc tính cơ bản

| Tham số | Ý nghĩa |
|---|---|
| `id` | ID duy nhất của emitter bên trong effect |
| `burst` | Số particle emitter tạo ra mỗi lần effect được phát |
| `startMs` | Thời gian chờ trước khi emitter bắt đầu |
| `primitive` | Hình particle do engine tự vẽ |
| `colors` | Danh sách màu được chọn cho particle |
| `pathStart` | Điểm A `[x, y]`, tương đối so với tâm effect |
| `pathEnd` | Điểm B `[x, y]`, tương đối so với tâm effect |
| `orientToPath` | Tự xoay sprite hoặc tia theo hướng A → B |

### Tạo tia sáng di chuyển từ A đến B

Trong thẻ **Emitter**, bật **Di chuyển tuyến tính từ A đến B**, rồi nhập tọa độ A và B. Engine nội suy vị trí theo vòng đời particle: ở 0% particle nằm tại A, ở 50% nằm giữa đường, và ở 100% tới B. Tọa độ dương X đi sang phải; tọa độ dương Y đi xuống.

Ví dụ một tia đi chéo từ trái trên sang phải dưới:

```json
"primitive": "ray",
"lifeMs": [1000, 1000],
"pathStart": [-180, -80],
"pathEnd": [180, 80],
"orientToPath": true
```

Để xem nhanh, chọn preset **Traveling Beam A→B** trong Primitive Library rồi kéo vào vùng preview. Sau đó tạm dừng và kéo timeline để quan sát tia tại từng vị trí. Khi dùng đường A→B, `speed`, `angleDeg`, `gravity` và orbit không tham gia tính vị trí; opacity, scale, spin và animation atlas vẫn hoạt động bình thường.

Các primitive hiện có:

- `dot`: điểm sáng tròn.
- `ring`: vòng tròn rỗng.
- `ray`: tia sáng dài.
- `wisp`: nét sáng cong dùng cho nhánh xoáy và luồng ma thuật.
- `vortex_ribbon`: nhiều nhánh xoắn ốc liên tục trong một primitive GPU.
- `crescent_ray`: tia cong bán nguyệt/trăng khuyết có độ dày, độ mở và tâm quay tùy chỉnh.
- `star`: ngôi sao năm cánh.
- `diamond`: tinh thể hình thoi đặc.
- `neon_diamond`: tinh thể viền neon.

Nếu emitter sử dụng `sprite` hoặc `spriteFrames`, texture atlas được hiển thị thay cho primitive.

### Giải thích chi tiết từng Primitive

#### `dot`

Hình tròn phát sáng, là primitive nhẹ nhất và linh hoạt nhất.

Phù hợp cho:

- Bụi phép thuật.
- Tâm sáng.
- Spark nhỏ.
- Particle nền số lượng lớn.

`size` là bán kính trực quan của điểm. Khi dùng `additive`, nhiều dot chồng lên nhau sẽ tạo vùng sáng mạnh.

#### `ring`

Vòng tròn rỗng, thường dùng với tốc độ bằng 0 và đường cong scale tăng dần.

Ví dụ vòng xung kích:

```json
"size": [100, 100],
"speed": [0, 0],
"opacity": [[0, 0], [0.1, 0.8], [1, 0]],
"scale": [[0, 0.2], [1, 1.4]],
"primitive": "ring"
```

`size` điều khiển đường kính tổng thể; độ dày viền được engine tính theo kích thước.

#### `ray`

Một tia sáng dài, hướng của tia lấy từ `rotationDeg`. Trường `aspect` điều khiển độ dày tương đối.

Phù hợp cho:

- Tia nổ 360 độ.
- Speed line.
- Chớp sáng hình chữ thập.
- Ánh sáng mặt trời.

Ví dụ tia 360 độ:

```json
"burst": 24,
"rotationDeg": [0, 360],
"size": [60, 110],
"aspect": 0.04,
"primitive": "ray"
```

`angleDeg` điều khiển hướng bay của particle, còn `rotationDeg` điều khiển hướng hình tia. Hai tham số này có thể giống hoặc khác nhau.

#### `wisp`

Một nét sáng cong có đầu bo tròn. Khi kết hợp `orbitRadius`, `orbitSpeedDeg`, `orbitScale` và `orientToOrbit: true`, nhiều wisp sẽ nối thành các nhánh xoắn ốc hút dần vào tâm. Preset **Curved Vortex Wisp** cung cấp cấu hình khởi đầu cho kiểu chuyển động này.

#### `vortex_ribbon`

Primitive này tự vẽ toàn bộ vòng xoáy thay vì ghép nhiều đoạn rời. `vortexArms` đặt số nhánh, `vortexTwist` đặt số vòng xoắn từ mép vào lõi và `vortexThickness` đặt độ dày. Dùng `spinDeg` để quay cả vortex và chồng hai ribbon khác kích thước/màu để tạo chiều sâu giống atlas.

#### `crescent_ray`

Đây là một tia cong dạng bán nguyệt hoặc trăng khuyết. `arcThickness` điều chỉnh độ dày từ 0.01 đến 0.5; `arcSweepDeg` điều chỉnh độ mở của cung từ 45° đến 330°. `arcPivot` chọn tâm quay: `center` quay quanh tâm hình, `start` ghim đầu A và `end` ghim đầu B. Preset **Orbiting Crescent Rays** kết hợp nhiều cung với `orbitRadius`, `orbitSpeedDeg` và `orbitPhaseDeg` để quay quanh một điểm. Đổi `arcPivot` sang đầu A/B nếu muốn mỗi cung đồng thời quét quanh một đầu mút.

#### `star`

Ngôi sao năm cánh có glow, phù hợp cho sparkle và điểm nhấn fantasy.

Phù hợp cho:

- Sao lấp lánh.
- Flash kết thúc.
- Particle hiếm có kích thước trung bình.

Không nên dùng quá nhiều star lớn cùng lúc vì vùng glow dễ bị cháy sáng.

#### `diamond`

Hình thoi đặc, mô phỏng mảnh tinh thể đơn giản. Có thể kết hợp `rotationDeg`, `spinDeg` và orbit.

Phù hợp cho:

- Mảnh Gemz.
- Tinh thể bay ra ngoài.
- Mưa shard.

#### `neon_diamond`

Tinh thể hình thoi có viền neon và glow bên trong. Đây là primitive phù hợp nhất cho vệ tinh tinh thể quay quanh Gemz.

Các tham số quan trọng:

- `size`: kích thước tinh thể.
- `aspect`: độ dày viền neon.
- `rotationDeg`: góc ban đầu.
- `spinDeg`: tốc độ tự xoay.
- `orbitRadius`: khoảng cách tới tâm.
- `orbitSpeedDeg`: tốc độ quay quanh tâm.

Ví dụ:

```json
"burst": 6,
"size": [12, 18],
"rotationDeg": [0, 360],
"spinDeg": [-120, 120],
"orbitRadius": [65, 95],
"orbitSpeedDeg": [80, 150],
"aspect": 0.1,
"primitive": "neon_diamond"
```

### Primitive và Sprite khác nhau thế nào?

| Primitive | Sprite/Atlas |
|---|---|
| Engine tự vẽ bằng GPU | Engine lấy hình từ PNG atlas |
| Đổi màu dễ bằng `$primary`, `$accent` | Phụ thuộc màu và alpha của texture |
| Nhẹ, sắc nét ở nhiều kích thước | Có thể thể hiện hình vẽ phức tạp |
| Không cần file ảnh | Cần khai báo atlas và frame |
| Hợp với ring, ray, dot, crystal đơn giản | Hợp với vortex, crown, explosion hoặc hình vẽ nhiều chi tiết |

Khi emitter có `sprite` hoặc `spriteFrames`, engine ưu tiên sprite và không vẽ primitive. Trường primitive vẫn có thể còn trong JSON nhưng không ảnh hưởng cho đến khi texture bị gỡ.

### Chọn Primitive nào?

- Muốn bụi sáng hoặc particle số lượng lớn: chọn `dot`.
- Muốn sóng năng lượng lan ra: chọn `ring`.
- Muốn nổ tia 360 độ: chọn `ray`.
- Muốn sparkle hoặc flash fantasy: chọn `star`.
- Muốn mảnh tinh thể đặc: chọn `diamond`.
- Muốn tinh thể phát sáng quay quanh Gemz: chọn `neon_diamond`.
- Muốn hình phức tạp được vẽ sẵn: dùng Atlas thay vì primitive.

### Các khoảng Min/Max

Mỗi particle lấy một giá trị ngẫu nhiên có seed trong khoảng Min/Max. Nhập hai giá trị giống nhau nếu muốn kết quả cố định.

| Tham số JSON | Tên trong Studio | Ý nghĩa | Đơn vị |
|---|---|---|---|
| `lifeMs` | Tuổi thọ | Thời gian sống của từng particle | ms |
| `speed` | Tốc độ | Vận tốc ban đầu | pixel/giây |
| `angleDeg` | Góc | Hướng phát | độ |
| `gravity` | Trọng lực | Gia tốc X và Y | pixel/giây² |
| `size` | Kích thước | Kích thước particle hoặc chiều rộng sprite | pixel |
| `rotationDeg` | Xoay | Góc xoay ban đầu | độ |
| `spinDeg` | Spin | Tốc độ tự xoay | độ/giây |
| `orbitRadius` | Bán kính orbit | Khoảng cách particle tới tâm quỹ đạo | pixel |
| `orbitSpeedDeg` | Tốc độ orbit | Tốc độ quay quanh tâm | độ/giây |

Ví dụ:

```json
"speed": [40, 120],
"angleDeg": [0, 360],
"size": [3, 9]
```

Cấu hình trên phát particle theo 360 độ, tốc độ từ 40 đến 120 và kích thước từ 3 đến 9 pixel.

### Màu particle

Các màu được phân cách bằng dấu phẩy:

```text
$primary, $accent, #ffffff
```

- `$primary`: sử dụng màu Core tại runtime.
- `$accent`: sử dụng màu Glow tại runtime.
- `#RRGGBB`: màu cố định, ví dụ `#ffcc33`.

Dùng token màu nếu effect cần đổi màu theo Gemz, skin hoặc cấp độ người chơi.

## 5. Đường cong Opacity và Scale

Đường cong là danh sách các điểm `[thời_gian_chuẩn_hóa, giá_trị]`. Thời gian chạy từ `0` đến `1`, tương ứng từ lúc particle bắt đầu đến lúc kết thúc.

Ví dụ opacity:

```json
[[0, 0], [0.12, 1], [0.8, 0.7], [1, 0]]
```

Ý nghĩa:

1. Particle bắt đầu trong suốt.
2. Tại 12% tuổi thọ, particle đạt độ sáng 100%.
3. Tại 80%, độ sáng còn 70%.
4. Khi kết thúc, particle mờ hoàn toàn.

Ví dụ scale:

```json
[[0, 0.2], [0.25, 1], [1, 0.1]]
```

Particle bắt đầu ở 20% kích thước, lớn nhanh tới 100%, sau đó thu nhỏ còn 10%.

Các mốc thời gian nên tăng dần và nằm trong khoảng `0–1`.

## 6. Orbit và tham số nâng cao

Các tham số sau được chỉnh trong thẻ **JSON**:

| Tham số | Ý nghĩa |
|---|---|
| `orbitPhaseDeg` | Góc bắt đầu trên quỹ đạo |
| `orbitAspect` | Tỷ lệ chiều cao quỹ đạo; `1` là hình tròn, nhỏ hơn `1` tạo ellipse |
| `orbitScale` | Đường cong thay đổi bán kính quỹ đạo theo thời gian |
| `aspect` | Độ dày tương đối của `ray` hoặc `neon_diamond` |
| `sprite` | Một frame atlas cố định, ví dụ `pick_gem.crown` |
| `spriteFrames` | Danh sách frame animation |
| `spriteFps` | Số frame chạy trong một giây |
| `spriteStartFrame` | Frame bắt đầu, thường là `0` |

Ví dụ particle quay vào tâm:

```json
"orbitRadius": [70, 110],
"orbitSpeedDeg": [120, 220],
"orbitPhaseDeg": [0, 360],
"orbitAspect": 0.82,
"orbitScale": [[0, 1], [1, 0.1]]
```

## 7. Primitive Library và tạo effect bằng kéo thả

### Primitive preset là gì?

Primitive Library nằm dưới danh sách effect ở cột trái. Mỗi thẻ trong thư viện là một **preset tái sử dụng**, gồm:

- Một hình GPU chuẩn hoặc Custom GPU Shape.
- Burst và thời lượng.
- Size, speed và góc phát.
- Aspect và spin.
- Màu, opacity và scale.
- Các thuộc tính nâng cao như orbit nếu preset có khai báo.

Preset đóng gói cấu hình emitter quanh các primitive dùng chung. `Custom GPU Shape` sử dụng shader thủ tục tổng quát của engine thay vì chèn mã shader tùy ý, nhờ đó effect kéo-thả vẫn cho kết quả tương ứng trên Web và Flutter.

### Các preset có sẵn

Ngoài nhóm cơ bản, Studio có thêm các preset chuyên dụng:

| Preset | Vai trò gợi ý |
|---|---|
| Aura Bloom | Cụm glow nở quanh tâm |
| Twin Shockwave | Nhiều vòng xung kích nối tiếp |
| Solar Cross | Tia sáng dài tạo starburst |
| Comet Trails | Tia chuyển động nhanh như đuôi sao chổi |
| Hex Sigil | Pháp trận lục giác neon |
| Neon Triangle | Tam giác năng lượng có orbit |
| Arcane Star | Ngôi sao tám cánh dạng phù hiệu |
| Crystal Crown | Tinh thể quay theo quỹ đạo dẹt |
| Micro Spark Storm | Mưa spark nhỏ số lượng lớn |
| Halo Satellites | Các vệ tinh sáng quay nhiều bán kính |

Preset mới được tự bổ sung vào bản nháp cũ nếu ID chưa tồn tại; preset người dùng đã sửa không bị ghi đè.

### Tự tạo hình GPU

Chọn **Custom GPU Shape** tại trường **Hình GPU** để tạo hình thủ tục mới. Hình được tính trực tiếp bởi WebGL trên Web và Path/Canvas trên Flutter, không cần PNG.

| Tham số | Ý nghĩa |
|---|---|
| `shapeSides` | Số cạnh hoặc số đỉnh ngoài, từ 3 đến 16 |
| `shapeInnerRatio` | Tỷ lệ bán kính đỉnh trong; `1` tạo polygon đều, nhỏ hơn `1` tạo hình sao |
| `shapeHollow` | Độ dày phần rỗng; `0` là hình đặc, tối đa `0.8` |
| `shapeSoftness` | Độ mềm của viền GPU, từ `0.01` đến `0.25` |

Một vài cấu hình gợi ý:

| Hình | Số cạnh | Tỷ lệ lõm | Độ rỗng |
|---|---:|---:|---:|
| Tam giác | 3 | 1 | 0 |
| Lục giác | 6 | 1 | 0 |
| Sao 5 cánh | 5 | 0.42 | 0 |
| Sao neon 6 cánh | 6 | 0.5 | 0.12 |
| Huy hiệu 8 cánh | 8 | 0.7 | 0.08 |

Quy trình:

1. Nhấn **+ Mới** trong Primitive Library.
2. Studio tạo sẵn một `Custom GPU Shape` sáu cạnh.
3. Chỉnh Số cạnh, Tỷ lệ lõm, Độ rỗng và Mềm viền.
4. Quan sát hình trong Live Preview.
5. Chỉnh thêm size, spin, màu, opacity và scale.
6. Kéo preset vào effect hoặc nhấn **+ Thêm Primitive vào effect**.

Custom GPU Shape được lưu trong emitter dưới primitive `custom_shape`, do đó không phụ thuộc vào Primitive Library sau khi đã thêm vào effect.

### Xem preview Primitive

1. Nhấn một thẻ Primitive ở cột trái.
2. Studio mở thẻ **Primitive** ở bảng bên phải.
3. Khung **Live Preview** tự phát lặp preset bằng VKL VFX Engine.
4. Thay Core hoặc Glow để kiểm tra khả năng đổi màu.
5. Thay một tham số và rời khỏi ô; Live Preview cập nhật ngay.

Studio tự thu phóng riêng khung preview để primitive lớn hoặc có orbit không bị cắt. Việc thu phóng này không thay đổi kích thước thật khi preset được thêm vào effect.

### Thêm Primitive mới

1. Nhấn **+ Mới** tại tiêu đề Primitive.
2. Đặt **Tên hiển thị** và **ID preset**.
3. Chọn hình GPU cơ sở.
4. Chỉnh thời lượng, burst, size, speed, góc, aspect và spin.
5. Đặt màu, opacity và scale.
6. Quan sát Live Preview.
7. Nhấn **Lưu bản nháp** hoặc **Xuất thư viện** để giữ preset.

### Sửa Primitive

1. Chọn thẻ Primitive trong thư viện.
2. Sửa trực tiếp các trường trong thẻ **Primitive**.
3. ID chỉ nên chứa chữ, số, `_` hoặc `-` và không được trùng preset khác.
4. Các emitter đã được tạo trước đó không tự thay đổi. Preset chỉ được sao chép tại thời điểm thêm vào effect.
5. Kéo lại preset vào effect nếu muốn tạo emitter mới với cấu hình mới.

### Nhân bản Primitive

1. Chọn preset gần giống kết quả mong muốn.
2. Nhấn **Nhân bản**.
3. Studio tạo ID mới có hậu tố `_copy`.
4. Đổi tên, ID và các tham số rồi dùng như một preset độc lập.

Đây là cách nhanh nhất để tạo biến thể từ một preset có sẵn.

### Xóa Primitive

1. Chọn preset cần xóa.
2. Nhấn **Xóa** trong thẻ Primitive.
3. Preset biến mất khỏi Primitive Library.

Xóa preset không xóa các emitter đã được tạo từ preset đó. Một effect luôn giữ bản sao emitter riêng. Studio yêu cầu giữ ít nhất một preset trong thư viện.

### Tạo effect bằng kéo thả

1. Nhấn **+ Effect mới** hoặc chọn effect cần bổ sung.
2. Tìm preset trong Primitive Library.
3. Giữ chuột trên thẻ preset.
4. Kéo vào vùng preview ở giữa.
5. Khi khung “Thả Primitive tại đây” xuất hiện, thả chuột.
6. Studio tạo emitter mới và chuyển sang thẻ **Emitter**.

Khi thêm bằng kéo thả, Studio tự động:

- Tạo ID emitter không trùng.
- Sao chép toàn bộ cấu hình preset.
- Tăng `durationMs` nếu preset dài hơn effect.
- Tăng `maxParticles` nếu tổng burst mới vượt giới hạn hiện tại.
- Phát lại preview với emitter vừa thêm.

Nếu thiết bị không thuận tiện cho kéo thả, chọn preset rồi nhấn **+ Thêm Primitive vào effect**. Hai thao tác cho kết quả giống nhau.

### Sắp xếp emitter bằng kéo thả

Trong thẻ **Effect**, kéo một emitter lên hoặc xuống emitter khác để thay đổi thứ tự trong JSON. Thứ tự thường không thay đổi particle procedural, nhưng hữu ích khi tổ chức các lớp và đọc file effect.

Primitive preset được lưu trong trường `studioPrimitives` của thư viện xuất ra. Runtime game bỏ qua trường này; Studio sử dụng nó để khôi phục Primitive Library trong lần import tiếp theo.

## 8. Sử dụng texture atlas

### 8.1. Atlas, frame và effect khác nhau thế nào?

- **Atlas** là một PNG lớn chứa nhiều hình nhỏ.
- **Frame** là một vùng hình chữ nhật `[x, y, width, height]` bên trong atlas.
- **Emitter** dùng một frame làm ảnh tĩnh hoặc dùng nhiều frame làm animation.
- **Effect** kết hợp emitter atlas với các emitter GPU như glow, ring, ray và particle.

Atlas chỉ là nguồn ảnh. Nhấn **Thêm vào Studio** chưa tự tạo một effect; sau đó vẫn cần tạo/chọn effect, chọn emitter và gán frame cho emitter đó.

Ví dụ một atlas 2048×256 gồm tám frame vuông 256×256:

```text
┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐
│frame_0 │frame_1 │frame_2 │frame_3 │frame_4 │frame_5 │frame_6 │frame_7 │
└────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘
```

Khai báo tương ứng của frame đầu là `frame_0: [0, 0, 256, 256]`; frame thứ hai là `frame_1: [256, 0, 256, 256]`.

### 8.2. Chuẩn bị PNG Atlas

Để Atlas Builder chia ảnh chính xác:

- Dùng PNG có alpha nếu nền cần trong suốt.
- Các ô trong một atlas nên có cùng kích thước.
- Giữ tâm hình ở cùng một vị trí trong mọi frame để animation không bị rung.
- Không để màu nền đen giả trong suốt; kiểm tra bằng lưới alpha.
- Chừa 2–4 px trong suốt quanh hình nếu glow sát mép bị cắt.
- Nếu texture được dùng với `additive`, tránh vùng trắng quá lớn vì dễ cháy sáng.
- Đặt các frame animation theo thứ tự đọc: trái sang phải, rồi trên xuống dưới.

Atlas Builder hiện tạo lưới ô đều. Nếu mỗi frame có kích thước khác nhau, cần sửa thủ công mảng `frames` trong JSON.

### 8.3. Tự vẽ Atlas bằng Atlas Paint Editor

Nhấn **✦ Mở Atlas Paint Editor** trong thẻ **Atlas** để tạo PNG RGBA hoàn toàn trong Studio.

#### Thiết lập project

| Tham số | Ý nghĩa |
|---|---|
| ID Atlas | ID sẽ được dùng trong `atlas.frame` |
| Tiền tố frame | Tên chuỗi, ví dụ `portal` tạo `portal_0`, `portal_1`... |
| Atlas W/H | Kích thước PNG cuối cùng, tối đa 4096×4096 |
| Frame W/H | Kích thước riêng của từng frame |
| Margin | Lề trong suốt quanh toàn bộ vùng đóng gói |
| Gap | Khoảng cách trong suốt giữa các frame |

Dòng trạng thái cho biết số cột, số hàng, số frame hiện có và sức chứa tối đa. Nếu frame không vừa Atlas hoặc số frame vượt sức chứa, nút tạo Atlas sẽ không hoàn tất.

#### Công cụ vẽ

- **Bút**: vẽ tự do với màu, alpha và kích thước nét hiện tại.
- **Tẩy**: giảm/xóa kênh alpha mà không tô màu nền giả.
- **Đường**: vẽ đường thẳng.
- **Chữ nhật** và **Ellipse**: vẽ viền hình học.
- **Đổ màu**: flood fill một vùng liên tục, bao gồm alpha của màu mới.
- **Lấy màu**: lấy cả RGB và alpha từ pixel trên frame.
- **Undo/Redo**: lưu tối đa 30 bước gần nhất cho từng frame.
- **Zoom −/+**: giảm hoặc tăng theo các mức 25%, 50%, 100%, 200%, 400% và 800%. Zoom chỉ thay đổi cách quan sát, không đổi pixel thật.
- **Xoay trái/phải 90°**: xoay nội dung quanh tâm frame và giữ nguyên kích thước frame.
- **Scale**: nhập 10–400% rồi nhấn **Áp dụng**. Scale nhỏ tạo thêm vùng trong suốt; scale lớn có thể cắt phần vượt khỏi frame.
- **Xóa frame**: làm toàn bộ frame trong suốt.

Thanh **Alpha** điều khiển độ trong suốt từ 0–100%. PNG được tạo giữ alpha thật; vùng trống không bị tô đen.

Xoay và scale đều được ghi vào lịch sử của frame, vì vậy có thể dùng Undo/Redo ngay sau biến đổi.

#### Quản lý frame

1. Nhấn **+ Frame** để tạo frame trong suốt.
2. Nhấn **Nhân bản** để sao chép frame đang chọn, phù hợp làm animation frame-by-frame.
3. Chọn thumbnail để chuyển frame.
4. Dùng `←` và `→` để đổi thứ tự animation.
5. Nhấn **Xóa** để bỏ frame; project luôn giữ ít nhất một frame.
6. Khi thay Frame W/H, nội dung cũ được giữ ở giữa khung mới và lịch sử undo của frame được làm mới.

#### Đưa hình vẽ vào Studio

1. Nhấn **Tạo Atlas và dùng trong Studio**.
2. Editor ghép frame theo thứ tự từ trái sang phải, rồi từ trên xuống dưới.
3. Atlas Builder mở lại với PNG vừa tạo, tọa độ từng frame và kênh alpha.
4. Kiểm tra khung chia frame.
5. Nhấn **Thêm vào Studio** để đăng ký Atlas với renderer.
6. Chọn emitter rồi nhấn **Dùng 1 frame** hoặc **Dùng cả chuỗi**.

Nhấn **Project mới** để xóa project vẽ hiện tại và trở về Atlas 1024×1024, frame 256×256.

### 8.4. Tự tạo Atlas từ một PNG mới

1. Mở Studio và chọn thẻ **Atlas**.
2. Nhấn **+ Nhập PNG Atlas**.
3. Chọn file PNG.
4. Nhập **ID atlas**, ví dụ `emerald_portal`.
5. Nhập **Tiền tố frame**, ví dụ `vortex`.
6. Nhập **Số cột** và **Số hàng**.
7. Nhập lề và khoảng cách nếu các frame không nằm sát nhau.
8. Kiểm tra các khung màu hồng trên preview. Mỗi khung phải bao trọn đúng một hình.
9. Kiểm tra dòng trạng thái: kích thước PNG, tổng số frame và kích thước mỗi frame.
10. Nhấn **Thêm vào Studio**.

Sau bước cuối, atlas xuất hiện trong danh sách **Atlas trong thư viện** và được đăng ký ngay với renderer đang chạy. Có thể gán frame và preview mà chưa cần tải lại trang.

Các tham số:

| Tham số | Ý nghĩa | Ví dụ |
|---|---|---|
| ID atlas | ID duy nhất, là phần trước dấu chấm trong `atlas.frame` | `emerald_portal` |
| Tiền tố frame | Phần tên dùng để gom animation | `vortex` tạo `vortex_0`, `vortex_1`... |
| Số cột | Số ô theo chiều ngang | `8` |
| Số hàng | Số ô theo chiều dọc | `1` |
| Lề X/Y | Khoảng trống từ mép PNG đến ô đầu tiên | `4`, `4` |
| Khoảng X/Y | Khoảng trống giữa hai ô liền nhau | `2`, `2` |

Công thức Studio dùng để tính kích thước một frame:

```text
frameWidth  = (imageWidth  - 2 × marginX - gapX × (columns - 1)) / columns
frameHeight = (imageHeight - 2 × marginY - gapY × (rows - 1)) / rows
```

Kết quả được làm tròn xuống theo pixel. Nếu khung cuối bị lệch, kiểm tra lại lề, gap hoặc kích thước PNG.

### 8.5. Tạo Atlas dựa trên Atlas có sẵn

1. Trong **Atlas trong thư viện**, chọn atlas cần dùng làm mẫu.
2. Nhấn **Dùng atlas đang chọn làm mẫu**.
3. Studio nạp PNG và tự suy ra số hàng, số cột, lề và khoảng cách.
4. ID mới mặc định có hậu tố `_copy`, nhờ đó không ghi đè atlas gốc.
5. Đổi ID, tiền tố hoặc cấu trúc lưới rồi nhấn **Thêm vào Studio**.

Tính năng suy ra lưới phù hợp nhất với atlas có các frame đều nhau. Luôn kiểm tra lại đường chia frame trước khi đăng ký.

### 8.6. Tạo effect dùng Atlas

#### Bước A — Tạo effect và emitter ảnh

1. Nhấn **+ Effect mới**.
2. Trong thẻ **Effect**, đặt ID, ví dụ `gem_emerald_portal`.
3. Đặt `durationMs`, ví dụ `1000` ms.
4. Chọn emitter mặc định hoặc nhấn **+ Thêm** để tạo emitter riêng.
5. Trong thẻ **Emitter**, đặt các giá trị khởi đầu dễ kiểm tra:
   - `burst`: `1`.
   - `startMs`: `0`.
   - `lifeMs`: `[1000, 1000]`.
   - `speed`: `[0, 0]`.
   - `size`: cùng một giá trị, ví dụ `[180, 180]`.
   - `rotationDeg`: `[0, 0]`.
   - `spinDeg`: `[0, 0]`.
   - màu particle: `#ffffff` nếu muốn giữ màu gốc của PNG.
6. Đặt blend mode của effect:
   - `normal` nếu texture có màu và alpha cần giữ nguyên.
   - `additive` nếu texture chủ yếu là ánh sáng, năng lượng hoặc glow.

Với sprite, `size` là chiều rộng hiển thị trước khi áp dụng đường cong `scale`. Chiều cao được giữ theo tỷ lệ của frame atlas.

#### Bước B — Gán một frame tĩnh

1. Chọn emitter vừa tạo.
2. Mở thẻ **Atlas**.
3. Chọn atlas và frame.
4. Nhấn **Dùng 1 frame**.

Nếu đây là emitter mặc định chưa chỉnh của một effect mới, Studio tự chuyển cấu hình bụi nhỏ sang một sprite 180 px, đứng yên và có `burst: 1`. Việc tự căn này giúp frame Atlas hiện rõ ngay trong preview. Emitter đã được tùy chỉnh hoặc đã dùng texture sẽ không bị tự thay đổi các tham số.

Emitter nhận thuộc tính:

```json
"sprite": "emerald_portal.vortex_0"
```

Cách này phù hợp cho huy hiệu, vòng sáng cố định, texture lõi hoặc một frame được kết hợp với scale/opacity procedural.

#### Bước C — Gán cả chuỗi animation

1. Chọn một frame thuộc chuỗi, ví dụ `vortex_0`.
2. Nhấn **Dùng cả chuỗi**.
3. Studio gom các frame có cùng tiền tố và sắp theo vị trí trên atlas.

Ví dụ khi chọn `starlight_0`, Studio lấy `starlight_0` đến `starlight_6`, nhưng không lấy `void_halo_0` đến `void_halo_6` trong cùng atlas.

Emitter nhận dữ liệu tương tự:

```json
{
  "spriteFrames": [
    "emerald_portal.vortex_0",
    "emerald_portal.vortex_1",
    "emerald_portal.vortex_2",
    "emerald_portal.vortex_3",
    "emerald_portal.vortex_4",
    "emerald_portal.vortex_5",
    "emerald_portal.vortex_6",
    "emerald_portal.vortex_7"
  ],
  "spriteFps": 8,
  "spriteStartFrame": 0
}
```

Studio tự tính `spriteFps` để chuỗi chạy xấp xỉ một vòng trong tuổi thọ lớn nhất của emitter. Có thể mở thẻ **JSON** để đổi FPS:

- 8 frame ở 8 FPS chạy hết một vòng trong 1 giây.
- 8 frame ở 16 FPS chạy hết một vòng trong 0,5 giây.
- Animation tự lặp khi particle vẫn còn sống.
- Muốn chỉ thấy một vòng, đặt `lifeMs ≈ frameCount / spriteFps × 1000`.
- Giữ `spriteStartFrame: 0` để mọi particle bắt đầu đồng bộ từ frame đầu.

Sau khi sửa JSON, nhấn **Áp dụng JSON**. Nếu chỉ nhấn **Định dạng**, dữ liệu chưa được đưa vào effect.

#### Bước D — Thêm các lớp GPU phụ

Một atlas animation thường đẹp hơn khi chỉ đóng vai trò lớp chính. Có thể thêm:

- `ring` mở rộng phía sau.
- `dot` hoặc `star` làm spark.
- `ray` làm tia nổ 360 độ.
- `custom_shape` làm ký hiệu hoặc tinh thể GPU.

Mỗi lớp nên là một emitter riêng để chỉnh `startMs`, `lifeMs`, opacity và scale độc lập.

### Đổi màu effect Atlas sau khi tạo

Chọn emitter đã gán Atlas rồi mở thẻ **Atlas**. Khu vực **Màu texture sau khi tạo** có hai chế độ:

- **Giữ màu gốc / Multiply** nhân màu đã chọn với RGB có sẵn trong PNG. Phù hợp khi atlas vốn đã đúng màu hoặc chỉ cần thay đổi sắc độ nhẹ.
- **Thay màu / Tint** thay RGB của texture bằng màu mới nhưng giữ nguyên kênh alpha và độ sáng của frame. Phù hợp để tái sử dụng một atlas trắng hoặc nhiều sắc độ cho các Gemz khác màu.

Sau khi chọn chế độ và nguồn màu, nhấn **Áp dụng màu cho emitter**. Studio tự chuyển atlas mới và bản nháp cũ chưa có chế độ màu sang Tint. `soft_vortex_atlas` mặc định dùng `$primary`, nên có thể đổi trực tiếp bằng ô màu Core và cũng nhận màu Core truyền từ game lúc runtime.

Nguồn màu có thể là màu cố định, `$primary` từ **Core**, hoặc `$accent` từ **Glow**. Core và Glow tiếp tục thay đổi được tại runtime sau khi effect được xuất.

Studio lưu lựa chọn vào emitter:

```json
"spriteColorMode": "tint",
"colors": ["$primary"]
```

Đổi color picker hoặc nguồn màu sẽ cập nhật preview ngay. Tính năng này áp dụng cho cả một frame và chuỗi `spriteFrames`.

### 8.7. Preview effect Atlas

#### Preview tự động

Sau khi nhấn **Dùng 1 frame**, **Dùng cả chuỗi** hoặc sửa emitter, Studio phát lại effect. Nếu đang dừng:

- Nhấn `▶` để chạy tiếp.
- Nhấn `↻` để phát lại từ 0 ms.
- Nhấn **Phát tại đây** để tạo lại effect ở tâm vùng preview.

#### Preview từng frame bằng timeline

1. Nhấn `Ⅱ` để pause, hoặc bắt đầu kéo timeline; Studio sẽ tự pause.
2. Kéo thanh timeline đến thời điểm cần xem.
3. Có thể nhập trực tiếp số mili giây ở ô cạnh timeline.
4. Dùng phím mũi tên trái/phải trong ô thời gian để đi từng bước 16 ms.

Ví dụ chuỗi 8 FPS đổi frame khoảng mỗi `125 ms`. Kiểm tra tại `0`, `125`, `250`, `375`... để xem từng frame liên tiếp.

`startMs` của emitter là mốc frame đầu xuất hiện. Nếu emitter bắt đầu ở 300 ms, hãy kiểm tra từ 300 ms thay vì từ đầu effect.

#### Checklist preview

- **Nền tối**: kiểm tra cường độ glow và blend additive.
- **Nền sáng**: kiểm tra texture có đủ tương phản không.
- **Lưới alpha**: tìm nền đen, viền vuông hoặc alpha bị bẩn.
- Kéo timeline chậm qua ranh giới frame để phát hiện animation rung hoặc sai thứ tự.
- Kiểm tra frame đầu và cuối có tạo cú nhảy lớn khi animation lặp không.
- Kiểm tra texture có bị cắt glow ở mép frame không.
- Kiểm tra `size`, `scale`, `rotationDeg` và `spinDeg` ở đầu, giữa và cuối effect.
- Nếu effect không xuất hiện, tạm đặt màu trắng, opacity `[[0,1],[1,1]]`, scale `[[0,1],[1,1]]` và speed bằng 0 để cô lập lỗi.

### 8.8. Bỏ hoặc thay Atlas trên emitter

- Chọn atlas/frame khác rồi nhấn **Dùng 1 frame** hoặc **Dùng cả chuỗi** để thay texture hiện tại.
- Nhấn **Bỏ texture khỏi emitter** để xóa `sprite`, `spriteFrames`, `spriteFps` và `spriteStartFrame`.
- Sau khi bỏ texture, emitter quay lại vẽ `primitive` đã chọn.

### 8.9. Lưu Atlas và effect vào source dự án

Atlas đăng ký bằng object URL chỉ tồn tại trong phiên Studio hiện tại. **Lưu bản nháp không thay thế việc lưu PNG vào dự án.** Quy trình hoàn chỉnh:

1. Trong Atlas Builder, nhấn **Tải khai báo JSON**. Trình duyệt tải:
   - `<atlas_id>.png`.
   - `<atlas_id>.atlas.json`.
2. Nếu trình duyệt hỏi, cho phép tải nhiều file.
3. Đặt PNG vào:

```text
vfx_data/atlases/<atlas_id>.png
```

4. Nhấn **Xuất thư viện** trong Studio để tải `effects.json`. File này chứa cả khai báo atlas và effect vừa tạo.
5. Dùng file xuất thay cho:

```text
vfx_data/effects.json
```

6. Chạy đồng bộ:

```text
node tool/build-vfx.mjs
```

Công cụ sẽ sao chép:

- JSON sang `web_client/assets/vfx/effects.json` và `client_flutter/assets/vfx/effects.json`.
- PNG sang thư mục asset Web và Flutter được khai báo trong atlas.
- ID effect sang danh mục phía server.

7. Chạy kiểm tra:

```text
node tool/build-vfx.mjs --check
node --test web_client/vfx/vkl-vfx.test.cjs
```

Không sửa trực tiếp các bản sao trong `web_client/assets` hoặc `client_flutter/assets`; lần build tiếp theo sẽ ghi đè chúng.

### 8.10. Dùng effect sau khi build

#### Web

Game hiện đã tạo `VklVfx.Engine` và render nó trong vòng lặp. Để phát trực tiếp theo ID:

```js
vklVfxEngine.spawn("gem_emerald_portal", {
  x: window.innerWidth / 2,
  y: window.innerHeight / 2,
  seed: 12345,
  primary: "#33ffd1",
  accent: "#ffffff",
});
```

Nếu effect bám vào một Gemz trong world, truyền `worldX`, `worldY` và `anchorId` theo luồng VFX hiện có của game. `seed` cố định giúp Web và Flutter tạo cùng bố cục particle procedural.

#### Flutter

Engine tải cùng `effects.json` và atlas đã được build:

```dart
final vfx = await VklVfxEngine.load();

vfx.spawn(
  'gem_emerald_portal',
  worldPosition: const Offset(120, 240),
  seed: 12345,
  primary: const Color(0xFF33FFD1),
  accent: const Color(0xFFFFFFFF),
);
```

Trong game loop, gọi `vfx.update(dt)`; trong bước vẽ, gọi `vfx.render(...)`. Shape Rush đã có sẵn luồng tích hợp này, nên effect đã build có thể được gọi theo ID mà không cần viết renderer atlas riêng.

#### Dùng alias cho Gemz effect

Nếu shop hoặc gameplay phát effect bằng alias, thêm ánh xạ vào `effectAliases` của thư viện:

```json
"effectAliases": {
  "emerald_portal": "gem_emerald_portal"
}
```

Sau đó Web dùng `spawnAlias("emerald_portal", options)` và Flutter dùng `spawnAlias(...)`. Target của alias phải là một effect ID tồn tại.

## 9. Tạo effect mới

1. Nhấn **+ Effect mới** trên thanh công cụ.
2. Trong thẻ **Effect**, đổi ID mặc định thành ID có ý nghĩa.
3. Đặt thời lượng, Particle tối đa và blend mode.
4. Chọn emitter mặc định và mở thẻ **Emitter**.
5. Chỉnh burst, tuổi thọ, tốc độ, góc, kích thước, màu, opacity và scale.
6. Nhấn **+ Thêm** để tạo thêm emitter.
7. Nếu cần texture, gán frame trong thẻ **Atlas**.
8. Dùng timeline và các chế độ nền để kiểm tra toàn bộ animation.
9. Nhấn **Xuất effect** để tải riêng effect hoặc **Xuất thư viện** để tải toàn bộ `effects.json`.

Cấu trúc phổ biến cho effect Gemz:

1. Emitter lõi hoặc texture chính.
2. Emitter vòng sáng.
3. Emitter particle bay hoặc orbit.
4. Emitter flash kết thúc.

## 10. Sửa effect có sẵn

1. Chọn effect trong danh sách bên trái.
2. Có thể nhấn **Nhân bản** trước khi chỉnh để giữ nguyên bản gốc.
3. Chỉnh thuộc tính Effect hoặc chọn một emitter để chỉnh chi tiết.
4. Khi thay đổi ID effect, bảo đảm ID mới không trùng với effect khác.
5. Pause và kéo timeline để kiểm tra từng giai đoạn.
6. Chuyển nền sáng, tối và lưới alpha để tìm viền texture hoặc glow quá mạnh.
7. Nhấn **Lưu bản nháp** nếu muốn tiếp tục trong lần mở Studio sau.
8. Khi hoàn tất, nhấn **Xuất thư viện**.

Các thay đổi trong Studio chỉ nằm trong bộ nhớ hoặc bản nháp của trình duyệt. Studio không tự ghi đè `vfx_data/effects.json`.

## 11. Xóa effect

1. Chọn effect cần xóa ở cột bên trái.
2. Nhấn **Xóa effect** trên thanh công cụ.
3. Effect bị xóa khỏi bản đang chỉnh và preview chuyển sang effect gần nhất.
4. Nếu effect có alias, Studio cũng xóa các alias đang trỏ trực tiếp đến effect đó để thư viện không bị lỗi.
5. Nhấn **Xuất thư viện** để tạo file JSON không còn effect đã xóa.

Xóa trong Studio chưa tác động đến file nguồn của game. Nếu xóa nhầm, nhấn **Khôi phục** trước khi lưu bản nháp hoặc tải lại trang khi chưa lưu bản nháp.

## 12. Import, export và bản nháp

### Nhập JSON

Nút **Nhập JSON** chấp nhận:

- Một effect JSON riêng lẻ.
- Một thư viện hoàn chỉnh có `format: "vkl-vfx"`.

Nếu effect nhập vào có ID trùng, Studio thay thế effect đang có ID đó. Nếu ID chưa tồn tại, effect được thêm mới.

### Xuất effect

Tải effect đang chọn thành một file JSON riêng. Phù hợp để chia sẻ preset hoặc lưu phiên bản.

### Xuất thư viện

Kiểm tra thư viện và tải toàn bộ dữ liệu. Studio chặn export nếu:

- ID effect bị trùng.
- ID emitter trong cùng effect bị trùng.
- Effect không có emitter.
- Tổng burst vượt `maxParticles`.
- Atlas hoặc frame được tham chiếu không tồn tại.

### Lưu bản nháp

Lưu thư viện hiện tại vào bộ nhớ của trình duyệt. Bản nháp chỉ có trên trình duyệt và máy đang sử dụng.

### Khôi phục

Xóa bản nháp và quay lại thư viện gốc được tải từ dự án.

## 13. Đưa effect vào game

Quy trình an toàn:

1. Nhấn **Xuất thư viện** trong Studio.
2. Sao lưu file hiện tại nếu cần.
3. Dùng file đã xuất thay cho `vfx_data/effects.json`.
4. Chạy công cụ đồng bộ VFX:

```text
node tool/build-vfx.mjs
```

5. Chạy kiểm tra:

```text
node tool/build-vfx.mjs --check
node --test web_client/vfx/vkl-vfx.test.cjs
```

Công cụ đồng bộ sao chép dữ liệu và atlas tới Web, Flutter và danh mục effect phía server.

## 14. Khuyến nghị hiệu năng

- Effect thường nên giữ dưới khoảng 40–60 particle.
- Effect có sprite lớn nên dùng 1–3 sprite và particle nhỏ làm chi tiết phụ.
- Hạn chế nhiều ring hoặc ray kích thước lớn tồn tại đồng thời.
- Trên Mobile, tránh glow phủ toàn màn hình quá lâu.
- Dùng atlas thay vì nhiều file PNG rời.
- Giảm `maxParticles` nếu effect chỉ xuất hiện rất ngắn.
- Luôn kiểm tra ở cả nền sáng và nền tối.
- Dùng `additive` có kiểm soát; nhiều lớp màu trắng dễ làm effect cháy sáng.

## 15. Lỗi thường gặp

### Studio hiển thị “Không thể tải VFX”

Studio có thể đang được mở bằng `file://`. Hãy mở qua web server của dự án.

### Effect không xuất hiện

Kiểm tra:

- `startMs` có lớn hơn `durationMs` không.
- Opacity hoặc Scale có luôn bằng `0` không.
- Màu có đúng định dạng `#RRGGBB` không.
- Frame atlas có tồn tại không.
- `lifeMs` có quá ngắn không.
- Nếu vừa gán Atlas cho một bản nháp cũ, PNG có thể chưa được nạp lại. Mở thẻ **Atlas**, nhập lại PNG rồi gán frame; Studio sẽ hiện thông báo cụ thể khi không tải được ảnh.

### Effect bị cắt hoặc có hình chữ nhật quanh texture

- Kiểm tra alpha của PNG.
- Bảo đảm frame không lấy sang hàng hoặc cột kế bên.
- Chừa padding giữa các frame atlas.
- Kiểm tra bằng nền sáng và lưới alpha.

### Không thể xuất thư viện

Đọc thông báo ở cuối màn hình, sau đó kiểm tra ID trùng, tổng burst, `maxParticles` và tham chiếu atlas.

### Hiệu ứng Web và Flutter khác nhau

- Bảo đảm đã chạy `node tool/build-vfx.mjs`.
- Không sửa trực tiếp JSON trong `web_client/assets` hoặc `client_flutter/assets`.
- Dùng cùng seed, Core color và Glow color khi so sánh.
