references/animation-state.md
# Animation State — StateComponent & State-Driven Animation
Cross-entity reference for the **state → animation** pipeline shared by monsters, NPCs, and players. Read this first whenever you see state-change or animation-swap bugs; entity-specific docs (`monster.md`, future `npc.md` etc.) build on top of these rules.
The pipeline is the same for every entity:
```
ChangeState(newState)
→ StateComponent updates CurrentStateName
→ fires StateChangeEvent
→ animation component reads its ActionSheet at this moment
→ raises AnimationClipEvent on the renderer
```
The renderer **does not re-poll** `ActionSheet` between transitions. The currently playing clip is whatever the last `StateChangeEvent` resolved to.
## 0. Two animation patterns — pick one before reading further
Monsters/NPCs drive their visible clip one of two ways. They are mutually exclusive; the rest of this doc and `monster.md` assume you've picked.
### Pattern A — script-driven `SpriteRUID` assignment (proven working canonical)
The verified working sample (`Soldier.model` + `script.SoldierAI`, full source inlined in [`monster.md` §7](monster.md)) takes this route. A custom Component holds its **own** state variable (e.g. `CurrentAIState ∈ {"ROAM","STAND","SAY","ATTACK"}`) and on each transition does `self.Entity.SpriteRendererComponent.SpriteRUID = <clipRUID>` directly. `StateComponent` is used only for `IDLE` ↔ `DEAD` transitions (so `script.Monster.IsDead` syncs and `DeadEvent` fires correctly). `StateAnimationComponent` + `ActionSheet` are still present in the `.model` for completeness, but the pipeline is bypassed.
In this pattern **`StateComponent.IsLegacy` does not need to be set** — Soldier.model leaves it at the default and animations work fine, because the script never relies on `StateChangeEvent → ActionSheet → AnimationClipEvent`.
Prefer Pattern A when:
- Behavior doesn't map cleanly to AIChase/AIWander (e.g. roam ↔ stand ↔ say ↔ attack with random idle picks, range-gated attack triggering).
- You want predictable clip swaps without depending on the `IsLegacy` quirk below.
### Pattern B — `StateChangeEvent` → `ActionSheet` pipeline (auto-swap)
`StateComponent:ChangeState("MOVE")` fires `StateChangeEvent`; `StateAnimationComponent` looks up `ActionSheet[StateStringToAnimationKey(state)]` and raises `AnimationClipEvent`. For this pipeline to actually swap clips, **`StateComponent.IsLegacy = false` must be set on the `.model`** — without it the events still fire but `StateAnimationComponent` ignores them (legacy mode pre-dates this pipeline). `IsLegacy` is a hidden property (not exposed via `mlua_api_retriever`) serialized in the `.model` JSON; default is **`true` (legacy)**.
`MonsterCanonical.model` in `../models/` is configured for Pattern B (sets `StateComponent.IsLegacy=false`, includes `AIChaseComponent`).
Builder call (only relevant if you choose Pattern B):
```javascript
b.value("StateComponent", "IsLegacy", false, "bool");
```
When you see "nothing animates, no errors" with this pattern, this missing flag is the first thing to verify.
> Symptoms of mixing the two patterns: AIChase forces velocity to zero outside its detection range while your custom script also tries to drive movement (see [`monster.md` §5d "Do not use AIChase/AIWander together with a custom chase/movement script"](monster.md)); or you call `ChangeState("MOVE")` but the clip never changes because `IsLegacy` is at its default `true`.
## 0.5. FSM (this doc) vs BT — picking the state-machine engine
This document covers the **FSM** path (`StateComponent` + `@State` StateType). MSW also natively supports a **Behaviour Tree** path (`AIComponent` + `@BTNode`) — see [`../../msw-combat-system/references/ai-bt.md`](../../msw-combat-system/references/ai-bt.md).
| Engine | Fit |
|---------|-----|
| **FSM** (`StateComponent`) | Simple enemies (3~5 states), player IDLE/HIT/DEAD, boss phase branching, **automatic avatar animation sync** (`AvatarStateAnimationComponent`). The pipeline this whole doc describes. |
| **BT** (`AIComponent`) | Patrol + chase + attack composition, varied boss patterns, probability-weighted actions, Composite/Decorator reuse |
Both are native. **If you want to drive behavior with states and bind those states to motion, prefer FSM.** Use BT when the decision tree is deep or node reuse is essential. BT-driven entities still rely on `StateComponent` for animation sync — BT nodes call `ChangeState` to drive the same pipeline this doc describes.
> This axis is **orthogonal to §0's Pattern A/B**. A custom-script monster (Pattern A — Soldier) can hold its own FSM-like state without going through `StateComponent` transitions at all. A `StateComponent`-driven monster (Pattern B) can be powered by either FSM (this doc) or BT (`ai-bt.md`).
## 1. Default states and who registers the rest
### 1.1. StateComponent API surface
```
@Component StateComponent
readonly @Sync property string CurrentStateName = "IDLE"
method boolean AddState(string stateName, Type stateType)
method boolean AddCondition(string stateName, string nextStateName, boolean reverseResult = false)
method boolean ChangeState(string stateName)
method void RemoveState(string name)
method void RemoveCondition(string stateName, string nextStateName)
```
`StateChangeEvent` payload: `CurrentStateName`, `PrevStateName`, `IsInitial`. `DeadEvent` / `ReviveEvent` / `StateChangeEvent` are all auto-emitted by the engine — you only need to subscribe (see §5 for chaining). Failure modes of `ChangeState` (lowercase, unregistered, DEAD lock, wrong ExecSpace) are tabulated in §4.5.
### 1.2. Default registration
`StateComponent` ships with **only `IDLE` and `DEAD`**. Every other state name (`MOVE`, `ATTACK`, `HIT`, `JUMP`, …) only exists on an entity if a companion component registered it, or you called `AddState` yourself. Calling `ChangeState("MOVE")` on an entity that never had `MOVE` registered throws `[LEA-3005] InvalidArgument : 'stateName' is not a valid argument`.
| State | Registered by |
|---|---|
| `IDLE`, `DEAD` | StateComponent itself (always) |
| `HIT` | `HitComponent` (raised by `HitEvent`, auto-exits to `IDLE` ~0.5s) |
| `MOVE` | `AIChaseComponent`, `AIWanderComponent`, **or** `PlayerControllerComponent` |
| `ATTACK`, `ATTACK_WAIT`, `JUMP`, `FALL`, `CLIMB`, `LADDER`, `CROUCH`, `SIT` | `PlayerControllerComponent` only (players) |
| Anything else (including `ATTACK` on monsters) | You — via `AddState(name, StateType)` server-side |
> ⚠ **Monsters do not auto-register `ATTACK`.** `script.MonsterAttack` does **not** register an `ATTACK` state — it just timer-attacks (`AttackFast`) while alive, irrespective of `StateComponent`. The two working canonicals both ship with an `attack` key in their `ActionSheet` (Pattern A — Soldier.model has `attack` and the SoldierAI uses it as the direct `SpriteRUID` during its self-managed `ATTACK` state; Pattern B — MonsterCanonical.model has `attack` reserved for a future custom `ATTACK` state), but **the `attack` key alone does not get played by `script.MonsterAttack`**. To actually swap to the attack clip you must either (Pattern A) set `SpriteRendererComponent.SpriteRUID` directly in your script's `ATTACK` branch, or (Pattern B) `AddState("ATTACK", SomeStateType)` yourself and call `ChangeState("ATTACK")` so the ActionSheet pipeline picks the `attack` clip.
If you write a custom controller that strips AI/MonsterAttack/PlayerController, you must register the states you intend to enter:
```lua
-- Reusable no-op state for marker transitions whose only job is to drive ActionSheet.
@State
script MarkerState extends StateType
end
```
```lua
@ExecSpace("ServerOnly")
method void OnBeginPlay()
local sc = self.Entity.StateComponent
sc:AddState("MOVE", MarkerState)
sc:AddState("ATTACK", MarkerState)
-- IDLE / DEAD always exist; HIT is registered by HitComponent if present.
end
```
## 2. State name casing
State names are **UPPERCASE** at the StateComponent layer (`IDLE`/`MOVE`/`ATTACK`/`HIT`/`DEAD`). The engine uppercases names passed to `AddState`/`ChangeState`, but always pass uppercase yourself — mixed-case is a hidden source of "transition doesn't fire" bugs.
The animation lookup *key* is component-specific (see §6).
## 3. The `SetActionSheet` vs `ChangeState` trap
**The single most common animation bug.** `SetActionSheet(key, ruid)` only edits the mapping table. It does **not** retrigger the clip currently playing.
| Goal | Correct call | Wrong (silent failure) |
|---|---|---|
| Play the walk clip now | `StateComponent:ChangeState("MOVE")` | `SetActionSheet("stand", moveRuid)` while in `IDLE` — mapping updates but `stand` keeps playing |
| Play the attack clip now | `StateComponent:ChangeState("ATTACK")` | Same anti-pattern |
| Randomize next `HIT` clip | Override `StateStringToAnimationKey(stateName)` and call `SetActionSheet("hit", randomRuid)` *inside* it before returning `__base:StateStringToAnimationKey(stateName)` | `SetActionSheet("hit", randomRuid)` from a timer — only affects the next HIT, not the one already playing |
| Permanently swap a clip | `SetActionSheet(key, newRuid)` once, then `ChangeState` to that state when you want it | — |
**Rule:** *`ChangeState` plays animations; `SetActionSheet` only changes which RUID a future state transition will resolve to.*
Reference implementation (random hit pick) lives in the engine API docs for `StateAnimationComponent` — fetch via `mlua_api_retriever`.
## 4. StateType authoring
Custom states (anything beyond the auto-registered set in §1) are written as `StateType` scripts.
1. Both `@State` and `extends StateType` are required. Without either, the script's `.codeblock` isn't generated and `AddState` silently receives a nil type.
2. Available hooks: `OnEnter()` / `OnUpdate()` / `OnExit()` / `OnConditionCheck(string nextStateName) → boolean`.
3. ⚠ `OnUpdate` on a `StateType` receives **no `delta`** (unlike Component/Logic `OnUpdate(number delta)`). Track elapsed time via `_TimerService:GetTime()` deltas or a one-shot timer in `OnEnter`.
4. ⚠ Inside hooks, the owning entity is reached via **`self.ParentComponent.Entity`** — `self.Entity` is nil because StateType is not a Component. Use this path to reach `TransformComponent`, `SpriteRendererComponent`, `MovementComponent`, etc.
5. **Execution space — `AddState` / `AddCondition` / `ChangeState` are authority-restricted:**
- These calls only succeed on the side that owns state-machine authority for the entity. Calling from the other side throws `[LEA-3022] InvalidExecSpace : The addition and removal of states and conditions are disabled in execution spaces where you have no permission.` — you cannot register on both sides.
- **Monsters / NPCs**: authority is on the **server**. Use `@ExecSpace("ServerOnly")` (or run inside `[server only]` code).
- **Player avatar**: authority is on the **client** (each player owns their own avatar). The official avatar custom-state example uses `[client only] OnBeginPlay()` with `AddState`/`ChangeState` for exactly this reason.
- **Animation on the client still works** even though monster `AddState` is server-only: `StateChangeEvent` is declared `Space: Server, Client` (see the API doc), so a server-side `ChangeState` causes the event to fire on the client too, and the client's `StateAnimationComponent.ReceiveStateChangeEvent` reads the sync'd `ActionSheet` to play the matching clip. The client does **not** need a separate `AddState` registration.
- Guard `StateChangeEvent` handlers that branch into custom states with `if self:IsServer()` when the branching logic is server-authoritative — but a plain animation-watching handler can read `CurrentStateName` on either side.
6. Wire transitions with `:AddCondition(from, to, reverseResult = false)`. Every frame the engine calls the `from` state's `OnConditionCheck(to)` with each candidate; transitions when it returns true (or false if `reverseResult`).
7. Map an `ActionSheet` action key for the new state — otherwise the state changes but the animation doesn't (see §6 for which `ActionSheet`).
```lua
@State
script WindupState extends StateType
property boolean done = false
method void OnEnter()
self.done = false
-- Tint sprite during wind-up using ParentComponent.Entity
-- self.ParentComponent.Entity.SpriteRendererComponent.Color = Color(1, 0.6, 0.6, 1)
_TimerService:SetTimerOnce(function() self.done = true end, 0.4)
end
method boolean OnConditionCheck(string nextStateName)
return self.done
end
end
```
```lua
@Component
script WindupSetup extends Component
-- Monster authority is on the server. AddState/AddCondition/ChangeState must
-- run server-only — calling from client throws [LEA-3022] InvalidExecSpace.
-- The client's StateAnimationComponent still plays the matching ActionSheet
-- clip via the sync'd StateChangeEvent; no client-side AddState required.
@ExecSpace("ServerOnly")
method void OnBeginPlay()
local s = self.Entity.StateComponent
s:AddState("WINDUP", WindupState)
s:AddCondition("WINDUP", "ATTACK")
end
end
```
> For the player avatar, the same calls are **client-only** instead — each player owns their avatar's state on their own client (see the official "Easy Control of Avatar Animation with ActionSheet" example).
> **Deprecated `StateComponent` overloads to watch out for in legacy code:**
> - `AddState(string stateName, func updateFunction)` — replaced by `AddState(string, Type)`. Old samples calling `AddState("NEW_ATTACK")` (single-arg form) still work for pure marker states but go through the deprecated path; prefer a `MarkerState extends StateType` for new code.
> - `AddCondition(string, string, func, boolean)` — replaced by `AddCondition(string, string, boolean)` + `OnConditionCheck` on the `StateType`.
>
> Cleanup methods (mirrors of the above) exist on `StateComponent`/`StateAnimationComponent`: `RemoveState(name)`, `RemoveCondition(from, to)`, `RemoveActionSheet(key)` — use these when tearing down dynamically registered states/clips.
## 4.5. `ChangeState` semantics — return value / failure matrix
| Call | Result |
|------|--------|
| Unregistered name | `[LEA-3005] InvalidArgument : 'stateName'` + returns `false` |
| Lowercase name | `[LEA-3005]` + UPPERCASE warning + returns `false` |
| Same as current state | Returns `false` (no transition, `OnEnter` is **not** re-called) |
| `CurrentStateName == "DEAD"` and target is **not** `"IDLE"` | `InvalidOperation` + returns `false` — the **DEAD lock** |
| Wrong ExecSpace (monster/NPC called on client, or avatar called on server) | `[LEA-3022] InvalidExecSpace` + returns `false` (see §4 point 5) |
| Normal | `OnExit(prev) → OnEnter(new) → EmitStateChangeEvent` in that order. Returns `true` |
### DEAD lock
When `CurrentStateName == "DEAD"`, **no transition to any state other than `IDLE` is allowed**. The revive flow must go through `ChangeState("IDLE")`. A direct `DEAD → REVIVE` jump in a boss revive sequence silently fails — re-enter `IDLE` first, then chain to the revive state from `StateChangeEvent` (§5).
### Auto transitions — `AddCondition` + `OnConditionCheck`
`AddCondition(from, to, reverseResult = false)` registers a per-frame check. Each frame the engine calls `from` state's `OnConditionCheck(to)` with each candidate `to` name and transitions when it returns `true` (or `false` when `reverseResult = true`). Multiple `to`s can be registered for the same `from`; they're checked in registration order and the first one passing wins — branch on `nextStateName` inside the check.
```lua
@State
script Phase1StateType extends StateType
method boolean OnConditionCheck(string nextStateName)
local monster = self.ParentComponent.Entity.MonsterScript
return monster.Hp <= monster.MaxHp * 0.5
end
end
-- Registration (server-side OnBeginPlay)
sc:AddState("PHASE1", Phase1StateType)
sc:AddState("PHASE2", Phase2StateType)
sc:AddCondition("PHASE1", "PHASE2") -- Auto-transitions when HP drops below half
```
For a forced transition from script, just call `sc:ChangeState("PHASE2")` directly.
> If the `from` state is the deprecated `AddState(name, func)` form, `AddCondition` is silently rejected — migrate to `AddState(name, XxxStateType)` first.
## 5. Chaining states via `StateChangeEvent`
The canonical way to slot a custom state into the built-in flow (e.g. `HIT` → `WINDUP` → `ATTACK`) is to handle `StateChangeEvent` and force the next transition. Guard server-only because the custom state doesn't exist client-side:
```lua
@ExecSpace("ServerOnly")
@EventSender("Self")
handler HandleStateChangeEvent(StateChangeEvent event)
if event.CurrentStateName == "HIT" then
self.Entity.StateComponent:ChangeState("WINDUP")
end
end
```
`StateChangeEvent` carries `PrevStateName` and `CurrentStateName` and is Space: **Server, Client**. `DeadEvent` (Space: Server, Client) fires additionally when entering `DEAD`. `ReviveEvent` (Space: **Server** only) fires when `PlayerComponent:Respawn()` is called on an entity that has both `StateComponent` and `PlayerComponent` — players use this to re-enter `IDLE`; monsters don't have it, so respawn logic for monsters re-enters `IDLE` manually (see `script.Monster.Respawn` in `monster.md` §6).
Use the modern `handler` syntax with `@ExecSpace`/`@EventSender` attributes (see canonical `script.Monster`'s `HandleHitEvent`) — not the legacy inline `[self] Handle…` block.
## 6. Animation component differences
The state machine is shared. The animation component that reads it differs by entity type.
| Entity | Component | Lookup table | `IsLegacy` |
|---|---|---|---|
| Monster, NPC | `StateAnimationComponent` | `ActionSheet` (`SyncDictionary<string,string>`) — map state-derived key (lowercase) → AnimationClip RUID | `StateAnimationComponent.IsLegacy` is not exposed in builder; both canonicals (Soldier.model, MonsterCanonical.model) leave it unset. The load-bearing flag is `StateComponent.IsLegacy` (Pattern B requires `false`; Pattern A leaves the default — see §0). For Pattern A, ActionSheet is not consulted at runtime — clip swaps come from direct `SpriteRendererComponent.SpriteRUID` assignment in the script. |
| Player (avatar) | `AvatarStateAnimationComponent` (extends StateAnimationComponent) | `StateToAvatarBodyActionSheet` (`SyncDictionary<string,string>`) — map state name (UPPERCASE) → `MapleAvatarBodyActionState` value (e.g. `walk`, `attack`, `rope`, `ladder`, …) | Must be **`false`** for `StateToAvatarBodyActionSheet` to drive animation. `IsLegacy` is `ReadOnly` at runtime — set it on the `.model` JSON (builder side); the engine docs note the legacy ActionSheet path "is no longer supported and will be deleted at a later date". |
> StateAnimationComponent itself is "monster/NPC only" per the engine docs — don't attach it to a player; use `AvatarStateAnimationComponent`.
### 6a. Monster/NPC key conversion (StateAnimationComponent)
State → action key happens via `StateStringToAnimationKey(stateName)`. Default behavior lowercases & maps:
| State | Default action key |
|---|---|
| `IDLE` | `stand` |
| `MOVE` | `move` |
| `JUMP` | `jump` |
| `ATTACK` | `attack` |
| `HIT` | `hit` |
| `DEAD` | `die` |
Custom states fall through to `string.lower(stateName)` unless you override `StateStringToAnimationKey`. Missing or typoed keys fail silently — the previous clip keeps playing.
To remap (e.g. `ATTACK` → `attack2`, random `hit`): either override `StateStringToAnimationKey` (preferred — keeps the swap synchronous with the transition) or call `SetActionSheet(key, clipRuid)` from script. Never edit the `.model` JSON.
### 6b. Player key conversion (AvatarStateAnimationComponent)
State → animation lookup uses the **uppercase state name directly as the key** into `StateToAvatarBodyActionSheet`. Default mapping (do not delete entries — missing keys break their animations):
| Key (state) | Value (`MapleAvatarBodyActionState`) | PlayRate |
|---|---|---|
| `IDLE` | `stand` | 1.0 |
| `MOVE` | `walk` | 1.68 |
| `ATTACK` | `attack` | 1.33 |
| `HIT` | `hit` | 1.0 |
| `CROUCH` | `crouch` | 1.0 |
| `FALL` | `fall` | 1.0 |
| `JUMP` | `fall` | 1.0 |
| `CLIMB` | `rope` | 1.0 |
| `LADDER` | `ladder` | 1.0 |
| `DEAD` | `dead` | 1.0 |
| `SIT` | `sit` | 1.0 |
Avatars don't use animationclip RUIDs here — they use the predefined `MapleAvatarBodyActionState` strings (`stand`/`walk`/`attack`/`alert`/`crouch`/`fall`/`sit`/`rope`/`ladder`/`dead`/`blink`/`fly`/`heal`/`hit`; full enum has 14 non-Invalid members, see `MapleAvatarBodyActionState` via `mlua_api_retriever`). `SetActionSheet(key, ruid)` on the avatar variant inserts an `AvatarBodyActionElement` with `AvatarBodyActionStateName = ruid` and default `PlayRate = 1` — adjust PlayRate by editing the element directly (or by editing the model's StateToAvatarBodyActionSheet entries) rather than re-setting via `SetActionSheet`, which resets it back to 1.
Player models also bring `PlayerControllerComponent` which auto-registers MOVE/ATTACK/CLIMB/LADDER/CROUCH/JUMP/FALL/SIT/ATTACK_WAIT and drives them from input. Removing PlayerControllerComponent breaks the default player flow. Two PlayerControllerComponent flags worth knowing:
- `AlwaysMovingState` (Sync, bool) — when `true`, the walk animation plays unconditionally regardless of input/movement. Useful for cutscene/forced-idle-walk states.
- `UseCustomScript` (ReadOnly, bool) — when `true`, some PlayerControllerComponent features are disabled so your own scripts can take over input → state mapping. Set via the `.model` for fully custom avatar control.
## 7. Pitfall table
| Symptom | Root cause | Fix |
|---|---|---|
| **Nothing animates at all** — sprite stuck on initial `stand`, neither `MOVE`/`move` nor `DEAD`/`die` clips switch, no error logs. State machine logs (`CurrentStateName` print) show transitions are happening. | You picked **Pattern B** (ActionSheet pipeline) but `StateComponent.IsLegacy` is missing/`true` on the `.model`. Legacy mode silently skips the `StateChangeEvent → ActionSheet → AnimationClipEvent` pipeline (see §0). | Either set `StateComponent.IsLegacy = false` on the `.model`, or switch to **Pattern A** and drive `SpriteRUID` directly from a script (canonical: `script.SoldierAI`). |
| `[LEA-3022] InvalidExecSpace : The addition and removal of states and conditions are disabled in execution spaces where you have no permission.` | `AddState` / `AddCondition` / `ChangeState` called on the side that **doesn't** own state-machine authority (see §4). | For monster/NPC: wrap in `@ExecSpace("ServerOnly")`. For player avatar: use `[client only]`. Never call on both sides — one side will always throw. |
| Custom-state animation never plays even though server logs show `ChangeState("MY_STATE")` succeeded | Pattern B: `StateComponent.IsLegacy` missing/`true` on the model (see §0). The pipeline is silently disabled in legacy mode regardless of whether the state is custom or built-in. | Set `StateComponent.IsLegacy = false` on the `.model`, or switch to Pattern A and call `sprite.SpriteRUID = <ruid>` from your `StateType:OnEnter` / handler. **Do not** mirror `AddState` on the client side — that throws `[LEA-3022]`. |
| `[LEA-3005] InvalidArgument : 'stateName' is not a valid argument` | Target state not registered on this entity (see §1) | Add the companion component that registers it, or `AddState(name, MarkerState)` first (on the side calling `ChangeState`) |
| Entity moves but `stand` clip keeps playing | `MovementComponent` was driven without `ChangeState("MOVE")` — and no AI component to auto-toggle | Add `AIChase`/`AIWander`, **or** call `ChangeState("MOVE")` / `ChangeState("IDLE")` from your controller |
| `SetActionSheet(...)` did nothing visible | `SetActionSheet` only edits the mapping; doesn't retrigger the current clip | Call `ChangeState` to a state that resolves to the new mapping (§3) |
| State changes but animation doesn't | Action key missing from ActionSheet, or wrong-cased key for monster/NPC (must be lowercase) | Fix the key; verify with `mlua_api_retriever` for the component |
| Avatar custom state animations broken | `AvatarStateAnimationComponent.IsLegacy` flipped to `true` | Set back to `false` (canonical default) |
| `ChangeState("MY_STATE")` does nothing on client | Custom states exist server-side only | Run from `[server only]`; guard event handlers with `if self:IsServer()` |
| `self.Entity` is nil inside StateType | StateType is not a Component | Use `self.ParentComponent.Entity` |
| HIT state loops forever | Treating HIT as a sticky state | Don't — HitComponent auto-returns to IDLE ~0.5s after entering HIT |
| State change races animation (avatar) | Default state-change condition fired before desired clip showed | If you need exact frames, use the `BodyActionStateChangeEvent` / `ActionStateChangedEvent` path instead of ActionSheet |
## 8. Verification
Don't ask "did it appear" — walk the state cycle:
1. Spawn → `CurrentStateName == "IDLE"`, idle clip plays.
2. Move begins → `CurrentStateName == "MOVE"`, move clip plays.
3. Hit → `CurrentStateName == "HIT"`, hit clip + damage skin → auto-return `IDLE` ~0.5s.
4. HP=0 (or `ChangeState("DEAD")`) → `CurrentStateName == "DEAD"`, die clip, `DeadEvent` fires, `IsAttackTarget` rejects further hits.
5. Each custom state you registered: confirm `ChangeState` succeeds (no `[LEA-3005]`), `CurrentStateName` updates, and the mapped clip plays.
If a clip looks stuck, log `CurrentStateName` per frame from a server script — distinguishes "state didn't change" from "ActionSheet key wrong".
### Pre-flight checklist
- [ ] Custom StateType scripts include **both** `@State` and `extends StateType`
- [ ] After `refresh`, a `.codeblock` with the same name exists next to the custom StateType `.mlua` — if missing, the annotation or `extends` is missing
- [ ] All state names are **UPPERCASE** at the StateComponent layer (`"ATTACK"` ✓, `"attack"` ✗)
- [ ] Before calling `ChangeState`, `AddState` was called with that name in `OnBeginPlay` (or registered by a companion component per §1.2)
- [ ] FSM operation methods use `@ExecSpace("ServerOnly")` for monster/NPC, **`[client only]` for player avatar** (§4 point 5) — never both sides
- [ ] No direct jumps from `DEAD` to states other than `IDLE` (§4.5 DEAD lock)
- [ ] Keys in `AvatarStateAnimationComponent.StateToAvatarBodyActionSheet` exactly match the state names registered in `StateComponent` (UPPERCASE for avatar; lowercase-converted for monster/NPC ActionSheet, see §6a)
- [ ] **`StateType.OnUpdate()` declared without `delta`** (the engine does not pass one; declaring `OnUpdate(number delta)` silently sets `delta = nil`)
- [ ] **`StateType.OnConditionCheck(string nextStateName)` declared with the argument** (the engine passes the candidate `to` name; omitting it disables `nextStateName`-based branching)
- [ ] When using auto transitions, `OnConditionCheck` is a cheap check even though it runs every frame (heavy work belongs in `OnUpdate`)
- [ ] `DisconnectEvent` external event handlers in `OnEndPlay` (if the StateType subscribed to events)
- [ ] **Pattern B**: `StateComponent.IsLegacy = false` on the `.model` (§0). Default is `true` (legacy) and silently disables the ActionSheet pipeline.
## 9. Reference — standard monster `IDLE/PATROL/CHASE/ATTACK/HIT/DEAD` FSM
A complete custom-FSM monster (Pattern B — uses `ChangeState` to drive `StateAnimationComponent`'s ActionSheet). `IDLE`/`DEAD` always auto-exist; `HIT` is auto-registered by `HitComponent` if attached. The rest is server-side.
```lua
@Component
script MonsterFSM extends Component
@ExecSpace("ServerOnly")
method void OnBeginPlay()
local sc = self.Entity.StateComponent
sc:AddState("PATROL", PatrolStateType)
sc:AddState("CHASE", ChaseStateType)
sc:AddState("ATTACK", AttackStateType)
-- IDLE / DEAD always exist; HIT exists if HitComponent is attached.
-- Auto transitions — each StateType's OnConditionCheck inspects the trigger
sc:AddCondition("PATROL", "CHASE") -- player detected
sc:AddCondition("CHASE", "ATTACK") -- entered attack range
sc:AddCondition("ATTACK", "CHASE") -- cooldown ended
sc:AddCondition("CHASE", "PATROL") -- target lost (branch via nextStateName or reverseResult)
sc:ChangeState("PATROL")
end
end
```
Each StateType's `OnConditionCheck` inspects distance / cooldown / target validity; `OnUpdate` handles in-state behavior. A clean separation:
```lua
@State
script AttackStateType extends StateType
property number Duration = 0.6
property number StartTime = 0
method void OnEnter()
self.StartTime = _TimerService:GetTime()
local entity = self.ParentComponent.Entity
-- Fire the attack resolution once on enter
entity.AttackComponent:AttackFast(BoxShape2D(1, 1), "monster_attack", CollisionGroups.Player)
end
method void OnUpdate()
if _TimerService:GetTime() - self.StartTime >= self.Duration then
self.ParentComponent:ChangeState("IDLE")
end
end
method void OnExit()
-- Cleanup (remove effects, etc.)
end
end
```
> The same skeleton scales up to boss phase branching (`PHASE1` / `PHASE2` with `OnConditionCheck` returning `Hp <= MaxHp * 0.5`, see §4.5 example) and down to a 3-state minion (`IDLE` ↔ `CHASE` ↔ `ATTACK`). Match the `ActionSheet` keys (lowercase per §6a) to the state names you register here.
## 10. Cross-references
| Doc | Why |
|---|---|
| [monster.md](monster.md) | Monster-specific composition, AI choices, HP/respawn, spawn, placement |
| [`../../msw-combat-system/references/ai-bt.md`](../../msw-combat-system/references/ai-bt.md) | The BT alternative to FSM (see §0.5) — `AIComponent` + Composite/Decorator + `@BTNode` |
| `mlua_api_retriever` MCP | API for `StateComponent`, `StateType`, `StateAnimationComponent`, `AvatarStateAnimationComponent`, `HitComponent`, `MovementComponent` |
| `mlua_document_retriever` MCP | Concept docs: "Controlling Entity Status", "Easy Control of Avatar Animation with ActionSheet", "Controlling Avatar Animations", "Setting and Controlling Player" |
| `msw-scripting` skill | Authoring `StateType`/Component/event-handler `.mlua` scripts |
references/authoring.md
# MSW File Authoring
Authoring guide for **.map / .model / .ui / .dataset** files and tile map assets in MSW world creation. Each file type has its own reference file — read only the topics you need.
> ⚠️ **Schema consistency warning (important)**
> `.map` / `.model` / `.ui` / `.tileset` / `.userdataset` / `.localedataset` are all large files with strict formats. Writing or modifying their JSON by hand easily produces **silent failures** like:
> - Missing model value metadata, duplicate UUIDs, broken component/value consistency
> - Mismatched tile map 2D array dimensions, `TileMapMode` ↔ Body mismatch, `tileIndex` offset
> - `.ui` anchor / pivot coordinate errors, missing RUID, parent-child `path` mismatch
> - Dataset row / column schema violations
>
> Use the dedicated builder or skill for each file type before editing. **The call protocol for `.map` / `.model` / `.ui` is one entry point — [`builder-protocol.md`](builder-protocol.md) (core) plus the per-builder files ([`builder-protocol-map.md`](builder-protocol-map.md) / [`builder-protocol-model.md`](builder-protocol-model.md) / [`builder-protocol-ui.md`](builder-protocol-ui.md)). The core + the matching per-builder file must be in context before any mutation (read only if missing).** `.model` files are builder-only. `.map` files are builder-first: see builder-protocol-map.md §1 (read alongside [`entity.md`](entity.md) for domain context) and use the builder for covered operations; direct `.map` JSON edits are reserved for the explicit coverage gaps in §1.6 and must be minimal scope plus verified by `refresh` / logs.
>
> **Entity reference binding (Entity/EntityRef property) is injected by the AI as a UUID string directly** — do not ask the user to drag in the Maker editor. Detail: `msw-scripting §7 Entity/Component reference properties`.
---
## Per-target Routing
| Task | File to read |
|------|--------------|
| Edit **tile map / tile set** in `.map` (TileMapMode, `tileMap` array, `.tileset`) | [tile.md](tile.md) |
| Create or modify a `.model` template | [model.md](model.md) — builder-only |
| Place **entities** in `.map`, spawn, parent-child, manage runtime components | [entity.md](entity.md) |
| `.map` / `.model` / `.ui` builder call protocol (unified) | [builder-protocol.md](builder-protocol.md) (core) + [builder-protocol-map.md](builder-protocol-map.md) / [builder-protocol-model.md](builder-protocol-model.md) / [builder-protocol-ui.md](builder-protocol-ui.md) |
| `.ui` authoring, component API, enums, mlua runtime patterns | **`msw-ui-system` skill** (single UI entry point — design guide + component API + builder invocation + runtime patterns) |
| `.userdataset` / `.localedataset` structure, types, runtime API | [dataset.md](dataset.md) |
| Template catalog when creating a new `.model` | [model.md §2.1](model.md) → `../models/*.model` |
| **Authoring a monster** (canonical components, `ActionSheet`, HitComponent, IsLegacy) | [monster.md](monster.md) → `../models/MonsterCanonical.model` |
### Keyword → File Map
- **tile, tile map, tileset, TileMapMode, RectTile, MapleTile, SideViewRectTile, tileIndex** → [`tile.md`](tile.md)
- **model, .model, template, NPC model, player, Foothold, Ladder, Rope, Portal, MapObject, particle, Sound, UIButton, Values, Children, BaseModelId** → [`model.md`](model.md) (+ builder + `../models/` catalog)
- **create monster, monster ActionSheet, stand/move/attack/hit/die/jump, HitComponent, IsLegacy, CollisionGroup, AIChase, AIWander, script.Monster, script.MonsterAttack** → [`monster.md`](monster.md) (+ `../models/MonsterCanonical.model`)
- **entity, .map, placement, spawn, SpawnService, CurrentMap, componentNames, modelId reference, hierarchy, Foothold** → [`entity.md`](entity.md)
- **UI, button, text, image, canvas, UITransform, anchoredPosition, AlignmentOption, UIGroup, DefaultShow, GridView, popup, anchor** → `msw-ui-system` skill (design, component API, and builder integrated)
- **dataset, UserDataSet, LocaleDataSet, translation, table, .userdataset, .localedataset, DataService** → [`dataset.md`](dataset.md)
---
## Shared Principles (across all 5 file types)
### Absolute Principles
1. **Prefer the dedicated skill or builder** — `.model` uses `ModelBuilder`; `.map` uses `MapBuilder`; `.ui` uses `msw-ui-system`; other files use their relevant reference/tooling.
2. **Inject entity references as UUID strings directly** — do not ask the user to drag in Maker.
3. **MCP `refresh` after every file change** (if in play mode, `stop` first).
4. **Never modify `Environment/*.d.mlua`** — API definitions are read-only.
5. **Never create or modify `.codeblock` by hand** — Maker `refresh` generates it from `.mlua`.
6. **Structured files prefer builders** — `.model` / `.ui` are builder-only. `.map` uses `MapBuilder` first; direct JSON edits are allowed only for unsupported gaps, with minimal scope and verification.
7. **Do not touch `Global/common.gamelogic` and the `common` entity** — these are special engine-managed entries. Do not edit the file's JSON directly, and do not attach components to the `common` entity (including via Maker `AddComponent` or runtime `AddComponent`). For global logic, **author a regular Logic script under `RootDesk/MyDesk/`** and wire up an entry point.
### UUID / ID Rules
- **`.model` identifiers are managed by `ModelBuilder`**. Use `fromTemplate()` / `renameModel()` instead of editing `EntryKey` or internal IDs directly.
- **Entity `id` in `.map` is managed by `MapBuilder`**, kept consistent with path and component metadata.
- **The id portion of `EntryKey` should be lowercase** (e.g., `model://mymonster`, `userdataset://itemtable`).
- When duplicating a file, **always generate a new UUID** with a cross-platform command: `node -e "console.log(require('node:crypto').randomUUID())"`.
### RUID Rules
- Resources are identified by an **RUID string**. If `SpriteRUID` is empty, the entity is **invisible on screen** (no error).
- In `.model`, set RUIDs through `ModelBuilder.value()`. `SpriteRUID` is a plain string.
- Use `msw-search` and `_ResourceService` for asset search. Replace temporary placeholders with real assets before deployment.
### Representation Consistency
- `.model` value descriptors are generated by `ModelBuilder.value()`. Pass an explicit `typeKey` for new or changed values.
- `.map` component values use a different representation; use `MapBuilder` for map edits.
### TileMapMode ↔ Body ↔ Entity
- The map root's `MapComponent.TileMapMode` (0/1/2) determines the **entire movement / gravity / collision / tile system**.
- If an entity's Body-family component does not match the map, it **does not move** (no error).
- Mapping table and check protocol: [platform.md §4](platform.md).
### Save Locations
- **New user models go under `RootDesk/MyDesk/`** (with a `Models/` subfolder; folder metadata comes from Refresh).
- **Adding new `.model` files arbitrarily under `Global/` may cause Maker to not recognize them.**
- Maps: `./map/`, UI: `./ui/`, datasets: under `RootDesk/MyDesk/`.
### Validation Loop
- **`refresh` → `logs`** → if needed, **`play` → `logs` → `stop`**.
- If a step fails, **stop later steps** — fix the cause and retry.
---
## Per File Type Summary
### `.map` tile map — [tile.md](tile.md)
The 3 `TileMapMode` values (MapleTile/RectTile/SideViewRectTile) completely change the tile map component (`TileMapComponent` vs `RectTileMapComponent`), the array key (`Tiles` vs `tileMap`), and the `TileSetRUID` form (DataId object vs `tileset://` string). Do not confuse tile coordinates (grid cells) with entity coordinates (world units).
### `.model` template — [model.md](model.md)
The blueprint for an entity. Pick the closest template from the **`../models/` catalog** (validated starting points for monsters/NPCs/players/terrain/UI/particles/sound/tile maps, etc.), load it with `ModelBuilder.fromTemplate()`, and customize it with builder methods. Spawn at runtime via `SpawnByModelId`, or place in `.map` by `modelId`.
### `.map` entity placement — [entity.md](entity.md)
Add entity instances under `.map`'s `ContentProto.Entities`. Use the `modelId` form (template reference + minimal override) or the inline form (`@components` listed in full). `id`/`path`/`componentNames`/`jsonString.path` consistency is mandatory. Runtime spawn uses `self.Entity.CurrentMap` as the parent.
### `.ui` — [`msw-ui-system`](../../msw-ui-system/SKILL.md)
Based on FHD 1920x1080 with the origin at center. Place via `UITransformComponent.anchoredPosition` + anchors (`AlignmentOption`, Anchors, Pivot) + `OffsetMin/Max` (do not touch `Position`). UIGroup separation principle, `DefaultShow`, Enable vs Visible, Connect / Disconnect event pairs. UI entities are **client-only** — server RPC / Sync do not work on them.
### `.dataset` — [dataset.md](dataset.md)
`UserDataSet` / `LocaleDataSet` each consist of a **`.userdataset`/`.localedataset` metadata wrapper + `.csv` sidecar pair**. The CSV holds the actual tabular data (all cells are strings); the wrapper holds the `EntryKey`, `name` (runtime lookup key), and `serveronly` flag. Required column rules for LocaleDataSet: `Key`/`Source`/`Note` + locale columns. Runtime APIs: **`_DataService:GetTable(name)` / `:GetCell` / `:GetRowCount`** for UserDataSet, **`_LocalizationService:GetText(key)`** (ClientOnly) for LocaleDataSet. Prefer Maker UI for create / delete.
---
## Related Skills / Documents
| Target | Purpose |
|--------|---------|
| [platform.md](platform.md) (core) | TileMapMode ↔ Body, SpriteRUID, spawn, coordinates, folder metadata, ID generation, `.config` (common to all map types) |
| [platform-maple.md](platform-maple.md) / [platform-rect.md](platform-rect.md) / [platform-sideview.md](platform-sideview.md) | Per-map-type physics, events, patterns, and checklists |
| [troubleshooting.md](troubleshooting.md) | Symptom → cause → fix reference (e.g., `LEA-3004`) |
| [workspace.md](workspace.md) | Workspace / hierarchy / file path rules |
| `msw-scripting` | Component/Logic, properties, lifecycle, @ExecSpace |
| `msw-defaultplayer` | Player model, Values, Body components |
| `msw-search` | RUID / asset / document search |
references/builder-protocol-map.md
# Builder Protocol — §1 MapBuilder (`.map`)
Per-builder file of the unified Builder Protocol. **[builder-protocol.md](builder-protocol.md) (core) must be in context alongside this file** — routing, the common workflow, the cross-builder chaining contract, §0 pre-flight, §4 cross-builder flow, and the §5 checklist live in the core and are not repeated here. Mutating another file type in the same turn requires that type's per-builder file too.
## Method index — what `MapBuilder` actually exposes
Alpha-sorted **camelCase** method names. **camelCase is canonical** — if a name does not appear here, do not call it. Signatures live in §1.3 below. Internal helpers (prefixed with `_`) are omitted.
**`MapBuilder`** — instance: `build` · `component` · `empty` · `entity` · `find` · `getFootholdBounds` · `getFootholds` · `getMapInfo` · `getTileAt` · `getTileBounds` · `getTileMapMode` · `getTiles` · `listEntities` · `patch` · `patchComponent` · `placeModel` · `remove` · `removeComponent` · `rename` · `snapshot` · `sprite` · `upsertComponent` · `write`. Static: `MapBuilder.fromTemplate` · `MapBuilder.load` · `MapBuilder.read` · `MapBuilder.snapshot` · `MapBuilder.templatePath`.
## §1 MapBuilder — `.map`
`MapBuilder` covers the safe subset needed for common agent map work. It does not replace Maker. Use it first for any covered operation; raw `.map` editing is allowed only for the explicit gaps listed in §1.6.
### §1.1 Load / Inspect
```javascript
const { MapBuilder } = require("./scripts/map/msw_map_builder.cjs");
const map = MapBuilder.read("map/map01.map");
MapBuilder.snapshot("map/map01.map"); // summary only, no instantiation
map.getMapInfo(); // TileMapMode, Gravity, IsInstanceMap, entity/tile/foothold counts
map.getTileMapMode(); // 0 MapleTile / 1 RectTile / 2 SideViewRectTile
map.listEntities(); // compact entity list
map.find("map01"); // by map root name
map.find("Monster01"); // child by relative name or /maps/... absolute path
map.component("Monster01", "MOD.Core.TransformComponent");
```
### §1.2 Snapshot Workflow (get → edit → set)
```
1. GET MapBuilder.read("./map/{map}.map")
2. EDIT builder API only (placeModel / sprite / patch / patchComponent / ...)
3. SET map.write("./map/{map}.map")
4. SYNC Maker MCP `refresh`
5. (opt.) `play` → verify via `logs`
```
`.map` `Entities` arrays are very large. Direct raw JSON editing is reserved for the §1.6 coverage gaps and must stay minimal — everyday work goes through the builder snapshot/patch API.
### §1.3 API Reference
| Method | Returns | Purpose |
|---|---|---|
| `MapBuilder.fromTemplate(templatePath, mapName)` | `MapBuilder` | Clone a Maker-saved `.map`, rewriting `EntryKey`, root path, root name, entity UUIDs, and internal UUID/path references |
| `MapBuilder.read(path)` | `MapBuilder` | Load a `.map` |
| `MapBuilder.snapshot(path)` | summary | Read-only summary without instantiating |
| `MapBuilder.templatePath(kind)` | absolute path | Skill-local validated map template: `maple`/`0`, `rect`/`1`, `sideview`/`2` |
| `getMapInfo()` | summary | TileMapMode, gravity, instance flag, counts |
| `getTileMapMode()` | `0`/`1`/`2` | MapleTile / RectTile / SideViewRectTile |
| `listEntities()` | array | Compact entity list |
| `find(name)` | entity record | Lookup by map root name, relative child name, or `/maps/...` path |
| `component(name, compType)` | component object | Read a component on an entity |
| `placeModel(name, modelPath, opts)` | `MapBuilder` | Place a `.model` instance (`pos`, `componentOverrides`, ...). Root id via `lastId()` |
| `sprite(name, opts)` | `MapBuilder` | Sprite-renderer entity (`ruid`, `pos`, `order`). Id via `lastId()` |
| `empty(name, opts)` | `MapBuilder` | Empty / script-only entity (`pos`, `scripts`). Id via `lastId()` |
| `entity(name, components, opts)` | `MapBuilder` | Low-level entity placement. Id via `lastId()`. Upsert: existing-path root metadata (`name`/`nameEditable`/`enable`/`visible`/`localize`/`modelId`/`origin`/`displayOrder`) is preserved unless overridden in `opts`. `@components` is rebuilt from the caller's array (caller's components are authoritative when calling `entity()` directly). `sprite()` / `empty()` / `placeModel()` route through `entity()` with an internal preserve flag: when the caller does NOT pass `pos` on re-call, the existing `MOD.Core.TransformComponent` is reused so the entity stays in place; passing `pos` triggers full transform replacement. To move an existing entity, pass `pos` explicitly to the same creator or call `patch({ pos })` / `patchComponent("MOD.Core.TransformComponent", { Position })` |
| `patch(name, updates)` | `MapBuilder` | Position / enable / rename in one call. Throws if `name` missing |
| `patchComponent(name, compType, fields)` | `MapBuilder` | Field-level component update. Throws if entity or component missing |
| `upsertComponent(name, compType, body)` | `MapBuilder` | Add or replace a component. Throws if entity missing |
| `removeComponent(name, compType)` | `MapBuilder` | Drop a component. Throws if entity or component missing |
| `rename(oldName, newName)` | `MapBuilder` | Rename an entity. Throws if `oldName` missing |
| `remove(name)` | `MapBuilder` | Delete an entity and its descendants. Throws if `name` missing |
| `lastId()` | UUID string \| `null` | UUID of the entity targeted by the most recent creator call — new path → fresh UUID, existing path → existing UUID (upsert). Not touched by update/remove mutators |
| `getTiles()` / `getTileAt(x,y)` / `getTileBounds()` | tile data | Tile inspection |
| `getFootholds(layer)` / `getFootholdBounds(layer)` | foothold data | Foothold inspection |
| `build()` | JSON | In-memory map JSON |
| `snapshot()` | summary | Current builder-state summary |
| `write(path)` | `MapBuilder` | Save back to `.map` |
Read-only inspection is `find()` + `component()`. To read raw entity JSON when the builder cannot cover the case, fall back to parsing the `.map` file's `ContentProto.Entities[*].jsonString` directly (only within a §1.6 gap).
`MapBuilder` throws when the target is missing (`patch` / `rename` / `upsertComponent` / `patchComponent` / `removeComponent` / `remove`). Use `find()` to pre-check if conditional behavior is needed.
```javascript
MapBuilder.read("map/map01.map")
.patch("Slime01", { pos: [5, 1, 0], enable: true })
.patchComponent("Slime01", "MOD.Core.SpriteRendererComponent", { OrderInLayer: 20 })
.write("map/map01.map");
```
### §1.3.1 New map files
Use `MapBuilder.fromTemplate(MapBuilder.templatePath(kind), mapName)`; do not start from `new MapBuilder(...)` or a blank JSON shell. Skill-local validated template kinds: `maple`/`0` = MapleTile, `rect`/`1` = RectTile, `sideview`/`2` = SideViewRectTile.
`mapName` is the plain map id: no `map://` prefix, no `.map` suffix, no path separators. The builder preserves terrain and map settings, rewrites `EntryKey` to `map://{mapName}`, rewrites `/maps/{old}` paths to `/maps/{mapName}`, regenerates every entity UUID, and updates internal UUID/path strings. After writing `map/{mapName}.map`, add `map://{mapName}` to `Global/SectorConfig.config` if the map should be reachable by the world.
```javascript
MapBuilder.fromTemplate(MapBuilder.templatePath("rect"), "city01")
.write("map/city01.map");
```
### §1.4 Entity Placement
Prefer `.model` + `modelId` placement for repeated or runtime-spawned content. `pos` accepts `[x, y, z]` (preferred), `{ x, y, z }`, or the exported `vector3(x, y, z)` helper; all normalize to the same component value.
> ⚠️ Unknown option keys are silently ignored — only `pos` and `componentOverrides` are read. Keys like `position`, `transform`, `location` are dropped without warning, so the entity spawns at `(0,0,0)` with no error.
> ⚠️ **Asymmetric re-call behavior.** `sprite()` / `empty()` / `placeModel()` on an existing path are NOT a full replace:
>
> - **`MOD.Core.TransformComponent`** — preserved when the call does NOT pass `pos`. Re-calling `mb.sprite("Tree", { ruid: "newRUID" })` (no `pos`) keeps the existing Position. Passing `pos` explicitly (`mb.sprite("Tree", { pos: [5, 5, 0], ruid: "newRUID" })`) triggers full replacement and moves the entity.
> - **Non-Transform components** (`SpriteRendererComponent` fields, scripts list, anything in the model template for `placeModel`) — **always rebuilt** from the call's arguments. Re-calling `mb.sprite("Tree", { ruid: "newRUID" })` after an earlier `mb.sprite("Tree", { color: "red" })` resets `Color` to the default because the new call did not pass `color`. For incremental updates to non-Transform components, use `patchComponent` / `upsertComponent`.
> - **`entity()` called directly** — caller's components array is authoritative; no preserve flag. The internal preservation only applies to the higher-level `sprite()` / `empty()` / `placeModel()` paths.
> - **`placeModel()` descendants** — wiped entirely on re-call regardless of `pos`. See the placeModel warning in [builder-protocol.md](builder-protocol.md) §4.
```javascript
map.placeModel("Monster01", "RootDesk/MyDesk/Models/Monsters/Slime.model", {
pos: [3, 1, 0],
});
map.sprite("Tree01", {
ruid: "1705e3c5b2c146ac9a699f96fb067408",
pos: [-2, 0, 0],
order: 5,
});
map.empty("WaveController", {
pos: [0, 0, 0],
scripts: ["script.WaveController"],
});
```
`placeModel()` mirrors the model's component list into the map instance and applies `Values` / property links to matching component fields. Per-instance overrides go in `componentOverrides`.
```javascript
map.placeModel("FastMonster01", "RootDesk/MyDesk/Models/Monsters/FastMonster.model", {
pos: [5, 1, 0],
componentOverrides: {
"MOD.Core.MovementComponent": { InputSpeed: 1.4 },
},
});
```
#### `modelId` vs Inline — decision rule
| Situation | Form |
|---|---|
| Same composition placed **≥2 times** in this map | **`modelId`** (always — author a `.model` first if none exists) |
| Same composition reused in **another map** | **`modelId`** |
| Will be spawned at runtime via `SpawnByModelId` | **`modelId`** (required) |
| Truly one-off composition that will never recur | inline `@components` is acceptable |
> When in doubt, choose `modelId`. Five inline copies of "the same monster" silently drift over edits (one gets `IsLegacy: true`, another loses `SortingLayer`). The model anchors the canonical values; a single edit propagates.
> ⚠️ **`modelId` is not an authoring-form signal.** `sprite()` / `empty()` also set it — to the shared system models `mapobject` / `mapempty` — so `listEntities()` shows a non-null `modelId` for every placed entity. You cannot tell "inline" from a `.model` instance by the field, and never edit `mapobject` / `mapempty` to change one sprite (they are shared by every such entity). To change any placed entity's content, mutate its map `@components` (`patchComponent` / `upsertComponent` / re-call the creator) — the map's inline `@components` is authoritative on load.
### §1.5 Map Mode Rules
Always confirm `TileMapMode` before any map work ([builder-protocol.md](builder-protocol.md) §0 Pre-flight). The builder can **read** the mode but never **write** it — mode switching is a Maker Hierarchy right-click operation.
The AI must never write `MapComponent.TileMapMode` directly. Mode switching swaps tile components, rebuilds footholds, and converts tile-data formats — Maker handles all of that internally.
Guide the user to switch the mode in Maker:
1. Open the Maker editor's **Hierarchy** window.
2. **Right-click the target map entity**.
3. From the context menu, choose the matching **"Switch ..."** option (Switch TileMap / RectTileMap / SideViewRectTileMap).
4. After the user reports the switch is complete, call MCP **`refresh`**, then re-read `getTileMapMode()` to verify and re-check every dynamic entity's Body component against the new mode.
### §1.6 Coverage gaps (operations the builder intentionally does not cover)
Use Maker UI first, or carefully scoped direct `.map` edits, when a task requires one of these — in either case, verify with `refresh` + logs.
- New map creation from an arbitrary blank schema
- `TileMapMode` switching
- Most tile-painting workflows
- Foothold add / delete / re-chain authoring
- MapLayer creation, rename, sorting, visibility, and locking
- Background editing
- Portal / SpawnLocation / SectorConfig high-level workflows
- RectTileMap-specific high-level editing
- Collision / sorting layer / camera / map bounds / map area high-level APIs
- Maker internal migration or normalization behavior
> Before filling any gap, verify the behavior against a Maker-saved file or engine metadata and add a focused smoke test.
### §1.7 Tile-map entity transform is locked
The map's tile-grid container — the entity carrying `TileMapComponent` (MapleTile) or `RectTileMapComponent` (RectTile / SideViewRectTile) — has its `TransformComponent` **locked by the tile-map component itself**. Writes to `TransformComponent.Position` / `EulerAngles` / `Scale` are **silently rejected** with a `LWA-3047 NativeIssue_UnableToChange` warning. The engine keeps this entity at a fixed origin (`(0, 0, z)`, or a half-cell offset for odd-grid RectTile maps) so tile coordinates and world coordinates stay in a known relationship.
This applies whether the entity was placed via `modelId` or as inline `@components` — the lock comes from the tile-map component, not the authoring form. Do not try to move the tile-map entity. Anchor your game's coordinate system to the locked origin: keep gameplay anchors (grid origin, spawn points, path waypoints) in tile coordinates and convert via the tile-map component's helpers (e.g. `RectTileMapComponent:ToWorldPosition(cellPos)`).
Symptoms when the rule is ignored:
- A `Position` written into `.map` JSON reverts to `(0, 0, z)` after Maker `refresh`.
- Runtime `TransformComponent.Position = ...` writes have no observable effect; `logs` shows `[LWA-3047] UnableToChange`.
- Adding a custom child entity to the tile-map entity works, but the child's effective world position is still measured relative to the locked parent at `(0, 0)`.
Decoration / spawn anchor / overlay entities that need to be elsewhere should live as **siblings under the map root, not as children of the tile-map entity**.
### §1.8 Entity instance invariants in `.map`
- **`id`**: UUID v4 (with hyphens). Generate a fresh one for new entities.
- **`path`**: `/maps/{mapname}/{entityname}` — parent-child hierarchy is the path prefix.
- **`componentNames`**: comma-joined `@type` values of `@components`, **kept in sync at all times**.
- **`jsonString.path`**: identical to the outer `path`.
- **`pathConstraints`**: root `//`, child `///`.
- **`displayOrder`**: avoid overlap among siblings.
For `modelId` entities, use `MapBuilder.placeModel()` — it creates the model-instance metadata, keeps component names in sync, mirrors model components, and applies per-instance `TransformComponent.Position` / `componentOverrides`.
Adding a new map to the world may require appending `map://{mapId}` to `entries` in `Global/SectorConfig.config`.
### §1.9 RPC → File-Based replacement table (legacy Maker RPC removed)
| Old (RPC) | Current equivalent |
|---|---|
| Create entity | Author `.model` under `RootDesk/MyDesk/Models/{Category}/` + place with `MapBuilder.placeModel()` |
| Delete entity | `MapBuilder.remove()` |
| Change property | `MapBuilder.patchComponent()` for map instances; `ModelBuilder` Values for templates |
| Add/remove component | `MapBuilder.upsertComponent()` / `removeComponent()` for one-off map-local changes |
| Register / edit / delete model | CRUD `.model` files under `RootDesk/MyDesk/` (`refresh`) |
| List entities | `MapBuilder.snapshot()` / `listEntities()` |
references/builder-protocol-model.md
# Builder Protocol — §2 ModelBuilder (`.model`) + CollisionGroupSetBuilder
Per-builder file of the unified Builder Protocol. **[builder-protocol.md](builder-protocol.md) (core) must be in context alongside this file** — routing, the common workflow, the cross-builder chaining contract, §0 pre-flight, §4 cross-builder flow, and the §5 checklist live in the core and are not repeated here. Mutating another file type in the same turn requires that type's per-builder file too.
## Method index — what `ModelBuilder` / `CollisionGroupSetBuilder` actually exposes
Alpha-sorted **camelCase** method names. **camelCase is canonical** — if a name does not appear here, do not call it. Signatures live in §2.3 / §2.7 below. Internal helpers (prefixed with `_`) are omitted.
**`ModelBuilder`** — instance: `addComponent` · `build` · `child` · `childComponent` · `childEnable` · `childEventLink` · `childFromModel` · `childFromTemplate` · `childProperty` · `childValue` · `childVisible` · `component` · `enable` · `entityEnable` · `entityVisible` · `eventLink` · `getChild` · `getChildValue` · `getValue` · `getValueEntry` · `hasChild` · `hasComponent` · `hasValue` · `listChildren` · `listComponents` · `listEventLinks` · `listValues` · `moveChild` · `property` · `removeChild` · `removeChildComponent` · `removeChildEventLink` · `removeChildProperty` · `removeChildValue` · `removeComponent` · `removeEventLink` · `removeProperty` · `removeValue` · `renameChild` · `renameModel` · `setBaseModelId` · `setChildBaseModelId` · `snapshot` · `upsertEventLink` · `validate` · `value` · `write`. Static: `ModelBuilder.fromTemplate` · `ModelBuilder.load` · `ModelBuilder.read` · `ModelBuilder.snapshot`.
**`CollisionGroupSetBuilder`** — instance: `addGroup` · `build` · `getGroup` · `getGroupId` · `hasGroup` · `listCollisions` · `listGroups` · `removeGroup` · `renameGroup` · `setCollidable` · `setCollidesWith` · `setGroupId` · `snapshot` · `validate` · `write`. Static: `CollisionGroupSetBuilder.load` · `CollisionGroupSetBuilder.read` · `CollisionGroupSetBuilder.snapshot`.
## §2 ModelBuilder — `.model`
A `.model` is an entity template. AI agents **do not** inspect or edit its JSON directly. All read / create / update / write operations go through the skill-local CJS builder:
```javascript
const { ModelBuilder, vector3 } = require("./scripts/model/msw_model_builder.cjs");
```
### §2.0 Non-Negotiable Rule
Do not use `Read`, `cat`, `Get-Content`, `grep`, or manual JSON patches on `.model` files for normal authoring.
Use:
- `ModelBuilder.read(filepath)` / `ModelBuilder.snapshot(filepath)` to inspect existing models.
- `ModelBuilder.fromTemplate(templatePath, name, { model_id })` to create from a shipped template.
- `component()`, `value()`, `property()`, `child()`, `childFromTemplate()`, `childFromModel()`, `eventLink()`, `setBaseModelId()`, `renameModel()` to mutate.
- `write(filepath)` to save, then Maker `refresh`.
The builder owns `EntryKey`, `ContentProto.Json.Id/Name`, value type descriptors, inspector-property links, child model shape, and event-link preservation.
Prefer fluent chaining for normal create/update flows. The mutation methods above return the builder instance, and `write()` also returns the builder after saving. Keep inspection and conditional methods outside the chain: `snapshot()`, `validate()`, `build()`, `get*()`, `has*()`, `list*()`, and boolean-returning removals return data or booleans, not the builder.
### §2.1 Template Catalog
Never start from a blank model. Pick the closest template from the skill-local `models/` folder and load it with `ModelBuilder.fromTemplate()`.
**Template path rule** — templates live in this skill's own `models/` folder, sibling to `scripts/` and `references/`. `fromTemplate`'s first argument is resolved against `process.cwd()`, so always pass either an **absolute path** or a `__dirname`-derived path. Never guess. Templates are **not** under `Global/`, `RootDesk/`, `MyDesk/`, or a top-level `Models/` — those are output locations. An error like `model file not found: ./Global/<Name>.model` means the path was fabricated; recompute it from the skill location, do not create a file there.
```javascript
const path = require("path");
const templateDir = path.join(__dirname, "..", "models"); // from a script under scripts/model/
ModelBuilder.fromTemplate(path.join(templateDir, "ChaseMonster.model"), "MyMonster");
```
#### Base
| Template | Use |
|---|---|
| `../models/TransformOnly.model` | Empty entity with only `TransformComponent` |
#### Characters / Players
| Template | Use |
|---|---|
| `../models/Player.model` | Player variant |
| `../models/DefaultPlayer.model` | DefaultPlayer customization, usually with `BaseModelId` |
#### Monsters
Read [`monster.md`](monster.md) before authoring a monster.
| Template | Use |
|---|---|
| `../models/MonsterCanonical.model` | Default start for new monsters |
| `../models/ChaseMonster.model` | Chasing side-view monster (caveats in [`monster.md`](monster.md)) |
| `../models/MoveMonster.model` | Patrol movement monster |
| `../models/StaticMonster.model` | Stationary attacker |
#### NPC / Interaction
| Template | Use |
|---|---|
| `../models/StaticNPC.model` | Static NPC with dialogue / name tag |
#### Terrain
| Template | Use |
|---|---|
| `../models/Foothold.model` | MapleTile foothold |
| `../models/Ladder.model` | Climbable ladder |
| `../models/Rope.model` | Climbable rope |
| `../models/Portal.model` | Map portal / teleport trigger |
#### Map Objects / Decoration
| Template | Use |
|---|---|
| `../models/MapObject.model` | Generic decorative object |
| `../models/ParticleMapObject.model` | Object with particles |
| `../models/SkeletonMapObject.model` | Skeleton-based animated object |
| `../models/ItemAsset.model` | Item display |
#### Particles / Effects
| Template | Use |
|---|---|
| `../models/BasicParticle.model` | Generic particle |
| `../models/SpriteParticle.model` | Sprite-sheet particle |
| `../models/AreaParticle.model` | Area effect |
| `../models/AnimationPlayer.model` | One-shot animation effect |
#### Sound
| Template | Use |
|---|---|
| `../models/Sound.model` | Position-based sound |
| `../models/SoundEffect.model` | One-shot SFX |
#### Tilemap Containers
| Template | Use |
|---|---|
| `../models/TileMap.model` | MapleTile tile container |
| `../models/RectTileMap.model` | RectTile / SideViewRectTile tile container |
| `../models/MapleMapLayer.model` | Maple-style map layer |
| `../models/MapEmpty.model` | Empty map container |
#### External Media / UI Prefabs
| Template | Use |
|---|---|
| `../models/WebSprite.model` | External image URL |
| `../models/YoutubePlayerWorld.model` | YouTube world object |
| `../models/UIButton.model` | UI button prefab |
| `../models/UIText.model` | Simple UI text prefab |
| `../models/UITextGUIRenderer.model` | Text GUI renderer prefab |
| `../models/UISprite.model` | UI sprite prefab |
| `../models/UIGroup.model` | UI group prefab |
| `../models/UIEmpty.model` | Empty UI prefab |
> For full UI layout work, use the `msw-ui-system` skill's `UIBuilder` ([builder-protocol-ui.md](builder-protocol-ui.md) §3) instead of authoring UI models directly.
### §2.2 Builder Workflow
#### Create from Template
```javascript
const path = require("path");
const { ModelBuilder, vector3 } = require("./scripts/model/msw_model_builder.cjs");
const b = ModelBuilder.fromTemplate(
path.join(__dirname, "..", "models", "TransformOnly.model"),
"MyObject"
);
b.component("SpriteRendererComponent")
.value("SpriteRendererComponent", "SpriteRUID", "1705e3c5b2c146ac9a699f96fb067408", "string")
.value("TransformComponent", "Position", vector3(0, 1, 0), "vector3")
.write("RootDesk/MyDesk/Models/MapObjects/MyObject.model");
console.log(b.snapshot());
```
#### Patch Existing Model
```javascript
const b = ModelBuilder.read("RootDesk/MyDesk/Models/Monsters/Slime.model");
b.value("MovementComponent", "InputSpeed", 2.5, "float")
.value("SpriteRendererComponent", "SpriteRUID", "1705e3c5b2c146ac9a699f96fb067408", "string")
.write("RootDesk/MyDesk/Models/Monsters/Slime.model");
console.log(b.snapshot());
```
Removal mutators throw when the target value/property/child/event-link is missing. Guard with `hasValue` / `hasChild` if you need idempotent semantics:
```javascript
const b = ModelBuilder.read("RootDesk/MyDesk/Models/Monsters/Slime.model");
if (b.hasValue("MovementComponent", "InputSpeed")) {
b.removeValue("MovementComponent", "InputSpeed");
}
b.value("MovementComponent", "InputSpeed", 2.5, "float")
.write("RootDesk/MyDesk/Models/Monsters/Slime.model");
```
#### Inspector Property
```javascript
b.property("speed", {
target: "MovementComponent",
property: "InputSpeed",
type_key: "float",
display_name: "Movement Speed",
show_in_inspector: true,
});
```
#### Child Entity
A `.model` describes a tree of entities. The root carries top-level `Components` / `Properties` / `Values` / `EventLinks`; additional entities live in `Children`.
**Child shell schema** — each entry in the root's `Children` array is a wrapper around a full inner model:
| Field | Meaning |
|---|---|
| `Id` | UUID of this child entity. Equals `Model.Id` for builder-created children |
| `ParentId` | UUID of the parent — either the root `model_id`, or another child's `Id` for nested trees |
| `Name` | Display name |
| `Model` | A complete model definition with the same schema as the root: `Version`, `Name`, `Id`, `BaseModelId`, `Components`, `Properties`, `Values`, `EventLinks`, `Children` |
| `ModelReplaced?` | Optional boolean flag set by `childFromTemplate` / `childFromModel` / `{ modelReplaced: true }` |
**Tree representation** — the builder stores all descendants in **one flat array** (`this.children`); the tree shape is recovered from `ParentId`. The inner `Model.Children` array is preserved on round-trip but the builder does **not** read from or write to it — to add grandchildren, pass `{ parent: "..." }` to `child()` so the new entry goes into the flat list with the right `ParentId`.
**Invariants**:
- `child.Id === child.Model.Id` for builder-created children. Templates may diverge unless `preserve_model_id: false` is used (which `childFromTemplate` defaults to).
- `child.ParentId` must point to the root `model_id` **or** another existing child's `Id`. Orphan values are rejected by `validate()` rule M034.
- Each child owns its `Components` / `Values` / `Properties` / `EventLinks` independently. **No implicit inheritance from the root** — to share a base, set `BaseModelId` on the child via `setChildBaseModelId`.
- New children automatically receive `MOD.Core.MODEntity.Enable = true` and `MOD.Core.MODEntity.Visible = true` in their `Values`.
- `renameModel(newName, newId)` rewrites only those `child.ParentId` entries that equal the old root `model_id`; nested (child-of-child) links are left intact, which is correct.
- Child `TransformComponent.Position` is **parent-local**, not world. In MSW 2D only X/Y are meaningful — depth ordering is controlled by `SpriteRendererComponent.SortingLayer` + `OrderInLayer`, not `z`. A child `QuaternionRotation` with `w = -1` is the common horizontal-flip pattern (alternative to `FlipX`).
**Examples**:
```javascript
b.child("WeaponSlot", ["TransformComponent", "SpriteRendererComponent"])
.childValue("WeaponSlot", "TransformComponent", "Position", vector3(0.5, 0, 0), "vector3");
```
For Maker-style model hierarchy work, prefer the options form — stable IDs, nested parents, template-backed children, model inheritance, and child-local properties / event links:
```javascript
b.child("Body", {
components: ["TransformComponent", "SpriteRendererComponent"],
id: "body",
enable: true,
visible: true,
})
.child("NameTag", {
parent: "Body",
components: ["TransformComponent", "TextComponent"],
id: "name_tag",
})
.childValue("NameTag", "TransformComponent", "Position", vector3(0, 1.1, 0), "vector3")
.childProperty("NameTag", "text", {
target: "TextComponent",
property: "Text",
type_key: "string",
});
```
Clone a shipped template as a child:
```javascript
b.childFromTemplate("Aura", "./skills/msw-general/models/BasicParticle.model", {
parent: "Body",
id: "aura",
preserve_model_id: false,
});
```
Use `model_id` / `base_model_id` only when the child is intentionally tied to a registered model identity. Otherwise let the builder create an owned child model ID from the child ID.
**Validation rules for children** — `b.validate()` (called automatically by `b.write()`) reports these schema violations:
| Rule | Trigger | Fix |
|---|---|---|
| M030 | Child has no `Id` | `child()` auto-fills with `randomUuid()`; only fires for hand-built shells |
| M031 | Child has no `ParentId` | Use `child()` / `moveChild()`, never write the shell directly |
| M032 | Two children share an `Id` | Pass distinct `id` options, or let the builder generate UUIDs |
| M033 | A child `Values` entry has no `ValueType.type` | Always pass `typeKey` when calling `childValue()` |
| M034 | `ParentId` does not match the root or any other child's `Id` | Pass an existing name/id to `parent`; `moveChild()` resolves names automatically |
| M035 | `ParentId === Id` (self-parenting) | `moveChild()` rejects this; only triggered by manual edits |
| M036 | Cycle in the `ParentId` chain | Avoid `moveChild()` calls that close a loop |
#### Event Link
EventLinks are intentionally generic because project shapes vary.
```javascript
b.eventLink({ Id: "openDialog", EventName: "TouchEvent", Target: "DialogLogic" }, { key: "Id" });
b.removeEventLink("Id", "openDialog");
```
### §2.3 Builder API Quick Reference
```javascript
new ModelBuilder(name, { model_id, base_model_id });
ModelBuilder.read(filepath);
ModelBuilder.load(filepath);
ModelBuilder.snapshot(filepath);
ModelBuilder.fromTemplate(templatePath, name, { model_id });
b.snapshot();
b.renameModel(name, modelId);
b.setBaseModelId(baseModelIdOrNull);
b.validate();
b.component(compName);
b.addComponent(compName);
b.hasComponent(compName);
b.removeComponent(compName);
b.listComponents(); // silent — returns array of component type names
b.printComponents(); // listComponents() + log each
b.value(targetType, name, val, typeKey);
b.getValue(targetType, name, fallback);
b.getValueEntry(targetType, name);
b.hasValue(targetType, name);
b.removeValue(targetType, name);
b.enable(targetType, enabled);
b.entityEnable(enabled);
b.entityVisible(visible);
b.listValues(); // silent — returns cloned values array
b.printValues(); // listValues() + log each
b.property(name, { target, property, type_key, display_name, show_in_inspector });
b.removeProperty(name);
b.child(name, components);
b.child(name, { components, parent, id, model_id, base_model_id, enable, visible });
b.childFromTemplate(name, templatePath, options);
b.childFromModel(name, modelJsonOrContent, options);
b.getChild(name);
b.hasChild(name);
b.childComponent(childName, compName);
b.removeChildComponent(childName, compName);
b.childValue(childName, targetType, name, val, typeKey);
b.getChildValue(childName, targetType, name, fallback);
b.removeChildValue(childName, targetType, name);
b.childEnable(childName, enabled);
b.childVisible(childName, visible);
b.childProperty(childName, name, { target, property, type_key, display_name, show_in_inspector });
b.removeChildProperty(childName, name);
b.setChildBaseModelId(childName, baseModelId);
b.moveChild(childName, parentNameOrId);
b.renameChild(childName, newName);
b.childEventLink(childName, linkObject, { key });
b.removeChildEventLink(childName, key, value);
b.removeChild(name);
b.listChildren(); // silent — returns cloned children array
b.printChildren(); // listChildren() + log summary
b.eventLink(linkObject, { key });
b.upsertEventLink(linkObject, { key });
b.removeEventLink(key, value);
b.listEventLinks(); // silent — returns cloned event_links array
b.printEventLinks(); // listEventLinks() + log each
b.build();
b.write(filepath, { ensure_sprite_ruid: true });
```
**Every mutator chains** (`return this`). Missing target → `Error`. See the cross-builder chaining contract above.
- Creators / updaters: `renameModel()`, `setBaseModelId()`, `component()`, `addComponent()`, `value()`, `enable()`, `entityEnable()`, `entityVisible()`, `property()`, `child()`, `childFromTemplate()`, `childFromModel()`, `childComponent()`, `childValue()`, `childEnable()`, `childVisible()`, `childProperty()`, `setChildBaseModelId()`, `moveChild()`, `renameChild()`, `childEventLink()`, `eventLink()`, `upsertEventLink()`, `write()`.
- Removers (throw on miss): `removeComponent()`, `removeChildComponent()`, `removeValue()`, `removeProperty()`, `removeChildValue()`, `removeChildProperty()`, `removeChildEventLink()`, `removeChild()`, `removeEventLink()`.
- Inspection (no chaining): `snapshot()`, `validate()`, `build()`, `get*()`, `has*()`, `list*()` — call on their own line.
**`typeKey` values**: `bool`, `int`, `long`, `float`, `double`, `string`, `vector2`, `vector3`, `quaternion`, `collision_group`, `data_ref`, `sync_string_dict`, `action_sheet`.
**Helpers**: `vector2`, `vector3`, `quaternion`, `collisionGroup` / `collision_group`, `dataRef` / `data_ref`, `actionSheet`.
`SpriteRUID` is a plain string. Do not wrap it in `dataRef()`.
The default generated MOD.Core assembly version is `26.7.0.0`. If a different project CoreVersion requires a different version for newly generated value type blocks, set `MSW_MODEL_BUILDER_MOD_CORE_VERSION` before running Node.
### §2.4 Component Combinations
| Entity type | Core components |
|---|---|
| Visual object | `TransformComponent`, `SpriteRendererComponent` |
| MapleTile side-view moving monster | `MovementComponent`, `RigidbodyComponent`, `StateComponent`, `HitComponent` |
| RectTile top-down moving object | `MovementComponent`, `KinematicbodyComponent` |
| SideViewRectTile moving object | `MovementComponent`, `SideviewbodyComponent` |
| Interactive NPC | `SpriteRendererComponent`, `TouchReceiveComponent` |
| Attackable enemy | `AttackComponent`, `HitComponent` |
The Body component must match the target map's `TileMapMode`; see [`platform.md`](platform.md) §4.
### §2.5 Script Components
Custom `script.XXX` components in a `.model` depend on the script type already being registered.
Required order:
1. Write the script `.mlua`.
2. Maker `refresh`.
3. Build or patch the `.model` through `ModelBuilder`.
4. Maker `refresh` again.
If this order is inconvenient, keep the `.model` native-only and attach the script at spawn time with `entity:AddComponent("ScriptName")`.
### §2.6 Model Checklist
- [ ] Used `ModelBuilder.read()` / `snapshot()` / `fromTemplate()`, not raw `.model` reading.
- [ ] `fromTemplate` path is absolute or `__dirname`-derived (§2.1); never `./Global/...`, `./Models/...`, or a guess.
- [ ] Saved under `RootDesk/MyDesk/Models/{Category}/`.
- [ ] Created any needed folder only; left folder metadata to Maker Refresh.
- [ ] Picked the Body component matching `TileMapMode`.
- [ ] Set a real `SpriteRUID` when using `SpriteRendererComponent`.
- [ ] Used explicit `typeKey` for new or changed values.
- [ ] Called Maker `refresh` after write.
- [ ] Checked logs after refresh / play.
### §2.7 CollisionGroupSetBuilder — `Global/CollisionGroupSet.collisiongroupset`
Use this builder for the existing workspace collision group set only. Do not create a new `.collisiongroupset` file under `Global/`.
```javascript
const { CollisionGroupSetBuilder } = require("./scripts/collisiongroupset/msw_collisiongroupset_builder.cjs");
const cg = CollisionGroupSetBuilder.read("Global/CollisionGroupSet.collisiongroupset");
cg.addGroup("Item")
.setCollidable("Player", "Item", true)
.setCollidable("Monster", "Item", false)
.write("Global/CollisionGroupSet.collisiongroupset");
```
Key rules:
- Scripts use `CollisionGroups.Name`; `.model` `CollisionGroup` values store the group `Id`.
- Keep built-ins (`Default`, `TriggerBox`, `HitBox`, `Interaction`, `Portal`, `Climbable`) intact unless the user explicitly asks for a project-level collision policy change.
- `addGroup()` creates a 32-character hex id by default; pass `{ id }` only when preserving an existing known id.
- The engine supports at most **15 user-defined groups** (excluding `Default` and `MOD@` system groups). `write()` rejects larger sets.
- `setCollidable(a, b, enabled)` is symmetric by default. Pass `{ symmetric: false }` only when the one-way matrix is intentional.
- `write()` rejects missing matrix rows, more than 15 user-defined groups, duplicate group ids, and non-array matrix rows. It warns for duplicate names, dangling matrix ids, one-way collisions, and built-in edits.
API:
```javascript
CollisionGroupSetBuilder.read(filepath);
CollisionGroupSetBuilder.load(filepath);
CollisionGroupSetBuilder.snapshot(filepath);
b.snapshot();
b.validate();
b.listGroups();
b.hasGroup(nameOrId);
b.getGroup(nameOrId);
b.getGroupId(nameOrId);
b.listCollisions(nameOrId);
b.addGroup(name, { id });
b.removeGroup(nameOrId);
b.renameGroup(nameOrId, newName);
b.setGroupId(nameOrId, newId);
b.setCollidable(groupA, groupB, enabled, { symmetric: true });
b.setCollidesWith(group, targets, { symmetric: false });
b.build();
b.write(filepath);
```
references/builder-protocol-ui.md
# Builder Protocol — §3 UIBuilder (`.ui`)
Per-builder file of the unified Builder Protocol. **[builder-protocol.md](builder-protocol.md) (core) must be in context alongside this file** — routing, the common workflow, the cross-builder chaining contract, §0 pre-flight, §4 cross-builder flow, and the §5 checklist live in the core and are not repeated here. Mutating another file type in the same turn requires that type's per-builder file too.
## Method index — what `UIBuilder` actually exposes
Alpha-sorted **camelCase** method names. **camelCase is canonical** — if a name does not appear here, do not call it. Signatures live in §3.5 below. Internal helpers (prefixed with `_`) are omitted.
**`UIBuilder`** — instance: `addComponent` · `areaParticle` · `avatar` · `basicParticle` · `build` · `button` · `chat` · `empty` · `find` · `getComponent` · `getId` · `gridView` · `group` · `hasComponent` · `injectBindings` · `joystick` · `line` · `listEntities` · `mask` · `panel` · `patch` · `patchComponent` · `polygon` · `remove` · `removeComponent` · `rename` · `script` · `scrollLayout` · `setComponentEnabled` · `skeleton` · `softMask` · `sprite` · `spriteParticle` · `text` · `textInput` · `touchReceive` · `upsertComponent` · `write`. Static: `UIBuilder.load` · `UIBuilder.read` · `UIBuilder.snapshot`.
## §3 UIBuilder — `.ui`
`.ui` layouts are mutated only through builder calls — never edit JSON directly. **This protocol alone is not enough** — UI calls only make sense on top of the design context (anchor/pivot, UIGroup hierarchy, component selection). Read the `msw-ui-system` design references listed in [builder-protocol.md](builder-protocol.md) §0 Pre-flight first.
### §3.1 Basic Workflow
1. Determine the target `.ui` path and the scope of entities / components to modify.
2. If the file already exists, load it with `UIBuilder.load()` (alias of `UIBuilder.read()`).
3. For one-off modifications, call directly; for repeated / high-risk modifications, separate into a `.builder-work/` temporary script.
4. Reopen the resulting `.ui` to verify hierarchy and rect / anchor.
5. If needed, run the preview script to check placement and touch-guide warnings.
### §3.2 Call Protocol
- Do not read the `.cjs` internal implementation every time. Call in the fixed order below.
- Basic order: `UIBuilder.read/load()` → `find/snapshot()` → `patch / entity / component API` → `write()`.
- Internal script inspection is limited to one-time, minimal scope only in exceptional situations (errors, unclear API).
### §3.3 `write()` Auto-Lint (Default ON)
`write(filepath)` automatically runs the sibling `msw-ui-system/scripts/ui_lint.cjs` immediately after saving. Default behavior:
- One or more errors → **build failure** via `RuntimeError` (the file remains on disk; the caller must observe the failure).
- Warnings only → one-line summary, details hidden.
- Nothing found → `✓ ui_lint: clean`.
`write(filepath)` overwrites the target `.ui` path. Do not delete and recreate `.ui` files — load (or construct) the intended state, then write once.
**Deleting a whole file** (distinct from mutating it): no builder exposes a delete-file API, and the registered guard blocks shell `rm` / `del` / `Remove-Item` on `.ui` / `.model`. To intentionally remove a file, use a cross-platform node unlink — `node -e "require('fs').unlinkSync('ui/<File>.ui')"` (or the `.model` path) — then `refresh` and confirm it dropped from the Glob index. The guard permits this because it is a `node` call, not a shell content-tool, and it does not scan inside `node -e` code.
Flags:
| Argument | Default | Meaning |
|---|---|---|
| `lint` | `True` | Setting to `False` skips lint entirely. Use only for special paths like one-off dumps. |
| `strict` | `True` | If `False`, errors are printed but proceed without exception. |
| `lint_verbose` | `False` | If `True`, prints full text of all warnings / errors. |
```javascript
b.write("ui/PopupGroup.ui"); // default: strict + summary
b.write("ui/PopupGroup.ui", { lint_verbose: true }); // verbose warnings
b.write("ui/_scratch.ui", { lint: false }); // skip lint
```
Applied rule IDs (`L001`–`L017`, `L023`–`L031`) are implemented as `ruleLNNN` functions in `msw-ui-system/scripts/ui_lint.cjs`. Hierarchy-focused guards:
- **`L025` (ERROR)** — an entity path implies an intermediate parent that does not exist in the file.
- **`L029` (ERROR)** — `UIGroupComponent` exists below the root group.
- **`L030` (WARN)** — a root-level text entity overlaps a sibling sprite/button box instead of being nested under it or merged onto it.
- **`L031` (WARN)** — `ScrollLayoutGroupComponent` layout / scrollbar direction enum values are outside their valid ranges.
### §3.4 pos / anchor Rules — Builder Auto-Pivot
Canvas 1920×1080, center origin `(0, 0)`. X: ±960, Y: ±540. All values are in **UI pixels**. For the coordinate model, 16 anchor presets (`top-left`–`stretch`), and the basic `pos = ±(margin + size/2)` formula, see [`ui-fundamentals.md`](../../msw-ui-system/references/ui-fundamentals.md) §1–§6 — only **builder-specific behavior** is covered here.
When the builder is called without a `pivot` argument, it automatically assigns a **pivot identical to the anchor point** (`middle-left` → (0, 0.5), `top-right` → (1, 1), `stretch*` → (0.5, 0.5), etc.). With edge anchors, supplying `pos = (margin, ...)` makes the element's **corresponding edge stick exactly at the margin position**:
```javascript
// auto pivot (recommended)
b.panel("Left", { anchor: "middle-left", pos: [20, 0], rect_size: [260, 80] });
// → pivot=(0, 0.5), rect left edge = x+20
// explicit pivot=(0.5, 0.5) — center-based offset (ui-fundamentals default mode)
b.panel("Left", {
anchor: "middle-left",
pos: [20, 0],
rect_size: [260, 80],
pivot: [0.5, 0.5],
});
// → rect left edge = x-110, outside parent boundary
```
**Two mode formulas**:
- Auto pivot (builder default): `pos = (±margin, ±margin)` — no need to add half the size.
- Explicit `pivot=(0.5, 0.5)`: `pos = ±(margin + size/2)` — the general formula from ui-fundamentals §4.
`ui_lint`'s `L005` rule detects edge-overflow patterns where "pos absolute value < size/2".
> **Breaking note**: among `.ui` files generated with older builder versions that appeared to use edge anchor + center pivot, restore intentionally center-based layouts by explicitly specifying `pivot=(0.5, 0.5)`.
All public APIs (`panel / text / sprite / button / script / slider / scrollLayout / textInput`, etc.) and `patch()` accept `pivot=(x, y)`. `patch()` preserves the existing `Pivot` value when not explicitly specified.
### §3.5 API Reference
`identifier` accepts three forms — all point to the same entity:
- Absolute path — `"/ui/<group>/Panel/Text"` (paths to other groups raise `ValueError`).
- Group-name prefix — `"<group>"`, `"<group>/Panel/Text"`.
- Relative name — `"Panel/Text"` (from direct children of the root).
To refer to the root itself, use any of `"<group>"`, `"/ui/<group>"`, or `"/"`. An empty string raises `ValueError`.
#### Hierarchy by Path
Builder creation methods do not take a separate `parent` argument. The parent is encoded in the `name` path:
```javascript
b.panel("Window", { rect_size: [700, 500] }); // /ui/<group>/Window
b.sprite("Window/Bg", { anchor: "stretch" }); // child of Window
b.button("Window/Card_SA", "A", { rect_size: [96, 132] }); // child of Window
```
Names without `/` are root-level children of the UI group. Passing `{ parent: "Window" }` or `{ parent: "/" }` to `empty()` / `panel()` / `text()` / `sprite()` / `button()` / other creator methods now throws. Use `"Window/Child"` path notation for nested children, or `"Child"` for root-level children. All missing intermediate parents must be created explicitly before adding children — adding a nested child whose parent entity does not yet exist now **throws** (and `ui_lint` rule `L025` flags any orphaned entity that reaches a file). An orphan cannot be mounted as a proper UI container on import, so create the parent `empty()` or `panel()` first.
Build related controls as a tree, not root-level coordinate overlays. A window, row, slot, tab, chip, or card should be a parent entity with its visual parts below it so movement / fade / enable / binding work as one unit. `ui_lint` rule `L030` warns when a root-level text entity overlaps a sibling sprite/button box. Fix by nesting the text under the box (`"Chip/Label"`), using `button()` for clickable labeled boxes, or using `panel()` / `sprite()` `text` options when the label belongs directly on the box entity.
Binding injection follows the same path notation. When a property points at `"Window/TitleText"`, pass that full path to `injectBindings`; a short leaf name such as `"TitleText"` is ambiguous and fails lookup.
#### Create / Load
```javascript
new UIBuilder(groupName, displayOrder = 1, defaultShow = true, defaultRuid = DEFAULT_SPRITE_RUID);
UIBuilder.load(filepath) | UIBuilder.read(filepath);
UIBuilder.snapshot(filepath); // returns compact entity view only
```
#### Entity Lookup
```javascript
b.find(identifier); // raw entity dict or null
b.getId(identifier); // UUID string or null (lookup by path)
b.lastId(); // UUID of entity targeted by the most recent creator call (new path → fresh UUID, existing path → existing UUID via upsert); not touched by update/remove
b.hasComponent(identifier, comp_type);
b.getComponent(identifier, comp_type); // {"@type": ..., ...} or null
b.listEntities(); // silent — returns array (name/path/depth/kind/pos/size/enable)
b.printEntities(); // listEntities() + indented tree log to console
```
`find()` return dict — `@components` is one level deeper, so direct access raises KeyError:
```
{
"id": str,
"path": str,
"componentNames": str,
"jsonString": {
"name", "path", "enable", "visible", "displayOrder", ...,
"@components": [ {"@type": "MOD.Core.UITransformComponent", ...}, ... ],
"@version": 1,
},
}
```
When you only need component data, use `b.getComponent(path, comp_type)` instead of unwrapping the raw structure:
```javascript
const btn = b.getComponent("Panel/BtnOk", "MOD.Core.ButtonComponent");
if (btn?.Enable) { /* use */ }
```
#### Entity Creation (upsert — components replaced, existing root metadata preserved)
> When the same path already exists, the creator preserves the existing root metadata (`name`, `nameEditable`, `visible`, `localize`, `revision`, `origin`) and re-applies only what the caller passed. `@components` is replaced with the new value. For `UITransformComponent`, a re-call with no transform option (`anchor`, `pos`, `rect_size`, `pivot`) preserves the existing transform, and a re-call with partial transform options merges omitted transform fields from the existing transform. Example: `sprite("Bg", { rect_size: [1200, 900] })` keeps the existing anchor / position / pivot and changes only the size. For stretch anchors, omitted stretch-axis offsets are preserved instead of being collapsed to the new `pos`. To change `name` / `enable` / `visible`, call `patch()` rather than re-invoking the creator, or pass the field explicitly in the creator options.
Tuple-shaped options (`pos`, `rect_size`, `cell_size`, `padding`, `spacing`, `softness`, ...) accept `[a, b]` / `[a, b, c, d]` (preferred) or `{ x, y, z, w }`. Both normalize to the same value.
Do not use Unicode emoji as icons inside text-shaped options (`text`, `placeholder`, button labels, panel/sprite embedded `text`) on `TextGUIRendererComponent` — glyph availability depends on the active UI font/fallback setup and can render as missing/broken glyphs. For an inline icon, use a configured `TextSpriteSet` rich-text sprite; for a standalone icon, use `SpriteGUIRendererComponent`/image assets.
> **Default sprite skin (applies to every SpriteGUIRendererComponent the builder mints).** `panel` / `sprite` / `button` / `slider` / `textInput` / `joystick` all default their background sprite to `image_ruid = "2860136c06ab075439721c027de365af"` (`DEFAULT_SPRITE_RUID`), `sprite_type = 1` (Sliced 9-slice), and `color = RGBA(26, 26, 26, 60)` (dark translucent).
>
> **This dark gray is only a DEFAULT, never a constraint.** It fills in what the caller leaves unspecified — it does **not** mean every panel must be gray. Give any individual element its own color whenever the design calls for it: pass `color` (for `panel`/`sprite`) or `bg_color` (for `button`/`slider`/`textInput`) as a hex string (`"#cc3344"`) or `{ r, g, b, a }`, and adjust `alpha` / `sprite_type` / `image_ruid` the same way. `color` for `button`/`slider`/`textInput` is the **text** color (defaulting to `#FFFFFF` so labels stay readable on the dark fill). Transparent helpers (`text` / `mask` / `softMask` / `chat`) keep their own invisible sprite (`alpha = 0`) and are unaffected.
>
> ```javascript
> b.panel("InfoBox"); // default dark-gray translucent
> b.panel("Danger", { color: "#cc3344" }); // red panel
> b.panel("Hero", { color: { r: 0.1, g: 0.3, b: 0.8, a: 0.9 } }); // custom blue, mostly opaque
> b.panel("Solid", { alpha: 1.0 }); // same gray, fully opaque
> ```
```javascript
b.panel(name, { anchor: "middle-center", pos: [0, 0], rect_size: [1920, 1080], color: null, alpha: null, sprite_type: 1, fill_method: 0, raycast: false, image_ruid: null, enable: true, pivot: null });
b.empty(name, { anchor: "middle-center", pos: [0, 0], rect_size: [100, 100], enable: true, pivot: null });
b.text(name, text, {
size: 24, color: null, bold: false,
alignment: 4, // 0=UpperLeft .. 4=MiddleCenter(default) .. 8=LowerRight
overflow: 0, // 0=Overflow, 1=Truncate, 2=Ellipsis
bestfit: false, min_size: 10, max_size: null,
outline: false, outline_color: null, outline_width: null,
anchor: "middle-center", pos: [0, 0], rect_size: null,
enable: true, pivot: null,
});
b.sprite(name, { anchor, pos, rect_size, color, alpha: null, fill_method: 0, sprite_type: 1, raycast: false, enable: true, image_ruid: null, pivot: null });
b.button(name, text, { rect_size: null, pos, anchor, font_size: 24, color: "#FFFFFF", bg_color: null, sprite_type: 1, enable: true, image_ruid: null, pivot: null });
b.slider(name, { min_val: 0, max_val: 1, value: 0, direction: 0, use_handle: true, use_integer: false, bg_color: null, sprite_type: 1, anchor, pos, rect_size: [200, 30], enable: true, image_ruid: null, pivot: null });
b.scrollLayout(name, { layout_type: 1, spacing: 0, cell_size: [100, 100], use_scroll: true, padding: [0, 0, 0, 0], v_scroll_dir: 2, h_scroll_dir: 0, anchor, pos, rect_size: [400, 600], enable: true, pivot: null });
b.textInput(name, { placeholder: "", char_limit: 0, content_type: 0, line_type: 0, font_size: 24, color: "#FFFFFF", bg_color: null, sprite_type: 1, anchor, pos, rect_size: [300, 50], enable: true, image_ruid: null, pivot: null });
b.script(name, scriptName, { anchor: "stretch", pos: [0, 0], rect_size: [1920, 1080], enable: true, pivot: null });
// Root UIGroup only; nested group() throws. Use empty()/panel() for inner containers.
b.group(name, { default_show: true, group_order: 0, group_type: 1, blocks_raycasts: true, group_alpha: 1.0, interactable: true, anchor: "stretch", pos: [0, 0], rect_size: [1920, 1080], enable: true, pivot: null });
// Clipping mask
b.mask(name, { shape: 0, padding: [0, 0, 0, 0], softness: [0, 0], anchor: "middle-center", pos: [0, 0], rect_size: [200, 200], color: null, alpha: 0.0, image_ruid: null, enable: true, pivot: null });
// Virtualized grid
b.gridView(name, { total_count: 0, cell_size: [100, 100], fixed_count: 1, fixed_type: 0, spacing: [0, 0], padding: [0, 0, 0, 0], use_scroll: true, scroll_bar_visible: 1, scroll_bar_thickness: 10.0, anchor, pos, rect_size: [400, 600], enable: true, pivot: null });
// Avatar / Touch / Skeleton / Particle
b.avatar(name, { color: null, flip_x: false, flip_y: false, play_rate: 1.0, preserve_avatar: 0, raycast: true, material_id: "", anchor, pos, rect_size: [200, 300], enable: true, pivot: null });
b.touchReceive(name, { anchor: "stretch", pos: [0, 0], rect_size: [1920, 1080], enable: true, pivot: null });
b.skeleton(name, { skeleton_ruid: "", animations: null, skins: null, color: null, flip_x: false, flip_y: false, loop: true, play_rate: 1.0, preserve_mode: 0, raycast: true, anchor, pos, rect_size: [200, 200], enable: true, pivot: null });
b.areaParticle(name, { particle_type: 0, area_size: [100, 100], area_offset: [0, 0], color: null, local_scale: [1, 1], play_speed: 1.0, particle_size: 1.0, particle_speed: 1.0, particle_count: 1.0, particle_lifetime: 1.0, loop: true, play_on_enable: true, prewarm: false, auto_random_seed: true, random_seed: 0, anchor, pos, rect_size: [100, 100], enable: true, pivot: null });
b.basicParticle(name, { particle_type: 0, color: null, local_scale: [1, 1], play_speed: 1.0, particle_size: 1.0, particle_speed: 1.0, particle_count: 1.0, particle_lifetime: 1.0, loop: true, play_on_enable: true, prewarm: false, auto_random_seed: true, random_seed: 0, anchor, pos, rect_size: [100, 100], enable: true, pivot: null });
b.spriteParticle(name, { particle_type: 0, sprite_ruid: "", apply_sprite_color: false, color: null, local_scale: [1, 1], play_speed: 1.0, particle_size: 1.0, particle_speed: 1.0, particle_count: 1.0, particle_lifetime: 1.0, loop: true, play_on_enable: true, prewarm: false, auto_random_seed: true, random_seed: 0, anchor, pos, rect_size: [100, 100], enable: true, pivot: null });
// Virtual joystick (mobile controls)
b.joystick(name, { dynamic_stick: true, axis: 1, up_arrow: 273, down_arrow: 274, left_arrow: 276, right_arrow: 275, anchor: "bottom-left", pos: [200, 200], rect_size: [300, 300], image_ruid: null, color: null, alpha: null, sprite_type: 1, enable: true, pivot: null });
// Soft mask (UGUI SoftMask style)
b.softMask(name, { invert_mask: false, invert_outsides: false, anchor: "middle-center", pos: [0, 0], rect_size: [200, 200], color: null, alpha: 0.0, image_ruid: null, enable: true, pivot: null });
// Chat UI
b.chat(name, { use_chat_balloon: false, expand: true, use_chat_emotion: true, chat_emotion_duration: 5.0, enable_voice_chat: true, hide_world_chat_button: false, message_align_bottom: false, anchor: "bottom-left", pos: [200, 200], rect_size: [400, 300], image_ruid: null, color: null, alpha: 0.0, enable: true, pivot: null });
// Line / Polygon renderer (HUD lines, guidelines, speech-bubble tails, custom shapes)
b.line(name, { points: [{ pos: [0, 0], color: "#FFFFFF", width: 2.0 }, /* ... */], is_flexible: true, flexibility: 3.0, is_smooth: false, loop: false, material_id: "", anchor, pos, rect_size: [100, 100], enable: true, pivot: null });
b.polygon(name, { points: [[0, 0], [100, 0], [50, 100]], color: null, use_custom_uvs: false, uvs: null, material_id: "", anchor, pos, rect_size: [100, 100], enable: true, pivot: null });
```
All creation methods return the builder for chaining. The UUID of the created / updated entity is exposed via `b.lastId()` — call it immediately after the creator if you need the id.
Use `button()` as the default for any colored or imaged rectangle that needs centered text and click handling. It creates the clickable tile as one entity instead of requiring a separate `sprite()` + `text()` pair. For non-clickable labeled boxes, `panel()` and `sprite()` accept `text`, `text_size`, `text_color`, `text_bold`, `text_alignment`, and `text_outline*` options; these add `TextGUIRendererComponent` to the same entity. Use a separate child `text()` only when the label needs its own rect inside a larger parent.
**Button color rule**:
- `button(..., { color })` controls `TextGUIRendererComponent.FontColor` only — button **text** color, not background. It defaults to `#FFFFFF` (white) so labels stay readable on the default dark fill.
- The background is the same entity's `SpriteGUIRendererComponent.Color` / `ImageRUID`, defaulting to the dark translucent skin (RGBA 26,26,26,60, Sliced). Retint it via the `bg_color` option (or `patchComponent`), not `color`.
- For light buttons, set a light `bg_color` **and** a dark text `color` (e.g. `color: "#111827"`); otherwise white text on a light fill becomes invisible.
```javascript
// Default dark button — white text on the dark translucent fill (no extra setup)
b.button("BtnAttack", "Attack", {
anchor: "bottom-center", pos: [-220, 80], rect_size: [400, 120], font_size: 30,
});
// Opaque dark button — override the default 60/255 alpha
b.button("BtnSolid", "Confirm", {
anchor: "bottom-center", pos: [0, 80], rect_size: [400, 120], font_size: 30,
bg_color: { r: 0.12, g: 0.16, b: 0.22, a: 1.0 },
});
// Light button with readable dark text
b.button("BtnRun", "Run", {
anchor: "bottom-center", pos: [220, 80], rect_size: [400, 120], font_size: 30,
color: "#111827", bg_color: { r: 0.90, g: 0.94, b: 1.0, a: 1.0 },
});
```
#### Signature gotchas
**`sprite()` fill options are int-only.** `sprite_type` and `fill_method` accept integer codes; string enums (`"Filled"`, `"Horizontal"`) throw at the int32 cast. The full enum catalog is in `#### Enum catalog` below — `sprite_type` ∈ `Simple=0 / Sliced=1 / Tiled=2 / Filled=3` (builder default is **Sliced=1**), `fill_method` ∈ `Horizontal=0 / Vertical=1 / Radial90=2 / Radial180=3 / Radial360=4`. `fill_origin` and `fill_amount` are **not** exposed as builder options — they start at engine defaults (`FillOrigin=0`, `FillAmount=1.0`). Runtime code that animates a fill writes `entity.FillAmount` directly each frame.
```javascript
b.sprite("Cooldown/Fill", { color: "2ecc71", sprite_type: 3, fill_method: 0 }); // ✅ int
b.sprite("Cooldown/Fill", { image_type: "Filled", fill_method: "Horizontal" }); // ❌ throws "FillMethod must be int32. Got 'Horizontal'"
b.sprite("HPBar/Fill", { image_ruid: "f0911af597259044aa624a11332c0595", sprite_type: 1, pivot: [0, 0.5] }); // ✅ linear HP: resize width at runtime
```
**`script(name, scriptName, options)` is 3-arg and `scriptName` must be fully qualified.** Same shape as `text(name, text, opts)` / `button(name, text, opts)` — the second positional argument is the **content string** (the script component type, e.g. `"script.WoWPlayerHUDController"`), not the options object. Packing the script name into options (`b.script(name, { scripts: ["script.X"] })`) now throws at the builder call site. Options-only patterns are reserved for content-free entities (`panel` / `sprite` / `mask` / etc.).
```javascript
b.script("Controller", "script.WoWPlayerHUDController", { anchor: "stretch", pos: [0, 0], rect_size: [1920, 1080] }); // ✅
b.script("Controller", { scripts: ["script.WoWPlayerHUDController"] }); // ❌ throws — use 3-arg form
```
#### Enum catalog
| Method | Argument | Enum | Values |
|---|---|---|---|
| `mask` | `shape` | `MaskShape` | `Rect=0` |
| `gridView` | `fixed_type` | `GridViewFixedType` | `ColumnCountFixed=0` (vertical scroll), `RowCountFixed=1` (horizontal) |
| `gridView` | `scroll_bar_visible` | `ScrollBarVisibility` | `AlwaysShow=0`, `AutoHide=1`, `Hide=2` |
| `avatar` | `preserve_avatar` | `PreserveSpriteType` | `None=0`, `AspectOnly=1`, `NativeSize=2` |
| `group` | `group_type` | `UIGroupType` | `DefaultType=0`, `UIType=1` (recommended), `EditorType=2` |
| `skeleton` | `preserve_mode` | `PreserveSpriteType` | `None=0`, `AspectOnly=1`, `NativeSize=2` |
| `areaParticle` | `particle_type` | `UIAreaParticleType` | `None=0`, `FogCalm=1`, `FogHeavy=2`, `FogLively=3`, `CalmStarField=4`, `StarFieldSimple=5`, `StarFog=6`, `StarFogFlow=7` |
| `basicParticle` | `particle_type` | `UIBasicParticleType` | `None=0` + 1–45 (full table in [`ui-system/references/component-api.md`](../../msw-ui-system/references/component-api.md) §Enums) |
| `spriteParticle` | `particle_type` | `UISpriteParticleType` | `None=0`, `BurstBig=1`, `SpawnField=2`, `BurstNova=3`, `SimpleSpawn=4`, `Burst=5`, `Stream=6`, `StreamSharp=7`, `AdditiveColor=8` |
| `joystick` | `axis` | `AxisType` | `Axis_4=0`, `Axis_8=1` (default) |
| `joystick` | arrow keys | `KeyboardKey` | Integer key codes. Defaults: `UpArrow=273`, `DownArrow=274`, `RightArrow=275`, `LeftArrow=276` |
#### Notes on group / mask / gridView
- **`group()` is root-only** — nested `group()` calls throw. For inner cards, gauges, tabs, sub-popups, and other local containers, create `empty()` or `panel()` and toggle that entity's `Enable`; use `CanvasGroup` when you need alpha/interactable control.
- **`mask` requires `SpriteGUIRenderer`** — the builder attaches it automatically, but leaving `image_ruid` empty renders a placeholder (SpawnLocation pin shape). To hide the visual mask shape, keep the default `alpha=0`; to make it visible, specify `alpha` / `color` / `image_ruid`.
- **`gridView`'s `ItemEntity` is a runtime prefab** — the builder only fills static fields like `TotalCount` / `CellSize`. The actual cell template must be injected in the script's `OnBeginPlay` via `self.Entity.GridViewComponent.ItemEntity = ...` followed by a `Refresh()` call. This is the only component that cannot be completed by the builder alone.
#### Notes on touchReceive / skeleton / particle
- **`touchReceive` alone receives NOTHING — the same entity (or a child) must carry a raycast-enabled renderer.** UI touch events are delivered only where a raycast hit lands, and the hit resolves upward through parents — a **sibling** sprite does not feed the receiver (silent failure: no error, no events). Standard recipe: `b.sprite(name, { alpha: 0, raycast: true, anchor, pos, rect_size })` then `b.addComponent(name, "MOD.Core.UITouchReceiveComponent")` — `sprite`'s `raycast` defaults to `false`, so set it explicitly. For a visible touch area, use a visible sprite (`alpha`/`color`/`image_ruid`) with `raycast: true` instead. All 7 events (`UITouchEnter/Exit/Down/Up/BeginDrag/Drag/EndDrag`) are ClientOnly. Actions requiring server sync (e.g. inventory moves resulting from a drag) should be delegated by calling `Server` ExecSpace methods.
- **`skeleton` is Spine 4.1 only** — RUIDs from other versions fail to load. Track 1 is reserved by the engine, so passing 1 as the `trackIndex` argument to `SetAnimation` / `AddAnimation` / `ClearTrack` in user code is ignored (use only 0, 2+). The `animations` / `skins` fields only set the initial track-0 animation and active skin list at builder time — runtime changes use ClientOnly methods (`SetAnimation`, `SetAttachment`, etc.).
- **`SkeletonRUID` is a plain string** — the builder serializes it as `"SkeletonRUID": "<ruid>"`. Do not confuse it with SpriteGUIRenderer's `ImageRUID: {"DataId": ...}` MODDataRef wrapping.
- **`areaParticle` / `basicParticle` are preset-based** — the `ParticleType` value determines the visual appearance. `LocalScale` / `ParticleSize` / `ParticleSpeed` / `ParticleCount` / `ParticleLifeTime` are global tuning multipliers on top of the preset. To change the shape itself, switch to a different `particle_type`.
- **Default particle Color is `(0.5, 0.25, 0.25, 1)`** (brown/sepia) — preserves the engine default. For white or high-saturation colors, specify `color="#FFFFFF"` / `color=(1,1,1)` explicitly.
- **`AreaSize` engine metadata default is `(0,0)`**, which emits particles from a point. The builder uses `(100, 100)` as a usable default. To intentionally emit from a point, specify `area_size=(0, 0)` explicitly.
- **`play_on_enable=True` (default) + `loop=True`** → infinite playback starts immediately when the entity is enabled. To show the effect only once, use `loop=False`, or set `play_on_enable=False` and control the `Play()` call from script. `Play` / `Stop` are ClientOnly.
#### Notes on joystick / softMask / chat / line / polygon
- **`joystick` is for mobile input only** — desktop uses keyboard mappings (`up_arrow` / `down_arrow` / `left_arrow` / `right_arrow`) for alternative input. With `dynamic_stick=true` (default), the stick follows the touch start position. The builder attaches both `SpriteGUIRenderer` and `Joystick`, and the engine automatically sets `SpriteGUIRenderer.RaycastTarget` to `false` at `BeginPlay`. If `image_ruid` is not specified, the builder's default sprite is used.
- **`softMask` is an unpublish feature** — gated by permission (`EnableUnpublishFeature`). Unlike `MaskComponent`, it supports soft-edge clipping, and only `RawImageGUIRenderer` / `SpriteGUIRenderer` children are clipped. `invert_mask=true` clips inside the mask, `invert_outsides=true` clips outside.
- **`chat` is a world / session-level chat UI** — typically only one per world. `use_chat_balloon=true` enables speech-bubble mode (bubbles above other users' characters). `expand` / `use_chat_emotion` / `enable_voice_chat` / `hide_world_chat_button` / `message_align_bottom` are UI display details.
- **`line`'s `points`** — `[{ pos: [x, y], color: "#RRGGBB" | Color, width: float }, ...]`. An empty array draws nothing. A single `null` point prevents the engine from drawing any of it. Corners are smoothed only when `is_flexible=true` + `flexibility>=1`.
- **`polygon`'s `points`** — `[[x, y], ...]` Vector2 array. Fewer than 3 points or self-intersecting polygons are not drawn (`IsDrawable()` false). `uvs` is used only when `use_custom_uvs=true`, and its length must match `points`.
#### WorldUI sort fields (common)
All 6 methods `sprite` / `text` / `button` / `slider` / `scrollLayout` / `textInput` support the same 4 sort fields. These are meaningful only when UITransform `UIMode=World(2)` (Screen UI ignores sort fields).
```javascript
b.text("BossName", "Boss", { world_ui: true, sorting_layer: "World", order_in_layer: 10 });
// world_ui: true → override_sorting=true, sorting_layer="UI" (default), order_in_layer=0, ignore_map_layer_check=false
// Individual override: specify override_sorting / sorting_layer / order_in_layer / ignore_map_layer_check directly
```
`override_sorting=false` (default) means sort fields are emitted but follow the UI group's sorting. Specify `world_ui: true` or `override_sorting: true` only when independent WorldUI sorting is needed.
#### Patch / Rename / Remove
```javascript
b.patch(identifier, { anchor, pos, rect_size, pivot, enable, visible, localize, display_order, new_name }); // throws if missing
b.rename(identifier, newName); // updates all child paths; throws if missing
b.remove(identifier); // deletes subtree (root not allowed); throws if missing
```
#### Component CRUD
```javascript
b.addComponent(identifier, comp_type, comp_data = null); // throws if it already exists
b.upsertComponent(identifier, comp_type, comp_data = null); // replaces if it exists
b.patchComponent(identifier, comp_type, updates); // field merge; throws if missing
b.removeComponent(identifier, comp_type); // rejects UITransform; throws if missing
b.setComponentEnabled(identifier, comp_type, enabled); // throws if missing
```
`comp_data` defaults to `{"@type": comp_type, "Enable": True}` when omitted. The `componentNames` field is auto-synced. All mutators return the builder; missing entity/component throws.
#### Output
```javascript
b.build(); // completed JSON (not saved to file)
b.write(filepath, { lint: true, strict: true, lint_verbose: false, bind: null });
```
### §3.6 Binding Injection (`.ui` UUID → `.mlua` property)
For `.mlua` scripts to reference entities created by the builder, the property default must contain that UUID. In the AI automation route, the builder updates the `.mlua` file in the same call right after `write()` — without drag binding.
**Key fact — a single entity UUID is all you need.** The right side of `.mlua` property defaults is always a **single entity UUID string**. Component-typed properties work the same way:
```lua
property Entity popupGroup = "<entity UUID>" -- Entity / EntityRef
property TextGUIRendererComponent message = "<entity UUID>" -- same for components
property ButtonComponent btnOk = "<entity UUID>"
```
The engine reads the property declaration type (`TextGUIRendererComponent`, etc.) and wraps it at runtime as `MODComponentRef("{uuid}:{TypeName}")` → resolves the component via `entity.GetComponent(typeId)`. Therefore the builder only needs to pass **one kind: `getId(path)`**. (Earlier guides describing a separate "extract component UUID" procedure were based on an incorrect assumption.)
**`write(path, { bind: ... })` — write + injection in one call**:
```javascript
b.write("ui/PopupGroup.ui", {
bind: {
mlua: "RootDesk/MyDesk/UIPopup.mlua",
props: {
popupGroup: "/ui/PopupGroup/Panel", // property Entity popupGroup
btnOk: "/ui/PopupGroup/Panel/BtnOk", // property ButtonComponent btnOk
btnCancel: "Panel/BtnCancel", // relative path also OK
message: "Panel/Message",
},
},
});
```
`props` = `{ mlua property name → entity path }`. The builder converts each path → entity UUID, uses regex to replace the `property <Type> <name> = "..."` line default in the target `.mlua`, and saves as UTF-8.
Or as separate calls:
```javascript
b.write("ui/PopupGroup.ui");
b.injectBindings("RootDesk/MyDesk/UIPopup.mlua", {
popupGroup: "Panel",
btnOk: "Panel/BtnOk",
});
```
**Protected failure cases (RuntimeError)**:
- The entity path does not exist.
- The target `.mlua` does not declare that property at all (typo / undeclared).
- The same property name is declared more than once in the `.mlua` (ambiguous).
- The target `.mlua` file does not exist → `FileNotFoundError`.
Verify that the `.mlua` actually exists and the target property is declared before calling. `.codeblock` is not touched — Maker Refresh regenerates it.
**Failure ordering** — `b.write({ bind })` runs `validate()` and pre-bakes the `.mlua` patch in memory **before** writing `.ui`. If anything before the `.ui` write throws (validation error, missing entity, undeclared property, duplicate property), neither file is touched. If strict `ui_lint` fails after `.ui` is on disk, the invalid `.ui` remains on disk for inspection/recovery, and `.mlua` is left untouched. `.mlua` is written last, only after `.ui` + lint pass. Property replacement is line-anchored and skips Lua line comments (`--`) and block comments (`--[[ ... ]]`), so a commented-out `property string Foo = "..."` is never overwritten.
**`b.validate()`** — call directly to inspect findings (`{ severity, rule, message }[]`) without writing. `write()` calls it internally and throws on any `severity: "error"`. Rules: `U001` invalid number (NaN / Infinity), `U002` int32 component field, `U003` finite-number component field, `U004` boolean component field, `U005` Vector2-shape component field (e.g. `GridViewComponent.Spacing` — must be `{ x, y }` with finite numbers).
**Naming convention (recommended)**:
```
/ui/Popup/Panel/BtnOk → btnOk (or okBtn)
/ui/Popup/Panel/Message → message (or messageText)
/ui/Popup/Panel → popupGroup / panel / root
```
Keep the last path segment in camelCase + role suffix (`Btn` / `Text` / `Panel`). When in doubt, **specify the injection table explicitly** and trust only that — do not auto-infer.
### §3.7 Scope (what UIBuilder covers)
- Adding empty / panel / text / sprite / button / slider / scrollLayout / textInput / script
- Root UIGroup (`group`) only; inner grouping uses `empty()` / `panel()`
- mask / gridView / avatar — clipping, virtualized lists, avatar rendering
- touchReceive — invisible drag / multi-touch receiver
- skeleton — Spine 4.1 skeleton UI renderer
- areaParticle / basicParticle / spriteParticle — preset-based particles
- anchor / position / rect_size adjustment
- HUD / popup / menu layout modification
- entity rename / remove (including subtree)
- component add / replace / patch / remove
- path-based entity lookup
### §3.8 `patchComponent` workaround for fields beyond the signature
Component fields not covered by the signature parameters of `text()` / `sprite()` / `button()` (e.g. `Font`, `FontStyle`, `Underlay`, `Padding`, `FillAmount`, `FillOrigin`, `OrderInLayer`) must be set explicitly via `patchComponent(path, comp_type, updates)`. `text()` / `button()` emit `TextGUIRendererComponent`, whose `Font` is a **string** (`"Default"` / `"Maple"` / `"Bazzi"` / `"Football"`) and whose drop shadow is the `Underlay` family — patch those, not the legacy `TextComponent` field names.
When patching `TextGUIRendererComponent` alignment fields directly, use the component enums, not the `text(..., { alignment })` 0-8 helper index: `HorizontalAlignment` uses `Left=1 / Center=2 / Right=4 / Justified=8`, and `VerticalAlignment` uses `Top=256 / Middle=512 / Bottom=1024`.
```javascript
b.patchComponent("Panel/Title", "MOD.Core.TextGUIRendererComponent",
{ Font: "Maple", FontStyle: 1 });
b.patchComponent("Panel/Title", "MOD.Core.TextGUIRendererComponent",
{ Underlay: true,
UnderlayColor: { r: 0, g: 0, b: 0, a: 0.6 } });
b.patchComponent("Cooldown/Fill", "MOD.Core.SpriteGUIRendererComponent",
{ Type: 3, FillMethod: 0, FillOrigin: 0,
FillAmount: 1.0 });
```
Per-entity forced values (intentional design separation):
- `button()` → `RaycastTarget` is always `True` (button = click area).
- `sprite(raycast=False)` is the default (sprite = decoration). Explicitly set `raycast=True` for modal dimmers and drag areas.
- `text()`'s background sprite is fixed as a transparent sprite with `alpha=0`.
Full enum lists (Alignment, Overflow, ImageType, etc.): [`ui-system/references/component-api.md`](../../msw-ui-system/references/component-api.md) §Enums.
### §3.9 UI-specific failure modes (must know before calling)
**`UITransformComponent.ActivePlatform` — UI not displayed when missing from JSON**
The `PlatformType` enum (`PC=1, Mobile=2, All=0xff(255)`) determines which platforms the UI is active on. If `ActivePlatform` is missing or set to `0`, the UI can be invisible on both PC and Mobile.
The builder automatically injects `ActivePlatform: 255` (all platforms) when creating a new UITransformComponent. Only watch out for these patterns:
- When partially modifying UITransform fields via `patchComponent(identifier, "MOD.Core.UITransformComponent", updates)`, do not touch `ActivePlatform`.
- For mobile-only UI, set explicitly with `b.patchComponent(name, "MOD.Core.UITransformComponent", { ActivePlatform: 2 })`. For PC-only, use `1`.
- Among **existing `.ui` files** loaded via `load()`, entries missing the `ActivePlatform` field entirely are **not** auto-corrected. Fill them in manually with `patchComponent`.
**`default_show=False` caveat — script lifecycle halted**
The `UIBuilder` default is `default_show=True` (recommended). If the root UIGroup is saved as hidden with `default_show=False`, `OnBeginPlay` / `OnUpdate` for scripts inside the group will not be called — a common cause of "the popup doesn't appear even after leveling up."
**Standard pattern** — always keep the root UIGroup at `default_show=True`, and have scripts toggle the `Enable` property of child entities (`Enable` vs `Visible` difference is covered in [`ui-hierarchy.md`](../../msw-ui-system/references/ui-hierarchy.md) §5 — summary: always use `Enable`; `Visible=False` keeps clicks alive and OnUpdate still runs).
```javascript
const ui = new UIBuilder("LevelUpUI"); // defaultShow=true (default)
ui.sprite("dimmer", { ... });
ui.text("title", "Level Up", { ... });
// Script starts with child entities Enable=false in OnBeginPlay,
// then sets Enable=true at the trigger point.
```
Use `default_show=False` only when the group contains **no** controller script and the flow toggles the group's `Enable` externally.
**Diagnosis** — when a popup doesn't appear: check root `UIGroupComponent.DefaultShow` → verify whether the controller's `OnBeginPlay` log fires → if not, the group being hidden is the cause. Recreate with `default_show=True` and migrate to the child `Enable` toggle pattern.
**`scrollLayout()` direction caveat — vertical lists need vertical scroll-bar enum values**
`layout_type`: `0=Horizontal`, `1=Vertical`, `2=Grid`. `v_scroll_dir` must use `2=BottomToTop` or `3=TopToBottom`; `0` and `1` are horizontal-scrollbar values. The builder defaults to `layout_type:1` and `v_scroll_dir:2`; when patching existing `.ui` files manually, keep both fields consistent.
### §3.10 UIBuilder coverage gaps (out of scope)
- `.map` / `.model` / `.tileset` builders — [builder-protocol-map.md](builder-protocol-map.md) §1 / [builder-protocol-model.md](builder-protocol-model.md) §2
- `.ui` JSON schema (raw field shapes, `@type` / `@components` wrapping, AlignmentOption 0–15 mapping) — handled internally by the builder; users / AI do not need to know directly.
- Accessibility patterns (alt text, screen-reader hints, focus order) — not covered.
- Error-state UI patterns (disabled-button styling beyond `Transition.Disabled`, validation messages, loading spinners) — not covered; design ad-hoc per project.
- Automated UI testing / layout assertions beyond `ui_lint.cjs` and `preview_ui_layout.cjs` — not provided.
- Custom shader materials (`MaterialId`) — the field is exposed but authoring shaders is out of scope.
references/builder-protocol.md
# Builder Protocol — `.map` / `.model` / `.collisiongroupset` / `.ui` Mutation
`.map` / `.model` / `.ui` are created and modified through dedicated CJS builders; the existing `Global/CollisionGroupSet.collisiongroupset` is **modified only** through `CollisionGroupSetBuilder` — never create a new `.collisiongroupset` file. The call protocol is one unified entry point split across **this core file (shared contract) plus one per-builder file per target type**:
| Protocol file | Covers |
|---|---|
| **this file (core)** | routing, common workflow, cross-builder chaining contract, §0 pre-flight, §4 cross-builder flow, §5 checklist |
| [builder-protocol-map.md](builder-protocol-map.md) | §1 `MapBuilder` (`.map`) |
| [builder-protocol-model.md](builder-protocol-model.md) | §2 `ModelBuilder` (`.model`) + §2.7 `CollisionGroupSetBuilder` (`.collisiongroupset`) |
| [builder-protocol-ui.md](builder-protocol-ui.md) | §3 `UIBuilder` (`.ui`) |
## ⚠️ MANDATORY — core + matching per-builder file(s) must be in context BEFORE invoking any builder
- **This core file, plus the per-builder file for every file type the turn mutates, must be fully in context** on every turn that touches `.map` / `.model` / `.ui`. `Read` a file in full only if it was never loaded this session or was lost to context compaction — do **not** re-read a file that is already fully in context. Working from a memorized summary of call signatures / `typeKey` values / coverage gaps is not an exemption — the in-context file is the source of truth.
- **"The core alone is enough" is a false assumption** — each per-builder file carries that builder's write-side contract (`componentNames` sync, `Values` metadata, write-time auto-lint, child-entity invariants, coverage gaps). Mutating a type whose per-builder file is not in context bypasses that contract in full. Cross-flow work (model authoring → map placement → ui binding, §4) loads every matching per-builder file.
- **No direct raw JSON editing.** Do not pull file contents with `Read` / `cat` / `Get-Content` / `Select-String` / `grep` and patch by hand either (a registered guard blocks `.ui`; the same rule applies to `.map` / `.model`). Use only the builders' read-side API (`Builder.read` / `snapshot` / `find` / `listEntities`) for inspection.
### File → Builder routing
| Target file | Builder class | Script path (when invoked from skill root) |
|---|---|---|
| `./map/*.map` | `MapBuilder` | `scripts/map/msw_map_builder.cjs` |
| `./RootDesk/MyDesk/Models/**/*.model` | `ModelBuilder` | `scripts/model/msw_model_builder.cjs` |
| existing `./Global/*.model` | `ModelBuilder` read + **write** (Maker Refresh after) | (same module as above) |
| existing `./Global/CollisionGroupSet.collisiongroupset` | `CollisionGroupSetBuilder` read + **write** (Maker Refresh after) | `scripts/collisiongroupset/msw_collisiongroupset_builder.cjs` |
| `./ui/*.ui` | `UIBuilder` | `../msw-ui-system/scripts/msw_ui_builder.cjs` |
Use `node scripts/...` after changing CWD to the relevant skill root. In JavaScript `require(...)`, use an explicit relative specifier such as `require("./scripts/map/msw_map_builder.cjs")`; Node treats `require("scripts/...")` as a package name, not a filesystem path. To reach a script in a different skill, resolve the sibling skill directory explicitly (for example `../msw-ui-system/scripts/...` from `msw-general`), because `<SKILL_ROOT>` is only documentation shorthand and is not automatically substituted at runtime.
### Decision matrix — which builder for which task?
| Task | Primary builder | Notes |
|---|---|---|
| Create a new `.map` from a validated map template | `MapBuilder.fromTemplate(MapBuilder.templatePath(...), mapName)` | Copies terrain / map mode, rewrites map ids and `/maps/{name}` paths; then register `map://{name}` in `SectorConfig.config` |
| Create a new `.model` from a template | `ModelBuilder.fromTemplate` | Never start from a blank model — pick the closest template from the catalog ([builder-protocol-model.md](builder-protocol-model.md) §2.1) |
| Edit Values / Components / Children on an existing `.model` | `ModelBuilder.read` → mutate → `write` | [builder-protocol-model.md](builder-protocol-model.md) §2 |
| Edit collision groups or collision matrix | `CollisionGroupSetBuilder.read` → mutate → `write` | [builder-protocol-model.md](builder-protocol-model.md) §2.7 |
| Place a model instance in a `.map` (≥2 instances / runtime spawn) | `MapBuilder.placeModel` | Author the `.model` first → `placeModel` (§4 cross-flow) |
| One-off inline sprite / empty entity in `.map` (single use) | `MapBuilder.sprite` / `empty` / `entity` | If the same composition appears ≥2 times, switch to `.model` immediately |
| Patch a component field, rename, remove on `.map` | `MapBuilder.patchComponent` / `patch` / `rename` / `remove` | [builder-protocol-map.md](builder-protocol-map.md) §1 |
| Tile painting / `TileMapMode` switching / Foothold chaining | Not a builder operation — guide the user to Maker UI | [builder-protocol-map.md](builder-protocol-map.md) §1.6 Coverage gaps |
| Create / patch `.ui`, component CRUD | the full `UIBuilder` API | [builder-protocol-ui.md](builder-protocol-ui.md) §3 |
| Inject `.ui` entity UUIDs into `.mlua` property defaults | `b.write(path, { bind })` or `b.injectBindings(...)` | [builder-protocol-ui.md](builder-protocol-ui.md) §3.6 Binding injection |
---
## Common Workflow — every builder follows this
```
(1) OPEN existing file (any builder) → Builder.read(path) / Builder.load(path)
new .map → MapBuilder.fromTemplate(MapBuilder.templatePath(kind), name)
new .model → ModelBuilder.fromTemplate(templatePath, name)
new .ui → new UIBuilder(groupName) (UIBuilder has no fromTemplate — builder-protocol-ui.md §3)
(2) INSPECT snapshot() / find() / listEntities() / getMapInfo() / listComponents()
(3) MUTATE builder fluent API only (never raw JSON)
(4) WRITE write(path) — auto lint (UI) / validate (Model) / id+componentNames sync (Map)
(5) REFRESH Maker MCP `refresh` (call `stop` first if in play mode)
```
On any mid-workflow failure (RuntimeError / validate failure / lint error), **stop immediately**. Do not proceed to later steps; fix the cause and restart from (1).
### Cross-builder chaining contract
All builders (`MapBuilder` / `ModelBuilder` / `CollisionGroupSetBuilder` / `UIBuilder`) share one contract: **every mutator — creators, updaters, removers, and `write()` — returns the builder itself, and a missing target throws `Error` (never returns `false` / `null`).** Inspection helpers (`find` / `getId` / `get*` / `has*` / `list*` / `snapshot` / `validate` / `build`) return data and must be called on their own line; pre-check with `has*()` / `find()` when conditional behavior is needed. `MapBuilder` and `UIBuilder` additionally expose `b.lastId()` — the id of the entity targeted by the most recent creator call (`entity` / `empty` / `sprite` / `placeModel`, or any UI creator). For a brand-new path a fresh UUID is assigned; for a path that already exists the creator upserts in place and `lastId()` returns the existing UUID, so the caller always gets the id usable to address that entity. Update/remove mutators (`patch` / `patchComponent` / `rename` / `upsertComponent` / `setComponentEnabled` / `remove` / `removeComponent`) do **not** touch `lastId()`. For `MapBuilder.placeModel`, `lastId()` returns the **root** id of the placed model, not the last placed child. `ModelBuilder` and `CollisionGroupSetBuilder` operate on a single file and have no `lastId()`.
> [!IMPORTANT]
> **`placeModel` has destructive descendant sync semantics.** The root path is updated in place, but when `placeModel` is called on a path that already exists, it removes every existing descendant before re-creating the model tree from the template. Any `patchComponent` overrides on the existing tree are lost. See the `placeModel` section in §4 for the full warning and workarounds.
```javascript
// MapBuilder — chain + lastId() for the newly created entity
const map = MapBuilder.read("map/map01.map")
.empty("WaveController", { pos: [0, 0, 0] })
.placeModel("Boss", "RootDesk/MyDesk/Models/Monsters/Boss.model", { pos: [3, 1, 0] });
const bossId = map.lastId(); // root id of the placed model
// ModelBuilder — chain + has-pre-check for conditional remove
const slime = ModelBuilder.read("RootDesk/MyDesk/Models/Monsters/Slime.model");
if (slime.hasValue("MovementComponent", "InputSpeed")) slime.removeValue("MovementComponent", "InputSpeed");
slime.value("MovementComponent", "InputSpeed", 2.5, "float").write("RootDesk/MyDesk/Models/Monsters/Slime.model");
```
### Rules common to all builders
1. **No raw JSON edits** — direct edits are allowed only in the coverage-gap areas listed in the per-builder files ([builder-protocol-map.md](builder-protocol-map.md) §1.6 / [builder-protocol-ui.md](builder-protocol-ui.md) §3.10), and only with minimal scope plus `refresh` + logs verification.
2. **Always `refresh` after a write** (`stop` first if in play mode). Maker must ingest content-file changes.
3. **Never touch `.codeblock`** — the `.codeblock` paired with a `.mlua` is auto-generated by Maker `refresh`.
4. **`Environment/*.d.mlua` is read-only** — API definitions, not for modification.
5. **Empty `SpriteRUID` = invisible** (no error). Never leave `SpriteRUID` empty in any builder.
6. **Entity / Component / EntityRef / ComponentRef property defaults are UUID strings.** In AI automation, the builder injects UUIDs directly — never tell the user to "drag in Maker."
7. **Stop work on CoreVersion mismatch** — verify `Environment/config`'s CoreVersion is `26.7.0.0` before any work.
8. **Component type strings are auto-qualified — but pass them fully qualified anyway.** Native components use `MOD.Core.XxxComponent` (e.g. `MOD.Core.TransformComponent`); mlua script components use `script.XxxComponent` (e.g. `script.Monster`). The engine keys `.map` / `.model` / `.ui` components by exact `@type`, and a wrongly-namespaced or mistyped `@type` silently fails to attach (Maker logs only a warning and the inspector shows no component). To remove that footgun, the component-bearing builders (`MapBuilder` / `ModelBuilder` / `UIBuilder`) **auto-qualify any bare (un-prefixed) component-type string at the call site**: a name in the native catalog becomes `MOD.Core.<name>`, any other bare name becomes `script.<name>`, and the builder prints a one-time advisory on stderr stating what it did and what to pass next time. A string that already starts with `MOD.` or `script.` is left untouched and silent — so the clean habit is to always pass the fully-qualified form. `null` / missing still throws `TypeError`.
- **The one residual footgun is a *misspelled native* name.** `"SpriteRendrerComponent"` is not in the catalog, so it auto-qualifies to `script.SpriteRendrerComponent` — which no script defines, so it silently fails to attach at runtime. The builders guard this by detecting a bare name within a small edit distance of a real native and emitting a louder advisory (`looks like a typo of native "MOD.Core.SpriteRendererComponent"`). Read the advisory; if you meant the native, fix the spelling.
- Auto-qualification fires on **every** helper that accepts a component-type string — not just `addComponent` / `upsertComponent`, but read-side helpers (`hasComponent` / `getComponent` / `patchComponent` / `removeComponent` / `setComponentEnabled` where the builder exposes them), value / property-link / event-link helpers that key by component type (`ModelBuilder.value(targetType, ...)`, `getValue`, `removeValue`, `property({ target, ... })`), and option-bag entries that key by component type (`MapBuilder.placeModel`'s `componentOverrides`). Each builder only exposes a subset; calling one a particular builder does **not** expose (e.g. `MapBuilder.hasComponent`, `ModelBuilder.getComponent`) raises `TypeError: ... is not a function`, not a qualification path.
- To confirm the canonical name of a native component, list the workspace's `Environment/NativeScripts/Component/*.d.mlua` — each filename (e.g. `MovementComponent.d.mlua`) is the bare name; prefix it with `MOD.Core.` to get the fully qualified `@type`.
---
## §0 Pre-flight (before any builder call)
### When working on `.map`
1. Identify the target map path and root entity explicitly.
2. Read `MapComponent.TileMapMode` as an **integer** via `MapBuilder.read(...).getTileMapMode()`.
3. Do not proceed with entity / model / script work while the mode is unknown. A mismatch surfaces as `[LEA-3004] MissingComponent` at runtime, or as a silent failure (entity refuses to move with no error).
| Value | Mode | Required Body | LEA-3004 log on mismatch |
|:--:|---|---|---|
| `0` | MapleTile (side-view + Foothold) | `RigidbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'RigidbodyComponent'.` |
| `1` | RectTile (top-down) | `KinematicbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'KinematicbodyComponent'.` |
| `2` | SideViewRectTile (side-view tile grid) | `SideviewbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'SideviewbodyComponent'.` |
> Changing the mode itself is a user action in Maker (Hierarchy right-click → Switch ...). The AI must never write `TileMapMode` directly ([builder-protocol-map.md](builder-protocol-map.md) §1.5 "Map Mode Rules").
### When working on `.model`
- **Do not start from a blank model.** Pick the closest template from the skill-local `models/` catalog and load it with `ModelBuilder.fromTemplate(absPath, name)`. Catalog: [builder-protocol-model.md](builder-protocol-model.md) §2.1.
- **Save into a typed subfolder**: `RootDesk/MyDesk/Models/{Category}/{Name}.model` (e.g. `Models/Monsters/Slime.model`). Never save directly under `MyDesk/`, directly under `Models/`, or under `Global/`.
- If the target folder does not exist, create the folder only and let Maker Refresh generate the metadata.
### When working on `.ui`
- Read at least one design reference from the `msw-ui-system` skill first — anchor/pivot modes, UIGroup hierarchy, and component-selection criteria live there. Knowing the call protocol without the design context produces "looks fine at authoring time, breaks on resolution change" UI.
- [`msw-ui-system/references/ui-fundamentals.md`](../../msw-ui-system/references/ui-fundamentals.md) §1–§6 — coordinate system + 16 anchor presets
- [`msw-ui-system/references/ui-hierarchy.md`](../../msw-ui-system/references/ui-hierarchy.md) — UIGroup / displayOrder / Enable vs Visible
- [`msw-ui-system/references/component-api.md`](../../msw-ui-system/references/component-api.md) — component selection + field/enum tables
- [`msw-ui-system/references/layout-recipes.md`](../../msw-ui-system/references/layout-recipes.md) — HUD / popup / toast / grid recipes
- **Name the `.ui` file the same as its UIGroup (root) name.** `new UIBuilder("ShopWindow")` sets the root entity path to `/ui/ShopWindow`, but `write(path)` writes to whatever path you pass and does **not** check that the file basename matches. Save it as `ui/ShopWindow.ui`, not `ui/Shop.ui`. A mismatch is silent at write time but surfaces after `refresh` as a renamed / duplicated `.ui` and a briefly stale Glob index (the file follows the UIGroup name, not your chosen filename).
---
## §4 Cross-Builder Workflow
The most common cross-flow: **author model → place in map → bind ui → refresh**.
```javascript
const path = require("path");
const { ModelBuilder, vector3 } = require("./scripts/model/msw_model_builder.cjs");
const { MapBuilder } = require("./scripts/map/msw_map_builder.cjs");
const skillRoot = path.join(process.cwd(), "skills", "msw-general");
// (1) Model authoring
const modelPath = "RootDesk/MyDesk/Models/Monsters/Slime.model";
ModelBuilder.fromTemplate(
path.join(skillRoot, "models", "MonsterCanonical.model"),
"Slime"
).value("TransformComponent", "Position", vector3(0, 0, 0), "vector3")
.write(modelPath);
// (2) Map placement
MapBuilder.read("map/map01.map")
.placeModel("Slime01", modelPath, {
pos: [3, 1, 0],
componentOverrides: {
"MOD.Core.SpriteRendererComponent": { OrderInLayer: 10 },
},
})
.write("map/map01.map");
// (3) Maker MCP `refresh`
```
`placeModel(name, modelPathOrJson, options)` behavior:
- Reads the `.model`, derives `modelId` from `ContentProto.Json.Id` or `EntryKey`, mirrors its component list into the placed map entity, and applies model `Values` to matching component fields.
- Returns the builder for chaining. The root entity id of the placed instance is exposed via `b.lastId()`.
- Places model children recursively, preserving parent-child paths and `origin` metadata.
- Accepts `options.pos` as `[x, y, z]` / `{ x, y, z }` / `vector3(...)`; arrays preferred.
- Accepts `options.componentOverrides` as a map keyed by component type. The target component must exist in the model or the builder throws.
- Accepts `options.modelId` only for an intentional override. Usually omit it and let the builder use the model's own id.
> [!WARNING]
> **`placeModel` is destructive on re-call.** When the target path already exists, `placeModel` wipes the existing root **and every descendant** before re-creating the tree from the template. Any in-place edits made between the original call and the re-call are lost:
>
> - `patchComponent("Monster01/Head", ...)` overrides on root or descendant entities.
> - Customizations applied in the Maker editor (color, position, custom child entities added by the level designer).
> - Child entities added by other builder calls (e.g. an `empty("Monster01/HPBar", ...)` placed after `placeModel`).
>
> **Re-running the same authoring script is a re-call.** If the script's flow is `placeModel(...) -> patchComponent(...) -> write(...)`, re-running it is safe — the override is reapplied each run. The footgun is mixing builder placement with out-of-band edits (Maker UI tweaks, second builder scripts that customize the instance) and then re-running the placement script later. The wipe happens with no warning.
>
> **Workarounds, in order of preference:**
>
> 1. **Don't re-call `placeModel` for in-place updates.** Make the placement call idempotent in your script — guard with `if (!map.find("Monster01")) map.placeModel(...)` if you want create-once semantics — and use `patchComponent` / `patch` / `upsertComponent` for everything else.
> 2. **Co-locate customization with placement.** Put the `patchComponent` calls in the same script as `placeModel` so the customization survives any re-run.
> 3. **Snapshot overrides before re-placing.** If you must re-run `placeModel` (e.g. swapping templates), `snapshot()` the entity tree first, re-place, then reapply the overrides from the snapshot.
>
> A `refreshModel`-style additive sync method is not provided — the cost / benefit didn't justify a built-in API. If you keep hitting this, raise it and we'll revisit.
**`.ui` ↔ `.mlua` integration** ([builder-protocol-ui.md](builder-protocol-ui.md) §3.6):
```javascript
const { UIBuilder } = require("../msw-ui-system/scripts/msw_ui_builder.cjs");
const ui = UIBuilder.load("ui/PopupGroup.ui");
// ... mutate ...
ui.write("ui/PopupGroup.ui", {
bind: {
mlua: "RootDesk/MyDesk/UI/PopupController.mlua",
props: {
popupGroup: "/ui/PopupGroup/Panel",
btnOk: "Panel/BtnOk",
},
},
});
```
After calls to multiple builders, consolidate into a single `refresh`.
---
## §5 Constraint Rules Checklist (common to all builders)
### Files / Editor / MCP
1. **`refresh` after file changes** (`stop` first if in play mode).
2. **`.map` / `.model` / `.ui` are all builder-first** — direct raw JSON edits are reserved for the explicit gaps in [builder-protocol-map.md](builder-protocol-map.md) §1.6 / [builder-protocol-model.md](builder-protocol-model.md) §2 / [builder-protocol-ui.md](builder-protocol-ui.md) §3.10, must stay minimal, and must be verified with `refresh` + logs.
3. **Do not modify `Environment/*.d.mlua`** — read-only API definitions.
4. **Do not create or edit `.codeblock` manually** — Maker `refresh` generates it from `.mlua`.
5. **Take `screenshot` only when the user explicitly asks or when identifying coordinates for input simulation.**
### Physics / Movement / Map
6. **TileMapMode ↔ Body components must match** (§0).
7. On **MapleTile**, placement Y is **foothold-based**; assumes gravity / Rigidbody.
8. On **RectTile**, do not expect vertical foothold physics — assumes Kinematicbody.
9. When inspecting or changing foothold data, use `MapBuilder` APIs so Id / Length / OwnerId consistency stays centralized.
### Render / Resource
10. **If a visual is needed, do not leave `SpriteRUID` empty.**
11. **RUIDs must be project-registered resources** — arbitrary strings are missing at runtime.
12. **Match the form of `TileSetRUID` / sprite DataRef** to existing maps.
### Entity / Spawn / Hierarchy
13. **Keep `id` / `path` / `componentNames` / `jsonString.path` consistent in `.map`.**
14. **`SpawnService` parent must not be nil** — pass a map entity such as `self.Entity.CurrentMap`.
15. **When referencing `modelId`**, `origin.entry_id` = `modelId`, and `origin.root_entity_id` = the entity's own outer `id` (top-level instance).
16. **Use `MapBuilder.placeModel()` for `modelId` instances** — it mirrors model components and keeps `componentNames` in sync. Empty component names or partial component arrays silently remove components at runtime.
17. Child entities must have a **`path` that is a prefix of the parent**.
### Input / UI Boundary
18. **TouchReceive (world) vs Button (UI)** — do not confuse input layers.
19. Keep UI-only groups (the `ui` hierarchy) and map entities' responsibilities separated.
### State / Animation
20. Do not confuse the roles of **StateComponent (logic)** vs **StateAnimationComponent (sprite action)**.
21. Action-name strings must **match** across code, action sheet, and animation data.
### Verification Loop
22. **`refresh` → `logs`** → **`play` → `logs` → `stop`**.
23. On intermediate failure, **stop later steps** — fix the cause and retry.
---
## Related Docs
| Doc | Purpose |
|---|---|
| [builder-protocol-map.md](builder-protocol-map.md) | §1 `MapBuilder` call protocol — API, placement, coverage gaps, map-mode rules |
| [builder-protocol-model.md](builder-protocol-model.md) | §2 `ModelBuilder` + §2.7 `CollisionGroupSetBuilder` call protocol — template catalog, `typeKey`, children, validation |
| [builder-protocol-ui.md](builder-protocol-ui.md) | §3 `UIBuilder` call protocol — creators, auto-lint, anchor/pivot, binding injection |
| [entity.md](entity.md) | `.map` entity domain — Scope, RUID, TileMapMode preflight, modelId vs inline rule, coordinate / foothold / camera, runtime verification |
| [model.md](model.md) | `.model` authoring domain — when to create, template catalog, component combinations, script-component lifecycle |
| [monster.md](monster.md) | Monster canonical 11 components + pitfalls — read before authoring a monster |
| [platform.md](platform.md) | TileMapMode ↔ Body mapping, spawn, RUID, coordinate system (common to all map types) |
| [platform-maple.md](platform-maple.md) / [platform-rect.md](platform-rect.md) / [platform-sideview.md](platform-sideview.md) | Per-map-type physics / events / patterns |
| [troubleshooting.md](troubleshooting.md) | Symptom → cause → fix (LEA-3004, "won't move", "won't render", LWA-3047, etc.) |
| [`msw-ui-system` SKILL](../../msw-ui-system/SKILL.md) | UI design guide + component API — read together when working on `.ui` |
| [`msw-ui-system/references/component-api.md`](../../msw-ui-system/references/component-api.md) | Full UI component fields / enums — when applying the `patchComponent` workaround |
| [`msw-ui-system/references/ui-fundamentals.md`](../../msw-ui-system/references/ui-fundamentals.md) | Coordinate system / 16 anchor presets, resolution / safe area |
**Core principle**: *"`.map` / `.model` / `.ui` mutations all go through dedicated builders, and every call is made with this core plus the matching per-builder file in context."*
references/dataset.md
# MSW Datasets (UserDataSet / LocaleDataSet)
Assets in MapleStory Worlds (MSW) for managing static game data and translation strings in tabular form.
**Practical paths for manipulating datasets**
1. Open the dataset view directly in the **Maker editor UI** (prefer this path for create / delete / row / column / cell editing).
2. Read and update at runtime from **scripts (.mlua)** via `_DataService` (UserDataSet table/cell access) and `_LocalizationService` (LocaleDataSet translation lookup, ClientOnly). Use for automation, validation, bulk changes.
---
## Dataset Types Summary
| Type | Extension | Sidecar | EntryKey prefix | Use |
|------|-----------|---------|-----------------|-----|
| **UserDataSet** | `.userdataset` | `.csv` | `userdataset://` | Static game data (item tables, wave settings, balance, etc.) |
| **LocaleDataSet** | `.localedataset` | `.csv` | `localedataset://` | Key-based multi-language translation tables |
---
## File-Pair Structure
A dataset is **not a single file**. It consists of a metadata wrapper + a CSV sidecar that holds the actual data.
```
RootDesk/MyDesk/
├─ ItemTable.userdataset ← metadata (JSON wrapper)
└─ ItemTable.csv ← real data (CSV)
```
- Editing cells in the Maker dataset editor updates the `.csv`.
- Changing column names or dataset properties updates the `.userdataset`.
- Both files must share the same base name and reside in the same directory.
> **Note**: Older docs show `columns`/`datas` arrays inline inside `ContentProto.Json`. Current Maker writes row data to the **`.csv` sidecar** instead and strips those keys from the `.userdataset` JSON on save. The engine still falls back to inline `columns`/`datas` if the CSV sidecar is missing, so older single-file datasets continue to load.
---
## UserDataSet (.userdataset)
### Metadata wrapper
```json
{
"Id": "",
"GameId": "",
"EntryKey": "userdataset://93729dda-ef49-403b-86f7-982d08fc353f",
"ContentType": "x-mod/userdataset",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.7.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"name": "dataset_sample",
"id": "93729dda-ef49-403b-86f7-982d08fc353f",
"serveronly": false,
"syncDataSetWebUrl": "",
"dynamicloading": 0
}
}
}
```
Key fields:
| Field | Meaning |
|-------|---------|
| `EntryKey` | `userdataset://<UUID>` — UUID matches `ContentProto.Json.id` |
| `ContentType` | Always `x-mod/userdataset` |
| `ContentProto.Json.name` | **Runtime lookup key.** Used in `_DataService:GetTable("<name>")` and `_DataService:GetCell("<name>", row, col)` |
| `ContentProto.Json.id` | UUID. Stays in sync with EntryKey |
| `ContentProto.Json.serveronly` | `true` = not exposed to client |
| `ContentProto.Json.syncDataSetWebUrl` | External sheet sync URL (Google Sheets, etc.). Leave empty if unused |
| `ContentProto.Json.dynamicloading` | Runtime dynamic-load option (0 = Off) |
### CSV sidecar
```csv
id,a,b,c
row1,1,2,foo
row2,3,4,bar
```
Rules:
- **UTF-8** encoding
- First line = **column header**, subsequent lines = data rows
- All cell values are **strings** (convert to number/boolean at runtime)
- Column names starting with `#` are **memo/comment only** — do not use as game-logic keys
- Blank cells may return **empty string `""`** rather than `nil` — check for both
- Row-identifier column name is free-form; `FindRow` searches by column name so only the header needs to be consistent
### Type conversion pitfalls
```lua
-- string → number
local x = tonumber(ds:GetCell(1, "a")) or 0
-- Integer ID matching: MSW number is float, tostring(3) may yield "3.0"
local key = tostring(math.floor(itemId))
local row = ds:FindRow("id", key)
```
### Runtime Lua access
`_DataService` is the runtime entry point for UserDataSet access.
```lua
-- Table-object style: fetch once, call methods on it
local ds = _DataService:GetTable("ItemTable") -- returns UserDataSet
local count = ds:GetRowCount()
local value = ds:GetCell(1, "Price") -- 1-based row index, column name
local row = ds:GetRow(1) -- UserDataRow
local found = ds:FindRow("ItemID", "003") -- search by column value, returns UserDataRow or nil
local col = ds:GetColumn("Price") -- table<string>
local all = ds:GetAllRow() -- table<UserDataRow>
-- Service-direct style: pass dataset name on every call
local n = _DataService:GetRowCount("ItemTable")
local cell = _DataService:GetCell("ItemTable", 1, "Price")
local cell2 = _DataService:GetCell("ItemTable", 1, 3) -- col index also OK (1-based)
```
`UserDataSet` and `UserDataRow` return all cell values as `string`; convert with `tonumber()` etc. as needed.
---
## LocaleDataSet (.localedataset)
### Metadata wrapper
Same structure as UserDataSet except:
- **`ContentType`**: `x-mod/localedataset`
- **`EntryKey`**: `localedataset://<UUID>`
### CSV column rules
| Column | Position | Role |
|--------|----------|------|
| `Key` | 1st (required) | Lookup key |
| `Source` | 2nd (required) | Source/reference text |
| `Note` | 3rd (required) | Translator notes |
| `ko`, `en`, … | 4th+ (at least one required) | Per-language translation columns |
The first three columns have fixed order and role; locale columns follow after them.
### Runtime Lua access
`LocaleDataSet` is queried through **`_LocalizationService`** (all methods `ClientOnly`). The service reads from whichever LocaleDataSet asset is in the workspace; you do not specify a dataset name.
```lua
-- Current client locale (uses _LocalizationService.CurrentLocaleId column)
local text = _LocalizationService:GetText("ui_start")
-- Format placeholders {0}, {1}, …
local greet = _LocalizationService:GetTextFormat("hello_user", playerName)
-- Force a specific language column via Translator
local en = _LocalizationService:GetTranslatorForLocale("en")
local enText = en:GetText("ui_start")
local enFmt = en:GetTextFormat("hello_user", playerName)
-- Local (current-locale) Translator shortcut
local koText = _LocalizationService.LocalTranslator:GetText("ui_start")
-- TextGUIRendererComponent with the localization-key flag set in the Maker editor:
-- GetLocalizedText() (no args) resolves the component's Text property as the key.
-- The flag is a Maker-editor setting, not a runtime .mlua property.
local rendered = self.Entity.TextGUIRendererComponent:GetLocalizedText()
```
> Calling `_LocalizationService:GetText` from a server-only context fails — translation is a client concern. For server-side localized messaging, send the key over RPC and let the client resolve it.
---
## Creating a Dataset
### Recommended: Maker UI
Let the editor handle UUID generation, EntryKey consistency, and CSV creation.
### Manual creation
1. Generate a UUID with a cross-platform command: `node -e "console.log(require('node:crypto').randomUUID())"`
2. Write `<Name>.userdataset` using the template above — fill in `name`, `id`, `EntryKey`
3. Write `<Name>.csv` — UTF-8, first line = header, then data rows
4. Place both in the same folder under `RootDesk/MyDesk/` and hit **Refresh** in Maker
---
## What Does NOT Work
| Approach | Status |
|----------|--------|
| HTTP RPC for dataset CRUD | **Removed** — old API no longer exists |
| Dedicated MCP tool for datasets | **None** — use Maker UI or script API only |
The agent must **not assume any arbitrary HTTP RPC endpoint** — only the two paths above (UI / script).
---
## Recommended Use Cases
- Wave config (enemy composition, spawn intervals, type weights)
- Skill balance (cost, damage, cooldown, RUID)
- Item pricing and effects
- Boss pattern thresholds (HP %, range, interval)
- Multi-language UI strings (LocaleDataSet)
Separating balance data into `.userdataset` + `.csv` allows patching by swapping CSV alone — fast iteration without code changes.
references/entity.md
# MSW Entity — `.map` Placement & Runtime
**Domain rules** for entity instances inside `.map` files — which mode (`TileMapMode`) places things where, how coordinates / footholds / camera / RUID interact, the `modelId` vs inline decision, runtime lifecycle. `.model` template authoring is split out into [model.md](model.md).
> **The actual call protocol for `.map` mutation (MapBuilder API, snapshot workflow, coverage gaps, `.map` / `.model` cross-flow) lives in [builder-protocol-map.md §1](builder-protocol-map.md), with the shared contract in the [builder-protocol.md](builder-protocol.md) core. Both must be in context on every turn that touches `.map` (read only if missing — see the Builder Protocol Preflight in SKILL.md); this document supplies the domain context (why the calls look that way) and is read alongside them.**
The legacy Maker RPC (curl) API has been removed. `.map` inspection and mutation go through `scripts/map/msw_map_builder.cjs` (= `MapBuilder`), followed by **msw-maker-mcp** verification tools.
---
## File / Tool Overview
| Area | Path | Role |
|------|------|------|
| Map | `./map/*.map` | Map root, footholds, tiles, **all placed entities** |
| User models | `./RootDesk/MyDesk/Models/{Category}/*.model` (typed subfolder, e.g. `Models/Monsters/`) | Custom `.model` templates ([model.md](model.md) — never save directly under `MyDesk/` or `Models/`) |
| Global models | `./Global/*.model` | Existing global templates edited in place via `ModelBuilder` + Maker Refresh. Do not create new files under `Global/` |
| UI | `./ui/*.ui` | UI-only entities and widgets ([`msw-ui-system`](../../msw-ui-system/SKILL.md)) |
> **Placing a monster** — read [monster.md](monster.md) first. The two verified working canonicals each have 11 components (`Soldier.model` for Pattern A — script-driven SpriteRUID; `MonsterCanonical.model` for Pattern B — `AIChaseComponent` + ActionSheet pipeline). `ActionSheet` keys are lowercase. `IsLegacy: false` is mandatory on `HitComponent` for both patterns; on `StateComponent` (and on `AIChaseComponent` if present) only for Pattern B — Pattern A leaves `StateComponent.IsLegacy` at the default. Mixing inline `@components` with `modelId` overrides on a system monster model produces `LEA-3046 InternalError` at runtime; bake the values into a dedicated `.model` instead.
> MCP tools are self-documenting when connected. If the user asks about MCP setup, share this link: https://maplestoryworlds-creators.nexon.com/ko/docs?postId=1368
---
## Scope Concept (file-edit workflow)
1. **Scope of "what shows up in the list"**
- The editor **hierarchy** and the builder entity list for `./map/{mapname}.map` only contain entities belonging to **that map instance**.
- When listing entities by opening a file, the **currently edited map file is the scope**. Placements in other maps live in other `.map` files.
2. **Scope of ID / path-based access**
- In runtime Lua, you can reference an entity in any map via a **global path** like **`_EntityService:GetEntityByPath("/maps/map01/Monster01")`**.
- Entity **`id` (UUID)** is also stored in the map file, and scripts can track it by the same ID (assuming the map is loaded).
**Practical implication**: "What's in this map?" → search `./map/{this}.map`. "Find this entity across the whole world" → grep all `.map` files.
---
## Component vs Logic
> See `msw-scripting` §3 for type comparison, declaration syntax, and decision criteria. Behavior attached to an entity → Component; global singleton manager → Logic.
---
## StateComponent vs StateAnimationComponent
### StateComponent
- **Role**: game-logic state machine (e.g. `Walk`, `Jump`, `Dead`, `Attack`).
- **May not play animation directly** — manages only state names and transition conditions.
- Controlled from scripts via `CurrentStateName`, `ChangeState()`, etc.
- The **DefaultPlayer** family uses `StateComponent` + `AvatarStateAnimationComponent`.
### StateAnimationComponent
- **Role**: visual state / action playback based on **sprite / action sheet** (`ActionSheet`).
- In monster / object models, handles the **action name ↔ sprite sequence** mapping.
- A common pattern is `actionSheet` etc. in `.model` `Properties` linking to `StateAnimationComponent`.
**Difference**: **StateComponent = logical state**; **StateAnimationComponent = sprite animation data**. In a model that has both, keep names and transition timing aligned.
---
## TouchReceiveComponent vs ButtonComponent
> See `msw-scripting` §10 for world input (TouchReceiveComponent + TouchEvent) vs UI input (ButtonComponent + ButtonClickEvent). Swapping them silently drops all input. Do not attach UI components to map entities.
---
## Entity.CurrentMap (strongly recommended)
At runtime, when spawning, parenting, or searching within the same map, use **`self.Entity.CurrentMap`** or an already-acquired map entity.
```lua
local map = self.Entity.CurrentMap
_SpawnService:SpawnByModelId("myenemy", "Enemy_1", position, map)
```
- **`SpawnService` parent must not be nil** — yields LWA-3019 warnings and undefined behavior.
- Unless a special case requires another parent (such as out-of-map common), **always pass the map entity**.
- For file-only edits, reflect the spawn position in `.map`'s `TransformComponent.Position`.
---
## RUID (Resource Unique ID)
- MSW resources (sprites, tilesets, sounds) are identified by **RUID strings**.
- An empty **`SpriteRendererComponent.SpriteRUID`** means **the entity is invisible** (with no error).
- In `.model` `Values` or `.map` `@components`, use either a string or `{ "DataId": "hex..." }` form — **match the existing pattern in the same map / model**.
**Asset search**: use the `msw-search` skill or `_ResourceService` API. Replace temporary placeholders with real assets before release ([platform.md](platform.md)).
---
## MapBuilder — call protocol lives in builder-protocol-map.md
`.map` snapshot workflow (get → edit → set), the API table, placement / patch / rename / remove / component CRUD / tile / foothold inspection, coverage gaps, Map Mode Rules, `false`-return handling — **every detail of MapBuilder invocation is consolidated in [builder-protocol-map.md §1](builder-protocol-map.md) (shared contract in the [builder-protocol.md](builder-protocol.md) core).** Have both in context before any `.map` work.
This document carries the **why** behind those calls — Scope, RUID, the meaning of the TileMapMode-to-Body mapping, the `modelId` vs inline decision rule, placement coordinates / footholds / camera visibility, runtime verification, and the constraint checklist.
Domain-side summary rules:
- `.map` `Entities` arrays are very large. **Direct raw JSON editing is reserved for builder coverage-gap areas only** — minimal scope plus `refresh` + logs verification.
- Patch values through world-unit vectors (`pos: [x, y, z]`), color helpers, and component override objects. Do not hand-write raw component JSON except as a builder argument for a single-component payload.
---
## TileMapMode ↔ Movement Components
`MapComponent.TileMapMode` on the map root determines the **entire movement / gravity / collision stack**. If an entity's Body-family component does not match the map, **it will not move** (with no error) — and the engine will log one of the `[LEA-3004] MissingComponent` messages ([platform.md §4](platform.md)).
### Map Work Preflight (mandatory before any map task)
Before touching a map in any way (entity placement, spawn, movement scripts, applying models, tile edits, etc.), always confirm the following two items **in order**:
1. **Identify which map you are working on and where it lives** (e.g. the `./map/{mapname}.map` path and its root entity).
2. **Use `MapBuilder.read(...).getTileMapMode()` to read `MapComponent.TileMapMode` as a number**, and keep the value in mind:
- `0` → **MapleTile** (side-view + Foothold; Body = `RigidbodyComponent`)
- `1` → **RectTile** (top-down grid; Body = `KinematicbodyComponent`)
- `2` → **SideViewRectTile** (side-view tile grid; Body = `SideviewbodyComponent`)
**Do not proceed with model / entity / script work while the `TileMapMode` value is unknown or unclear.** The three modes differ completely in Body component, events, gravity, and collision. A mismatch is not a compile-time error — it surfaces as a runtime `[LEA-3004] MissingComponent` log **or** as a silent failure where the entity simply refuses to move with no error at all.
### Recommending the mode (when starting a new map or when the current mode is wrong for the user's goal)
For **new map authoring** — or whenever the current map's `TileMapMode` clearly does not fit the gameplay the user described — **explicitly recommend the appropriate `TileMapMode` before doing any further entity / model / script work** (do not silently proceed with whatever is already on disk).
Use this decision matrix:
| User's intended game / gameplay | Recommend | Why |
|---|---|---|
| MapleStory-style side-scrolling action · jump · ladder · freely placed footholds (platformer) | **`0` MapleTile** | Side-view + gravity + freely placed Foothold line segments |
| Top-down RPG · maze · board game · dungeon crawler · Bomberman-style · farming sim | **`1` RectTile** | Top-down 4-directional free move, no gravity, square-tile grid |
| Tile-based side-scrolling platformer · Mario-style pixel action · side-view puzzle | **`2` SideViewRectTile** | Side-view + gravity **on a tile grid** (not free footholds) |
If the user has not yet told you what kind of game they want, **ask one short question first** (e.g. "Is it top-down, or side-scrolling (jump/ladder)? Is it based on freely placed footholds, or a square tile grid?") and only then recommend.
### Changing `TileMapMode` — user action in Maker, not an AI file edit
The AI must **never** write a new value into `MapComponent.TileMapMode` directly in the `.map` JSON. Mode switching swaps tile components, rebuilds footholds, and converts tile-data formats — Maker handles all of that internally.
Guide the user to switch the mode in the Maker editor as follows:
1. Open the Maker editor's **Hierarchy** window.
2. **Right-click the target map entity** in the Hierarchy.
3. Choose the **"Switch ..." option that matches the target mode** (Switch TileMap / RectTileMap / SideViewRectTileMap) from the context menu.
4. After the user reports the switch is complete, call MCP **`refresh`**, then re-read `MapComponent.TileMapMode` to confirm and re-check every dynamic entity's Body component against the new mode.
> AI role on mode changes: **recommend mode → wait for the user to right-click-switch in the Maker Hierarchy → refresh → fix Body components / scripts that no longer match**. Do not flip `TileMapMode` from a file edit.
> Mapping table / check protocol / transition limits: [platform.md §4](platform.md). `LEA-3004` and other silent-failure symptoms (won't move / won't render / floating in mid-air / stuck in a wall …): [troubleshooting.md](troubleshooting.md). Per-map-type code patterns: [platform-maple.md](platform-maple.md) / [platform-rect.md](platform-rect.md) / [platform-sideview.md](platform-sideview.md). Tile painting itself: [tile.md](tile.md).
---
## Two-Step Map Editing Workflow (create → place)
1. **Create** — define the `.model` under `RootDesk/MyDesk/Models/{Category}/{Name}.model` (typed subfolder; details in [model.md §1, §2.2](model.md)).
2. **Place**
- `MapBuilder.read(...)` → `map.placeModel(...)` → `map.write(...)`. Concrete call sequence, API tables, and option details live in [builder-protocol-map.md §1](builder-protocol-map.md) + [builder-protocol.md §4](builder-protocol.md).
- `placeModel()` returns the builder; read the placed root UUID with `map.lastId()` before `write()` if a script binding needs it.
- **`modelId` form (default — required for ≥2 instances)**: `placeModel()` mirrors model components and applies per-instance overrides.
- **Inline form**: use `sprite()` / `empty()` only for truly one-off map-local entities.
- `refresh`.
### `modelId` vs Inline — Decision Rule
| Situation | Form |
|---|---|
| Same composition placed **≥2 times** in this map | **`modelId`** (always — author a `.model` first if none exists) |
| Same composition reused in **another map** | **`modelId`** |
| Will be spawned at runtime via `SpawnByModelId` | **`modelId`** (required) |
| Truly one-off composition that will never recur | inline `@components` is acceptable |
> When in doubt, choose `modelId`. Five inline copies of "the same monster" silently drift apart over edits (one gets `IsLegacy: true`, another loses `SortingLayer`); the model anchors the canonical values and a single edit propagates.
> "Inline" describes where the composition lives (the entity's own `@components`), not the `modelId` field — `sprite()` / `empty()` still set a shared system `modelId` (`mapobject` / `mapempty`), so the field never distinguishes the two forms. To edit a placed entity, mutate its map `@components`, not the shared system model.
---
## Handling Entity Instances in `.map`
### Common fields (must match)
- **`id`**: UUID v4 (with hyphens). Generate fresh for new entities.
- **`path`**: `/maps/{mapname}/{entityname}` — parent-child hierarchy is the path prefix.
- **`componentNames`**: comma-joined `@type` values of `@components`, **kept in sync**.
- **`jsonString.path`**: same as the outer `path`.
- **`pathConstraints`**: root `//`, child `///`.
- **`displayOrder`**: avoid overlap among siblings.
### modelId entities
Use `MapBuilder.placeModel()` — it creates the model-instance metadata, keeps component names in sync, mirrors model components, and applies per-instance `TransformComponent.Position` and `componentOverrides`. For the call signature and option details, see [builder-protocol-map.md §1.4](builder-protocol-map.md) + [builder-protocol.md §4](builder-protocol.md).
### Adding a new map to the world
- Create a new `.map` only with `MapBuilder.fromTemplate(MapBuilder.templatePath(kind), mapName)`; `kind` is `maple`/`0`, `rect`/`1`, or `sideview`/`2`. Do not start from blank JSON.
- Save as `map/{mapName}.map`. `mapName` is plain (`city01`), not `map://city01` and not `city01.map`.
- Add `map://{mapName}` to `entries` in `Global/SectorConfig.config` when the map should be part of the world. Removing a map is the reverse: remove the sector entry first, then delete `map/{mapName}.map`.
---
## Tile-map entity transform is locked
The map's tile-grid container — the entity carrying `TileMapComponent` (MapleTile) or `RectTileMapComponent` (RectTile / SideViewRectTile) — has its `TransformComponent` **locked by the tile-map component itself**. Writes to `TransformComponent.Position` / `EulerAngles` / `Scale` are **silently rejected** with a `LWA-3047 NativeIssue_UnableToChange` warning. The engine keeps this entity at a fixed origin (`(0, 0, z)`, or a half-cell offset for odd-grid RectTile maps) so that tile coordinates and world coordinates stay in a known relationship.
**This applies regardless of whether the entity was placed with `modelId` or as an inline `@components` block** — the lock comes from the tile-map component, not from how the entity was authored. Moving the entity in `.map` JSON appears to take, but `refresh` reverts it to `(0, 0, z)`; runtime `Position = ...` writes produce no visible movement and log `[LWA-3047]`.
**Workaround**: do not try to move the tile-map entity. Anchor your game's coordinate system to the locked origin instead — keep gameplay anchors (grid origin, spawn points, path waypoints) in tile coordinates and convert via the tile-map component's helpers (e.g. `RectTileMapComponent:ToWorldPosition(cellPos)` — see [`platform-rect.md`](platform-rect.md) §3).
Symptoms when the rule is ignored:
- A `Position` written into `.map` JSON reverts to `(0, 0, z)` after Maker `refresh`.
- Runtime `TransformComponent.Position = ...` writes have no observable effect; `logs` shows `[LWA-3047] UnableToChange`.
- Adding a custom child entity to the tile-map entity works, but the child's effective world position is still measured relative to the locked parent at `(0, 0)`.
This is **by design** — the tile-map entity is the canonical reference frame for tile↔world conversion. Decorations, spawn anchors, or overlays that need to be elsewhere should live as **siblings under the map root, not as children of the tile-map entity**.
---
## Placement Coordinate Rules
### Y (MapleTile + footholds)
- Align character / foothold-based entities to the **top of the foothold**.
- The **`y`** of each foothold's `StartPoint`/`EndPoint` in `FootholdComponent` is the platform height.
- A small offset (+0.01 to 0.05) may be needed depending on sprite anchor / collider offset — verify with `play` + `screenshot`.
### Horizontal Spacing (multiple monsters / objects in a row)
- **Always use the `modelId` form** for repeated entities (see "Two-Step Map Editing Workflow → Decision Rule" above). The N instances should share one `.model` and differ only in `TransformComponent.Position`.
- Space along X **without overlap** based on each entity's **bound width** (`TiledSize`, `BoxSize`).
- Even spacing: `x_i = x0 + i * (width + gap)`.
- On the same foothold, share **the same Y** and only shift X.
### RectTile / SideViewRectTile
- **Grid-based** placement — verify the conversion between `RectTileMapComponent` tile coordinates and world coordinates ([tile.md](tile.md)).
- On RectTile (no gravity), assume **Kinematicbody** and move on the XY plane.
### Camera Visible Area
- See the rough visible-world-unit table in [platform.md §5](platform.md) for PC / mobile — verify the **start position is on-screen**.
---
## RPC → File-Based Replacement Table
| Old (RPC) | Current equivalent |
|----------------|-----------|
| Create entity | Author `.model` under `RootDesk/MyDesk/Models/{Category}/` + place it with `MapBuilder.placeModel()` |
| Delete entity | `MapBuilder.remove()` |
| Change property | `MapBuilder.patchComponent()` for map instances or ModelBuilder values for templates |
| Add/remove component | `MapBuilder.upsertComponent()` / `removeComponent()` for one-off map-local instance changes |
| Register/edit/delete model | CRUD `.model` files under `RootDesk/MyDesk/` (`refresh`) |
| List entities | `MapBuilder.snapshot()` / `listEntities()` |
---
## Runtime Verification
For **runtime state** that's hard to know from files alone, use `logs` and `log()` in play mode.
### Flow
1. Add `log()` in `.mlua` for the value to inspect
2. `refresh` → `play` → collect via `logs`
3. `stop` → edit files → repeat
### When you don't know an API
1. **`.d.mlua`** — search `Environment/NativeScripts/` for `EntityService`, `SpawnService` signatures
2. **`msw-search`** — API details, implementation guide
After work, `**stop**` to return to edit mode.
---
## Constraint Rules Checklist
### Files / Editor / MCP
1. **Run `refresh` after file changes** (not allowed during play — `stop` first).
2. **Use `MapBuilder` first for `.map` work** — direct raw JSON edits are only for explicitly unsupported gaps, and must be minimal plus verified.
3. **Do not modify `Environment/*.d.mlua`** — API definitions are read-only.
4. **Do not create or edit `.codeblock` manually** — Maker `refresh` generates it from `.mlua`.
5. **Take `screenshot` only when the user explicitly asks or when identifying coordinates for input simulation.**
### Physics / Movement / Map
6. **TileMapMode ↔ Body components** must match.
7. On **MapleTile**, placement Y is **foothold-based**; assumes gravity / Rigidbody.
8. On **RectTile**, do not expect vertical foothold physics — assumes Kinematicbody.
9. When inspecting or changing foothold data, use `MapBuilder` APIs so Id / Length / OwnerId consistency is centralized.
### Render / Resource
10. **If a visual is needed, do not leave `SpriteRUID` empty.**
11. **RUIDs must be project-registered resources** — arbitrary strings are missing at runtime.
12. **Match the form of TileSetRUID / sprite DataRef** to existing maps.
### Entity / Spawn / Hierarchy
13. **Keep `id` / `path` / `componentNames` / `jsonString.path` consistent in `.map`.**
14. **`SpawnService` parent must not be nil** — pass a map entity such as `self.Entity.CurrentMap`.
15. **When referencing `modelId`**, `origin.entry_id` = `modelId`, and `origin.root_entity_id` = the entity's own outer `id` (top-level instance).
16. **Use `MapBuilder.placeModel()` for `modelId` instances** — it mirrors model components and keeps `componentNames` in sync. Empty component names or partial component arrays silently remove components at runtime.
17. Child entities must have a **`path` that is a prefix of the parent**.
### Input / UI Boundary
18. **TouchReceive (world) vs Button (UI)** — do not confuse input layers.
19. Keep the responsibilities of UI-only groups (the `ui` hierarchy) and map entities separated.
### State / Animation
20. Do not confuse the roles of **StateComponent (logic)** vs **StateAnimationComponent (sprite action)**.
21. Action-name strings must **match** across code, action sheet, and animation data.
### Verification Loop
22. **`refresh` → `logs`** → **`play` → `logs` → `stop`**.
23. On intermediate failure, **stop the following steps** — fix the cause and retry.
---
## Related Skills / Docs
| Doc | Purpose |
|-------------|------|
| [builder-protocol-map.md §1](builder-protocol-map.md) | **`.map` call protocol — MapBuilder API, snapshot workflow, coverage gaps, `false`-return handling** (with the [builder-protocol.md](builder-protocol.md) core — both in context whenever a turn touches `.map`) |
| [builder-protocol.md §4](builder-protocol.md) | `.model` author → `.map` placement → `refresh` cross-flow |
| [model.md](model.md) | `.model` template authoring domain (when / catalog / component combinations) |
| [tile.md](tile.md) | Tile maps / tilesets |
| [`msw-ui-system`](../../msw-ui-system/SKILL.md) | UI authoring |
| [platform.md](platform.md) (core) | TileMapMode ↔ Body mapping, spawn, RUID, coordinates, `.directory`, ID, `.config` (common to all map types) |
| [platform-maple.md](platform-maple.md) / [platform-rect.md](platform-rect.md) / [platform-sideview.md](platform-sideview.md) | Per-map-type physics / events / patterns / checklists |
| [troubleshooting.md](troubleshooting.md) | Symptom → cause → fix (`LEA-3004`, "won't move", "won't render" …) |
| `msw-defaultplayer` | Player model / Values / components |
| `msw-scripting` | Component / Logic, properties, lifecycle |
| `msw-search` | RUID / asset / doc search |
Core principle of entity work: **"models are templates, maps are instances, the builder-protocol core + builder-protocol-map.md are the call manual, MCP is for verification."**
references/material.md
# MSW `.material` Files — Shader Effects via MCP-Driven Lookup
A `.material` file is the asset that decides **how** a renderer draws its sprite (outline, blur, color mod, screen post-process, …). The visual effect itself is encoded in the **shader**; the material is the configured instance of that shader.
This reference is intentionally short. **The full shader catalog, per-shader property names, default values, and component compatibility are not memorized here** — they live in the live docs and must be retrieved each session through the `mlua_Document_Retriever` / `mlua_API_Retriever` MCP tools (the `msw-guide-mcp` server, identifier `user-msw-guide-mcp`).
> **Visual polish gate.** Whenever the user asks for "shader effect", "outline", "glow", "blur", "rainbow", "vignette", "pixelate", "color flash", "post-process", "screen filter", "hit flash", "material", or any rendering effect on top of a sprite — read this file **first**, then drive the answer from MCP lookups, not from memory.
---
## 0. When to Read This File — **MUST**
If **any** of the following triggers fires, read this file in full before proposing a plan:
- The user says "shader", "material", "outline", "glow", "blur", "pixelate", "rainbow", "color flash", "tint", "grayscale", "vignette", "screen filter", "lens distortion", "wave", "ripple", "distortion", "dissolve", "additive", "blend mode", "hologram", "mask", "stencil", "post-process".
- The user wants a visual effect that is **not** a particle, **not** a separate sprite swap, and **not** an animation clip — i.e. an effect baked onto an existing renderer.
- You need to set / change a `MaterialId` on any renderer component.
- A script needs to call `ChangeMaterial(...)` or `_MaterialService:ChangeMaterialProperty(...)`.
- You will create, edit, or delete a `*.material` file under `RootDesk/MyDesk/`.
---
## 1. Core Concepts (Memorize Only This)
| Concept | Meaning |
|---|---|
| **Material** | An asset (`.material`) that pairs **one shader** with the values of that shader's properties. Each material is identified by its `EntryKey` (`material://<uuid>`). |
| **Shader** | The rendering code/effect. Shader **type is fixed per material** — to switch effects you swap the material, not the shader inside it. |
| **Renderer component** | The component that actually draws something (Sprite / Polygon / Line / RawImage / Avatar / WebSprite / Camera). It exposes a **`MaterialId`** (lowercase `d`, `Sync`) string property and a `ChangeMaterial(materialId)` runtime method. |
| **Entry ID** | The UUID portion of the material's `EntryKey`. **Runtime APIs (`renderer:ChangeMaterial(...)`, `_MaterialService:ChangeMaterialProperty(...)`) take the bare `<EntryId>` UUID — no `material://` prefix.** `_EntryService:GetMaterialIdByName(name)` returns the bare UUID, so pass its return value directly without re-wrapping. The `"material://<EntryId>"` URL form is only for **static asset fields** (the `.material` file's own `EntryKey`, and the `MaterialId` field that Maker writes into `.model` / `.map` files). |
| **Shader category** | A logical grouping of shaders (e.g. `Outline`, `ColorEffect`, `Blurry`, `UVEffect`, `BlendColor`, `Screen`, `AlphaMask`, `AlphaBlend`, `PolygonRenderer`, `LineRenderer`). Some categories are **component-restricted** (e.g. `Screen` is `CameraComponent` only). |
Three operational rules that follow from the above:
1. **The shader code is static. Only its property values are dynamic.** To change the *kind* of effect at runtime you must swap the entire material via `ChangeMaterial(...)`. To merely tween an effect's strength/color/center, edit properties via `_MaterialService:ChangeMaterialProperty(...)`.
2. **`_MaterialService:ChangeMaterialProperty` is shared state.** It mutates the material asset itself, so **every** entity referencing that `EntryId` sees the change. If two instances need different live values, author two distinct `.material` files.
3. **`_MaterialService:ChangeMaterialProperty` is `ClientOnly`.** Server scripts cannot mutate material properties. For server-driven effects, send an RPC and let the client call `ChangeMaterialProperty`.
---
## 2. MCP-Driven Lookup — **the only sustainable way to use shaders**
Do **not** memorize shader names, property names, default values, or compatibility tables. There are 60+ shaders across 10+ categories and each has its own property set; this churn does not fit in a single reference file. Always **fish, don't archive**.
> ⚠️ **Reality check on what MCP can give you.** `mlua_Document_Retriever` reliably returns the **shader category list** and the **member shader names + 1-line descriptions** (the `Designing Materials → Shader Type` chapter). For many shaders it does **not** return the per-property name list — only a handful (e.g. `Hologram`, `Rainbow`, `Pixel`) are documented in retrievable examples. For the rest, fall back to §3.3 path (let Maker generate the material once and read its file).
### 2.1 Which MCP tool to use
| Tool | Use for | Typical phrasing |
|---|---|---|
| **`mlua_Document_Retriever`** | Concepts, recipes, full shader catalog by category, "how do I make X effect?", step-by-step authoring guides, examples that combine multiple systems. | Natural-language sentences. Mention **what effect you want**, not just the shader name. |
| **`mlua_API_Retriever`** | Exact API: class/component/service properties, method signatures, parameter types, ClientOnly/ServerOnly annotations. | **Single API symbol** or short symbol list. E.g. `"SpriteRendererComponent"`, `"MaterialService"`, `"EntryService GetMaterialIdByName"`. |
Server identifier in MCP calls: **`user-msw-guide-mcp`**.
### 2.2 Effective query patterns (copy these, adapt the keyword)
**Finding which shader fits an effect** → `mlua_Document_Retriever`:
```
"Shader types in MapleStory Worlds list - Outline, ColorEffect, Blurry, UVEffect, BlendColor, AlphaMask, AlphaBlend, Screen, PolygonRenderer, LineRenderer categories and their member shaders"
```
```
"How to make a sprite glow / outline / pixelate / shake / dissolve / flash in MSW with material shader"
```
**Finding the exact property names of one shader** → `mlua_Document_Retriever`:
```
"Designing Materials Shader Type <CategoryName> property list and meaning"
```
(e.g. replace `<CategoryName>` with `Blurry`, `Outline`, `ColorEffect`, `UVEffect`, `BlendColor`, `Screen`, `AlphaMask`, `AlphaBlend`.)
**Finding which components accept a material** → `mlua_API_Retriever`:
```
"<ComponentName>"
```
(e.g. `"SpriteRendererComponent"`, `"PolygonRendererComponent"`, `"LineRendererComponent"`, `"RawImageRendererComponent"`, `"RawImageGUIRendererComponent"`, `"PolygonGUIRendererComponent"`, `"WebSpriteComponent"`, `"AvatarRendererComponent"`, `"CameraComponent"`.) Look for the `MaterialId` property (lowercase `d`, `Sync`) and the `ChangeMaterial(string materialId)` method.
**Finding the runtime API** → `mlua_API_Retriever`:
```
"MaterialService ChangeMaterialProperty"
"EntryService GetMaterialIdByName"
```
### 2.3 When a query returns nothing
- A bare keyword like `"Shader"` often returns **0 results**. Expand into a sentence with context (effect goal + component + words like "material", "shader type", "MSW").
- If `mlua_API_Retriever` misses, try `mlua_Document_Retriever` with `"Implementing Materials"` or `"Designing Materials"` (the canonical guide titles).
- If a shader name is unknown, ask `mlua_Document_Retriever` for the **category** ("Shader types ColorEffect chapter list") and pick the entry whose `Description` matches the goal — then re-query for that specific shader's properties.
### 2.4 The 30-second pre-authoring loop (do this every time)
1. `mlua_Document_Retriever`: "*Which shader category covers <user's goal>?*" → pick the category.
2. `mlua_Document_Retriever`: "*Shader Type <Category>*" → pick the exact shader name + read its 1-line description.
3. `mlua_Document_Retriever`: "*Material Property Control example with <ShaderName>*" or read the matching `Designing Materials` chapter to learn the **property names + default value ranges**.
4. (If scripting) `mlua_API_Retriever`: `"<RendererComponent>"` + `"MaterialService ChangeMaterialProperty"` to confirm signatures.
5. Author the `.material` and wire it.
---
## 3. `.material` File Anatomy
`.material` is a plain JSON asset under `RootDesk/MyDesk/Materials/` (create the folder once; Maker Refresh generates folder metadata). It is **not** a builder-only file like `.model`/`.ui` — direct authoring is fine, but the recommended path is "let Maker generate the skeleton once, then edit".
### 3.1 Skeleton (always present, do not invent values for these)
```json
{
"Id": "",
"GameId": "",
"EntryKey": "material://<uuid>",
"ContentType": "x-mod/material",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.7.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"name": "<MaterialName>",
"id": "<uuid>",
"shadertype": "<ShaderName>",
"IsUIMaterial": false,
"RequiresUIStencilStateChange": false
// ... shader-specific properties below ...
}
}
}
```
Invariants:
- `EntryKey` is `"material://" + <uuid>` and the `<uuid>` **must match** `ContentProto.Json.id`. If they drift, scripts that lookup by `EntryKey` will silently miss.
- `ContentType` is always `"x-mod/material"`. `CoreVersion` must equal the project CoreVersion (`26.7.0.0`).
- `Id` / `GameId` / `Content` are populated by Maker; leave them empty on hand-authored files and let `refresh` finalize.
- `ContentProto.Json.shadertype` is the **shader name** (e.g. `"Hologram"`, `"InnerOutline"`, `"Rainbow"`, `"Pixel"`, `"Vignette"`) — **not** the category name.
- `IsUIMaterial` / `RequiresUIStencilStateChange` are always present; set `IsUIMaterial=true` only if the material is being applied to UI renderer components (`RawImageGUIRendererComponent`, `PolygonGUIRendererComponent`, …).
### 3.2 Shader-specific properties (this is where MCP lookup lives)
Every entry below `shadertype` is **shader-defined**. Example for `Hologram`:
```json
"shadertype": "Hologram",
"Blend": 0.5,
"ChangeAmount": 0.3,
"HologramColor": { "r": 0.0, "g": 1.0, "b": 0.0, "a": 1.0 },
"LitMode": 0,
"MaxAlpha": 0.75,
"MinAlpha": 0.1,
"Rotate": 0.0,
"TimeOffset": 0.0,
"TimeScale": 1.0,
"UnchangeAmount": 0.2
```
A different shader has a completely different property set. **Never guess these.** Per shader you must:
1. Call `mlua_Document_Retriever` with: `"Designing Materials Shader Type <Category> <ShaderName> properties"`. **Often returns only the category-level shader list, not per-property names.** Confirmed exposed: `Hologram` (Blend / ChangeAmount / HologramColor / LitMode / MaxAlpha / MinAlpha / Rotate / TimeOffset / TimeScale / UnchangeAmount), `Rainbow` (Blend / Rotate / Spread / TimeOffset / TimeScale), `Pixel` (PixelateSize). For other shaders the property list is generally **not** retrievable.
2. If MCP doesn't give the property names, use the **recommended authoring path** below — let Maker create a sample `.material` with that shader once, then read its file.
> 🔴 **Critical:** writing only `shadertype` plus the §3.1 skeleton is **not enough for a visible effect**. Maker's `refresh` does NOT inject shader-specific property defaults into hand-authored files. With property values missing/zero, most shaders render as Default (or with a 0-strength effect, which looks unchanged). You must either (a) fill in the property values yourself from confirmed lists, or (b) author the material in Maker first and copy its filled defaults into the canonical file.
### 3.3 Recommended authoring path
**This is the default path for any shader whose property names are not in the confirmed list in §3.2.** Hand-authoring skeleton-only files for unfamiliar shaders silently fails (no visible effect).
1. Ask the user (or decide via MCP) which **shader category + shader name** matches the goal.
2. **First-time use of that shader:** ask the user to create one throwaway `.material` with that shader in Maker (Workspace `[+]` → Material → set Shader in Property Editor), then `refresh`. Read the generated file — its property defaults are the source of truth and the only reliable way to get the full property name set.
3. Save the canonical version under `RootDesk/MyDesk/Materials/<Name>.material` (copy the Maker-generated property block verbatim), tweak property values as needed (still as a plain JSON edit), then `refresh`.
4. **For repeated use of the same shader**, keep a reference template in the project so future AI sessions can copy from a known-good file.
> Folder rule: materials live under `RootDesk/MyDesk/Materials/` (create the folder if missing). Never place them directly under `MyDesk/`, under `Global/`, or alongside `.model` / `.map` files.
---
## 4. Applying a Material to a Renderer Component
This is the bridge from "material asset exists" to "entity shows the effect".
### 4.1 Renderer components that accept a material
These all expose a `MaterialId` string property **and** a `void ChangeMaterial(string materialId)` method:
- World renderers: `SpriteRendererComponent`, `WebSpriteComponent`, `AvatarRendererComponent`, `PolygonRendererComponent`, `LineRendererComponent`, `RawImageRendererComponent`
- UI renderers: `RawImageGUIRendererComponent`, `PolygonGUIRendererComponent` (+ other GUI renderers — confirm per component via `mlua_API_Retriever`)
- Special: `CameraComponent` (only **Screen** category shaders — `Vignette`, `LensDistortion` — apply meaningfully to the camera, as a full-screen post-process)
> The full per-component support matrix changes with engine updates. **Always confirm by calling `mlua_API_Retriever` with the component name** before claiming "X component supports material Y". Some renderers restrict which shader categories work.
### 4.2 Setting `MaterialId` statically — three places
Pick the place that matches *where* the material assignment should live for the entity:
1. **On a `.model` file (canonical)** — the right choice when the same entity composition appears more than once. Use `ModelBuilder` (see [model.md](model.md)):
```javascript
b.component("SpriteRendererComponent")
.value("SpriteRendererComponent", "MaterialId", "material://<EntryId>", "string");
```
The value goes in as a plain `string` typeKey, exactly like `SpriteRUID`. Do **not** wrap it in `dataRef()`.
2. **Inline on a `.map` entity** — for genuinely one-off scene entities. Use `MapBuilder` (see [builder-protocol-map.md §1](builder-protocol-map.md); domain context in [`entity.md`](entity.md)) to set the same `MaterialId` value on the entity's `SpriteRendererComponent`.
3. **In the Maker editor** — Property Editor → renderer component → `MaterialId` field → pick from Reference window. Use this only when the user is iterating live and you have no automation route.
The value format is identical in all three places: the **`material://<EntryId>`** URL (some component variants accept just the bare `<EntryId>` as well — when in doubt, use the `material://` form).
### 4.3 Swapping the material at runtime
Use `ChangeMaterial(materialId)` on the renderer component. **The argument is the bare `<EntryId>` UUID — do not add a `material://` prefix.**
> ⚠️ **Common mistake.** Wrapping the value as `"material://" .. entryId` causes the lookup to fail (the renderer does not strip the prefix). Pass the raw UUID string only.
```lua
property string outlineMatId = ""
-- Resolve once (e.g. in OnBeginPlay) so you don't pay name lookup per call
self.outlineMatId = _EntryService:GetMaterialIdByName("Outline_Red")
-- self.outlineMatId is the bare "<uuid>" — pass it straight through
-- Swap when something happens
self.Entity.SpriteRendererComponent:ChangeMaterial(self.outlineMatId)
```
If you already know the literal `EntryId` (copied via Maker → context menu → Copy Entry ID), pass it directly as a bare string:
```lua
self.Entity.SpriteRendererComponent:ChangeMaterial("b97f4743-af7a-44a7-8b7b-388628534910")
```
To **remove** the effect, swap to a `Default` shader material (author one once and reuse), or to a known "neutral" material. There is no documented "clear material" API — keep a `Default.material` around for resets.
### 4.4 Tweaking a property in real time
Use `_MaterialService:ChangeMaterialProperty(entryId, { [PropertyName] = value })`. **ClientOnly**, **shared across all entities using that material**. Like `ChangeMaterial`, the `entryId` argument is the **bare UUID** — no `material://` prefix.
```lua
property string materialEntryId = ""
@ExecSpace("ClientOnly")
method void OnBeginPlay()
self.materialEntryId = _EntryService:GetMaterialIdByName("HologramAura") -- bare "<uuid>"
end
@ExecSpace("ClientOnly")
method void OnUpdate(number delta)
_MaterialService:ChangeMaterialProperty(self.materialEntryId, {
["TimeScale"] = 2.0,
["MinAlpha"] = 0.3
})
end
```
Two important consequences:
- **Per-instance unique values are not possible with one material.** If two enemies need *different* outline thicknesses, create two materials. The "live property" channel is global to the asset.
- **Property names are case-sensitive and shader-specific.** A `Hologram` material exposes `TimeScale`/`MinAlpha`/`HologramColor` etc.; an `Outline` material exposes a different set. Always confirm names via `mlua_Document_Retriever` against the matching `Designing Materials → Shader Type → <Category>` chapter, or by inspecting a Maker-generated `.material` file.
### 4.5 CameraComponent (Screen shaders, full-screen post-process)
Screen-category shaders (`Vignette`, `LensDistortion`) only do anything when assigned to the **active** `CameraComponent`'s material slot. Applying them to a `SpriteRendererComponent` is silently a no-op. When you want a "whole screen tints / pulses / distorts" effect:
1. Locate the player's active `CameraComponent` (typically on `DefaultPlayer`).
2. Assign a Screen-shader material to its material slot (component property is `MaterialId` — confirm exact spelling via `mlua_API_Retriever "CameraComponent"`).
3. Drive properties via `_MaterialService:ChangeMaterialProperty(...)` from client scripts.
### 4.6 Polygon / Line renderers (sprite-tiling shaders)
`PolygonRendererComponent` + `PolygonSprite` shader, and `LineRendererComponent` + `SpritePattern` shader, both **tile a sprite across a geometric primitive**. Two gotchas:
- The sprite (user upload or cloned MSW resource) **must have its lab mode set to `Repeat`**, otherwise the tile boundary breaks.
- These shaders are **not interchangeable with `SpriteRendererComponent`**. The Shader Picker in Maker filters this automatically; via scripts/JSON you must match shader category to renderer.
---
## 5. Shader Catalog — Category Index Only
The exhaustive shader-by-shader list is **not** copied here on purpose (it changes; copying it bloats this file and creates drift). What you need to remember is the **categories** so you can route an MCP query correctly:
| Category | Typical use | Component restriction |
|---|---|---|
| `Default` | "No effect" baseline | any renderer |
| `Outline` | Contour lines around / inside sprite | sprite-like renderers |
| `ColorEffect` | Colorize, gradient, gray, rainbow, hologram, dropshadow, posterize, concentration line | sprite-like renderers |
| `Blurry` | Blur, pixelate, chromatic aberration, motion blur, radial blur, watercolor | sprite-like renderers |
| `AlphaMask` | Cutoff, clip, custom mask texture | sprite-like renderers |
| `AlphaBlend` | Additive / soft-additive transparency for FX layers | sprite-like renderers |
| `UVEffect` | Noise, wave, ripple, glitch, scroll, grass-sway, distortion | sprite-like renderers |
| `BlendColor` | Photoshop-style blend modes (Multiply, Screen, Overlay, …) | sprite-like renderers |
| `Screen` | Full-screen post-process (Vignette, LensDistortion) | **`CameraComponent` only** |
| `PolygonRenderer` | Tile a sprite across a polygon (`PolygonSprite`) | **`PolygonRendererComponent` only** |
| `LineRenderer` | Tile a sprite across a line (`SpritePattern`) | **`LineRendererComponent` only** |
For the **shader names inside each category** and their **property lists** → call `mlua_Document_Retriever` with `"Designing Materials Shader Type <Category>"`.
---
## 6. End-to-End Recipe (template you can adapt)
Goal: monster flashes a red outline when hit.
1. **Decide effect → category** → `Outline` category, `InnerOutline` shader (or `Outline` shader). Confirm via `mlua_Document_Retriever`: "Shader Type Outline list".
2. **Get the property set** for `InnerOutline` from `mlua_Document_Retriever`: "Designing Materials Shader Type Outline InnerOutline properties". (Or generate a sample `.material` in Maker once and copy its defaults.)
3. **Author `RootDesk/MyDesk/Materials/InnerOutline_Red.material`** using the skeleton in §3.1 with `shadertype: "InnerOutline"` plus the outline color/thickness properties from step 2.
4. **Refresh** so Maker registers the new entry.
5. **Set the monster's default material** on its `.model` via `ModelBuilder` (§4.2): `SpriteRendererComponent.MaterialId = "material://<EntryId of InnerOutline_Red>"`. Or, if you don't want the outline by default, leave the model's `MaterialId` unset/`Default` and swap at hit time only.
6. **Hit script (client side)** — on `OnHit` (or via an RPC from server `OnHit`):
```lua
property any outlineId = nil
property any defaultId = nil
-- GetMaterialIdByName returns the bare "<uuid>" — pass it to ChangeMaterial as-is, no "material://" prefix
self.outlineId = self.outlineId or _EntryService:GetMaterialIdByName("InnerOutline_Red")
self.defaultId = self.defaultId or _EntryService:GetMaterialIdByName("Default_Material")
self.Entity.SpriteRendererComponent:ChangeMaterial(self.outlineId)
_TimerService:SetTimerOnce(function()
self.Entity.SpriteRendererComponent:ChangeMaterial(self.defaultId)
end, 0.15)
```
7. **Verify in play mode** and check `logs` for `LEA-` errors.
---
## 7. Pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
| Material assigned but no visible effect | Wrong renderer for that shader category (e.g. `Screen` shader on a `SpriteRendererComponent`, or `PolygonSprite` on a sprite) | Re-check the component restriction in §5; re-query `mlua_API_Retriever` for the component to confirm what it supports. |
| Effect works in editor but disappears at runtime | `_MaterialService:ChangeMaterialProperty` was called from a **server** script | Move the call to a `[client only]` method or RPC the event to clients. |
| All instances of the monster suddenly change at once | `ChangeMaterialProperty` mutates the **asset**, not the instance | Author separate `.material` files per intended live-value group. |
| `EntryKey` lookup misses ("material not found") | `EntryKey`'s UUID and `ContentProto.Json.id` are out of sync, or the file was not `refresh`-ed | Make the two UUIDs match; `refresh` Maker. |
| Polygon/Line shader shows broken seams | Sprite resource lab mode is not `Repeat` | Re-import the sprite with `Repeat` lab mode. |
| Camera Screen shader does nothing | Material assigned to non-active camera, or to a sprite renderer | Assign to the active `CameraComponent`'s material slot. |
| Want to switch *effect* not value, but `ChangeMaterialProperty` doesn't help | Shader type is fixed per material | Use `ChangeMaterial(materialId)` to swap the whole material. |
| `ChangeMaterial(...)` silently does nothing / "material not found" at runtime | Passed `"material://" .. entryId` instead of the bare UUID | Pass the **bare `<EntryId>` UUID** directly — `_EntryService:GetMaterialIdByName(name)` already returns it in that form. The `material://` URL form is only used in static asset fields (the `.material` file's own `EntryKey`, and the `MaterialId` field in `.model` / `.map` JSON), never in `ChangeMaterial` / `ChangeMaterialProperty`. |
| Material is applied (no error) but renders identically to Default | Hand-authored skeleton has `shadertype` set but the shader-specific properties are missing/zero; `refresh` does not fill them in | Create the same-shader material once in Maker so it generates the full property set, then copy that property block into the canonical `.material` file. |
---
## 8. Checklist
- [ ] Read this file in full (triggered by §0).
- [ ] Confirmed the shader category + name via `mlua_Document_Retriever` (not memory).
- [ ] Confirmed property names + types via `mlua_Document_Retriever` (or a Maker-generated `.material` sample).
- [ ] Confirmed the renderer component accepts that shader category via `mlua_API_Retriever`.
- [ ] `.material` saved under `RootDesk/MyDesk/Materials/<Name>.material` with the §3.1 skeleton, matching `EntryKey` UUID and `ContentProto.Json.id`, correct `CoreVersion`.
- [ ] `MaterialId` wired on the target renderer through `ModelBuilder` (§4.2) for canonical entities or via `MapBuilder` for one-off placements — not by hand-editing `.model` JSON.
- [ ] Runtime swaps go through `renderer:ChangeMaterial(entryId)` where `entryId = _EntryService:GetMaterialIdByName(name)` (bare UUID — **never** wrap it as `"material://" .. entryId`).
- [ ] Property tweens go through `_MaterialService:ChangeMaterialProperty(entryId, {...})` from `[client only]` code, using the same bare UUID.
- [ ] Per-instance unique live values? → multiple materials, not one shared.
- [ ] Called Maker `refresh` after authoring the file and after any model/map mutation.
---
## 9. Related Docs
| Doc | Why |
|---|---|
| [model.md](model.md) | Setting `MaterialId` as a `.model` value via `ModelBuilder` (§4.2). |
| [builder-protocol-map.md §1](builder-protocol-map.md) | MapBuilder call protocol for patching `MaterialId` on inline `.map` entities (domain context in [entity.md](entity.md)) |
| [platform.md](platform.md) | `SortingLayer` / `OrderInLayer` / `SpriteRUID` — separate from materials but often involved when the material "doesn't seem to show". |
| `msw-scripting` skill | Authoring the `.mlua` that calls `ChangeMaterial` / `ChangeMaterialProperty`. Read [`msw-scripting/SKILL.md`](../../msw-scripting/SKILL.md) + [`verify-checklist.md`](../../msw-scripting/references/verify-checklist.md) before writing any `.mlua`. |
| `msw-search` skill | Finding sprite RUIDs that pair with the material (e.g. the base sprite under an outline). |
| `msw-ui-system` skill | Applying materials to UI renderers (`RawImageGUIRendererComponent`, `PolygonGUIRendererComponent`) — UI work always routes through this skill first. |
**MCP servers used by this reference**
- `user-msw-guide-mcp` → `mlua_Document_Retriever` (concepts, shader catalog, recipes), `mlua_API_Retriever` (component & service signatures). Treat these as the source of truth for all shader-specific detail every time.
references/model.md
# MSW `.model` Files — Authoring Domain
A `.model` is an entity template. This document carries the **domain rules** of `.model` authoring — when to create one, which template to start from, which component combinations fit which entity types, and the lifecycle order when a script component is bound to a model.
> **The actual call protocol for `.model` mutation — `ModelBuilder` API, fluent-chaining rules, `typeKey` values, validation (M030–M036), child entity invariants, event-link authoring, `.model` → `.map` cross-flow — lives in [builder-protocol-model.md §2](builder-protocol-model.md), with the shared contract in the [builder-protocol.md](builder-protocol.md) core. Both must be in context on every turn that touches `.model` (read only if missing — see the Builder Protocol Preflight in SKILL.md).**
## 0. Non-Negotiable Rule (summary)
- Do not inspect or edit `.model` JSON directly. No `Read` / `cat` / `Get-Content` / `grep` / manual JSON patches.
- All read / create / update / write goes through `scripts/model/msw_model_builder.cjs` (`ModelBuilder`).
- The builder **fully owns** `EntryKey`, `ContentProto.Json.Id/Name`, value type descriptors, inspector-property links, child model shape, and event-link preservation.
- Concrete call patterns / API tables / chaining-safe vs non-builder returns / `typeKey` values / helper functions → [builder-protocol-model.md §2](builder-protocol-model.md).
## 1. When to Create a `.model`
Default rule: if the same entity composition will appear two or more times, author a `.model` and place instances via `modelId`. Runtime spawning with `SpawnByModelId` also requires a registered model.
| Situation | Choice |
|---|---|
| Same composition placed `>= 2` times in one map | Create `.model` |
| Same composition used across maps | Create `.model` |
| Runtime spawn via `SpawnByModelId` | Create `.model` |
| Complex inspector-exposed defaults | Create `.model` |
| Truly one-off decoration used once | Inline map entity is acceptable |
Save user models under `RootDesk/MyDesk/Models/{Category}/{Name}.model`, never directly under `MyDesk/`, directly under `Models/`, or under `Global/`.
When creating a new folder, create the folder only. Maker Refresh generates folder metadata later.
## 2. Template Catalog
Never start from a blank model. Pick the closest template from the skill-local `models/` folder, then load it with `ModelBuilder.fromTemplate()`.
### 2.0 Template Path
Templates live in this skill's own `models/` folder, sibling to `scripts/` and `references/`. The `../models/<Name>.model` notation in the tables below is a **catalog identifier** — not the literal string to pass to `fromTemplate`.
`fromTemplate`'s first argument is resolved against `process.cwd()`, so always pass either an **absolute path** or a `__dirname`-derived path. Never guess. Templates are NOT under `Global/`, `RootDesk/`, `MyDesk/`, or a top-level `Models/` — those are output locations. An error like `model file not found: ./Global/<Name>.model` means the path was fabricated; recompute it from the skill location, do not create a file there.
```javascript
const path = require("path");
const templateDir = path.join(__dirname, "..", "models"); // from a script under scripts/model/
ModelBuilder.fromTemplate(path.join(templateDir, "ChaseMonster.model"), "MyMonster");
```
### Base
| Template | Use |
|---|---|
| `../models/TransformOnly.model` | Empty entity with only `TransformComponent` |
### Characters / Players
| Template | Use |
|---|---|
| `../models/Player.model` | Player variant |
| `../models/DefaultPlayer.model` | DefaultPlayer customization, usually with `BaseModelId` |
### Monsters
Read [`monster.md`](monster.md) before authoring a monster.
| Template | Use |
|---|---|
| `../models/MonsterCanonical.model` | Default start for new monsters |
| `../models/ChaseMonster.model` | Chasing side-view monster, with caveats in [`monster.md`](monster.md) |
| `../models/MoveMonster.model` | Patrol movement monster, with caveats in [`monster.md`](monster.md) |
| `../models/StaticMonster.model` | Stationary attacker, with caveats in [`monster.md`](monster.md) |
### NPC / Interaction
| Template | Use |
|---|---|
| `../models/StaticNPC.model` | Static NPC with dialogue/name tag |
### Terrain
| Template | Use |
|---|---|
| `../models/Foothold.model` | MapleTile foothold |
| `../models/Ladder.model` | Climbable ladder |
| `../models/Rope.model` | Climbable rope |
| `../models/Portal.model` | Map portal/teleport trigger |
### Map Objects / Decoration
| Template | Use |
|---|---|
| `../models/MapObject.model` | Generic decorative object |
| `../models/ParticleMapObject.model` | Object with particles |
| `../models/SkeletonMapObject.model` | Skeleton-based animated object |
| `../models/ItemAsset.model` | Item display |
### Particles / Effects
| Template | Use |
|---|---|
| `../models/BasicParticle.model` | Generic particle |
| `../models/SpriteParticle.model` | Sprite-sheet particle |
| `../models/AreaParticle.model` | Area effect |
| `../models/AnimationPlayer.model` | One-shot animation effect |
### Sound
| Template | Use |
|---|---|
| `../models/Sound.model` | Position-based sound |
| `../models/SoundEffect.model` | One-shot SFX |
### Tilemap Containers
| Template | Use |
|---|---|
| `../models/TileMap.model` | MapleTile tile container |
| `../models/RectTileMap.model` | RectTile/SideViewRectTile tile container |
| `../models/MapleMapLayer.model` | Maple-style map layer |
| `../models/MapEmpty.model` | Empty map container |
### External Media / UI Prefabs
| Template | Use |
|---|---|
| `../models/WebSprite.model` | External image URL |
| `../models/YoutubePlayerWorld.model` | YouTube world object |
| `../models/UIButton.model` | UI button prefab |
| `../models/UIText.model` | Simple UI text prefab |
| `../models/UITextGUIRenderer.model` | Text GUI renderer prefab |
| `../models/UISprite.model` | UI sprite prefab |
| `../models/UIGroup.model` | UI group prefab |
| `../models/UIEmpty.model` | Empty UI prefab |
For full UI layout work, use the `msw-ui-system` skill instead of authoring UI models directly.
## 3. Builder Workflow / 4. API Quick Reference — see builder-protocol-model.md
The call sequence (`fromTemplate` / `read` / fluent mutate / `write`), per-method API signatures, chaining-safe vs `false`-return distinction, `typeKey` values (`bool` / `int` / ... / `action_sheet`), helpers (`vector2` / `vector3` / `quaternion` / `dataRef` / `collisionGroup` / `actionSheet`), Inspector Property / Child Entity tree (child shell schema, ParentId invariants, validation rules M030–M036), Event Link, and `.model` → `.map` cross-flow (`ModelBuilder.write` → `MapBuilder.placeModel`) — **every invocation detail is consolidated in [builder-protocol-model.md §2](builder-protocol-model.md) + [builder-protocol.md §4](builder-protocol.md).**
This document covers only the **domain** side of `.model` authoring:
- When to create a `.model` (§1)
- Which template to start from (§2)
- Which component combinations fit which entity types (§5)
- Lifecycle order when a script component lives inside a `.model` (§6)
- Pre-completion checklist (§7)
## 5. Component Combinations
| Entity Type | Core Components |
|---|---|
| Visual object | `TransformComponent`, `SpriteRendererComponent` |
| MapleTile side-view moving monster | `MovementComponent`, `RigidbodyComponent`, `StateComponent`, `HitComponent` |
| RectTile top-down moving object | `MovementComponent`, `KinematicbodyComponent` |
| SideViewRectTile moving object | `MovementComponent`, `SideviewbodyComponent` |
| Interactive NPC | `SpriteRendererComponent`, `TouchReceiveComponent` |
| Attackable enemy | `AttackComponent`, `HitComponent` |
Body component must match the target map's `TileMapMode`; see [`platform.md`](platform.md) §4.
## 6. Script Components
Custom `script.XXX` components in `.model` depend on the script type already being registered.
Required order:
1. Write the script `.mlua`.
2. Maker `refresh`.
3. Build or patch the `.model` through `ModelBuilder`.
4. Maker `refresh` again.
If this order is inconvenient, keep the `.model` native-only and attach the script at spawn time with `entity:AddComponent("ScriptName")`.
## 7. Checklist
- [ ] Used `ModelBuilder.read()` / `snapshot()` / `fromTemplate()`, not raw `.model` reading.
- [ ] `fromTemplate` path is absolute or `__dirname`-derived (§2.0); never `./Global/...`, `./Models/...`, or a guess.
- [ ] Saved under `RootDesk/MyDesk/Models/{Category}/`.
- [ ] Created any needed folder only; left folder metadata to Maker Refresh.
- [ ] Picked the Body component matching `TileMapMode`.
- [ ] Set a real `SpriteRUID` when using `SpriteRendererComponent`.
- [ ] Used explicit `typeKey` for new or changed values.
- [ ] Called Maker `refresh` after write.
- [ ] Checked logs after refresh/play.
## 8. Related Docs
| Doc | Purpose |
|---|---|
| [builder-protocol-model.md §2](builder-protocol-model.md) | **`.model` call protocol — ModelBuilder API, chaining rules, `typeKey`, Child Entity, Event Link, validation** (with the [builder-protocol.md](builder-protocol.md) core — both in context whenever a turn touches `.model`) |
| [builder-protocol.md §4](builder-protocol.md) | `.model` → `.map` cross-flow (`ModelBuilder.write` → `MapBuilder.placeModel` → `refresh`) |
| [`entity.md`](entity.md) | Placing the authored model in a map, spawn, runtime verification domain |
| [`monster.md`](monster.md) | Monster-specific canonical defaults and pitfalls |
| [platform.md](platform.md) (core) | File location rules, folder metadata, TileMapMode ↔ Body, ID generation |
| [platform-maple.md](platform-maple.md) / [platform-rect.md](platform-rect.md) / [platform-sideview.md](platform-sideview.md) | Per-map-type Body / movement patterns |
| `msw-scripting` | Authoring the `.mlua` scripts attached to models |
| `msw-search` | Resource lookup such as `SpriteRUID` |
references/model/model-schema.md
# `.model` Schema Detail
Normal AI authoring must not use raw `.model` schema details.
Use [`../model.md`](../model.md) and `scripts/model/msw_model_builder.cjs` instead:
- inspect with `ModelBuilder.read()` / `ModelBuilder.snapshot()`
- create with `ModelBuilder.fromTemplate()`
- patch with builder methods
- save with `write()`
This file is intentionally kept as a compatibility stub so older links do not break. It does not expose raw field details because `.model` JSON should be treated as a builder-managed format.
references/monster.md
# MSW Monster — Builder-Only Authoring
This reference is for building a working monster model on MapleTile side-view maps. `.model` JSON is builder-managed; do not inspect or edit raw `.model` internals.
**Read [`animation-state.md`](animation-state.md) §0 first to pick an animation pattern** — Pattern A (script-driven `SpriteRUID`, proven by `Soldier.model`) vs Pattern B (`ActionSheet` auto-swap, `MonsterCanonical.model`). Composition / `IsLegacy` requirement / `ActionSheet` keys all differ. The rest of this doc covers monster-specific composition, AI choices, HP/respawn, spawn, and placement under both patterns.
**Canonical working sample (Pattern A):** a Soldier monster setup — `Soldier/SoldierAI.mlua`, `Soldier/SoldierAttack.mlua`, `Soldier/SoldierSpawner.mlua`, `Monster.mlua`, and `Models/Monsters/Soldier.model`. Full source for all four scripts is inlined in §7 below; refer back to those sections any time the skill output drifts from what visibly works.
```javascript
const { ModelBuilder, vector2, collisionGroup, actionSheet } = require("./scripts/model/msw_model_builder.cjs");
```
## 1. Silent Failures to Avoid
| Symptom | Root cause | Fix |
|---|---|---|
| Monster invisible | Missing or wrong `SpriteRUID` | `value("SpriteRendererComponent", "SpriteRUID", standRuid, "string")` |
| **Nothing animates** — stuck on `stand`, neither move nor die clip plays, no errors | You chose Pattern B (ActionSheet pipeline) but `StateComponent.IsLegacy` is missing from `.model` (defaults to `true` = legacy mode that ignores `ActionSheet`). See [`animation-state.md` §0](animation-state.md). | Either `b.value("StateComponent", "IsLegacy", false, "bool")` (Pattern B path — also reflected in `MonsterCanonical.model`), or switch to Pattern A and drive `SpriteRendererComponent.SpriteRUID` directly from a script (canonical: `script.SoldierAI` — Soldier.model leaves `IsLegacy` unset and works). |
| `[LEA-3022] InvalidExecSpace` on `AddState` / `ChangeState` | Called from the wrong execution space — monster state authority is server-only | Wrap the call in `@ExecSpace("ServerOnly")`. **Do not** also mirror on client (throws on whichever side lacks authority). See [`animation-state.md` §4](animation-state.md). |
| Other animation / state-related bugs | See pitfall table in [`animation-state.md` §7](animation-state.md) | — |
| `[LWA-3019] ... Legacy` on AI/Hit | Legacy defaults | Set `IsLegacy = false` on `AIChase`/`AIWander`/`HitComponent` |
| Monster behind tiles | Wrong sorting layer | Set `SortingLayer = "MapLayer0"` and suitable `OrderInLayer` |
| Hit or attack does nothing | Missing hit box / collision group | Set `HitComponent` box, offset, and monster collision group |
| Monster faces wrong direction | Sprite resources usually face left | Invert `TransformComponent.Scale.x` from movement direction, not `SpriteRendererComponent.FlipX`, so the sprite and collider stay aligned |
## 2. Standard Monster Composition
| Component | Role | Pattern A (Soldier) | Pattern B (MonsterCanonical) |
|---|---|---|---|
| `TransformComponent` | Position, facing (invert `Scale.x` for direction — sprite resources usually face left). | ✅ | ✅ |
| `SpriteRendererComponent` | Visible sprite; needs `SpriteRUID`, `SortingLayer`, `OrderInLayer`. | ✅ | ✅ |
| `StateAnimationComponent` | Pattern B: drives the clip from `ActionSheet` on every `StateChangeEvent`. Pattern A: present in the model but bypassed — script sets `SpriteRUID` directly. | ✅ (decorative) | ✅ (load-bearing) |
| `StateComponent` | State machine. Defaults `IDLE`/`DEAD` only. Pattern A uses it only for `IDLE`/`DEAD` (DeadEvent / IsDead sync). Pattern B also requires `IsLegacy=false` so `ActionSheet` actually runs. | ✅ (`IsLegacy` unset) | ✅ (`IsLegacy=false`) |
| Body (`Rigidbody` / `Kinematicbody` / `Sideviewbody`) | Body for the map type ([`platform.md`](platform.md) §4). Required for MovementComponent / gravity / tile collision. | ✅ | ✅ |
| `MovementComponent` | `InputSpeed`, `JumpForce`, `MoveToDirection`, `Stop`. **Only moves the body — does not change `StateComponent`.** | ✅ | ✅ |
| `AIChaseComponent` *or* `AIWanderComponent` | Toggles `IDLE`↔`MOVE` and overwrites Body velocity from a built-in BehaviorTree. **Mutually exclusive with a custom AI script** — see §5d. | ❌ omitted | ✅ |
| `HitComponent` | Hit collider; receives `HitEvent`. `IsLegacy = false` mandatory. Registers `HIT` state on the entity (built-in HitComponent auto-returns to `IDLE` ~0.5s). | ✅ | ✅ |
| `DamageSkinSpawnerComponent` | Floats damage numbers on `HitEvent`. | ✅ | ✅ |
| `script.Monster` | HP, death, respawn. Calls `ChangeState("DEAD")` on HP=0 then hides/destroys/respawns. | ✅ | ✅ |
| custom AI script (`script.SoldierAI`-style) | Pattern A only — owns its own state variable, sets `SpriteRUID` per state, drives `MovementComponent:MoveToDirection`, gates attack on range. Replaces AIChase/AIWander entirely. | ✅ | — |
| `script.MonsterAttack` / `script.SoldierAttack` | `AttackComponent` subclass — `AttackFast(shape, nil, CollisionGroups.Player)` to deliver hits. MonsterAttack auto-timers `AttackFast` while alive; SoldierAttack exposes `DoAttack()` invoked by the AI script's `ATTACK` state. | ✅ (SoldierAttack: on-demand) | ✅ (MonsterAttack: timer) |
`script.Monster` / `script.MonsterAttack` / `script.SoldierAI` / `script.SoldierAttack` must be registered first. Safe order: write `.mlua` → `refresh` (generates `.codeblock`) → only after `.codeblock` exists, include the script in the `.model` → `refresh`.
> **Pattern A canonical layout** (verbatim from `Soldier.model`'s `Components` array, in order):
> `TransformComponent`, `StateAnimationComponent`, `SpriteRendererComponent`, `RigidbodyComponent`, `MovementComponent`, `StateComponent`, `HitComponent`, `DamageSkinSpawnerComponent`, `script.Monster`, `script.SoldierAI`, `script.SoldierAttack`. No AI component. Eleven entries total.
### Body ↔ map type mapping
| TileMapMode | Body component |
|-------------|----------------|
| MapleTile (0) | `MOD.Core.RigidbodyComponent` |
| RectTile (1) | `MOD.Core.KinematicbodyComponent` |
| SideViewRectTile (2) | `MOD.Core.SideviewbodyComponent` |
Map type check: [`platform.md`](platform.md) §4. Per-Body knockback differences: [`msw-combat-system/SKILL.md`](../../msw-combat-system/SKILL.md) §3-1.
## 3. Action RUID Mapping (`StateAnimationComponent.ActionSheet`)
Lowercase action keys consumed by the engine after `StateStringToAnimationKey` conversion (full table in [`animation-state.md` §6a](animation-state.md)):
| Resource action | Monster action key | State that uses it |
|---|---|---|
| `stand` | `stand` | `IDLE` |
| `move` | `move` | `MOVE` |
| `jump` | `jump` | `JUMP` |
| `attack`, `attack1`, `attack2` | `attack` | `ATTACK` |
| `hit`, `hit1`, `hit2` | `hit` | `HIT` |
| `die`, `die1` | `die` | `DEAD` |
Only set keys you have RUIDs for. Always point `SpriteRUID` at the `stand` RUID so the first pre-state-change frame renders.
**Minimum keys actually required for visible behavior:**
| Pattern | Required keys | Optional |
|---|---|---|
| A (script-driven `SpriteRUID`) | none — ActionSheet is bypassed. The script holds per-state RUIDs as its own properties (e.g. `StandRUID`/`MoveRUID`/`AttackRUID`/`DieRUID`/`Die2RUID` on `SoldierAI`) and assigns `SpriteRUID` directly. Keep ActionSheet filled (e.g. `stand`/`move`/`attack`/`die`) for `Soldier.model` parity, but it does not drive playback. | — |
| B (ActionSheet pipeline) | `stand` (rendered before any state change). `move` if the monster ever enters `MOVE`. `die` if you want a visible death — otherwise the corpse freezes on the last frame and `DeadEvent` still fires. | `hit` (HitComponent auto-enters HIT for ~0.5s — without a key the previous clip simply keeps playing during that window; `Soldier.model` omits `hit` entirely). `attack` only if you register a custom `ATTACK` state and want a distinct clip; the built-in `script.MonsterAttack` damages players without changing state. `jump` only if your controller calls `ChangeState("JUMP")`. |
Use `msw-search` to find animationclip RUIDs. Missing keys fail silently — the previous clip keeps playing.
Example ActionSheet JSON (Pattern A — Soldier.model layout):
```json
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "<stand clip RUID>",
"move": "<move clip RUID>",
"attack": "<attack clip RUID>",
"die": "<die clip RUID>"
},
"Enable": true
}
```
## 4. HitComponent hitbox
The verified working canonicals (`MonsterCanonical.model` and `Soldier.model`) both use:
```
BoxSize = (0.67, 1.42)
ColliderOffset = (-0.005, 0.71)
CollisionGroup.Id = "8992acd1e8cd45838db6f10a7b41df09" -- UUID for MOD@HitBox
IsLegacy = false
```
Use these as the starting point for human-sized monsters (Soldier dimensions). Without the correct `CollisionGroup.Id` UUID, `AttackComponent:Attack(..., CollisionGroups.Monster)` will not interact. The value serialized in `.model` JSON is always the **resolved UUID** — not the human-readable name `"MOD@HitBox"`.
For a custom-sized monster, derive from the sprite bounds:
1. Inspect the sprite's actual bounds (sprite size ÷ PPU or via the editor)
2. `BoxSize` = same as bounds.size, or slightly smaller
3. `ColliderOffset` = `(bounds.center.x − position.x, bounds.center.y − position.y)`
```
-- Example: bounds.size=(1.15, 0.87), bounds.center=(-1.80, 1.19), position=(-1.66, 0.77)
BoxSize = (1.15, 0.87)
ColliderOffset = (-1.80 - (-1.66), 1.19 - 0.77) = (-0.14, 0.42)
```
### 4a. Sprite-pivot-based dynamic collider (runtime calculation)
Instead of computing steps 1~3 by hand, derive `BoxSize` and `PositionOffset` automatically in `OnBeginPlay` from the first-frame sprite metadata of the `AnimationClip` — same code reused across monsters of varying size (especially for contact-attack areas like `MonsterAttack` / `SoldierAttack`).
```lua
@ExecSpace("ServerOnly")
method void OnBeginPlay()
local clip = _ResourceService:LoadAnimationClipAndWait(self.Entity.SpriteRendererComponent.SpriteRUID)
local sprite = clip.Frames[1].FrameSprite
local sizePx = Vector2(sprite.Width, sprite.Height)
local ppu = sprite.PixelPerUnit
self.SpriteSize = sizePx / ppu -- world-unit size
self.PositionOffset = (sizePx / 2 - sprite.PivotPixel:ToVector2()) / ppu -- pivot correction
-- Then in AttackNear etc.:
-- local shape = BoxShape(myPos + self.PositionOffset, self.SpriteSize, 0)
end
```
All APIs used are documented in `.d.mlua` (`_ResourceService:LoadAnimationClipAndWait`, `Sprite.Width/Height/PixelPerUnit/PivotPixel`). Values are normalized to **workspace units (world units)**, so they can be used directly in `BoxShape(position, size, angle)` (use `angle=0` for an axis-aligned rectangle).
> ⚠ `LoadAnimationClipAndWait` is a synchronous load (blocks the server for one frame). Call it once in `OnBeginPlay` and cache in `_T` or a property. Do not re-load every frame inside `OnUpdate`. Wrap in `_ResourceService:PreloadAsync({ruid}, function() ... end)` if you want to avoid the block — `MonsterAttack` does this.
## 5. AI Choice
| Want | Approach |
|---|---|
| Chase nearest player | `AIChaseComponent` (Pattern B) |
| Patrol/wander on footholds | `AIWanderComponent` (Pattern B) |
| Stay still and only attack | Remove both `AIChaseComponent` and `AIWanderComponent`; add a custom timer-driven AttackComponent |
| Chase the attacker instead of nearest player | `AIChaseComponent` with `IsChaseNearPlayer = false`; call `SetTarget(attacker)` from a `HitEvent` handler (Pattern B) |
| Multi-step behavior (patrol → alert → chase → cooldown, attack patterns, talking idle, range-gated attack) | **Custom AI script (Pattern A — Soldier).** Remove both AIChase/AIWander entirely (their built-in BT overwrites velocity every frame and stomps your script). Author a single `@Component script MyAI extends Component` with its own state variable, an `OnBeginPlay` that calls `self:EnterState("ROAM")`, an `EnterState(newState)` that sets the right `SpriteRUID` + duration + facing, and an `OnUpdate(delta)` that ticks state timers, finds the nearest player via `_UserService:GetUsersByMapComponent(map.MapComponent)`, and transitions on range. Death is driven by reading `self.Entity.Monster.IsDead` and playing one die clip once. |
| Multi-step via engine BehaviorTree | Replace AI components with `AIComponent` and author a BehaviorTree from `BTNodeType` scripts. Drive `StateComponent` from nodes so animations still follow [`animation-state.md` §3](animation-state.md). Requires Pattern B setup (`IsLegacy=false`). |
Both AI components need correct Body + Movement + State setup. On MapleTile use `RigidbodyComponent`. AI auto-adds `StateComponent` if missing, but list it in the model anyway so load-time dependencies are stable.
### 5a. `AIChaseComponent` (chaser)
Auto-chases any player inside the detection range. Stops chasing on range exit.
```
property float DetectionRange = 5 -- detection radius (units)
property boolean IsChaseNearPlayer = true -- true: auto-chase the nearest player
property EntityRef TargetEntityRef -- fixed target entity
property boolean IsLegacy = false -- must be false
method Entity GetCurrentTarget() -- return the current chase target
method void SetTarget(Entity targetEntity) -- set a fixed target (auto-disables IsChaseNearPlayer)
method BTNode CreateLeafNode / CreateNode / SetRootNode -- BT customization
```
Builder tuning:
```javascript
b.value("AIChaseComponent", "DetectionRange", 6.0, "float")
.value("AIChaseComponent", "IsChaseNearPlayer", true, "bool")
.value("AIChaseComponent", "IsLegacy", false, "bool");
```
`DetectionRange` pauses/resumes the chase as the target leaves/re-enters; `IsChaseNearPlayer = true` auto-targets the nearest player in range, overridden by `SetTarget(entity)` / `TargetEntityRef`; read with `GetCurrentTarget()`.
### 5b. `AIWanderComponent` (wanderer)
Wanders autonomously near the spawn position. Ignores the player.
```
property boolean IsLegacy = false -- must be false
property boolean LogEnabled = false
property UpdateAuthorityType UpdateAuthority = UpdateAuthorityType.Server
method BTNode CreateLeafNode(string nodeName, func<float> -> BehaviourTreeStatus)
method BTNode CreateNode(string nodeType, string nodeName, func<float> -> BehaviourTreeStatus)
method void SetRootNode(BTNode node)
```
Just adding the component (without custom BT nodes) yields default wander behavior. For advanced patrol routes, build a BT directly via `SetRootNode`. For MapleTile patrol use `PredictFootholdEnd` to reverse at edges ([`platform-maple.md`](platform-maple.md) §5).
### 5c. Switching pattern (swap AI from script)
```lua
-- Wander → Chase swap (dynamic swap from script)
entity:RemoveComponent("AIWanderComponent")
entity:AddComponent("AIChaseComponent")
local chase = entity.AIChaseComponent
chase.DetectionRange = 8.0
```
> `.model`-level swap: replace the `AIWanderComponent` entry with an `AIChaseComponent` entry, then Maker Refresh.
### 5d. ⚠ Do not use AIChase/AIWander together with a custom chase/movement script
`AIChaseComponent` / `AIWanderComponent` **run a BehaviorTree every frame in OnUpdate and overwrite the Body velocity directly.**
- Chase node (`Chase`): if the target is within `DetectionRange` (default 5 units), calls `MovementComponent.MoveToDirection(dir)` → `Body.SetVelocity(dir)`
- Stop node (`Stop`): if the target is out of range or absent, every frame calls `MoveToDirection(zero)` → **forces velocity = 0**
- Additionally, on `FinishedConstruct` it force-overwrites Rigidbody properties: `WalkSpeed=0.5, WalkAcceleration=0.5, WalkDrag=1000, IsolatedMove=true` (on MapleTile)
**Symptom**: Even if a custom chase script tries to chase via `body.MoveVelocity = (vx, vy)`, AIChase immediately overwrites it the next frame. Outside 5 units it gets stomped to 0 every frame, so **the monster appears stuck.** Changing Rigidbody settings is ignored (WalkSpeed is pinned to 0.5).
**Resolution**: If you use a custom AI, **completely remove** `AIChaseComponent` / `AIWanderComponent` from the `.model`. Partial use does not work — leaving either of them in causes the conflict above.
Procedure (Maker Inspector):
1. Open the target `.model` in Maker and delete `AIChaseComponent` / `AIWanderComponent` from the Components panel.
2. Explicitly re-set `RigidbodyComponent.WalkSpeed` to the desired value (e.g. `1.4`) — prevents the `0.5` residue from the AIComponent's force-overwrite.
3. Save, then apply the change via MCP `refresh`.
If you want to keep only part of `AIChase`'s BT, you can leave the component but swap the BT root via `SetRootNode` to disable the default Stop/Chase behavior — but **removal is recommended**.
### 5e. Driving the state machine yourself (no AI component)
If you remove `AIChase`/`AIWander` and write your own controller, you have two sub-options for animation:
- **Sub-option A (Soldier — simplest, recommended).** Do **not** register `MOVE`/`ATTACK` on `StateComponent`, do **not** call `ChangeState("MOVE")` / `ChangeState("ATTACK")` at all. Track behavior in the script's own property (`CurrentAIState = "ROAM"/"STAND"/"SAY"/"ATTACK"`). Swap clips by assigning `self.Entity.SpriteRendererComponent.SpriteRUID = <ruid>` inside your `EnterState` method. Reserve `StateComponent` for `IDLE` ↔ `DEAD` only (handled by `script.Monster` on HP=0). This is what the working `SoldierAI` does — `StateComponent.IsLegacy` doesn't need to be set. Full source: §7b below.
- **Sub-option B (StateComponent + marker states).** Drive everything through `StateComponent` so `StateChangeEvent` fires and the ActionSheet pipeline picks the clip. `MOVE`/`ATTACK` are not registered by default (see [`animation-state.md` §1](animation-state.md) — `[LEA-3005]` otherwise), so register first with `AddState("MOVE", MarkerState)` / `AddState("ATTACK", MarkerState)` server-side. Requires `StateComponent.IsLegacy=false`. Use this when other code (other Components, event handlers, debugging logs) needs to read `CurrentStateName`.
Both still need explicit movement calls because `MovementComponent` does not touch StateComponent. Sub-option B skeleton:
```lua
@Component
script MyMonsterController extends AttackComponent
property number attackTimer = 0
property number AttackCooldown = 1.0
property number facing = 1
@ExecSpace("ServerOnly")
method void OnBeginPlay()
local sc = self.Entity.StateComponent
sc:AddState("MOVE", MarkerState)
sc:AddState("ATTACK", MarkerState)
sc:ChangeState("IDLE")
end
@ExecSpace("ServerOnly")
method void EnterMode(string m)
local sc = self.Entity.StateComponent
if sc.CurrentStateName == m then return end
sc:ChangeState(m) -- triggers StateChangeEvent → animation swap
end
@ExecSpace("ServerOnly")
method void OnUpdate(number delta)
local target = self:FindPlayerInRange()
if isvalid(target) then
self:EnterMode("ATTACK")
self.attackTimer = self.attackTimer - delta
if self.attackTimer <= 0 then
self:DoAttackHitBox()
self.attackTimer = self.AttackCooldown
end
return
end
if self:WantsToWalk() then
self.Entity.MovementComponent:MoveToDirection(Vector2(self.facing, 0), delta)
self:EnterMode("MOVE")
else
self.Entity.MovementComponent:Stop()
self:EnterMode("IDLE")
end
end
end
```
Rules for Sub-option B:
- Every distinct behavior segment is a `StateComponent` state. Don't gate animation on a private `self.mode` string — that doesn't fire `StateChangeEvent`.
- After the attack window, transition out of `ATTACK` — otherwise the attack clip loops and the engine still considers the monster attacking. `script.MonsterAttack` handles this exit automatically; rolling your own means doing it by hand.
- For NPC-style "talk" idle variants, prefer adding a `SAY` custom state with its own `say` action key over re-mapping `stand` at runtime — see [`animation-state.md` §3](animation-state.md) for why mapping-only swaps don't replay.
## 6. Recommended Build Path
### 6a. Pattern A — start from Soldier (no AI component, script-driven)
Faithful to the proven working sample. Use this when behavior needs anything beyond AIChase's nearest-player chase.
```javascript
const { ModelBuilder, vector2, collisionGroup, actionSheet } = require("./scripts/model/msw_model_builder.cjs");
// No template — assemble from scratch with the 11-component Soldier layout.
const b = new ModelBuilder("Slime");
b.component("MOD.Core.TransformComponent")
.component("MOD.Core.StateAnimationComponent")
.component("MOD.Core.SpriteRendererComponent")
.component("MOD.Core.RigidbodyComponent") // MapleTile — swap per platform.md §4
.component("MOD.Core.MovementComponent")
.component("MOD.Core.StateComponent") // IsLegacy left at default
.component("MOD.Core.HitComponent")
.component("MOD.Core.DamageSkinSpawnerComponent")
.component("script.Monster") // HP/death/respawn
.component("script.MyMonsterAI") // your SoldierAI-style controller
.component("script.MyMonsterAttack"); // your SoldierAttack-style on-demand AttackComponent
b.value("SpriteRendererComponent", "SpriteRUID", standRuid, "string")
.value("SpriteRendererComponent", "SortingLayer", "MapLayer0", "string")
.value("SpriteRendererComponent", "OrderInLayer", 2, "int")
// ActionSheet kept for parity with Soldier.model — bypassed at runtime.
.value("StateAnimationComponent", "ActionSheet", actionSheet({
stand: standRuid,
move: moveRuid,
attack: attackRuid,
die: dieRuid,
}), "action_sheet")
.value("HitComponent", "BoxSize", vector2(0.67, 1.42), "vector2")
.value("HitComponent", "ColliderOffset", vector2(-0.005, 0.71), "vector2")
.value("HitComponent", "CollisionGroup", collisionGroup("8992acd1e8cd45838db6f10a7b41df09"), "collision_group")
.value("HitComponent", "IsLegacy", false, "bool")
.value("MovementComponent", "InputSpeed", 1.0, "float")
.value("MovementComponent", "JumpForce", 6.0, "float")
.value("script.Monster", "MaxHp", 100.0, "double")
.value("script.Monster", "RespawnOn", false, "bool");
b.write("RootDesk/MyDesk/Models/Monsters/Slime.model");
```
The AI script holds the per-state RUIDs as its own properties and assigns them on transitions — see §7b. Do **not** add AIChase/AIWander alongside this — the built-in BT overwrites your velocity every frame (§5d).
### 6b. Pattern B — start from MonsterCanonical (AIChase + ActionSheet pipeline)
Use when AIChase's behavior matches your needs exactly. **`StateComponent.IsLegacy = false` is mandatory here.**
```javascript
const b = ModelBuilder.fromTemplate(
"./skills/msw-general/models/MonsterCanonical.model",
"Slime"
);
b.value("SpriteRendererComponent", "SpriteRUID", standRuid, "string")
.value("SpriteRendererComponent", "SortingLayer", "MapLayer0", "string")
.value("SpriteRendererComponent", "OrderInLayer", 2, "int")
.value("StateAnimationComponent", "ActionSheet", actionSheet({
stand: standRuid,
move: moveRuid,
attack: attackRuid,
hit: hitRuid,
die: dieRuid,
jump: jumpRuid,
}), "action_sheet")
.value("HitComponent", "BoxSize", vector2(0.67, 1.42), "vector2")
.value("HitComponent", "ColliderOffset", vector2(-0.005, 0.71), "vector2")
.value("HitComponent", "CollisionGroup", collisionGroup("8992acd1e8cd45838db6f10a7b41df09"), "collision_group")
.value("HitComponent", "IsLegacy", false, "bool")
.value("AIChaseComponent", "IsLegacy", false, "bool")
.value("StateComponent", "IsLegacy", false, "bool") // mandatory for Pattern B — see animation-state.md §0
.value("MovementComponent", "InputSpeed", 1.5, "float")
.value("MovementComponent", "JumpForce", 6.0, "float")
.value("script.Monster", "MaxHp", 500.0, "double");
b.write("RootDesk/MyDesk/Models/Monsters/Slime.model");
```
Omit any action key whose RUID is missing from the resource pack.
## 7. Canonical Pattern A Scripts (Soldier)
Three `.mlua` files cover HP / AI / attack, plus a fourth for spawning. Write each `.mlua` → Maker Refresh once → `.codeblock` is generated → then include in the `.model` (script-component lifecycle order — see §2 above). Full source for all four scripts (`Monster.mlua`, `SoldierAI.mlua`, `SoldierAttack.mlua`, `SoldierSpawner.mlua`) is inlined verbatim in §7a–§7d.
### 7a. `script.Monster` — HP / Death / Respawn (shared between Pattern A and B)
Uses `double` for HP (`@Sync property number` — `number` in mlua is double-precision; serialized as `System.Double`). Drives `ChangeState("DEAD")` and the `IsDead` sync flag; the AI script reads `IsDead` and plays the die clip itself.
```lua
@Component
script Monster extends Component
@Sync property number MaxHp = 100
@Sync property number Hp = 0
@Sync property boolean RespawnOn = false
@Sync @HideFromInspector property boolean IsDead = false
@Sync property number RespawnDelay = 5
@Sync property number DestroyDelay = 0.6
property string DamageSkinRUID = "02c22d93421b4038b3c413b3e40b57ec"
method void OnBeginPlay()
self.Hp = self.MaxHp
local skinSetting = self.Entity.DamageSkinSettingComponent
if isvalid(skinSetting) then
skinSetting.DamageSkinId = DataRef(self.DamageSkinRUID) -- DataRef wrap is mandatory
end
end
@ExecSpace("ServerOnly")
method void Dead()
self.IsDead = true
local sc = self.Entity.StateComponent
if sc then sc:ChangeState("DEAD") end
local delayHide = function()
self.Entity:SetVisible(false)
self.Entity:SetEnable(false)
if self.RespawnOn == false then self.Entity:Destroy() end
end
_TimerService:SetTimerOnce(delayHide, self.DestroyDelay)
end
@ExecSpace("ServerOnly")
method void Respawn()
self.IsDead = false
self.Entity:SetVisible(true)
self.Entity:SetEnable(true)
self.Hp = self.MaxHp
local sc = self.Entity.StateComponent
if sc then sc:ChangeState("IDLE") end
end
@ExecSpace("ServerOnly")
@EventSender("Self")
handler HandleHitEvent(HitEvent event)
local originalHp = self.Hp
self.Hp = self.Hp - event.TotalDamage
if self.Hp > 0 or originalHp <= 0 then return end
self:Dead()
if self.RespawnOn then
_TimerService:SetTimerOnce(function() self:Respawn() end, self.RespawnDelay)
end
end
end
```
Notes:
- The `originalHp <= 0` guard on `HandleHitEvent` makes the handler idempotent — re-entering `Dead()` on an already-dead monster is suppressed.
- No manual `DisconnectEvent` in `OnEndPlay` — `@EventSender("Self")` handlers auto-disconnect with the Component.
- `DamageSkinSettingComponent` is optional; nil-check before assigning. `DamageSkinId` is a `DataRef`, not a string — wrap with `DataRef(...)`.
- Tune via builder values: `b.value("script.Monster", "MaxHp", 500.0, "double").value("script.Monster", "RespawnOn", true, "bool").value("script.Monster", "RespawnDelay", 5.0, "double")`.
### 7b. `script.SoldierAI` — Pattern A AI controller (no built-in AI component)
Owns its own state variable, swaps `SpriteRUID` per state, drives `MovementComponent`, gates `ATTACK` on range. Reads `script.Monster.IsDead` to play one die clip once.
Key shape (full source ≈200 lines):
```lua
@Component
script SoldierAI extends Component
property string StandRUID = "..."
property string MoveRUID = "..."
property string SayRUID = "..."
property string AttackRUID= "..."
property string DieRUID = "..."
property string Die2RUID = "..." -- random pick on death
property number AttackRange = 1.0
property number AttackCooldown = 1.0
@HideFromInspector property string CurrentAIState = "ROAM"
@HideFromInspector property number StateTimer = 0
@HideFromInspector property number MoveDirection = 1
@HideFromInspector property number AttackTimer = 0
@HideFromInspector property boolean DeathPlayed = false
@ExecSpace("ServerOnly")
method void OnBeginPlay()
self:EnterState("ROAM")
end
@ExecSpace("ServerOnly")
method void EnterState(string newState)
self.CurrentAIState = newState
local sprite = self.Entity.SpriteRendererComponent
if newState == "ROAM" then
-- random left/right for 2~4 sec
self.MoveDirection = (_UtilLogic:RandomDouble() < 0.5) and -1 or 1
self.StateTimer = 2 + _UtilLogic:RandomDouble() * 2
if isvalid(sprite) then sprite.SpriteRUID = self.MoveRUID end
-- Sprite faces left by default → moving right (+1) flips Scale.x negative
local t = self.Entity.TransformComponent
if isvalid(t) then
local s = t.Scale
t.Scale = Vector3(math.abs(s.x) * -self.MoveDirection, s.y, s.z)
end
elseif newState == "STAND" then
self.StateTimer = 1 + _UtilLogic:RandomDouble() * 1.5
if isvalid(sprite) then sprite.SpriteRUID = self.StandRUID end
self:StopMovement()
elseif newState == "SAY" then
self.StateTimer = 1.5 + _UtilLogic:RandomDouble() * 1.5
if isvalid(sprite) then sprite.SpriteRUID = self.SayRUID end
self:StopMovement()
elseif newState == "ATTACK" then
self.StateTimer = 0.6
if isvalid(sprite) then sprite.SpriteRUID = self.AttackRUID end
self:StopMovement()
local atk = self.Entity:GetComponent("script.SoldierAttack")
if isvalid(atk) then atk:DoAttack() end
self.AttackTimer = self.AttackCooldown
end
end
@ExecSpace("ServerOnly")
method Entity FindNearestPlayer()
local map = self.Entity.CurrentMap
if not isvalid(map) then return nil end
local mapComp = map.MapComponent
if not isvalid(mapComp) then return nil end
local users = _UserService:GetUsersByMapComponent(mapComp)
if users == nil then return nil end
-- iterate, return nearest by squared distance
...
end
@ExecSpace("ServerOnly")
method void OnUpdate(number delta)
local monster = self.Entity.Monster
if isvalid(monster) and monster.IsDead then
if not self.DeathPlayed then
self.DeathPlayed = true
local sprite = self.Entity.SpriteRendererComponent
if isvalid(sprite) then
sprite.SpriteRUID = (_UtilLogic:RandomDouble() < 0.5) and self.DieRUID or self.Die2RUID
end
self:StopMovement()
end
return
end
...
-- range check → EnterState("ATTACK"); ROAM timer expiry → PickIdleState()
end
end
```
Notes:
- `self.Entity.Monster` reads the `script.Monster` Component instance directly via its name (works because scripts attached to the entity expose themselves under their class name).
- Facing direction is set via `TransformComponent.Scale.x` sign, **not** `SpriteRendererComponent.FlipX` — keeps the sprite and any per-entity colliders aligned.
- `_UserService:GetUsersByMapComponent(map.MapComponent)` is the canonical "find players on this map" call. Returns `nil` when no users.
- `script.SoldierAttack` is invoked **on demand** from `EnterState("ATTACK")` via `atk:DoAttack()`. This differs from `script.MonsterAttack` (which uses an `AttackInterval` timer to fire `AttackFast` periodically while alive).
### 7c. `script.SoldierAttack` — on-demand `AttackComponent` subclass
```lua
@Component
script SoldierAttack extends AttackComponent
property number AttackDamage = 10
@HideFromInspector property any Shape = nil
@ExecSpace("ServerOnly")
method void OnBeginPlay()
self.Shape = BoxShape(Vector2.zero, Vector2(1.2, 1.2), 0)
end
@ExecSpace("ServerOnly")
method void DoAttack()
local transform = self.Entity.TransformComponent
if not isvalid(transform) then return end
local pos = transform.WorldPosition
-- Scale.x negative = facing right (sprite default is left)
local dir = (transform.Scale.x < 0) and 1 or -1
self.Shape.Position = Vector2(pos.x + 0.4 * dir, pos.y + 0.5)
self.Shape.Size = Vector2(1.2, 1.2)
self.Shape.Angle = 0
self:AttackFast(self.Shape, nil, CollisionGroups.Player)
end
method integer CalcDamage(Entity attacker, Entity defender, string attackInfo)
return self.AttackDamage
end
method boolean IsAttackTarget(Entity defender, string attackInfo)
if isvalid(defender.PlayerComponent) == false then return false end
return __base:IsAttackTarget(defender, attackInfo)
end
end
```
### 7d. Spawner (Pattern A — periodic spawn)
`_SpawnService:SpawnByModelId(modelId, name, position, parent)` — `parent` must not be `nil` (pass `self.Entity.CurrentMap`). Returns `nil` for a bad `modelId`; nil-check the return.
```lua
@Component
script SoldierSpawner extends Component
property string ModelId = "soldier"
property number SpawnInterval = 3.0
property number SpawnY = 0.4 -- foothold + 0.4 lands cleanly on MapleTile
property number MinX = -6.0
property number MaxX = 6.0
@HideFromInspector property integer SpawnIndex = 0
@HideFromInspector property integer TimerId = 0
@ExecSpace("ServerOnly")
method void OnBeginPlay()
self.TimerId = _TimerService:SetTimerRepeat(function() self:SpawnOne() end, self.SpawnInterval)
end
@ExecSpace("ServerOnly")
method void OnEndPlay()
if self.TimerId ~= 0 then
_TimerService:ClearTimer(self.TimerId)
self.TimerId = 0
end
end
@ExecSpace("ServerOnly")
method void SpawnOne()
local map = self.Entity.CurrentMap
if not isvalid(map) then return end
self.SpawnIndex = self.SpawnIndex + 1
local x = self.MinX + _UtilLogic:RandomDouble() * (self.MaxX - self.MinX)
local pos = Vector3(x, self.SpawnY, 0)
local name = "Soldier_" .. tostring(self.SpawnIndex)
local e = _SpawnService:SpawnByModelId(self.ModelId, name, pos, map)
if e == nil then log_error("SpawnByModelId returned nil") end
end
end
```
## 8. HP / Respawn flow
Custom HP logic must drive the state machine — when HP hits 0, call `StateComponent:ChangeState("DEAD")` so `die` plays, `DeadEvent` fires, and `IsAttackTarget` rejects further hits. The canonical implementation is `script.Monster` (§7a):
1. `HandleHitEvent` subtracts `event.TotalDamage` from `Hp` and guards against re-entry (`originalHp <= 0`).
2. On `Hp <= 0`, `Dead()` flips `IsDead = true`, calls `ChangeState("DEAD")`, then schedules `delayHide` after `DestroyDelay` (0.6s) — long enough for the `die` clip / Pattern A's direct SpriteRUID swap to play.
3. `delayHide` hides/destroys (or, if `RespawnOn`, only hides — the second timer calls `Respawn()` after `RespawnDelay`).
4. `Respawn()` re-enables visibility, restores `Hp`, and calls `ChangeState("IDLE")`.
If you skip default `script.Monster` and roll your own, mirror this flow — direct HP subtraction without `ChangeState("DEAD")` skips the damage skin / hit effect / `IsAttackTarget` immunity entirely (see [`msw-combat-system/SKILL.md`](../../msw-combat-system/SKILL.md) §2-3).
## 9. Spawn Position
For MapleTile, spawn above the foothold (`footholdY + 0.4`) so gravity lands the monster cleanly. Spawning below makes the monster fall forever and breaks AI. The canonical `SoldierSpawner` (§7d) uses `SpawnY = 0.4` for this reason.
Runtime: `_SpawnService:SpawnByModelId(modelEntryId, name, position, parent)`. `parent` must not be nil (pass `self.Entity.CurrentMap`); nil-check the return — a bad `modelEntryId` returns `nil` silently.
## 10. Speed / physics reference
Verified working baselines:
| Source | InputSpeed | JumpForce | Body | AI |
|--------|------------|-----------|------|----|
| `Soldier.model` (Pattern A) | `1.0` | `6.0` | `RigidbodyComponent` (defaults — `WalkSpeed` not set) | custom `script.SoldierAI` |
| `MonsterCanonical.model` (Pattern B) | `1.5` | `6.0` | `RigidbodyComponent` (defaults) | `AIChaseComponent` (BT force-overwrites `WalkSpeed=0.5` on FinishedConstruct) |
Aspirational ranges if you need to deviate (not measured against the canonicals):
| Monster type | InputSpeed | Notes |
|--------------|------------|-------|
| Slow field mob | 0.5~1.0 | AIWander or custom AI |
| Standard field mob | 1.0~1.5 | matches Soldier / MonsterCanonical |
| Fast / aggressive | 2.0~3.0 | watch foothold edge prediction |
| Flying | 1.0~2.0 | `Gravity = 0` on the Body |
Actual movement speed = `InputSpeed × WalkSpeed`. When `AIChaseComponent`/`AIWanderComponent` is present, **it pins `WalkSpeed=0.5` on FinishedConstruct** (§5d) — Pattern A avoids this because no AI component is attached. Per-map-type conversion → [`platform.md`](platform.md) §10.
## 11. Placement
After writing the model:
1. Maker `refresh`.
2. Place instances in `.map` via `modelId`; see [`entity.md`](entity.md).
3. Do not partially override a system model through a map `modelId` instance. Bake monster defaults into a dedicated `.model`.
4. For repeated monsters, all instances should share one model and only differ in transform/position.
## 12. Verification + checklist
1. `refresh` → check build logs; `play` → check runtime logs.
2. Walk the **state cycle** (see [`animation-state.md` §8](animation-state.md) for the generic checklist): spawn → `IDLE`/`stand`; move → `MOVE`/`move`; hit → `HIT`/`hit` + damage skin → auto-return `IDLE` ~0.5s; HP=0 → `DEAD`/`die` + no further hits, respawn (if enabled).
3. If animation looks stuck, log `CurrentStateName` per frame — distinguishes "state didn't change" from "ActionSheet key wrong".
4. `stop` before further file changes.
### Checklist (both patterns)
- [ ] Body matches the TileMapMode (MapleTile=Rigidbody, RectTile=Kinematic, SideViewRectTile=Sideview)
- [ ] `SpriteRUID` set (stand clip RUID)
- [ ] `HitComponent.IsLegacy = false`, `CollisionGroup.Id = "8992acd1e8cd45838db6f10a7b41df09"` (UUID, **not** `"MOD@HitBox"`)
- [ ] `HitComponent.BoxSize`/`ColliderOffset` derived from sprite bounds (canonical: `(0.67, 1.42)`/`(-0.005, 0.71)`)
- [ ] `DamageSkinSpawnerComponent` included (auto damage-number display)
- [ ] Custom scripts (`.mlua`) are included in `.model` only **after** one Maker Refresh has generated their `.codeblock`
- [ ] `script.Monster` with `MaxHp` (double) / `RespawnOn` / `IsDead` set
### Pattern A (Soldier — recommended)
- [ ] **No** `AIChaseComponent`/`AIWanderComponent` on the model
- [ ] Custom AI script attached (`script.MyMonsterAI` style), holds per-state RUIDs and sets `SpriteRendererComponent.SpriteRUID` on every transition
- [ ] `StateComponent.IsLegacy` left at the default (not set) — animation is not driven by the pipeline
- [ ] ActionSheet filled with `stand`/`move`/`attack`/`die` for parity (bypassed at runtime)
### Pattern B (MonsterCanonical)
- [ ] Exactly one AI component (`AIChase` **or** `AIWander`, never both); `IsLegacy = false`
- [ ] `StateComponent.IsLegacy = false` — **mandatory**, otherwise the pipeline silently does nothing
- [ ] ActionSheet maps every key whose state will be entered (`stand` always; `move` if monster moves; `die` for visible death; `hit`/`attack`/`jump` as needed)
## 13. Cross-References
| Doc | Why |
|---|---|
| [animation-state.md](animation-state.md) | StateComponent, StateType, ActionSheet, `[LEA-3005]`, `SetActionSheet` vs `ChangeState`, monster/NPC/player differences — read first for any state/animation issue |
| [model.md](model.md) | Builder-only `.model` authoring rules and API |
| [entity.md](entity.md) | Placing a monster in a `.map` |
| [builder-protocol-map.md §1](builder-protocol-map.md) + [builder-protocol.md §4](builder-protocol.md) | Builder-first `.map` inspection + ModelBuilder → MapBuilder placement cross-flow |
| [platform-maple.md](platform-maple.md) | MapleTile physics, `PredictFootholdEnd`, foothold AI patterns |
| [platform.md](platform.md) §4 | TileMapMode ↔ Body mapping, LEA-3004 |
| [troubleshooting.md](troubleshooting.md) | Symptom → cause → fix reference |
| [`msw-combat-system/SKILL.md`](../../msw-combat-system/SKILL.md) | Attack/Hit pipeline, damage model, FSM/BT AI patterns, damage skin, hit effect |
| `msw-search` | Animation packs (`categories: ["mob","npc"]`) |
| `msw-scripting` | Custom monster behaviors (`script.Monster`, `StateType`, `HitEvent`/`StateChangeEvent`/`DeadEvent` handlers) |
| `mlua_api_retriever` MCP | Runtime API for `AIChaseComponent`/`AIWanderComponent`/`AIComponent`/`HitComponent` (state/animation APIs covered in `animation-state.md` §9) |
| [`../models/MonsterCanonical.model`](../models/MonsterCanonical.model) | Pattern B verbatim copy source (paste, then swap RUIDs) |
| Soldier reference (this file, §7a–§7d) | Pattern A verified canonical — full source for `Monster.mlua` + `SoldierAI.mlua` + `SoldierAttack.mlua` + `SoldierSpawner.mlua` inlined; `.model` composition in §2 + §6a |
references/platform-maple.md
# Platform: MapleTile (Side-View Platformer) — `TileMapMode = 0`
**When to read this file**: When the `.map` you're working with has `MapComponent.TileMapMode` = **`0` (MapleTile)**, or when working on MapleStory-style side-scrolling action (jump / ladder / free-position platforms).
> This file was split from [`platform.md`](platform.md) as a **MapleTile-specific guide**. Rules common to all map types (8 core, coordinate system, RUID, spawn, ID, .config) remain in [`platform.md`](platform.md). Other map types: [`platform-rect.md`](platform-rect.md) / [`platform-sideview.md`](platform-sideview.md).
---
## 1. Map Type at a Glance
| Item | Value |
|---|---|
| TileMapMode | `0` |
| Enum name | `MapleTile` |
| View | Side-view (side-scrolling) |
| Body component | **`RigidbodyComponent`** |
| Map component | `TileMapComponent` + `FootholdComponent` |
| Gravity | **Yes (built-in, adjustable)** |
| Terrain | **Foothold (line segments)** — non-grid free placement |
| Movement axes | Left/right + jump (Y = gravity) |
| Collision | Foothold collision |
| Representative genres | MapleStory-style platformer |
---
## 2. Grid / Physics / Properties
### Grid Size
`TileMapComponent.GridSize` is **fixed at `(0.45, 0.3)`** (`static readonly`). Cannot be changed.
### Physics System
MapleStory's unique **Foothold-based** physics.
- **Gravity**: `RigidbodyComponent.Gravity` (has default, adjustable)
- **Walking on platforms**: `WalkSpeed`, `WalkAcceleration`, `WalkDrag`
- **Air movement**: `AirAccelerationX`, `AirDecelerationX`, `FallSpeedMaxX/Y`
- **Jump**: `WalkJump` (height), `JumpBias` (hang time)
- **Mass**: `Mass` (acceleration/deceleration responsiveness)
```lua
-- RigidbodyComponent key properties example setup
local rb = self.Entity.RigidbodyComponent
rb.Gravity = 30 -- gravity strength
rb.WalkSpeed = 3 -- max movement speed
rb.WalkJump = 6 -- jump height
rb.WalkAcceleration = 10 -- movement acceleration
rb.WalkDrag = 1 -- movement friction
rb.Mass = 1 -- mass
```
---
## 3. Foothold System (Terrain & Collision)
- **`FootholdComponent`**: Manages all footholds on the map. Interacts with `RigidbodyComponent`.
- Footholds are **line segments (StartPoint ~ EndPoint)**.
- Walking only happens on footholds — **falls due to gravity** without one.
- `DownJump()`: Jump downward (fall through foothold)
- `IsOnGround()`: Check if standing on a foothold
- `GetCurrentFoothold()`: Get current foothold info under feet
- `PredictFootholdEnd(distance, isForward)`: Predict distance to foothold end
```lua
-- Check if on a foothold
if self.Entity.RigidbodyComponent:IsOnGround() then
-- Logic that only runs while on a foothold
end
-- Foothold end detection (AI monster)
if self.Entity.RigidbodyComponent:PredictFootholdEnd(1, true) then
-- Within 1 unit of right edge → reverse direction
end
```
---
## 4. Events
| Event | Triggered when |
|---|---|
| `FootholdEnterEvent` | Landing on a foothold |
| `FootholdLeaveEvent` | Leaving a foothold |
| `FootholdCollisionEvent` | Colliding with a foothold |
| `RigidbodyAttachEvent` | Attached via `AttachTo` |
| `RigidbodyDetachEvent` | Detached via `Detach` |
---
## 5. Monster / NPC Development
**Requirements**:
1. Monster `.model` must include **`RigidbodyComponent`**.
2. `Gravity = 0` means **floating in mid-air** → always set to positive.
3. `WalkSpeed = 0` means **cannot move**.
4. Spawn Y must be above a foothold (below foothold = infinite fall).
```lua
-- MapleTile monster basic patrol pattern
@Component
script MonsterAI extends Component
property boolean movingRight = true
[server only]
void OnUpdate(number delta)
{
local rb = self.Entity.RigidbodyComponent
if rb == nil then return end
-- Only move while on a foothold
if rb:IsOnGround() == false then return end
-- Detect foothold end → reverse direction
if rb:PredictFootholdEnd(0.5, self.movingRight) then
self.movingRight = not self.movingRight
end
-- Set movement direction
local dir = 1
if self.movingRight == false then dir = -1 end
rb.MoveVelocity = Vector2(dir, 0)
}
end
```
---
## 6. Special Features
- **KinematicMove mode**: Setting `RigidbodyComponent.KinematicMove = true` switches to top-down movement mode (moves top-down style on a MapleTile map).
- **AttachTo / Detach**: Attach to another entity (moving platforms, etc.).
- **AddForce / SetForce**: Apply physics-based forces (knockback, push).
---
## 7. MovementComponent — InputSpeed Conversion
`MovementComponent` is a high-level wrapper usable with all Body types.
**In MapleTile**: `InputSpeed` is passed directly to Rigidbody.
| TileMapMode | Actual speed | Notes |
|---|---|---|
| **MapleTile (this file)** | `InputSpeed` passed directly to Rigidbody | — |
| RectTile | `direction * InputSpeed / 1.2f` | 1.2 divisor for migration compatibility |
| SideViewRectTile | `direction.x * InputSpeed * 1.5f`, Y preserved | Correction similar to Rigidbody |
- `InputSpeed` default: `1.0` (`MovementComponent`'s `[MODProperty]`, `@Sync`)
- The same `InputSpeed = 3` feels different across map types.
```lua
local movement = self.Entity.MovementComponent
movement.InputSpeed = 3
movement.JumpForce = 1.5
movement:Jump()
movement:DownJump()
movement:MoveToDirection(Vector2(1, 0), delta)
movement:Stop()
```
`PlayerControllerComponent` handles input → action mapping and internally uses `MovementComponent`. Default key mapping: arrows (movement), Alt/Space (jump), down+jump (down-jump). For custom movement, set `PlayerControllerComponent.Enable = false` then control the Body directly.
---
## 7. Troubleshooting (MapleTile Only)
| Symptom | Cause | Fix |
|---|---|---|
| Entity doesn't move (no error) | Body is not `RigidbodyComponent` | Body swap |
| Log `[LEA-3004] MissingComponent : Entity is missing 'RigidbodyComponent'.` | Dynamic entity missing `RigidbodyComponent` | Add `RigidbodyComponent` to model/entity's `@components` |
| Monster floating in mid-air | `Gravity = 0` | Set `Gravity` to positive |
| Monster falls off platform edge | No foothold-end handling | Reverse direction with `PredictFootholdEnd` |
| Monster disappears off-screen | Spawn Y is below foothold | Move spawn Y above foothold |
| Jump can't reach platform | `WalkJump` is less than foothold gap | Increase `WalkJump` |
> Full symptom dictionary: [`troubleshooting.md`](troubleshooting.md). Recommended to compare there when confused with other map types.
---
## 8. Checklist
### Common (All Map Types)
- [ ] Read `MapComponent.TileMapMode` as a number directly from `.map` and confirm it is 0
- [ ] Player.model Body is `RigidbodyComponent` active (DefaultPlayer handles this automatically)
- [ ] Monster/NPC `.model` includes `RigidbodyComponent`
- [ ] `SpriteRendererComponent.SpriteRUID` is set
- [ ] Spawn calls pass map entity as `parent` (`self.Entity.CurrentMap`)
### MapleTile Specific
- [ ] `RigidbodyComponent.Gravity` > 0 (if not using default)
- [ ] Monster spawn Y is above foothold
- [ ] Foothold-end handling logic (`PredictFootholdEnd` or `IsolatedMove`)
- [ ] `WalkJump` provides sufficient jump height for foothold gaps
---
## 9. Cross-references
- [`platform.md`](platform.md) — 8 core, TileMapMode↔Body mapping table, coordinate system, RUID, spawn, ID
- [`platform-rect.md`](platform-rect.md) / [`platform-sideview.md`](platform-sideview.md) — Other map types
- [`troubleshooting.md`](troubleshooting.md) — Unified symptom dictionary
- [`tile.md`](tile.md) — Tile painting (FootholdComponent editing, etc.)
- [`entity.md`](entity.md) — Entity placement / Map Work Preflight
references/platform-rect.md
# Platform: RectTile (Top-Down) — `TileMapMode = 1`
**When to read this file**: When the `.map` you're working with has `MapComponent.TileMapMode` = **`1` (RectTile)**, or when working on top-down RPG / maze / board game / dungeon crawler / Bomberman-style / RTS-style / farming simulation.
> This file was split from [`platform.md`](platform.md) as a **RectTile-specific guide**. Rules common to all map types (8 core, coordinate system, RUID, spawn, ID, .config) remain in [`platform.md`](platform.md). Other map types: [`platform-maple.md`](platform-maple.md) / [`platform-sideview.md`](platform-sideview.md).
---
## 1. Map Type at a Glance
| Item | Value |
|---|---|
| TileMapMode | `1` |
| Enum name | `RectTile` |
| View | Top-down |
| Body component | **`KinematicbodyComponent`** |
| Map component | `RectTileMapComponent` |
| Gravity | **None** |
| Terrain | Square tile grid |
| Movement axes | **Free 4-directional** |
| Collision | Tile collision (Movable property) |
| Representative genres | Top-down RPG, Bomberman, dungeon crawler |
---
## 2. Physics System
**No gravity**. Free 4-directional movement. Tile-based collision.
- **Movement speed**: `KinematicbodyComponent.SpeedFactor` (separate X / Y multiplier)
- **Jump**: Optional (`EnableJump = true` is visual-only jump — no actual height change)
- **Shadow**: `EnableShadow` shows shadow during jump
### Engine defaults (`KinematicbodyComponent`)
A freshly-added `KinematicbodyComponent` already has movement-enabling defaults. The most common "my entity is mysteriously slow / can't move / has no shadow" issues come from **overwriting** these defaults with zero / false, not from forgetting to set them.
| Property | Default | Notes |
|---|---|---|
| `SpeedFactor` | `Vector2(1, 1)` | Per-axis multiplier. `(0, 0)` means cannot move at all; `(1, 0)` locks Y. |
| `EnableTileCollision` | `true` | Set `false` only when an entity must pass through walls (projectiles, ghosts). |
| `EnableJump` | `true` | Visual-only jump — does not change height. |
| `JumpSpeed` | `6.3` | Upward velocity at jump start. |
| `JumpDrag` | `20` | Downward acceleration during fall. |
| `EnableShadow` | `true` | Shadow under the entity. |
| `ShadowColor` | `Color(0.3, 0.3, 0.3, 0.8)` | RGBA. |
| `ShadowOffset` | `Vector2(0, 0)` | Local offset in tiles. |
| `ShadowSize` | `Vector2(0.7, 0.3)` | Width × height in tiles. |
| `ShadowScalingRatio` | `0.5` | How much the shadow shrinks as the entity rises. |
| `ApplyClimbableRotation` | `true` | Rotate sprite on climbable tiles. |
> `KinematicbodyComponent.Acceleration` is **deprecated** — do not rely on it for new code. Use `SpeedFactor` plus `MoveVelocity` for tuning.
```lua
-- KinematicbodyComponent key properties
local kb = self.Entity.KinematicbodyComponent
kb.SpeedFactor = Vector2(3, 3) -- movement speed multiplier
kb.EnableJump = true -- enable jump (visual only)
kb.JumpSpeed = 5 -- jump speed
kb.JumpDrag = 3 -- fall speed
kb.EnableTileCollision = true -- enable tile collision
kb.EnableShadow = true -- show shadow
kb.ShadowSize = Vector2(0.5, 0.2) -- shadow size
```
---
## 3. Terrain & Collision
- **`RectTileMapComponent`**: Square tile grid.
- Per-tile **Movable property**: passable/blocked setting (in tile editor).
- `EnableTileCollision`: collision detection toggle.
- Coordinate conversion: `ToCellPosition(worldPos)` ↔ `ToWorldPosition(cellPos)`
- **The entity carrying `RectTileMapComponent` has its `TransformComponent` locked** at a fixed origin — direct `Position` / `EulerAngles` / `Scale` writes are silently rejected with `[LWA-3047]`. Game-side anchors (grid origin, spawn points, waypoints) must align to the locked origin and use `ToWorldPosition(cellPos)` to convert tile↔world. See [entity.md "Tile-map entity transform is locked"](entity.md#tile-map-entity-transform-is-locked).
```lua
-- World coordinates → tile coordinates
local tilemap = self.Entity.CurrentMap:GetFirstChildComponentByTypeName("RectTileMapComponent")
local cellPos = tilemap:ToCellPosition(self.Entity.TransformComponent.WorldPosition)
local tileInfo = tilemap:GetTile(cellPos)
if tileInfo ~= nil then
log("Current tile: " .. tileInfo.Name)
end
```
---
## 4. Events
| Event | Triggered when |
|---|---|
| `RectTileEnterEvent` | Entering a tile |
| `RectTileLeaveEvent` | Leaving a tile |
| `RectTileCollisionBeginEvent` | Contact with non-passable tile begins |
| `RectTileCollisionEndEvent` | Contact with non-passable tile ends |
| `KinematicbodyJumpEvent` | Jump state change |
---
## 5. Monster / NPC Development
**Requirements**:
1. Monster `.model` must include **`KinematicbodyComponent`**.
2. **No gravity** → no fall handling needed.
3. Free 4-directional movement.
4. `SpeedFactor = (0,0)` means cannot move.
```lua
-- RectTile monster basic patrol pattern (top-down)
@Component
script MonsterPatrol extends Component
@Sync
property int32 patrolIndex = 0
property table patrolPoints = {
Vector2(1, 0), Vector2(0, 1), Vector2(-1, 0), Vector2(0, -1)
}
[server only]
void OnUpdate(number delta)
{
local kb = self.Entity.KinematicbodyComponent
if kb == nil then return end
local dir = self.patrolPoints[(self.patrolIndex % #self.patrolPoints) + 1]
kb.MoveVelocity = dir
}
end
```
---
## 6. Special Features
- **Per-tile speed changes**: Adjust speed when entering specific tiles via `RectTileEnterEvent`.
- **Dynamic tile placement**: Modify the map at runtime with `SetTile()`, `RemoveTile()`, `BoxFill()`.
- **Tile name-based logic**: Distinguish tile types via `tileInfo.Name`.
---
## 7. MovementComponent — InputSpeed Conversion
**In RectTile**: `direction * InputSpeed / 1.2f` — divided by 1.2 for migration compatibility.
| TileMapMode | Actual speed | Notes |
|---|---|---|
| MapleTile | `InputSpeed` passed directly to Rigidbody | — |
| **RectTile (this file)** | `direction * InputSpeed / 1.2f` | Migration compatibility |
| SideViewRectTile | `direction.x * InputSpeed * 1.5f`, Y preserved | Similar to Rigidbody |
The same `InputSpeed = 3` feels slightly slower than MapleTile.
```lua
local movement = self.Entity.MovementComponent
movement.InputSpeed = 3
movement:MoveToDirection(Vector2(1, 0), delta)
movement:Stop()
-- Jump is visual-only — no actual height change
```
---
## 7. Troubleshooting (RectTile Only)
| Symptom | Cause | Fix |
|---|---|---|
| Entity doesn't move (no error) | Body is not `KinematicbodyComponent` | Body swap |
| Log `[LEA-3004] MissingComponent : Entity is missing 'KinematicbodyComponent'.` | Dynamic entity missing `KinematicbodyComponent` | Add `KinematicbodyComponent` to model/entity's `@components` |
| Monster disappears off-map | Leftover gravity code (from another map type) | Remove gravity code in RectTile |
| Can't jump over walls | RectTile jump is **visual-only** (no height change) | Change tile's Movable property |
| Passes through walls | Tile Movable not set or `EnableTileCollision = false` | Set Movable in tile editor, `EnableTileCollision = true` |
| Only some of 4 directions work | `SpeedFactor` X or Y is 0 | Set both to positive values |
> Full symptom dictionary: [`troubleshooting.md`](troubleshooting.md).
---
## 8. Checklist
### Common
- [ ] Read `MapComponent.TileMapMode` as a number directly from `.map` and confirm it is 1
- [ ] Player.model Body is `KinematicbodyComponent` active (DefaultPlayer handles this automatically)
- [ ] Monster/NPC `.model` includes `KinematicbodyComponent`
- [ ] `SpriteRendererComponent.SpriteRUID` is set
- [ ] Spawn calls pass map entity as `parent`
### RectTile Specific
- [ ] **No gravity code present** (unnecessary)
- [ ] `KinematicbodyComponent.SpeedFactor` ≠ `(0, 0)`
- [ ] `EnableTileCollision = true` (when wall/obstacle collision is needed)
- [ ] Tile Movable properties set as intended (tile editor)
---
## 9. Cross-references
- [`platform.md`](platform.md) — 8 core, TileMapMode↔Body mapping table, coordinate system, RUID, spawn, ID
- [`platform-maple.md`](platform-maple.md) / [`platform-sideview.md`](platform-sideview.md) — Other map types
- [`troubleshooting.md`](troubleshooting.md) — Unified symptom dictionary
- [`tile.md`](tile.md) — Tile painting (Movable property editing, etc.)
- [`entity.md`](entity.md) — Entity placement / Map Work Preflight
references/platform-sideview.md
# Platform: SideViewRectTile (Side-View on Tile Grid) — `TileMapMode = 2`
**When to read this file**: When the `.map` you're working with has `MapComponent.TileMapMode` = **`2` (SideViewRectTile)**, or when working on tile-based side-scrolling platformer / Mario-style pixel action / side-view puzzle (square-tile side-view).
> This file was split from [`platform.md`](platform.md) as a **SideViewRectTile-specific guide**. Rules common to all map types (8 core, coordinate system, RUID, spawn, ID, .config) remain in [`platform.md`](platform.md). Other map types: [`platform-maple.md`](platform-maple.md) / [`platform-rect.md`](platform-rect.md).
---
## 1. Map Type at a Glance
| Item | Value |
|---|---|
| TileMapMode | `2` |
| Enum name | `SideViewRectTile` |
| View | Side-view (side-scrolling) |
| Body component | **`SideviewbodyComponent`** |
| Map component | `RectTileMapComponent` |
| Gravity | **Yes (built-in, no Gravity property)** |
| Terrain | Square tile grid |
| Movement axes | Left/right + jump (Y = gravity) |
| Collision | Tile collision (Collision property) |
| Representative genres | Side-view action, tile-based platformer |
**Key difference**: RectTile's tile grid system + MapleTile's side-view gravity. **A hybrid of both modes.** Walks on square tiles with gravity instead of Foothold line segments.
---
## 2. Physics System
Combines RectTile's tile grid + side-view gravity.
- **Gravity**: Engine built-in (no separate `Gravity` property — fall speed is adjusted via `JumpDrag`).
- **Movement**: Left/right + jump.
- **Jump**: `JumpSpeed` (jump velocity), `JumpDrag` (fall speed).
- **Down-jump**: `EnableDownJump`, `DownJumpSpeed`.
```lua
-- SideviewbodyComponent key properties
local svb = self.Entity.SideviewbodyComponent
svb.JumpSpeed = 5 -- jump speed (higher = jumps higher)
svb.JumpDrag = 3 -- fall speed (higher = falls faster)
svb.EnableDownJump = true -- enable down-jump
svb.DownJumpSpeed = 3.3 -- down-jump rebound speed
```
---
## 3. Terrain & Collision
- Uses **`RectTileMapComponent`** (same tile system as RectTile).
- Concept of standing on tiles + falling due to gravity.
- `IsOnGround()`: Whether standing on a tile.
- `GetUnderfootTile()`: Info about the tile currently under feet.
```lua
-- Check underfoot tile
local svb = self.Entity.SideviewbodyComponent
local tileInfo = svb:GetUnderfootTile()
if tileInfo ~= nil then
log("Underfoot tile: " .. tileInfo.Name)
end
```
---
## 4. Events
| Event | Triggered when |
|---|---|
| `RectTileEnterEvent` | Entering a tile |
| `RectTileLeaveEvent` | Leaving a tile |
| `RectTileCollisionBeginEvent` | Collision tile contact begins (**used for wall detection**) |
| `RectTileCollisionEndEvent` | Collision tile contact ends |
---
## 5. Monster / NPC Development
**Requirements**:
1. Monster `.model` must include **`SideviewbodyComponent`**.
2. **Gravity is automatic** → must spawn on top of tiles to avoid falling.
3. Left/right movement only (no top-down movement).
4. Movement driven by `MoveVelocity`.
```lua
-- SideViewRectTile monster basic patrol pattern
@Component
script MonsterWalk extends Component
@Sync
property boolean movingRight = true
[server only]
void OnUpdate(number delta)
{
local svb = self.Entity.SideviewbodyComponent
if svb == nil then return end
-- Only move while on the ground
if svb:IsOnGround() == false then return end
-- Set movement direction
local dir = 1
if self.movingRight == false then dir = -1 end
svb.MoveVelocity = Vector2(dir, 0)
}
-- Reverse direction on wall collision (no PredictFootholdEnd, so use collision events)
[self]
HandleRectTileCollisionBeginEvent(RectTileCollisionBeginEvent event)
{
local normal = event.Normal
if normal == Vector2.left or normal == Vector2.right then
self.movingRight = not self.movingRight
end
}
end
```
---
## 6. Special Features
- **Wall detection**: Identify wall direction via `RectTileCollisionBeginEvent`'s `Normal` vector.
- **Custom movement**: Drive `MoveVelocity` directly → implement slippery floors, acceleration/deceleration.
- **Dynamic tiles**: Runtime tile manipulation available, same as RectTile.
---
## 7. MovementComponent — InputSpeed Conversion
**In SideViewRectTile**: `direction.x * InputSpeed * 1.5f`, Y preserves existing velocity (to avoid breaking gravity).
| TileMapMode | Actual speed | Notes |
|---|---|---|
| MapleTile | `InputSpeed` passed directly to Rigidbody | — |
| RectTile | `direction * InputSpeed / 1.2f` | Migration compatibility |
| **SideViewRectTile (this file)** | `direction.x * InputSpeed * 1.5f`, Y preserved | Correction similar to Rigidbody |
The same `InputSpeed = 3` feels faster than RectTile (×1.5 acceleration).
```lua
local movement = self.Entity.MovementComponent
movement.InputSpeed = 3
movement.JumpForce = 1.5
movement:Jump()
movement:DownJump()
```
---
## 7. Troubleshooting (SideViewRectTile Only)
| Symptom | Cause | Fix |
|---|---|---|
| Entity doesn't move (no error) | Body is not `SideviewbodyComponent` | Body swap |
| Log `[LEA-3004] MissingComponent : Entity is missing 'SideviewbodyComponent'.` | Dynamic entity missing `SideviewbodyComponent` | Add `SideviewbodyComponent` to model/entity's `@components` |
| Floating in mid-air | Using `KinematicbodyComponent` (brought RectTile model as-is) | Switch to `SideviewbodyComponent` |
| Tile collision broken | Using `RigidbodyComponent` (brought MapleTile model as-is) | Switch to `SideviewbodyComponent` |
| Error when calling `PredictFootholdEnd` | Not a Foothold system (MapleTile-only) | Use `RectTileCollisionBeginEvent` Normal for wall detection |
| Monster gets stuck in wall | No wall detection logic | Use `RectTileCollisionBeginEvent + Normal` |
| Down-jump doesn't work | `EnableDownJump = false` | Set `EnableDownJump = true` |
| Falls too fast / too slow | Inappropriate `JumpDrag` | Adjust `JumpDrag` |
> Full symptom dictionary: [`troubleshooting.md`](troubleshooting.md).
---
## 8. Checklist
### Common
- [ ] Read `MapComponent.TileMapMode` as a number directly from `.map` and confirm it is 2
- [ ] Player.model Body is `SideviewbodyComponent` active (DefaultPlayer handles this automatically)
- [ ] Monster/NPC `.model` includes `SideviewbodyComponent`
- [ ] `SpriteRendererComponent.SpriteRUID` is set
- [ ] Spawn calls pass map entity as `parent`
### SideViewRectTile Specific
- [ ] Confirmed using `SideviewbodyComponent` (not `Rigidbody` / `Kinematicbody`)
- [ ] Spawn is on top of tiles (will fall due to gravity)
- [ ] Wall collision handling (`RectTileCollisionBeginEvent + Normal`)
- [ ] `EnableDownJump = true` set if down-jump is needed
---
## 9. Cross-references
- [`platform.md`](platform.md) — 8 core, TileMapMode↔Body mapping table, coordinate system, RUID, spawn, ID
- [`platform-maple.md`](platform-maple.md) / [`platform-rect.md`](platform-rect.md) — Other map types
- [`troubleshooting.md`](troubleshooting.md) — Unified symptom dictionary
- [`tile.md`](tile.md) — Tile painting (Collision property editing, etc.)
- [`entity.md`](entity.md) — Entity placement / Map Work Preflight
references/platform.md
# MSW Platform Rules — Core
Core rules that apply **across all map types** in the MSW engine. Without these, you'll keep falling into the trap of "the code is correct but nothing happens."
> **What you can only find in this file**: 8 core rules / File authority & `.directory` (§2) / `.mlua + .codeblock` pair (§3) / TileMapMode↔Body mapping + LEA-3004 (§4) / Coordinate system & visible screen range (§5) / SortingLayer & OrderInLayer (§6) / SpriteRUID rules (§7) / `SpawnByModelId` usage & initialization order (§8) / Dynamic entity classification (§8.5) / `MovementComponent` per-map-type InputSpeed conversion formula (§10) / ECS (§13) / ID generation (§14) / `.config` (§15) / CoreVersion (§16).
>
> **What is NOT in this file (split out)**:
> - **Per-map-type development guides** (Foothold patrol / RectTile 4-directional movement / SideView wall detection, etc.) → [`platform-maple.md`](platform-maple.md) / [`platform-rect.md`](platform-rect.md) / [`platform-sideview.md`](platform-sideview.md)
> - **Symptom→cause→fix dictionary** ("not moving" / "not visible" / `LEA-3004` / "floating in mid-air", etc. — 17 cases) → [`troubleshooting.md`](troubleshooting.md)
> - **Per-map-type checklists** → §8 of each `platform-{type}.md`
---
## 1. Rule Summary (Mandatory)
1. **TileMapMode ↔ Body mapping** mismatch causes entities not to move — **fails silently with no error** (§4).
2. User scripts only work as `.mlua` + `.codeblock` **pairs**. `.codeblock` is generated by Maker Refresh (§3).
3. If `SpriteRUID` is an empty string, the entity is **invisible on screen** (§7).
4. Passing a non-map entity as `parent` in `SpawnByModelId` causes a runtime error (§8).
5. Coordinates use **world units** (1 unit = 100 px). Pixel values are off by 100× (§5).
6. Maker only scans `RootDesk/`. User files placed in `Global/` are not recognized (§2).
7. **Do not modify** `.d.mlua` / `.codeblock`.
8. CoreVersion is `26.7.0.0`. Do not proceed if mismatched (§16).
---
## 2. File-Level Authority
| File type | AI create | AI edit | Location | Notes |
|---|:---:|:---:|---|---|
| `.mlua` | **O** | **O** | `RootDesk/MyDesk/` | Script source — AI's primary work target |
| `.codeblock` | **X** | **X** | `RootDesk/MyDesk/` | Auto-generated by **Maker Refresh** after `.mlua` is written |
| `.model` | **O** | **O** | `RootDesk/MyDesk/` | Entity template. **Not recognized if placed in Global/** |
| `.directory` | **X** | **X** | `RootDesk/MyDesk/` | Auto-generated by **Maker Refresh** after creating actual folders |
| `.ui` | △ | △ (properties) | `ui/` | Complex hierarchy. Maker or dedicated tools recommended |
| `.map` | △ | △ (properties/entities) | `map/` | Complex hierarchy. Maker or dedicated tools recommended |
| `.config` | X | **O** (values only) | `Global/` | WorldConfig, SectorConfig (managed by Maker directly) |
| `.d.mlua` | **X** | **X** | `Environment/NativeScripts/` | Engine API definitions. **Do not modify** |
| `.collisiongroupset` | X | △ | `Global/` | Collision group matrix |
> **Global/ warning**: `Global/` is for engine default templates only. Since Maker's file scan only looks at `RootDesk/`, files created by AI in `Global/` **will not appear in the Maker editor**.
### `.directory` Refresh Rule
`.directory` is treated as a Refresh artifact, same as `.codeblock`.
- If a subdirectory (e.g., `Models/`) is needed, just create the actual folder.
- File operations proceed based on the actual folder path even if `.directory` doesn't exist yet.
- Missing `.directory` files are auto-generated when Maker Refresh runs.
- Only run Refresh when the folder needs to appear in Maker UI immediately. If no Refresh tool is available, just leave the folder.
---
## 3. File Pair Rules
All user scripts must exist as `.mlua` + `.codeblock` pairs.
| File | Role | Creation |
|---|---|---|
| `ScriptName.mlua` | Lua source code | **AI Agent writes directly** |
| `ScriptName.codeblock` | JSON metadata (type, inheritance, ID) | **Maker Refresh auto-generates** |
**Rules**:
- Creating only `.mlua` won't register it in MSW → **Maker Refresh required**
- Maker auto-generates `.codeblock` by analyzing the `script ... extends ...` declaration in `.mlua`
- Script location: `RootDesk/MyDesk/`
- After Maker Refresh, `.codeblock`'s `Id` / `Name` auto-align with the script name
### AI Workflow
```
1. AI writes .mlua (RootDesk/MyDesk/)
2. User runs Refresh in Maker
3. Maker auto-generates .codeblock
4. Script is recognized → executable
```
---
## 4. TileMapMode ↔ Body Mapping (Most Important)
**If the mapping is wrong, the entity won't move — silent failure with no error.**
| Value | TileMapMode | View | Body Component | Map Component | Gravity | Terrain | Movement axes | Collision system | Representative genres |
|:---:|---|---|---|---|:---:|---|---|---|---|
| `0` | `MapleTile` | Side-view (side-scrolling) | `RigidbodyComponent` | `TileMapComponent` + `FootholdComponent` | O (built-in) | Foothold (line segments) | Left/right + jump (Y = gravity) | Foothold collision | MapleStory-style platformer |
| `1` | `RectTile` | Top-down | `KinematicbodyComponent` | `RectTileMapComponent` | X | Square tile grid | Free 4-directional | Tile collision (Movable) | Top-down RPG, Bomberman |
| `2` | `SideViewRectTile` | Side-view (side-scrolling) | `SideviewbodyComponent` | `RectTileMapComponent` | O (built-in) | Square tile grid | Left/right + jump (Y = gravity) | Tile collision (Collision) | Side-view action, tile platformer |
> In `.map` files, this field is **serialized as a number** (e.g., `"TileMapMode": 0`). The `Value` column above is that mapping.
**Check protocol** (mandatory preflight for all map work):
1. Identify the target map file (e.g., `./map/{mapname}.map`).
2. Open the `.map` file and read `MapComponent.TileMapMode` as a **number**. Since 0 / 1 / 2 determines the entire physics/movement/collision/terrain stack, **no entity placement, model authoring, or script work may begin without knowing this value.**
3. Verify that Player.model / monster .model / all other dynamic entities have the correct Body per the table above.
4. If mismatched → Body component swap. **Cannot be fixed by modifying script code.**
### LEA-3004 MissingComponent — Runtime Signal of TileMapMode ↔ Body Mismatch
When an entity has logic requiring a Body (`MovementComponent`, `PlayerControllerComponent`, AI components, etc.) but the matching Body is missing, the engine logs exactly one of these three:
| TileMapMode | Required Body | Log when missing |
|---|---|---|
| `0` MapleTile | `RigidbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'RigidbodyComponent'.` |
| `1` RectTile | `KinematicbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'KinematicbodyComponent'.` |
| `2` SideViewRectTile | `SideviewbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'SideviewbodyComponent'.` |
**Meaning**:
- Almost always a model that doesn't match the map's `TileMapMode` was placed, or a dynamic entity is missing a Body entirely.
- **Fix**: Add/swap the Body from the table above in the `.model` or the map entity's `@components` → `refresh`. **Do not work around this by removing `MovementComponent`** — collision/events will break.
- **Prevention**: Following the Check protocol above prevents this error entirely. This is a silent-failure zone that syntax/API validation cannot catch, so if `LEA-3004` appears, re-verify the Body ↔ TileMapMode mapping before anything else.
### TileMapMode Switching Is Performed by the User in Maker
**MapleTile ↔ RectTile ↔ SideViewRectTile mode switching itself** is not done by AI editing `.map` files directly. Mode switching requires tile component swap, Foothold reconstruction, tile data conversion, etc. — internal Maker processing — so **the user must change it directly in the Maker editor**.
**Maker procedure (convey to user)**:
1. Open the **Hierarchy** panel in the Maker editor.
2. **Right-click the target map entity**.
3. Select the **"Switch ..." option** from the context menu (Switch TileMap (MapleTile) / Switch RectTileMap (RectTile) / Switch SideViewRectTileMap (SideViewRectTile)). Maker performs conversion, tile component swap, and terrain reset.
4. User saves → AI runs MCP **`refresh`** → re-read `MapComponent.TileMapMode` to confirm the new value.
> What AI cannot do: Change `TileMapMode` value by directly editing `.map` JSON
> User action required: **Maker Hierarchy panel → right-click target map entity → select "Switch ..." menu**
> What AI can do: **Recommend** the right mode for the user's game — matrix is in [`msw-general` SKILL.md "Recommending the right mode"](../SKILL.md). After switching, fix Body component mismatches and adjust scripts.
### DefaultPlayer Body Component Structure
The DefaultPlayer model **includes all 3 Body components**.
```
DefaultPlayer
├── RigidbodyComponent ← Active in MapleTile
├── KinematicbodyComponent ← Active in RectTile
├── SideviewbodyComponent ← Active in SideViewRectTile
├── MovementComponent ← high-level wrapper (always active)
└── PlayerControllerComponent ← input handling (always active)
```
**The engine automatically activates the appropriate Body based on map type.** The rest are deactivated.
→ Custom monster/NPC models only need **the Body matching the map type**. No need to include all 3.
### Cautions When Switching Map Type
**A single map can only use one TileMapMode.**
When changing map type:
1. **All existing terrain is reset** (irreversible).
2. Body component swap required (Player.model + all monster/NPC models).
3. Movement/collision scripts must be overhauled.
4. Event handlers must be replaced (Foothold events ↔ RectTile events).
→ **Decide the map type early in the project and do not change it.**
---
## 5. Coordinate System and Screen
### Visible Screen Range
Camera is in 2D Orthographic mode. The following area is visible centered on the player:
| Platform | Width | Height | OrthographicSize | Example (camera at 0,0) |
|---|---|---|:---:|---|
| **PC** | **12.8** world units | **7.2** world units | 3.6 | X: ±6.4, Y: ±3.6 |
| **Mobile** | **9.6** world units | **5.4** world units | 2.7 | X: ±4.8, Y: ±2.7 |
- 1 world unit = **100 pixels** (PPU)
- Zoom range: **30% ~ 500%** (visible range changes with zoom)
> Use this range to determine whether an entity is on-screen or off-screen.
### World Coordinates
- Unit: **world unit** (1 unit = 100 px). Since the visible range is about ±6.4 units, coordinate values are typically **single or double digits**.
- Y axis: positive = up (standard game coordinate system).
- `TransformComponent.Position`: **local coordinates relative to parent** (`Vector3`, world units). Serialized/synced.
- `TransformComponent.WorldPosition`: **absolute coordinates** through the parent chain (`Vector3`, world units). Usually read-only — setting it internally converts to Position via the parent's inverse matrix.
- Root entities without a parent have `Position == WorldPosition`.
- Z value: affects render order (larger value = further back).
- Waypoints, spawn coordinates, movement targets — all Position-based values use world units. **Pixel values (hundreds range) are off by 100×.**
### UI Coordinates
- **Reference resolution**: 1920 x 1080 (Landscape) / 1080 x 1920 (Portrait)
- Origin: screen center `(0, 0)`
- Screen edges: X `±960`, Y `±540` (Landscape)
- `UITransformComponent.anchoredPosition`: coordinates relative to parent
- Scales based on 1920x1080 reference regardless of actual device resolution
### Cautions
- World coordinates and UI coordinates are **separate spaces** — no direct conversion.
- UI element `Position` is fixed at `(0, 0, 0)` — **use `anchoredPosition` for placement**.
- To place UI at screen edges, use anchor presets (safer than specifying ±960/±540 directly with `anchoredPosition`).
---
## 6. Map Layer Priority (Rendering Order)
Entity display order is determined by the following **3-level priority**:
```
Priority 1: SpriteRendererComponent's SortingLayer (map layer)
Priority 2: SpriteRendererComponent's OrderInLayer
Priority 3: TransformComponent's Position Z value
```
### Default OrderInLayer by Model Type
When a model is placed on a map, a default OrderInLayer is automatically assigned based on type:
| Model Type | Default OrderInLayer |
|---|:---:|
| Object | 0 |
| Tile | 1 |
| Monster, NPC, Platform, Ladder, Rope, Portal, Trap, Item | 2 |
| Other player avatars | 3 |
| My avatar | 4 |
> When OrderInLayer is the same, **smaller Z values are drawn in front**. If an entity is hidden behind another, check this 3-level priority in order.
---
## 7. SpriteRUID Is Mandatory
If `SpriteRendererComponent`'s `SpriteRUID` is an empty string (`""`), the entity is **invisible on screen**.
- The component name alone doesn't make it obvious that an RUID is required.
- All entities that should be visible (player, monster, object) need an RUID.
- Default sprite RUID: `1705e3c5b2c146ac9a699f96fb067408` (placeholder when nothing else is available)
**Setting RUID**:
```lua
-- Set at runtime
self.Entity.SpriteRendererComponent.SpriteRUID = "ruid-string-here"
-- In .model file (Values array)
{
"Name": "SpriteRUID",
"Value": "1705e3c5b2c146ac9a699f96fb067408"
}
```
**`animationclip` and `thumbnail://`**: `SpriteRUID` also accepts an `animationclip` RUID directly for looping animation playback. To display any resource (`animationclip` / `skeleton` / `avataritem`) as a **static thumbnail**, prepend `thumbnail://` — see `msw-sprite-ruid` skill.
```lua
-- Animation playback
self.Entity.SpriteRendererComponent.SpriteRUID = animationClipRuid
-- Static thumbnail of any resource type
self.Entity.SpriteRendererComponent.SpriteRUID = "thumbnail://" .. anyRuid
```
**Searching for RUID**: Query assets via `_ResourceService`. Only resources registered in the project can be used. (Or use the `msw-search` skill — more accurate.)
---
## 8. Entity Spawn Rules
### SpawnService Usage Rules
```lua
-- Correct usage (model ID is case-insensitive)
_SpawnService:SpawnByModelId("chasemonster", "entityName", spawnPosition, mapEntity)
-- Error (parent is nil)
_SpawnService:SpawnByModelId("chasemonster", "entityName", spawnPosition, nil) -- runtime error!
```
**Rules**:
- First parameter `id` is the **model ID** — the string after `model://` in the `.model` file's EntryKey.
- Case-insensitive (`"ChaseMonster"`, `"chasemonster"` both OK).
- Returns nil if the model doesn't exist (silent failure) → **always nil-check the return value.**
- Pass a **map entity** as the `parent` parameter.
- Passing `nil` causes a runtime error.
- Get the map entity via `self.Entity.CurrentMap`.
### SpawnByModelId vs SpawnByEntity
| Method | Purpose | Notes |
|---|---|---|
| `SpawnByModelId(id, name, pos, parent)` | Create from `.model` | Model file required |
| `SpawnByEntity(entity, name, pos, parent)` | Clone existing entity | Source entity required |
### Spawn Initialization Order
Execution order guaranteed by the engine when creating an entity:
```
1. Empty entity created
2. Components from Components array instantiated
3. Properties links connected
4. Values array iterated → defaults applied by TargetType + Name matching
5. Synchronization (@Sync) registered
6. OnBeginPlay() callback invoked
```
**Implication**: At OnBeginPlay time, all components and default values are already in place.
---
## 8.5 Dynamic Entities and Body Components
**Dynamic entity** = an entity that moves or needs to collide with terrain at runtime.
Examples: Player, Monster, NPC, Pet/Follower, Projectile, Movable Trap, Pushable Object, Drop Item.
**Rule**: Every dynamic entity needs the Body component matching the map's `TileMapMode`:
| TileMapMode | Body component |
|---|---|
| `MapleTile` (0) | `RigidbodyComponent` |
| `RectTile` (1) | `KinematicbodyComponent` |
| `SideViewRectTile` (2) | `SideviewbodyComponent` |
Static decorations, trigger areas, and VFX-only entities do **not need** a Body.
> The 3 Body types are **not interchangeable** — their APIs, properties, events, and physics models all differ. Switching map modes requires not just component swaps but also rewriting movement/collision scripts (see §9.x of each `platform-{type}.md`).
---
## 9. Per-Map-Type Development Guides — moved
Per-map-type detailed guides (physics setup, foothold/tile collision, events, monster patrol patterns, special features) have been **split by map type**. Read only the file matching the `.map`'s `TileMapMode` you're working with.
| TileMapMode | File |
|---|---|
| `0` MapleTile | [`platform-maple.md`](platform-maple.md) — Foothold, `Gravity`, `WalkSpeed`, `PredictFootholdEnd`, `IsOnGround`, `DownJump`, FootholdEnter/LeaveEvent |
| `1` RectTile | [`platform-rect.md`](platform-rect.md) — `SpeedFactor`, free 4-directional movement, Tile Movable, RectTileEnter/LeaveEvent, dynamic tiles |
| `2` SideViewRectTile | [`platform-sideview.md`](platform-sideview.md) — `JumpSpeed`/`JumpDrag`, `EnableDownJump`, wall detection (`RectTileCollisionBeginEvent + Normal`), `GetUnderfootTile` |
Each file includes its own **map-type-specific troubleshooting + checklist** in §7 / §8.
---
## 10. MovementComponent — Common Wrapper
`MovementComponent` is a **high-level movement controller** usable with all 3 Body types.
```lua
-- MovementComponent (map-type agnostic)
local movement = self.Entity.MovementComponent
movement.InputSpeed = 3 -- movement speed
movement.JumpForce = 1.5 -- jump strength
movement:Jump() -- execute jump
movement:DownJump() -- down-jump
movement:MoveToDirection(Vector2(1, 0), delta) -- directional movement
movement:Stop() -- stop
```
**How InputSpeed converts to actual speed per map type** (engine-internal, `MovementComponentPartDefault`):
| TileMapMode | Actual speed | Notes |
|---|---|---|
| **MapleTile** | `InputSpeed` passed directly to Rigidbody | — |
| **RectTile** | `direction * InputSpeed / 1.2f` | 1.2 divisor for migration compatibility |
| **SideViewRectTile** | `direction.x * InputSpeed * 1.5f`, Y preserved | Correction similar to Rigidbody |
- `InputSpeed` default: `1.0` (`MovementComponent`'s `[MODProperty]`, `@Sync`)
- The same `InputSpeed = 3` feels different across map types.
### Interaction with PlayerControllerComponent
`PlayerControllerComponent` handles input → action mapping and internally uses `MovementComponent`.
- Default key mapping: arrows (movement), Alt/Space (jump), down+jump (down-jump).
- For custom movement: set `PlayerControllerComponent.Enable = false` then control the Body component directly.
> Per-map-type `MovementComponent` usage code examples are in each [`platform-{type}.md` §7](platform-maple.md).
---
## 11. Troubleshooting — moved
The symptom→cause→fix dictionary has been moved to [`troubleshooting.md`](troubleshooting.md). Open that file immediately for these cases:
- When `[LEA-3004]` appears in the log
- Silent-failure symptoms like "not moving" / "not visible" / "floating in mid-air" / "stuck in wall"
- Coordinates off by 100× / file not visible in Maker / no multiplayer sync
Map-type-specific troubleshooting (e.g., MapleTile foothold-end handling, SideView wall detection) is also in §7 of each `platform-{type}.md`.
---
## 12. Per-Map-Type Development Checklist — moved
Per-map-type checklists are inside each [`platform-maple.md` §8](platform-maple.md) / [`platform-rect.md` §8](platform-rect.md) / [`platform-sideview.md` §8](platform-sideview.md) as **common checks + map-type-specific checks**. Just read the file for the map type you're working on.
---
## 13. ECS Architecture Essentials
MSW uses Entity-Component-System architecture: Entity (ID + Component set), Component (attached to entity), Logic (global singleton), Service (engine system API).
> **Script type comparison (@Component vs @Logic), declaration syntax, access pattern code** — see `msw-scripting` §3.
---
## 14. ID Generation Rules
ID rules used by AI Agents when creating/editing files.
### EntryKey Patterns
| File type | EntryKey format | Example |
|---|---|---|
| `.model` | `model://{name_lowercase}` | `model://chasemonster` |
| `.map` | `map://{mapname_lowercase}` | `map://map01` |
| `.ui` | `ui://{root_entity_UUID}` | `ui://c1ee38e4-b61d-4299-a1dd-d7a9b11f55c5` |
| `.codeblock` | `codeblock://{scriptname_lowercase}` | `codeblock://monster` |
| `.config` | `config://{fixed_value}` | `config://world`, `config://sectors` |
### Entity ID
Entity IDs inside `.ui` / `.map` files are **UUID v4** (with hyphens).
```
Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Example: bdadf19a-cc27-4a45-99c6-7a439c858a1b
```
- Generate a new UUID each time AI adds an entity.
- `.ui` file's EntryKey must match the root entity's UUID.
### Model ID
- `.model`'s `ContentProto.Json.Name`: display name (e.g., `"ChaseMonster"`).
- EntryKey identifier: lowercase of the name (e.g., `model://chasemonster`).
- The value passed as the first parameter to `SpawnByModelId()`.
### Top-level Id / GameId
- All files' top-level `Id` / `GameId`: **always keep as empty string** `""`.
- System fills them at runtime — AI should not touch them.
### CollisionGroup ID
- System groups: `MOD@{name}` format (do not modify).
- Custom groups: 32-character hex (no hyphens), e.g., `a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6`.
---
## 15. .config File Structure
### Environment/config
```json
{
"CoreVersion": "26.7.0.0"
}
```
### WorldConfig.config
```json
{
"CoreVersion": "26.7.0.0",
"LegacyAnimationSupport": false,
"PlayerEntityAuthorityCheck": false,
"ServiceAuthorityCheck": false,
"RestrictedPlayerEntitySync": false,
"SourceLanguage": "en",
"UseExtendedScriptFormat": true,
"UseLitDefaultMaterial": false
}
```
### SectorConfig.config
```json
{
"Sectors": [
{
"id": "sector01",
"name": "sector01",
"maxUserNo": 16,
"entries": ["map://map01"]
}
]
}
```
- `entries`: `"map://mapname"` format.
- Register a new entry here when adding a map.
### CollisionGroupSet.collisiongroupset
Collision group matrix. Defines which groups collide with which. Edit the existing `Global/CollisionGroupSet.collisiongroupset` through `CollisionGroupSetBuilder`, not raw JSON.
- `Groups[]`: `{ Id, Name }`. Scripts use `CollisionGroups.Name`; `.model` `CollisionGroup` values store `Id`.
- `Matrix`: keys and values are group `Id`s. Use `setCollidable()` for symmetric pairs unless one-way collision is intentional.
- Keep built-ins (`Default`, `TriggerBox`, `HitBox`, `Interaction`, `Portal`, `Climbable`) intact unless the user explicitly asks for a project-level collision policy change.
---
## 16. CoreVersion Compatibility
- Currently supported version: **`26.7.0.0`**
- Location: `Environment/config` → `CoreVersion`
- **Do not proceed if CoreVersion mismatches** (Global Rule).
references/tile.md
# MSW Tile Map (Tile) — `.map` Tile Authoring
In MapleStory Worlds Maker, **map type (`TileMapMode`)**, **tileset / tile data**, **2D tile arrays**, and **player Body** are bundled as one set. Tile map state was previously queried and changed through RPC, but **RPC has been removed.** Tile management is now done through the **Maker tile painter UI**, with the AI in a guidance role.
---
## Policy: AI guides, user paints (MANDATORY)
**The AI must NOT directly write into the tile arrays (`TileMapComponent.Tiles` / `RectTileMapComponent.tileMap`) of a `.map` file.** Hand-authoring tile grids in JSON desyncs the tile palette index, breaks rule-tile face/corner `type` values, and produces silent visual gaps that are hard to diagnose.
Instead, **guide the user to place tiles themselves in the Maker editor**, using the official MSW Creator Center docs as the procedure source of truth:
| Map type | Official tile placement guide |
|----------|------------------------------|
| **MapleTile** (side-view, MapleRuleTile + Foothold) | https://maplestoryworlds-creators.nexon.com/ko/docs/?postId=747 |
| **RectTile / SideViewRectTile** (rect tile + tileset) | https://maplestoryworlds-creators.nexon.com/ko/docs/?postId=589 |
When a user asks the AI to "place tiles" / "fill tiles" / "draw the floor" / "build terrain":
1. **Stop before editing the `.map`.** Tell the user explicitly that tile painting is a Maker-UI task.
2. **Identify the map's `TileMapMode`** with `MapBuilder.read(...).getTileMapMode()`.
3. **Pick the matching official doc** from the table above and either link it directly or summarize the painter steps relevant to the user's goal (selecting tileset, choosing brush, paint / erase / fill, save).
4. **Offer pre-painting setup the AI CAN do** — choose / create the tileset (via the dedicated tileset skill if available), set `TileMapMode`, set `TileSetRUID`, prepare the `RectTileMap` / `TileMap` entity skeleton, configure player Body to match the mode.
5. **After the user reports they finished painting in Maker** → call MCP **`refresh`** so on-disk changes propagate, then verify by reading the tile arrays back (no edit) to confirm dimensions and `tileIndex` validity.
**Exception** — direct `.map` tile-array edits are permitted **only** when:
- Removing all tiles (`tileIndex: -1`) for a clean reset, **or**
- The user explicitly requests programmatic placement (e.g. procedurally generated terrain) **and** confirms the AI may write the array.
In every other case, route to the Maker UI.
---
## Workflow After RPC Removal
| Old RPC concept | Current equivalent |
|----------------|-----------|
| Get tile map mode | `MapBuilder.read(...).getTileMapMode()` |
| List/get/create tile data sets | `.tileset` files (browse the workspace via MCP or open directly) |
| Change tile placement | **User paints in the Maker tile editor UI** (see Policy section above and the official docs). AI does not edit the `Tiles` / `tileMap` arrays directly. |
| Apply changes | After the user finishes painting, run MCP **`refresh`** to apply on-disk changes to the editor |
**Summary**
1. **Change `TileMapMode`** → user performs Maker Hierarchy right-click Switch; AI verifies with `MapBuilder.read(...).getTileMapMode()` afterward.
2. **Tile placement** → guide the user to the Maker tile painter UI using the official docs above. Do **not** write `Tiles` / `tileMap` from the AI.
3. **Runtime tile manipulation from scripts** → use the engine API (see runtime docs and `msw-search` for prerequisites such as map/entity load).
4. **After Maker edits** → run MCP **`refresh`** to update the workspace/scene.
---
## TileMapMode (3 modes)
A single number in `MapComponent.TileMapMode` determines the Body, collision, movement, the type of tile map component, and the rendering stack. Lock in the mode early in map authoring. Changing it later may force a full rebuild of footholds, tiles, and entity Bodies.
> **TileMapMode ↔ Body mapping, check protocol, transition restrictions**: see [platform.md §4](platform.md).
>
> **Choosing the right mode for the user's game (recommendation matrix) + the Maker Hierarchy right-click switch procedure**: see the "Recommending the right mode" and "Changing `TileMapMode`" subsections in [../SKILL.md](../SKILL.md). For any new map (or when the current mode does not fit the user's intended gameplay), the AI must **recommend the appropriate mode first** and then guide the user to **right-click the map in the Maker Hierarchy → "Switch ..." menu** — never write a new `TileMapMode` value from a file edit.
### Tile-map-specific dependencies (per-mode tile system differences)
| System | MapleTile (0) | RectTile (1) | SideViewRectTile (2) |
|--------|-----------|----------|------------------|
| **Tile map component** | `TileMapComponent` | `RectTileMapComponent` | `RectTileMapComponent` |
| **Tileset resource** | `MapleTileSetData` family | `MODTileSetEntry` (`.tileset`) | `MODTileSetEntry` (`.tileset`) |
| **Rendering** | `UnityTileMap` (MapleRuleTile) | `UnityRectTileMap` | `UnityRectTileMap` |
| **Grid** | Fixed (product default grid) | Variable (default 1×1 world unit) | Variable (same) |
| **Foothold** | `FootholdComponent`-centric terrain | Optional | Optional |
| **Collision events** | Standard Rigidbody collisions | 4 RectTileCollision events | 4 RectTileCollision events |
### RectTile vs SideViewRectTile
**Tile map data structure, editing, storage, and rendering are identical.** The only differences are the **physics Body** and gravity / movement handling (see [platform.md §4](platform.md)).
**Shared:** `RectTileMapComponent`, `MODTileSetEntry`, `UnityRectTileMap`, the four RectTileCollision events, and the grid size setting.
### MapleTile is a separate system
- `TileMapComponent` + MapleRuleTile + MapleTileSetData
- Terrain centers on **`FootholdComponent`** linked-list footholds
- Do not confuse it with the **RectTileMap** coordinate / `tileMap` description below.
### RectTileCollision events (RectTile / SideViewRectTile only)
| Event | Meaning |
|--------|------|
| `RectTileCollisionBeginEvent` | Tile collision begins |
| `RectTileCollisionEndEvent` | Tile collision ends |
| `RectTileEnterEvent` | Tile cell entered |
| `RectTileLeaveEvent` | Tile cell exited |
**These events do not fire on MapleTile maps.**
---
## TileDataSet / Tileset Concept (MODTileSetEntry)
The **tile palette** used by RectTile / SideViewRectTile is expressed as a `.tileset` resource (**`MODTileSetEntry`**). In docs and tools, treat one entry at the **TileDataSet / MODTileSetData** level.
```
MODTileSetEntry
├── EntryKey: "tileset://{UUID}" ← string-matches the map's TileSetRUID
├── ContentType: "x-mod/tileset"
└── datas[]: List<MODTileSetData> ← tile palette (order is the index)
├── Id (GUID), Name, IsCollidable, sprite refs, etc.
└── ...
```
- **`datas[].Id`**: the tile's **immutable identifier** (GUID).
- **0-based array index of `datas[]`**: the meaning of the **`tileIndex`** referenced by the map's JSON `tileMap[]` array and by `RectTileInfo.Index`. Inserting, deleting, or reordering entries changes what each index means. ⚠️ **Runtime `SetTile` / `BoxFill` (`int32 tileIndex` overload) take a 1-based input** — see [§Runtime API: `tileIndex` is 1-based](#runtime-api-tileindex-is-1-based-mismatch-with-json--recttileinfoindex) below.
- **`IsCollidable`**: tile metadata such as Rect tile collision flag.
**Map linkage**
- `RectTileMapComponent.TileSetRUID` is a **`"tileset://..."` string** and must match the `EntryKey` of that `.tileset`.
- A single tileset may be referenced by **multiple maps** simultaneously.
**When editing a tileset externally**
- Reordering `datas[]` makes existing `tileIndex` values point at the wrong tile. The runtime does not auto-correct.
- When deleting, inserting, or reordering tiles, you must remap every `tileIndex` across all maps.
---
## 2D Tile Array Coordinate System
### Common: a single tile entry
Both MapleTile's `TileMapComponent.Tiles` and the Rect-family `RectTileMapComponent.tileMap` give each tile roughly this shape:
- **`position`**: `{ "x": int, "y": int }` — **cell coordinate on the tile grid** (not world meters).
- **`tileIndex`**: **0-based index** within the tileset (this is the JSON-storage form). **`-1`** means an empty cell (no tile). ⚠️ Note that the runtime `SetTile` / `BoxFill` (`int32` overload) take a **1-based** value — see [§Runtime API: `tileIndex` is 1-based](#runtime-api-tileindex-is-1-based-mismatch-with-json--recttileinfoindex).
- **`type`**: in MapleTile, takes various values for face / corner rule tiles. In RectTileMap, normally **`0`** (default).
`type` examples for MapleTile `TileMapComponent` (rule tiles linked to foothold visuals):
| type | Meaning (summary) |
|:----:|------------|
| 0 | Fill (interior face) |
| 5 | Top face |
| 6 | Right face |
| 7 | Bottom face |
| 8 | Lower-left corner |
| 9 | Upper-left corner |
| 11 | Upper-right corner |
(Additional types may exist depending on project / assets.)
### RectTileMap ↔ World Units (player / entity placement)
**Tile `position` is a grid cell**; **entity `TransformComponent.Position` is in world units**. Mixing them desyncs spawn position, range, and movement.
Common conventions used with **default** RectTileMap (assuming default camera / grid):
| Item | Detail |
|------|------|
| One tile size | **1 × 1 world unit** (default grid) |
| x axis | Left `-` → right `+` |
| y axis | Down `-` → up `+` |
| Screen origin | Around **(0, 0)** in map-root space is normally screen center |
If you change `CameraComponent.Ratio` or the grid size of `RectTileMapComponent`, the world-unit ↔ screen-pixel mapping changes, so **revalidate placement and range constants**.
---
## Runtime API: `tileIndex` is 1-based (mismatch with JSON / `RectTileInfo.Index`)
> [!IMPORTANT]
> **The `RectTileMapComponent` runtime methods that take an `int32 tileIndex` argument are 1-based**, while the **`.map` JSON `tileMap[].tileIndex` and `RectTileInfo.Index` (returned by `GetTile`) are 0-based**. This asymmetry is the most common cause of `LEA-3003 OutOfRange ('tileIndex_0based' ...)` errors and silent off-by-one tile placement. Applies to `RectTileMapComponent` only (RectTile / SideViewRectTile); MapleTile's `TileMapComponent` does not expose a script-side `SetTile`.
### Affected APIs — 1-based `int32 tileIndex` input
| Method | Signature | First tile = |
|---|---|---|
| `SetTile` | `method void SetTile(int32 tileIndex, Vector2Int cellPosition)` | `1` |
| `SetTile` | `method void SetTile(int32 tileIndex, int32 cellPositionX, int32 cellPositionY)` | `1` |
| `BoxFill` | `method void BoxFill(int32 tileIndex, Vector2Int from, Vector2Int to)` | `1` |
Valid input range is `1 .. #tilesetData.datas` (inclusive). Passing `0` always triggers `LEA-3003`. Passing values past the upper bound also triggers `LEA-3003`. **To clear a cell, use `RemoveTile(cellPosition)`** — `SetTile` does not accept `-1` or `0`.
The string-name overloads (`SetTile(string tileName, ...)`, `BoxFill(string tileName, ...)`) are the safest entry point because the `+1` shift happens internally — script code never sees the boundary.
### Still 0-based (do NOT shift)
- **`RectTileInfo.Index`** — what `GetTile(cell).Index` returns. First tile of the tileset is `0`. When feeding this back into `SetTile` / `BoxFill` (`int32` overload), pass `Index + 1`.
- **`.map` JSON `tileMap[].tileIndex`** — the on-disk form inside `.map` files. First tile is `0`. The `+1` shift exists only at the runtime API boundary; hand-edited JSON (the exception path per [§Policy: AI guides, user paints](#policy-ai-guides-user-paints-mandatory)) stays 0-based.
- **`MODTileSetData` ordering inside `datas[]`** — palette order; first entry is `0`.
In one line: **wire format and the `Index` property stay 0-based; only the `int32 tileIndex` argument of `SetTile` / `BoxFill` is shifted by `+1` at the API boundary.**
### Debugging `LEA-3003 tileIndex_0based ...`
The console reports the engine's **internal post-shift value** (your input `- 1`):
| Console message | What your script actually passed |
|---|---|
| `tileIndex_0based -1` (cannot be less than 0) | passed `0` → always invalid |
| `tileIndex_0based <N>` where `N >= #datas` | passed `N + 1` → above the tileset upper bound |
Fix: add `+1` to whatever index source you are using, or switch to the `string tileName` overload.
---
## Tile-Related Structure in `.map` Files
Map files live under the workspace **`./map/`** (e.g. `map/map01.map`).
### MapleTile (TileMap entity)
- Entity name normally **`TileMap`**
- Component: **`TileMapComponent`**
- Tile array key: **`Tiles`**
- **`TileSetRUID`**: object form `{ "DataId": "GUID" }`
### RectTile / SideViewRectTile (RectTileMap entity)
- Entity name normally **`RectTileMap`** (multiple per layer allowed)
- Component: **`RectTileMapComponent`**
- Tile array key: **`tileMap`** (warning: not `Tiles`)
- **`TileSetRUID`**: **`"tileset://..."` string**
A map may contain **multiple `RectTileMap`** entities (for layer separation), each referencing a different tileset.
---
## Related Skills
- **[`entity.md`](entity.md)** — `.map` entity / component domain rules; **call protocol lives in [`builder-protocol-map.md §1`](builder-protocol-map.md)**
- **[platform.md](platform.md)** — TileMapMode ↔ Body, SpriteRUID, spawn rules (core)
- **[platform-maple.md](platform-maple.md)** / **[platform-rect.md](platform-rect.md)** / **[platform-sideview.md](platform-sideview.md)** — Per-map-type physics, events, and troubleshooting
- **`msw-defaultplayer`** — per-mode movement components (`KinematicbodyComponent`, `SideviewbodyComponent`, etc.)
---
## Summary Checklist
- [ ] Does `TileMapMode` (0/1/2) match the planned viewpoint and physics?
- [ ] Does the **Body** of player / NPCs match that mode?
- [ ] If Rect-family: **`RectTileMap` + `tileMap` + `tileset://`**; if MapleTile: **`TileMap` + `Tiles` + DataId object**?
- [ ] Does the **JSON / `RectTileInfo.Index`** `tileIndex` point to the **0-based `datas[]`** of that `.tileset`? (`-1` = empty)
- [ ] When calling **runtime `SetTile` / `BoxFill` with the `int32` overload**, is the `tileIndex` **1-based** (or are you using the `string tileName` overload to avoid the shift)?
- [ ] Are tile-grid coordinates and **world-unit coordinates** kept distinct?
- [ ] **Was the tile painting handed off to the user (Maker UI + official docs)** instead of being written directly by the AI?
- [ ] After Maker edits, was **`refresh`** run?
references/troubleshooting.md
# MSW Troubleshooting — Symptom Dictionary
**A debugging dictionary indexed by symptom.** When a user says things like "it's not moving" / "it's not showing" / `LEA-3004`, **open this file first**, find the matching row, and navigate to the referenced § in [`platform.md`](platform.md) / `platform-{type}.md`.
> This file was split from [`platform.md` §11 / §12](platform.md) as a unified troubleshooting index. Per-map-type troubleshooting details also appear in the "Troubleshooting" sections of [`platform-maple.md`](platform-maple.md) / [`platform-rect.md`](platform-rect.md) / [`platform-sideview.md`](platform-sideview.md).
---
## 1. Symptom-Driven Index — Trigger Phrases First
| What the user/log shows | Primary suspect | Go-to section |
|---|---|---|
| "Won't move" / "Movement broken" / entity stays still with no error | TileMapMode ↔ Body mismatch | [§2 Map Type Mismatch](#2-map-type-mismatch) → the corresponding `platform-*.md` |
| "Won't render" / "Disappeared" / blank spot on screen | `SpriteRUID = ""` | [§3 Other Common Pitfalls — invisible](#3-other-common-pitfalls) → [`platform.md` §7](platform.md) |
| "Floating in mid-air" / "Won't touch the ground" | `Gravity = 0` (MapleTile) or wrong Body | [`platform-maple.md` §7](platform-maple.md) |
| "Stuck in a wall" / "Passes through walls" | Wall detection not implemented (SideView) or tile Movable not set (RectTile) | [`platform-sideview.md` §7](platform-sideview.md) / [`platform-rect.md` §7](platform-rect.md) |
| "Falls off the foothold" / "Should stop at the edge" | `PredictFootholdEnd` not used (MapleTile) | [`platform-maple.md` §5 Monster patrol pattern](platform-maple.md) |
| "Disappears off the map" | Gravity code left over in a RectTile map | [`platform-rect.md` §7](platform-rect.md) |
| "Can't jump over the wall" (RectTile) | RectTile jump is visual-only — Movable property needed | [`platform-rect.md` §6](platform-rect.md) |
| "Won't spawn" / "Runtime error" | `parent = nil` or wrong modelId | [§3](#3-other-common-pitfalls) → [`platform.md` §8](platform.md) |
| "Coordinates are way too big/small" / entity off-screen | Pixel values used (missing 1 unit = 100px conversion) | [`platform.md` §5](platform.md) |
| "File doesn't show up in Maker" | File created under `Global/` | [`platform.md` §2](platform.md) |
| "Only moves on the client / no multiplayer sync" | Missing `[server only]` | [§3](#3-other-common-pitfalls) |
| Occluded entity appears behind another entity | SortingLayer / OrderInLayer / Z priority | [`platform.md` §6](platform.md) |
---
## 2. Map Type Mismatch
**The most common silent failure when authoring maps.**
### LEA-3004 MissingComponent
**If any of the following three messages appear in the runtime log, it is 100% a TileMapMode ↔ Body mismatch.**
| TileMapMode | Required Body | Log when missing |
|---|---|---|
| `0` MapleTile | `RigidbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'RigidbodyComponent'.` |
| `1` RectTile | `KinematicbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'KinematicbodyComponent'.` |
| `2` SideViewRectTile | `SideviewbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'SideviewbodyComponent'.` |
**Cause patterns**:
- A `.model` imported from a different map type (Body doesn't match)
- A dynamic entity missing a Body entirely
- The map's `TileMapMode` was changed but existing models/entities weren't updated to match
**Fix**: Add/swap the correct Body from the table above in the `.model` or the entity's `@components` → `refresh`. **Do not work around this by removing `MovementComponent`** — collision/events will all break.
**Prevention**: Always read `MapComponent.TileMapMode` as a number at the start of work, and verify that every dynamic entity's Body matches ([`platform.md` §4 Check protocol](platform.md)).
### Map Type Mismatch — Per-Body Symptoms
| Symptom | Cause | Fix |
|---|---|---|
| Entity doesn't move (no error) | Body ↔ TileMapMode mismatch | Body swap per [`platform.md`](platform.md) §4 mapping table |
| Monster floating in mid-air (MapleTile) | `Gravity = 0` | Set `Gravity` to a positive value |
| Monster falls off platform edge (MapleTile) | No foothold-end handling | Reverse direction with `PredictFootholdEnd` |
| Monster disappears off-map (RectTile) | Leftover gravity code | Remove gravity code in RectTile |
| Can't jump over walls in RectTile | RectTile jump is **visual-only** | Change the tile's Movable property |
| Floating in mid-air in SideViewRectTile | Using `KinematicbodyComponent` | Switch to `SideviewbodyComponent` |
| Tile collision broken in SideViewRectTile | Using `RigidbodyComponent` | Switch to `SideviewbodyComponent` |
| `PredictFootholdEnd` error in SideViewRectTile | Not a Foothold system (MapleTile-only) | Use `RectTileCollisionBeginEvent` Normal for wall detection |
| Monster gets stuck in wall (SideViewRectTile) | No wall detection logic | Use `RectTileCollisionBeginEvent + Normal` |
---
## 3. Other Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Not visible on screen | `SpriteRUID = ""` | Find and assign an RUID via `msw-search` or Resource API ([`platform.md` §7](platform.md)) |
| Only `TransformComponent` moves; gravity/collision ignored | Not using Body's `MoveVelocity` | Use the Body component's `MoveVelocity` |
| Moves only on client; no multiplayer sync | Missing `[server only]` | Run movement logic on the server |
| `SpawnByModelId` runtime error | `parent = nil` | Pass `self.Entity.CurrentMap` |
| `SpawnByModelId` returns nil | Model id typo / doesn't exist | nil-check the return value |
| Coordinates off by 100× | Using pixel values | Use world units (÷100) |
| File not visible in Maker | Created under `Global/` | Move to `RootDesk/MyDesk/` |
| Should be on top but rendered behind | SortingLayer / OrderInLayer / Z priority not set | Check the 3-level priority in [`platform.md` §6](platform.md) |
| All existing models break after mode switch | Body and event handlers not updated after TileMapMode change | [`platform.md` §4 Cautions When Switching Map Type](platform.md) |
| New folder not recognized in Maker | Folder meta Refresh not run | Run Maker Refresh. If no Refresh tool is available, just leave the folder ([`platform.md` §2](platform.md)) |
| `script ... extends ...` not registered | `.codeblock` not created | Run `refresh` ([`platform.md` §3](platform.md)) |
| CoreVersion warning | CoreVersion mismatch in `Environment/config` | Verify it is `26.7.0.0` ([`platform.md` §16](platform.md)) |
---
## 4. When Stuck — Decision Tree
1. **If `[LEA-3004]` appears in the log** → Go straight to §2 LEA-3004 table. Body swap.
2. **If nothing in the log but entity doesn't move** → Almost certainly a silent failure. Re-read `TileMapMode` as a number ([`platform.md` §4](platform.md)) and re-verify the Body mapping.
3. **If nothing in the log but entity is invisible** → Check if `SpriteRUID` is `""` first ([`platform.md` §7](platform.md)). Then check SortingLayer ([`platform.md` §6](platform.md)).
4. **If nothing in the log but coordinates are wrong** → Pixel ↔ world unit conversion ([`platform.md` §5](platform.md)).
5. **If still unresolved** → Read the entire Troubleshooting section of the matching `platform-{maple|rect|sideview}.md` and compare.
---
## 5. Cross-references
- [`platform.md`](platform.md) — 8 core rules, TileMapMode↔Body mapping, common rules for coordinates/RUID/spawn/SortingLayer
- [`platform-maple.md`](platform-maple.md) — MapleTile (side-view + foothold) patterns, events, checklist
- [`platform-rect.md`](platform-rect.md) — RectTile (top-down) specific
- [`platform-sideview.md`](platform-sideview.md) — SideViewRectTile (side-view + tile grid) specific
- [`entity.md`](entity.md) — Entity placement / Map Work Preflight
- [`tile.md`](tile.md) — Tile painting / Movable property
references/workspace.md
# MSW Workspace / Domain Knowledge
World architecture, workspace structure, hierarchy, file path rules, and play mode rules.
---
## World Architecture
### World Instance
- **World instance**: an execution unit created from the world data authored in Maker.
- **Auto-created / destroyed** based on max player count: e.g., a world capped at 10 receiving 100 players spawns 10 instances.
- All instances **share a single DataStorage**.
- Inter-instance communication: use `_RoomService`, `_WorldInstanceService`.
- After some time post-creation, an instance enters retirement — existing users stay, new users cannot join.
### Room (Static Room / Instance Room)
| Aspect | Static Room | Instance Room |
|--------|-------------|---------------|
| Creation timing | **Always** when a world instance is created | Created **dynamically** by the server |
| Maps included | Static Maps only | Instance Maps only |
| Destruction | With the instance | On explicit deletion or instance shutdown |
- **Static Map**: a `.map` file whose MapComponent has InstanceMap unchecked (default).
- **Instance Map**: a map whose MapComponent has InstanceMap checked. Exists only inside Instance Rooms.
### Local Entity
- An entity that **exists only on the client, not on the server**.
- Invisible to other clients.
- Used for effects, client-only UI objects, etc.
### Shared Memory
| Scope | Class | Range |
|-------|-------|-------|
| Within a Room | `RoomSharedMemory` | Data shared between players in the same Room |
| Within an Instance | `WorldInstanceSharedMemory` | Data shared across the entire world instance |
### WorldConfig Settings
Key settings in `Global/WorldConfig.config` that control world behavior:
| Setting | Function |
|---------|----------|
| `LegacyAnimation` | Apply legacy MapleStory Worlds movement / animation |
| `PlayerEntityAuthorityCheck` | Restrict server function calls on player entities to the local client (security hardening) |
| `ServiceAuthorityCheck` | Switch native service server functions to ServerOnly (security hardening) |
| `SourceLanguage` | Source language for auto-translation |
---
## Workspace Core Concepts
- **Workspace**: the top-level container of a game project. All models, components, scripts, and maps live inside it.
- **Model**: an entity template (preset) registered in the workspace. Components and properties are pre-configured; instances inherit the configuration as-is.
- **Entity**: an actual object instance placed on a map. Created from a model or assembled directly from scratch.
- **Hierarchy**: the tree-structure panel of entities placed on the current map.
- **Engine Component**: a unit of functionality attached to an entity (Transform, SpriteRenderer, Rigidbody, etc.).
- **Script (CodeBlock)**: a code unit residing in the workspace. Written in MSW's custom Lua dialect (mlua).
---
## File Path Rules
| Folder | Contents | AI work |
|--------|----------|---------|
| `./Global/` | Existing engine/global templates (`*.model`, configs, sets) | Existing `*.model` files are editable in place through `ModelBuilder`; `CollisionGroupSet.collisiongroupset` through `CollisionGroupSetBuilder`; `.config` values-only. Do not create new files here |
| `./RootDesk/MyDesk/` | User scripts (.mlua), user models (.model) | **AI's primary work area** |
| `./map/` | Map files (.map) | Editable |
| `./ui/` | UI files (.ui) | Properties editable |
| `./Environment/NativeScripts/` | Engine API definitions (.d.mlua) | **Never modify** |
> **Key**: the AI creates new scripts and new models under `./RootDesk/MyDesk/`. Within `./Global/`, edit only existing files in place; never add new Global assets. Use `ModelBuilder` for every `.model` edit and `CollisionGroupSetBuilder` for `CollisionGroupSet.collisiongroupset`.
---
## Hierarchy Structure
```
World (top)
├── common ← Game-wide common entities (GameManager, etc.)
├── maps ← Per-map entities
│ └── map01 ← Currently active map
└── ui ← UI editor-only entities
├── DefaultGroup
├── PopupGroup
└── ToastGroup
```
**File ↔ hierarchy relationship:**
- `.map` files in `./map/` → map entities under `maps`
- `.ui` files in `./ui/` → UI entities under `ui`
- `.model` files in `./RootDesk/MyDesk/` → workspace models (when placed on a map, they appear in the hierarchy)
- `.config` and `.model` files in `./Global/` → system settings, default models
---
## Play Mode Rules
- **Edit operations are blocked during play mode.** File modification, refresh, etc. are not allowed.
- If play mode is on before an edit operation, end it first with `stop`.
- By default, every operation assumes **edit (authoring) mode**.
---
## refresh Call Rule
After completing any operation that **changes the workspace** — creating, modifying, or deleting a file — **always call the MCP `refresh` tool**. This rule applies universally.
---
## Handling Mid-Workflow Failure
If a step in a multi-step operation fails, **do not proceed to later steps.** Fix the root cause first, then continue.
---
## Common Work Patterns
### Refresh workspace after editing
```
1. Create / modify a file (e.g., .mlua, .model)
2. Call MCP refresh
3. Verify the change
```
### Playtesting / debugging
Default flow: **edit file → refresh → (check build logs) → play → control / logs → stop → repeat**.
> Build log triage, error classification, regression testing, Lua debugging, and other **detailed workflow are in the `msw-scripting` skill**.
resources/maps/RectTileMapTemplate.map
{
"Id": "",
"GameId": "",
"EntryKey": "map://recttilemaptemplate",
"ContentType": "x-mod/map",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.7.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Binary",
"Entities": [
{
"id": "81677731-8a4c-462b-8e02-d664b729e6a6",
"path": "/maps/RectTileMapTemplate",
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent",
"jsonString": {
"name": "RectTileMapTemplate",
"path": "/maps/RectTileMapTemplate",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 0,
"pathConstraints": "//",
"revision": 0,
"modelId": null,
"@components": [
{
"@type": "MOD.Core.MapComponent",
"AirAccelerationXFactor": 1.0,
"AirDecelerationXFactor": 1.0,
"FallSpeedMaxXFactor": 1.0,
"FallSpeedMaxYFactor": 1.0,
"Gravity": 1.0,
"IsInstanceMap": false,
"TileMapMode": 1,
"WalkAccelerationFactor": 1.0,
"WalkDrag": 1.0,
"Enable": true
},
{
"@type": "MOD.Core.FootholdComponent",
"FootholdsByLayer": {},
"Enable": true
}
],
"@version": 1
}
},
{
"id": "7387375d-7c58-42f5-8e1e-5af0342013f7",
"path": "/maps/RectTileMapTemplate/Background",
"componentNames": "MOD.Core.BackgroundComponent",
"jsonString": {
"name": "Background",
"path": "/maps/RectTileMapTemplate/Background",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 0,
"pathConstraints": "///",
"revision": 0,
"modelId": null,
"@components": [
{
"@type": "MOD.Core.BackgroundComponent",
"SolidColor": {
"r": 0.549019635,
"g": 0.34117648,
"b": 0.164705887,
"a": 1.0
},
"TemplateRUID": "794ad8421e2543d8a6d2c70307637450",
"Type": 2,
"WebUrl": "",
"Enable": true
}
],
"@version": 1
}
},
{
"id": "078501cb-2160-4947-94a9-3c2c61506d6c",
"path": "/maps/RectTileMapTemplate/MapleMapLayer",
"componentNames": "MOD.Core.MapLayerComponent",
"jsonString": {
"name": "MapleMapLayer",
"path": "/maps/RectTileMapTemplate/MapleMapLayer",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 1,
"pathConstraints": "///",
"revision": 0,
"origin": {
"type": "Model",
"entry_id": "maplemaplayer",
"sub_entity_id": null,
"root_entity_id": null,
"replaced_model_id": null
},
"modelId": "maplemaplayer",
"@components": [
{
"@type": "MOD.Core.MapLayerComponent",
"IsVisible": true,
"LayerSortOrder": 0,
"Locked": false,
"MapLayerName": "Layer1",
"Thumbnail": "",
"Enable": true
}
],
"@version": 1
}
},
{
"id": "60382c26-3aae-417f-bf19-78c0074e5bc5",
"path": "/maps/RectTileMapTemplate/SpawnLocation",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.SpriteRendererComponent,MOD.Core.SpawnLocationComponent",
"jsonString": {
"name": "SpawnLocation",
"path": "/maps/RectTileMapTemplate/SpawnLocation",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 2,
"pathConstraints": "///",
"revision": 0,
"modelId": null,
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Rotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"Position": {
"x": 0.0,
"y": 0.0,
"z": 999.999
},
"QuaternionRotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0,
"w": 1.0
},
"Scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
},
"ZRotation": 0.0,
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"DrawMode": 0,
"EndFrameIndex": 2147483647,
"FlipX": false,
"FlipY": false,
"IgnoreMapLayerCheck": false,
"OrderInLayer": 0,
"PlayRate": 1.0,
"RenderSetting": 0,
"SortingLayer": "Default",
"SpriteRUID": "8ef238e0d0ca4bb783aca526cff35d11",
"StartFrameIndex": 0,
"TiledSize": {
"x": 1.0,
"y": 1.0
},
"Color": {
"r": 1.0,
"g": 1.0,
"b": 1.0,
"a": 1.0
},
"Enable": true
},
{
"@type": "MOD.Core.SpawnLocationComponent",
"Enable": true
}
],
"@version": 1
}
},
{
"id": "278ca2cf-72ed-4fae-aaa5-82874282968e",
"path": "/maps/RectTileMapTemplate/RectTileMap",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.RectTileMapComponent",
"jsonString": {
"name": "RectTileMap",
"path": "/maps/RectTileMapTemplate/RectTileMap",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 3,
"pathConstraints": "///",
"revision": 1,
"origin": {
"type": "Model",
"entry_id": "recttilemap",
"sub_entity_id": null,
"root_entity_id": null,
"replaced_model_id": null
},
"modelId": "recttilemap",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Rotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"Position": {
"x": 0.0,
"y": 0.0,
"z": 1000.0
},
"QuaternionRotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0,
"w": 1.0
},
"ZRotation": 0.0,
"Enable": true
},
{
"@type": "MOD.Core.RectTileMapComponent",
"SortingLayer": "MapLayer0",
"TileSetRUID": "tileset://a5aaa9bc-0684-4c21-b803-73fbcbf0ac74",
"Enable": true,
"tileMap": [
{
"type": 0,
"position": {
"x": -1,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 0,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 0,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": 2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": 3
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -1,
"y": 4
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -1,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -3,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -4,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -2,
"y": -2
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 0,
"y": -2
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 1,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 2,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 4,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -2
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": 6,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": -2
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -6,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -4,
"y": -3
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -3,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -2,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -6,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 0,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 1,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 2,
"y": -3
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 3,
"y": -3
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 4,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 6,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 6,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 6,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 6,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 6,
"y": 2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 6,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -6,
"y": 2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -6,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -6,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -6,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -6,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -6,
"y": 4
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -5,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": 1
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -5,
"y": 2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -4,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -4,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -4,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -4,
"y": 2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -4,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -4,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -3,
"y": -1
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -3,
"y": 0
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -3,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -3,
"y": 2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -3,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -3,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -2,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -2,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -2,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -2,
"y": 2
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -2,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -2,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 0,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 0,
"y": 2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 0,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 0,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 1,
"y": -1
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": 1,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 1,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 1,
"y": 2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 1,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 1,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 2,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 2,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 2,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 2,
"y": 2
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": 2,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 2,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": 2
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 3,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 4,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 4,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 4,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 4,
"y": 2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 4,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 4,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": 2
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": 5,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 6,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": 4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": 3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": 2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": 1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": 0
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": -3
},
"tileIndex": 4
}
]
}
],
"@version": 1
}
}
]
}
}resources/maps/SideViewRectTileMapTemplate.map
{
"Id": "",
"GameId": "",
"EntryKey": "map://sideviewrecttilemaptemplate",
"ContentType": "x-mod/map",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.7.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Binary",
"Entities": [
{
"id": "53879ca3-a146-4ac7-9fbf-d4e698cdaa5e",
"path": "/maps/SideViewRectTileMapTemplate",
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent",
"jsonString": {
"name": "SideViewRectTileMapTemplate",
"path": "/maps/SideViewRectTileMapTemplate",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 0,
"pathConstraints": "//",
"revision": 0,
"modelId": null,
"@components": [
{
"@type": "MOD.Core.MapComponent",
"AirAccelerationXFactor": 1.0,
"AirDecelerationXFactor": 1.0,
"FallSpeedMaxXFactor": 1.0,
"FallSpeedMaxYFactor": 1.0,
"Gravity": 1.0,
"IsInstanceMap": false,
"TileMapMode": 2,
"WalkAccelerationFactor": 1.0,
"WalkDrag": 1.0,
"Enable": true
},
{
"@type": "MOD.Core.FootholdComponent",
"FootholdsByLayer": {},
"Enable": true
}
],
"@version": 1
}
},
{
"id": "40ac1bf7-2c52-41bb-b3b0-63e40d13aead",
"path": "/maps/SideViewRectTileMapTemplate/Background",
"componentNames": "MOD.Core.BackgroundComponent",
"jsonString": {
"name": "Background",
"path": "/maps/SideViewRectTileMapTemplate/Background",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 0,
"pathConstraints": "///",
"revision": 0,
"modelId": null,
"@components": [
{
"@type": "MOD.Core.BackgroundComponent",
"SolidColor": {
"r": 0.5019608,
"g": 0.5019608,
"b": 0.5019608,
"a": 0.7058824
},
"TemplateRUID": "65c4167ea7484196b890022354e5a4a4",
"Type": 1,
"WebUrl": "",
"Enable": true
}
],
"@version": 1
}
},
{
"id": "1a7f391c-6ba5-4b47-a615-13e971e08fa9",
"path": "/maps/SideViewRectTileMapTemplate/MapleMapLayer",
"componentNames": "MOD.Core.MapLayerComponent",
"jsonString": {
"name": "MapleMapLayer",
"path": "/maps/SideViewRectTileMapTemplate/MapleMapLayer",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 1,
"pathConstraints": "///",
"revision": 0,
"origin": {
"type": "Model",
"entry_id": "maplemaplayer",
"sub_entity_id": null,
"root_entity_id": null,
"replaced_model_id": null
},
"modelId": "maplemaplayer",
"@components": [
{
"@type": "MOD.Core.MapLayerComponent",
"IsVisible": true,
"LayerSortOrder": 0,
"Locked": false,
"MapLayerName": "Layer1",
"Thumbnail": "",
"Enable": true
}
],
"@version": 1
}
},
{
"id": "86db5b89-4766-42e5-8c7f-311c8eb088f5",
"path": "/maps/SideViewRectTileMapTemplate/SpawnLocation",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.SpriteRendererComponent,MOD.Core.SpawnLocationComponent",
"jsonString": {
"name": "SpawnLocation",
"path": "/maps/SideViewRectTileMapTemplate/SpawnLocation",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 2,
"pathConstraints": "///",
"revision": 0,
"modelId": null,
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Rotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"Position": {
"x": 0.0,
"y": 0.0,
"z": 999.999
},
"QuaternionRotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0,
"w": 1.0
},
"Scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
},
"ZRotation": 0.0,
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"DrawMode": 0,
"EndFrameIndex": 2147483647,
"FlipX": false,
"FlipY": false,
"IgnoreMapLayerCheck": false,
"OrderInLayer": 0,
"PlayRate": 1.0,
"RenderSetting": 0,
"SortingLayer": "Default",
"SpriteRUID": "8ef238e0d0ca4bb783aca526cff35d11",
"StartFrameIndex": 0,
"TiledSize": {
"x": 1.0,
"y": 1.0
},
"Color": {
"r": 1.0,
"g": 1.0,
"b": 1.0,
"a": 1.0
},
"Enable": true
},
{
"@type": "MOD.Core.SpawnLocationComponent",
"Enable": true
}
],
"@version": 1
}
},
{
"id": "fc4dab96-e97c-4d38-a5aa-0cbd9441b922",
"path": "/maps/SideViewRectTileMapTemplate/RectTileMap",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.RectTileMapComponent",
"jsonString": {
"name": "RectTileMap",
"path": "/maps/SideViewRectTileMapTemplate/RectTileMap",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 3,
"pathConstraints": "///",
"revision": 1,
"origin": {
"type": "Model",
"entry_id": "recttilemap",
"sub_entity_id": null,
"root_entity_id": null,
"replaced_model_id": null
},
"modelId": "recttilemap",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Rotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"Position": {
"x": 0.0,
"y": 0.0,
"z": 1000.0
},
"QuaternionRotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0,
"w": 1.0
},
"ZRotation": 0.0,
"Enable": true
},
{
"@type": "MOD.Core.RectTileMapComponent",
"SortingLayer": "MapLayer0",
"TileSetRUID": "tileset://a5aaa9bc-0684-4c21-b803-73fbcbf0ac74",
"Enable": true,
"tileMap": [
{
"type": 0,
"position": {
"x": -1,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": 0,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": 1,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": 2,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": 4,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": 3,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": 5,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": 6,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": 7,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": -3,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": -2,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": -4,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": -5,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": -6,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": -7,
"y": -1
},
"tileIndex": 87
},
{
"type": 0,
"position": {
"x": -7,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -7,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -6,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -5,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -4,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -3,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -3,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -2,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -4,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -5,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -6,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -2,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -1,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": -1,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 0,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 1,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 0,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 1,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 2,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 2,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 3,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 3,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 4,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 5,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 6,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 7,
"y": -2
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 7,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 6,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 5,
"y": -3
},
"tileIndex": 63
},
{
"type": 0,
"position": {
"x": 4,
"y": -3
},
"tileIndex": 63
}
]
}
],
"@version": 1
}
}
]
}
}resources/maps/TileMapTemplate.map
{
"Id": "",
"GameId": "",
"EntryKey": "map://tilemaptemplate",
"ContentType": "x-mod/map",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.7.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Binary",
"Entities": [
{
"id": "bdadf19a-cc27-4a45-99c6-7a439c858a1b",
"path": "/maps/TileMapTemplate",
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent",
"jsonString": {
"name": "TileMapTemplate",
"path": "/maps/TileMapTemplate",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 0,
"pathConstraints": "//",
"revision": 0,
"modelId": null,
"@components": [
{
"@type": "MOD.Core.MapComponent",
"AirAccelerationXFactor": 1.0,
"AirDecelerationXFactor": 1.0,
"FallSpeedMaxXFactor": 1.0,
"FallSpeedMaxYFactor": 1.0,
"Gravity": 1.0,
"IsInstanceMap": false,
"TileMapMode": 0,
"WalkAccelerationFactor": 1.0,
"WalkDrag": 1.0,
"Enable": true
},
{
"@type": "MOD.Core.FootholdComponent",
"FootholdsByLayer": {
"1": [
{
"Length": 1.27999973,
"NextFootholdId": 2,
"PreviousFootholdId": 27,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 1,
"StartPoint": {
"x": -8.93,
"y": -0.04000002
},
"EndPoint": {
"x": -7.65000057,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.8999996,
"NextFootholdId": 3,
"PreviousFootholdId": 1,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 2,
"StartPoint": {
"x": -7.64999962,
"y": -0.04000002
},
"EndPoint": {
"x": -6.75,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.8999996,
"NextFootholdId": 4,
"PreviousFootholdId": 2,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 3,
"StartPoint": {
"x": -6.74999952,
"y": -0.04000002
},
"EndPoint": {
"x": -5.85,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.8999996,
"NextFootholdId": 5,
"PreviousFootholdId": 3,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 4,
"StartPoint": {
"x": -5.84999943,
"y": -0.04000002
},
"EndPoint": {
"x": -4.95,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.8999996,
"NextFootholdId": 6,
"PreviousFootholdId": 4,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 5,
"StartPoint": {
"x": -4.95,
"y": -0.04000002
},
"EndPoint": {
"x": -4.05,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.900000334,
"NextFootholdId": 7,
"PreviousFootholdId": 5,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 6,
"StartPoint": {
"x": -4.05,
"y": -0.04000002
},
"EndPoint": {
"x": -3.14999986,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.9000001,
"NextFootholdId": 8,
"PreviousFootholdId": 6,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 7,
"StartPoint": {
"x": -3.14999986,
"y": -0.04000002
},
"EndPoint": {
"x": -2.24999976,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.899999738,
"NextFootholdId": 9,
"PreviousFootholdId": 7,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 8,
"StartPoint": {
"x": -2.24999976,
"y": -0.04000002
},
"EndPoint": {
"x": -1.35,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.9000001,
"NextFootholdId": 10,
"PreviousFootholdId": 8,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 9,
"StartPoint": {
"x": -1.35,
"y": -0.04000002
},
"EndPoint": {
"x": -0.449999958,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.9,
"NextFootholdId": 11,
"PreviousFootholdId": 9,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 10,
"StartPoint": {
"x": -0.45,
"y": -0.04000002
},
"EndPoint": {
"x": 0.449999958,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.9,
"NextFootholdId": 12,
"PreviousFootholdId": 10,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 11,
"StartPoint": {
"x": 0.450000018,
"y": -0.04000002
},
"EndPoint": {
"x": 1.35,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.9000002,
"NextFootholdId": 13,
"PreviousFootholdId": 11,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 12,
"StartPoint": {
"x": 1.34999979,
"y": -0.04000002
},
"EndPoint": {
"x": 2.25,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.9000001,
"NextFootholdId": 14,
"PreviousFootholdId": 12,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 13,
"StartPoint": {
"x": 2.25,
"y": -0.04000002
},
"EndPoint": {
"x": 3.15,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.899999857,
"NextFootholdId": 15,
"PreviousFootholdId": 13,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 14,
"StartPoint": {
"x": 3.14999986,
"y": -0.04000002
},
"EndPoint": {
"x": 4.04999971,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.8999996,
"NextFootholdId": 16,
"PreviousFootholdId": 14,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 15,
"StartPoint": {
"x": 4.05,
"y": -0.04000002
},
"EndPoint": {
"x": 4.95,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.8999996,
"NextFootholdId": 17,
"PreviousFootholdId": 15,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 16,
"StartPoint": {
"x": 4.95000029,
"y": -0.04000002
},
"EndPoint": {
"x": 5.85,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.8999996,
"NextFootholdId": 18,
"PreviousFootholdId": 16,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 17,
"StartPoint": {
"x": 5.85,
"y": -0.04000002
},
"EndPoint": {
"x": 6.74999952,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 1.27999973,
"NextFootholdId": 19,
"PreviousFootholdId": 17,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 18,
"StartPoint": {
"x": 6.75,
"y": -0.04000002
},
"EndPoint": {
"x": 8.03,
"y": -0.04000002
},
"Variance": {
"x": 1.0,
"y": 0.0
}
},
{
"Length": 0.859999955,
"NextFootholdId": 20,
"PreviousFootholdId": 18,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 19,
"StartPoint": {
"x": 8.03,
"y": -0.04000002
},
"EndPoint": {
"x": 8.03,
"y": -0.9
},
"Variance": {
"x": 0.0,
"y": -1.0
}
},
{
"Length": 0.6,
"NextFootholdId": 21,
"PreviousFootholdId": 19,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 20,
"StartPoint": {
"x": 8.03,
"y": -0.9000001
},
"EndPoint": {
"x": 8.03,
"y": -1.50000012
},
"Variance": {
"x": 0.0,
"y": -1.0
}
},
{
"Length": 0.6,
"NextFootholdId": 22,
"PreviousFootholdId": 20,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 21,
"StartPoint": {
"x": 8.03,
"y": -1.50000012
},
"EndPoint": {
"x": 8.03,
"y": -2.10000014
},
"Variance": {
"x": 0.0,
"y": -1.0
}
},
{
"Length": 0.5999999,
"NextFootholdId": 23,
"PreviousFootholdId": 21,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 22,
"StartPoint": {
"x": 8.03,
"y": -2.10000014
},
"EndPoint": {
"x": 8.03,
"y": -2.7
},
"Variance": {
"x": 0.0,
"y": -1.0
}
},
{
"Length": 0.5999999,
"NextFootholdId": 24,
"PreviousFootholdId": 22,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 23,
"StartPoint": {
"x": 8.03,
"y": -2.70000029
},
"EndPoint": {
"x": 8.03,
"y": -3.30000019
},
"Variance": {
"x": 0.0,
"y": -1.0
}
},
{
"Length": 0.5999999,
"NextFootholdId": 25,
"PreviousFootholdId": 23,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 24,
"StartPoint": {
"x": 8.03,
"y": -3.30000019
},
"EndPoint": {
"x": 8.03,
"y": -3.9
},
"Variance": {
"x": 0.0,
"y": -1.0
}
},
{
"Length": 0.600000143,
"NextFootholdId": 26,
"PreviousFootholdId": 24,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 25,
"StartPoint": {
"x": 8.03,
"y": -3.90000033
},
"EndPoint": {
"x": 8.03,
"y": -4.50000048
},
"Variance": {
"x": 0.0,
"y": -1.0
}
},
{
"Length": 0.6000004,
"NextFootholdId": 0,
"PreviousFootholdId": 25,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 26,
"StartPoint": {
"x": 8.03,
"y": -4.5
},
"EndPoint": {
"x": 8.03,
"y": -5.10000038
},
"Variance": {
"x": 0.0,
"y": -1.0
}
},
{
"Length": 0.859999955,
"NextFootholdId": 1,
"PreviousFootholdId": 28,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 27,
"StartPoint": {
"x": -8.93,
"y": -0.9
},
"EndPoint": {
"x": -8.93,
"y": -0.04000002
},
"Variance": {
"x": 0.0,
"y": 1.0
}
},
{
"Length": 0.6,
"NextFootholdId": 27,
"PreviousFootholdId": 29,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 28,
"StartPoint": {
"x": -8.93,
"y": -1.50000012
},
"EndPoint": {
"x": -8.93,
"y": -0.9000001
},
"Variance": {
"x": 0.0,
"y": 1.0
}
},
{
"Length": 0.6,
"NextFootholdId": 28,
"PreviousFootholdId": 30,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 29,
"StartPoint": {
"x": -8.93,
"y": -2.10000014
},
"EndPoint": {
"x": -8.93,
"y": -1.50000012
},
"Variance": {
"x": 0.0,
"y": 1.0
}
},
{
"Length": 0.5999999,
"NextFootholdId": 29,
"PreviousFootholdId": 31,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 30,
"StartPoint": {
"x": -8.93,
"y": -2.7
},
"EndPoint": {
"x": -8.93,
"y": -2.10000014
},
"Variance": {
"x": 0.0,
"y": 1.0
}
},
{
"Length": 0.5999999,
"NextFootholdId": 30,
"PreviousFootholdId": 32,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 31,
"StartPoint": {
"x": -8.93,
"y": -3.30000019
},
"EndPoint": {
"x": -8.93,
"y": -2.70000029
},
"Variance": {
"x": 0.0,
"y": 1.0
}
},
{
"Length": 0.5999999,
"NextFootholdId": 31,
"PreviousFootholdId": 33,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 32,
"StartPoint": {
"x": -8.93,
"y": -3.9
},
"EndPoint": {
"x": -8.93,
"y": -3.30000019
},
"Variance": {
"x": 0.0,
"y": 1.0
}
},
{
"Length": 0.600000143,
"NextFootholdId": 32,
"PreviousFootholdId": 34,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 33,
"StartPoint": {
"x": -8.93,
"y": -4.50000048
},
"EndPoint": {
"x": -8.93,
"y": -3.90000033
},
"Variance": {
"x": 0.0,
"y": 1.0
}
},
{
"Length": 0.6000004,
"NextFootholdId": 33,
"PreviousFootholdId": 0,
"groupID": 1,
"layer": 1,
"sortingLayerName": "MapLayer0",
"attribute": {
"walk": 1.0,
"force": 0.0,
"drag": 1.0,
"isBlockVertical": false,
"isDynamic": false,
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"Id": 34,
"StartPoint": {
"x": -8.93,
"y": -5.10000038
},
"EndPoint": {
"x": -8.93,
"y": -4.5
},
"Variance": {
"x": 0.0,
"y": 1.0
}
}
]
},
"Enable": true
}
],
"@version": 1
}
},
{
"id": "7ee4456d-3399-46c6-b9eb-fbf5228839e1",
"path": "/maps/TileMapTemplate/Background",
"componentNames": "MOD.Core.BackgroundComponent",
"jsonString": {
"name": "Background",
"path": "/maps/TileMapTemplate/Background",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 0,
"pathConstraints": "///",
"revision": 0,
"modelId": null,
"@components": [
{
"@type": "MOD.Core.BackgroundComponent",
"SolidColor": {
"r": 0.5019608,
"g": 0.5019608,
"b": 0.5019608,
"a": 0.7058824
},
"TemplateRUID": "794ad8421e2543d8a6d2c70307637450",
"Type": 1,
"WebUrl": "",
"Enable": true
}
],
"@version": 1
}
},
{
"id": "21ab86fc-7e59-4230-acbb-881856f9bc30",
"path": "/maps/TileMapTemplate/MapleMapLayer",
"componentNames": "MOD.Core.MapLayerComponent",
"jsonString": {
"name": "MapleMapLayer",
"path": "/maps/TileMapTemplate/MapleMapLayer",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 1,
"pathConstraints": "///",
"revision": 0,
"origin": {
"type": "Model",
"entry_id": "maplemaplayer",
"sub_entity_id": null,
"root_entity_id": null,
"replaced_model_id": null
},
"modelId": "maplemaplayer",
"@components": [
{
"@type": "MOD.Core.MapLayerComponent",
"IsVisible": true,
"LayerSortOrder": 0,
"Locked": false,
"MapLayerName": "Layer1",
"Thumbnail": "",
"Enable": true
}
],
"@version": 1
}
},
{
"id": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"path": "/maps/TileMapTemplate/TileMap",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.TileMapComponent",
"jsonString": {
"name": "TileMap",
"path": "/maps/TileMapTemplate/TileMap",
"nameEditable": false,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 2,
"pathConstraints": "///",
"revision": 0,
"origin": {
"type": "Model",
"entry_id": "tilemap",
"sub_entity_id": null,
"root_entity_id": null,
"replaced_model_id": null
},
"modelId": "tilemap",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Rotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"Position": {
"x": -0.225,
"y": -0.15,
"z": 1000.0
},
"QuaternionRotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0,
"w": 1.0
},
"Scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
},
"ZRotation": 0.0,
"Enable": true
},
{
"@type": "MOD.Core.TileMapComponent",
"Color": {
"r": 1.0,
"g": 1.0,
"b": 1.0,
"a": 1.0
},
"FootholdDrag": 1.0,
"FootholdForce": 0.0,
"FootholdWalkSpeedFactor": 1.0,
"IgnoreMapLayerCheck": false,
"IsOddGridPosition": false,
"OrderInLayer": 1,
"SortingLayer": "MapLayer0",
"TileMapVersion": 1,
"TileSetRUID": {
"DataId": "9dfea3808bbd49a5877d8624df21b1c7"
},
"Tiles": [
{
"type": 5,
"position": {
"x": -16,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -17,
"y": -5
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -16,
"y": -5
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -15,
"y": -5
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -17,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -15,
"y": -4
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -17,
"y": -3
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -16,
"y": -3
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -15,
"y": -3
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -14,
"y": -4
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": -14,
"y": -3
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -13,
"y": -3
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -12,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -11,
"y": -3
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -9,
"y": -3
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -10,
"y": -3
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -8,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": -3
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 7,
"y": -7
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 7,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 7,
"y": -5
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -6,
"y": -3
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -5,
"y": -3
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -4,
"y": -3
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -3,
"y": -3
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -2,
"y": -3
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -1,
"y": -3
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 0,
"y": -3
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 1,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 2,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": -3
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 4,
"y": -3
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 5,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 6,
"y": -3
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 7,
"y": -3
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 6,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 7,
"y": -4
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 8,
"y": -4
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": 8,
"y": -5
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 9,
"y": -5
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 9,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 8,
"y": -3
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 9,
"y": -3
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -16,
"y": -2
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -17,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -15,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -17,
"y": -1
},
"tileIndex": 2
},
{
"type": 9,
"position": {
"x": -16,
"y": -1
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -15,
"y": -1
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -14,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -13,
"y": -2
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -13,
"y": -1
},
"tileIndex": 3
},
{
"type": 9,
"position": {
"x": -14,
"y": -1
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -12,
"y": -2
},
"tileIndex": 0
},
{
"type": 9,
"position": {
"x": -12,
"y": -1
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -11,
"y": -1
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -11,
"y": -2
},
"tileIndex": 0
},
{
"type": 9,
"position": {
"x": -10,
"y": -1
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -9,
"y": -1
},
"tileIndex": 0
},
{
"type": 9,
"position": {
"x": -8,
"y": -1
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -7,
"y": -1
},
"tileIndex": 3
},
{
"type": 9,
"position": {
"x": -6,
"y": -1
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -5,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": -2
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -4,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -3,
"y": -2
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -3,
"y": -1
},
"tileIndex": 0
},
{
"type": 9,
"position": {
"x": -4,
"y": -1
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": -2,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": -1
},
"tileIndex": 0
},
{
"type": 9,
"position": {
"x": -2,
"y": -1
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 0,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 1,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 1,
"y": -1
},
"tileIndex": 1
},
{
"type": 9,
"position": {
"x": 0,
"y": -1
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": 2,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": -2
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 3,
"y": -1
},
"tileIndex": 2
},
{
"type": 9,
"position": {
"x": 2,
"y": -1
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 4,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -1
},
"tileIndex": 3
},
{
"type": 9,
"position": {
"x": 4,
"y": -1
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 6,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 7,
"y": -2
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 7,
"y": -1
},
"tileIndex": 1
},
{
"type": 9,
"position": {
"x": 6,
"y": -1
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 8,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 9,
"y": -2
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 9,
"y": -1
},
"tileIndex": 3
},
{
"type": 9,
"position": {
"x": 8,
"y": -1
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -7,
"y": -2
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -8,
"y": -2
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": -9,
"y": -2
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -10,
"y": -2
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -6,
"y": -2
},
"tileIndex": 1
},
{
"type": 9,
"position": {
"x": 10,
"y": -1
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 11,
"y": -1
},
"tileIndex": 0
},
{
"type": 9,
"position": {
"x": 12,
"y": -1
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 13,
"y": -1
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 13,
"y": -2
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 13,
"y": -3
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 13,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 13,
"y": -5
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": 12,
"y": -4
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 11,
"y": -5
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 12,
"y": -5
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 11,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 11,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 12,
"y": -3
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 10,
"y": -4
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 10,
"y": -5
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 10,
"y": -3
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 10,
"y": -2
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 11,
"y": -2
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 12,
"y": -2
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": 11,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 11,
"y": -7
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": 10,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 9,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 9,
"y": -7
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 10,
"y": -7
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 8,
"y": -6
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": 8,
"y": -7
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -17,
"y": -6
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -17,
"y": -7
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -16,
"y": -7
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -15,
"y": -7
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -15,
"y": -6
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -16,
"y": -6
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 12,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 13,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 13,
"y": -7
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 12,
"y": -7
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 14,
"y": -6
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": 14,
"y": -7
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 15,
"y": -7
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 15,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 14,
"y": -5
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 15,
"y": -5
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 14,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 14,
"y": -3
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 15,
"y": -3
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 15,
"y": -4
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 14,
"y": -2
},
"tileIndex": 0
},
{
"type": 9,
"position": {
"x": 14,
"y": -1
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 15,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 15,
"y": -2
},
"tileIndex": 0
},
{
"type": 9,
"position": {
"x": 16,
"y": -1
},
"tileIndex": 3
},
{
"type": 11,
"position": {
"x": 17,
"y": -1
},
"tileIndex": 2
},
{
"type": 6,
"position": {
"x": 17,
"y": -2
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 17,
"y": -3
},
"tileIndex": -1
},
{
"type": 6,
"position": {
"x": 17,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 17,
"y": -5
},
"tileIndex": -1
},
{
"type": 6,
"position": {
"x": 17,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 17,
"y": -7
},
"tileIndex": -1
},
{
"type": 6,
"position": {
"x": 17,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 17,
"y": -9
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 16,
"y": -7
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": 16,
"y": -6
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 16,
"y": -5
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 16,
"y": -4
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": 16,
"y": -3
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 16,
"y": -2
},
"tileIndex": 4
},
{
"type": 5,
"position": {
"x": 16,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 16,
"y": -9
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 15,
"y": -9
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 15,
"y": -8
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 16,
"y": -10
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": 15,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 16,
"y": -11
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 17,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 15,
"y": -10
},
"tileIndex": 0
},
{
"type": 6,
"position": {
"x": 17,
"y": -10
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 16,
"y": -12
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 15,
"y": -13
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 16,
"y": -13
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 17,
"y": -13
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 15,
"y": -12
},
"tileIndex": 0
},
{
"type": 6,
"position": {
"x": 17,
"y": -12
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 16,
"y": -14
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 15,
"y": -15
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 16,
"y": -15
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 17,
"y": -15
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 15,
"y": -14
},
"tileIndex": 1
},
{
"type": 6,
"position": {
"x": 17,
"y": -14
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 14,
"y": -14
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 13,
"y": -15
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 14,
"y": -15
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 13,
"y": -14
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 13,
"y": -13
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 14,
"y": -13
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 12,
"y": -14
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 11,
"y": -15
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 12,
"y": -15
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 11,
"y": -14
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 11,
"y": -13
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 12,
"y": -13
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 10,
"y": -14
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 9,
"y": -15
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 10,
"y": -15
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 9,
"y": -14
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 9,
"y": -13
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 10,
"y": -13
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 8,
"y": -14
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": 7,
"y": -15
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 8,
"y": -15
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 7,
"y": -14
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 7,
"y": -13
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 8,
"y": -13
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 6,
"y": -15
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 5,
"y": -15
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 4,
"y": -15
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 3,
"y": -15
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 2,
"y": -15
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 1,
"y": -15
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 0,
"y": -15
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -1,
"y": -15
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -2,
"y": -15
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -3,
"y": -15
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -2,
"y": -16
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -3,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": -2,
"y": -17
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -1,
"y": -17
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -3,
"y": -16
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": -16
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -4,
"y": -16
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -5,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": -4,
"y": -17
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": -16
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": -15
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -4,
"y": -15
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -6,
"y": -16
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -7,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": -6,
"y": -17
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -7,
"y": -16
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -7,
"y": -15
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -6,
"y": -15
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -8,
"y": -16
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -9,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": -8,
"y": -17
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -9,
"y": -16
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -9,
"y": -15
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -8,
"y": -15
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -10,
"y": -16
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -11,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": -10,
"y": -17
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -11,
"y": -16
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -11,
"y": -15
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -10,
"y": -15
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": -12,
"y": -16
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": -13,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": -12,
"y": -17
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -13,
"y": -16
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -13,
"y": -15
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -12,
"y": -15
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -14,
"y": -16
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -15,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": -14,
"y": -17
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -15,
"y": -16
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -15,
"y": -15
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -14,
"y": -15
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -16,
"y": -16
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": -17,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": -16,
"y": -17
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -17,
"y": -16
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -17,
"y": -15
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -16,
"y": -15
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -17,
"y": -14
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -17,
"y": -13
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -17,
"y": -12
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -17,
"y": -11
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -17,
"y": -10
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -17,
"y": -9
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -17,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -18,
"y": -3
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -19,
"y": -3
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": -18,
"y": -4
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -18,
"y": -5
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -19,
"y": -5
},
"tileIndex": -1
},
{
"type": 6,
"position": {
"x": -19,
"y": -4
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -18,
"y": -6
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -18,
"y": -7
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -19,
"y": -7
},
"tileIndex": -1
},
{
"type": 6,
"position": {
"x": -19,
"y": -6
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -18,
"y": -8
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -18,
"y": -9
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -19,
"y": -9
},
"tileIndex": -1
},
{
"type": 6,
"position": {
"x": -19,
"y": -8
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -18,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -18,
"y": -11
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -19,
"y": -11
},
"tileIndex": -1
},
{
"type": 6,
"position": {
"x": -19,
"y": -10
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -18,
"y": -12
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -18,
"y": -13
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -19,
"y": -13
},
"tileIndex": -1
},
{
"type": 6,
"position": {
"x": -19,
"y": -12
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -18,
"y": -14
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -18,
"y": -15
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -19,
"y": -15
},
"tileIndex": -1
},
{
"type": 6,
"position": {
"x": -19,
"y": -14
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -18,
"y": -16
},
"tileIndex": 0
},
{
"type": 7,
"position": {
"x": -18,
"y": -17
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -19,
"y": -17
},
"tileIndex": -1
},
{
"type": 8,
"position": {
"x": -19,
"y": -16
},
"tileIndex": 1
},
{
"type": 6,
"position": {
"x": -19,
"y": -2
},
"tileIndex": 1
},
{
"type": 11,
"position": {
"x": -19,
"y": -1
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -18,
"y": -2
},
"tileIndex": 1
},
{
"type": 9,
"position": {
"x": -18,
"y": -1
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -15,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -15,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -15,
"y": -9
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -15,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -15,
"y": -12
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -15,
"y": -13
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": -16,
"y": -12
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -16,
"y": -13
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -16,
"y": -11
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -16,
"y": -10
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -16,
"y": -9
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": -16,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -15,
"y": -14
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -14,
"y": -14
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -13,
"y": -13
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -12,
"y": -13
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -11,
"y": -13
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -10,
"y": -13
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -9,
"y": -13
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -8,
"y": -13
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -7,
"y": -13
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -14,
"y": -13
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -13,
"y": -14
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -16,
"y": -14
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": -6,
"y": -13
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -5,
"y": -13
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -4,
"y": -13
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -3,
"y": -13
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -2,
"y": -13
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -1,
"y": -13
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 0,
"y": -13
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 1,
"y": -13
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 2,
"y": -13
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": -13
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 4,
"y": -13
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -13
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 6,
"y": -13
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 6,
"y": -14
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -14
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": 4,
"y": -14
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": -14
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 2,
"y": -14
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": 1,
"y": -14
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 2,
"y": -16
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 1,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": 2,
"y": -17
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 3,
"y": -17
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 1,
"y": -16
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": -16
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 0,
"y": -16
},
"tileIndex": 5
},
{
"type": 7,
"position": {
"x": 0,
"y": -17
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 4,
"y": -16
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -16
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": 4,
"y": -17
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 6,
"y": -16
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 7,
"y": -16
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 7,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": 6,
"y": -17
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 8,
"y": -16
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 9,
"y": -16
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 9,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": 8,
"y": -17
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 10,
"y": -16
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 16,
"y": -16
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": 15,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": 16,
"y": -17
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 17,
"y": -17
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 15,
"y": -16
},
"tileIndex": 1
},
{
"type": 8,
"position": {
"x": 17,
"y": -16
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 14,
"y": -16
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 13,
"y": -16
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 13,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": 14,
"y": -17
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 12,
"y": -16
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 11,
"y": -16
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 11,
"y": -17
},
"tileIndex": -1
},
{
"type": 7,
"position": {
"x": 12,
"y": -17
},
"tileIndex": 0
},
{
"type": 7,
"position": {
"x": 10,
"y": -17
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -4,
"y": -14
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -5,
"y": -14
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -3,
"y": -14
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -6,
"y": -14
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": -14
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": -8,
"y": -14
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -9,
"y": -14
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -14,
"y": -12
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -12,
"y": -14
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -11,
"y": -14
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -10,
"y": -14
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -1,
"y": -14
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -2,
"y": -14
},
"tileIndex": 4
},
{
"type": 5,
"position": {
"x": 0,
"y": -14
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 7,
"y": -9
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 7,
"y": -8
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 7,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 7,
"y": -10
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 6,
"y": -12
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 7,
"y": -12
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 8,
"y": -8
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 10,
"y": -8
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": 9,
"y": -9
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 10,
"y": -9
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 11,
"y": -9
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 9,
"y": -8
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 11,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 8,
"y": -9
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 10,
"y": -10
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": 9,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 10,
"y": -11
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 11,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 9,
"y": -10
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 11,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 9,
"y": -12
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 8,
"y": -11
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 8,
"y": -10
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": 12,
"y": -9
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 13,
"y": -9
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": 12,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 12,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 13,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 13,
"y": -10
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 12,
"y": -12
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 11,
"y": -12
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 13,
"y": -12
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 13,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 14,
"y": -9
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 14,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 14,
"y": -11
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 14,
"y": -8
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 12,
"y": -8
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": 14,
"y": -12
},
"tileIndex": 5
},
{
"type": 5,
"position": {
"x": 10,
"y": -12
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 8,
"y": -12
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": -11,
"y": -7
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -10,
"y": -8
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -11,
"y": -9
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -10,
"y": -9
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -9,
"y": -9
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -11,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -9,
"y": -8
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -10,
"y": -7
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -9,
"y": -7
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -8,
"y": -9
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -7,
"y": -9
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -6,
"y": -9
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": -9
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -4,
"y": -9
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -3,
"y": -9
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -2,
"y": -9
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": -9
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": -7
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": 0,
"y": -8
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 0,
"y": -9
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 1,
"y": -9
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 1,
"y": -8
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 0,
"y": -7
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 1,
"y": -7
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 2,
"y": -7
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 3,
"y": -7
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": 2,
"y": -6
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": 1,
"y": -6
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 3,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 1,
"y": -5
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 2,
"y": -5
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": -5
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 0,
"y": -5
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": -5
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -2,
"y": -4
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -3,
"y": -5
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -2,
"y": -5
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -3,
"y": -4
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -1,
"y": -4
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -4,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": -5
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -6,
"y": -5
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -7,
"y": -5
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -7,
"y": -6
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -7,
"y": -7
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -8,
"y": -7
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -8,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -9,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -9,
"y": -5
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -8,
"y": -5
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": -10,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -11,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -11,
"y": -5
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -10,
"y": -5
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": -12,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -13,
"y": -7
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -12,
"y": -7
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -13,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -13,
"y": -5
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -12,
"y": -5
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": -12,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -13,
"y": -4
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -11,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -14,
"y": -5
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -10,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -9,
"y": -4
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -8,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": -4
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -6,
"y": -4
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": -5,
"y": -4
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -4,
"y": -5
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 0,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 1,
"y": -4
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 2,
"y": -4
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 3,
"y": -4
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 4,
"y": -4
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -4
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 5,
"y": -5
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 4,
"y": -5
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 6,
"y": -5
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": 6,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 6,
"y": -7
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": 5,
"y": -7
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 5,
"y": -6
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 4,
"y": -7
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 0,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -1,
"y": -6
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -2,
"y": -6
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -3,
"y": -6
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": -4,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -3,
"y": -7
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -2,
"y": -7
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": -6
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": -6,
"y": -6
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -5,
"y": -7
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -6,
"y": -7
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -4,
"y": -7
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -14,
"y": -6
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -14,
"y": -7
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -14,
"y": -9
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -13,
"y": -9
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -13,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -13,
"y": -11
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": -12,
"y": -10
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -12,
"y": -11
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -11,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -11,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -12,
"y": -9
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": -10,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -5,
"y": -8
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -4,
"y": -8
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -3,
"y": -8
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -2,
"y": -8
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 2,
"y": -8
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 3,
"y": -8
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 3,
"y": -9
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 2,
"y": -9
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": 4,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -9
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 4,
"y": -9
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 4,
"y": -6
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -10
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 5,
"y": -11
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": 6,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 6,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 6,
"y": -9
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 5,
"y": -12
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 6,
"y": -8
},
"tileIndex": 5
},
{
"type": 5,
"position": {
"x": 4,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 4,
"y": -11
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 3,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": 3,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 2,
"y": -11
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 1,
"y": -11
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 0,
"y": -11
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -1,
"y": -11
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -1,
"y": -12
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": 3,
"y": -12
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": 1,
"y": -10
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 0,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -1,
"y": -10
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 0,
"y": -12
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": 1,
"y": -12
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": 2,
"y": -12
},
"tileIndex": 5
},
{
"type": 5,
"position": {
"x": 4,
"y": -12
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": 2,
"y": -10
},
"tileIndex": 5
},
{
"type": 0,
"position": {
"x": -9,
"y": -10
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -9,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -10,
"y": -11
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": -8,
"y": -10
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": -10
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": -6,
"y": -10
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -7,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -8,
"y": -11
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": -10
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": -4,
"y": -10
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -5,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -6,
"y": -11
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -3,
"y": -10
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -3,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -4,
"y": -11
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -8,
"y": -8
},
"tileIndex": 0
},
{
"type": 0,
"position": {
"x": -7,
"y": -8
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -12,
"y": -8
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -13,
"y": -8
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -14,
"y": -8
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": -14,
"y": -10
},
"tileIndex": 3
},
{
"type": 0,
"position": {
"x": -14,
"y": -11
},
"tileIndex": -1
},
{
"type": 0,
"position": {
"x": -13,
"y": -12
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -2,
"y": -11
},
"tileIndex": 3
},
{
"type": 5,
"position": {
"x": -6,
"y": -8
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -2,
"y": -10
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": -10,
"y": -12
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -9,
"y": -12
},
"tileIndex": 1
},
{
"type": 0,
"position": {
"x": -11,
"y": -12
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -8,
"y": -12
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -7,
"y": -12
},
"tileIndex": -1
},
{
"type": 5,
"position": {
"x": -6,
"y": -12
},
"tileIndex": 2
},
{
"type": 0,
"position": {
"x": -5,
"y": -12
},
"tileIndex": 0
},
{
"type": 5,
"position": {
"x": -4,
"y": -12
},
"tileIndex": 4
},
{
"type": 0,
"position": {
"x": -3,
"y": -12
},
"tileIndex": 1
},
{
"type": 5,
"position": {
"x": -12,
"y": -12
},
"tileIndex": 2
},
{
"type": 5,
"position": {
"x": -2,
"y": -12
},
"tileIndex": 4
}
],
"Enable": true
}
],
"@version": 1
}
},
{
"id": "5acc8562-5a0a-4065-98f9-2806f15facca",
"path": "/maps/TileMapTemplate/SpawnLocation",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.SpriteRendererComponent,MOD.Core.SpawnLocationComponent",
"jsonString": {
"name": "SpawnLocation",
"path": "/maps/TileMapTemplate/SpawnLocation",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 3,
"pathConstraints": "///",
"revision": 0,
"modelId": null,
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Rotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"Position": {
"x": 0.0,
"y": 0.0,
"z": 999.999
},
"QuaternionRotation": {
"x": 0.0,
"y": 0.0,
"z": 0.0,
"w": 1.0
},
"Scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
},
"ZRotation": 0.0,
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"DrawMode": 0,
"EndFrameIndex": 2147483647,
"FlipX": false,
"FlipY": false,
"IgnoreMapLayerCheck": false,
"OrderInLayer": 0,
"PlayRate": 1.0,
"RenderSetting": 0,
"SortingLayer": "Default",
"SpriteRUID": "8ef238e0d0ca4bb783aca526cff35d11",
"StartFrameIndex": 0,
"TiledSize": {
"x": 1.0,
"y": 1.0
},
"Color": {
"r": 1.0,
"g": 1.0,
"b": 1.0,
"a": 1.0
},
"Enable": true
},
{
"@type": "MOD.Core.SpawnLocationComponent",
"Enable": true
}
],
"@version": 1
}
}
]
}
}scripts/collisiongroupset/msw_collisiongroupset_builder.cjs
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const CONTENT_TYPE = "x-mod/collisiongroupset";
const ENTRY_KEY = "collisiongroupset://unique";
const MAX_USER_DEFINED_GROUPS = 15;
const PROTECTED_NAMES = new Set(["Default", "TriggerBox", "HitBox", "Interaction", "Portal", "Climbable"]);
const PROTECTED_IDS = new Set(["MOD@TriggerBox", "MOD@HitBox", "MOD@Interaction", "MOD@Portal", "MOD@Climbable"]);
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function readJsonFile(filepath, label) {
try {
return JSON.parse(fs.readFileSync(filepath, "utf8"));
} catch (err) {
if (err && err.code === "ENOENT") throw new Error(`${label} not found: ${filepath}`);
throw new Error(`Invalid JSON in ${label} ${filepath}: ${err.message}`);
}
}
function randomHexId() {
return crypto.randomUUID().replace(/-/g, "");
}
function normalizeName(name) {
if (name == null) throw new TypeError("Collision group name must not be null");
const normalized = String(name).trim();
if (!normalized) throw new Error("Collision group name must not be empty");
return normalized;
}
function normalizeId(id) {
if (id == null) throw new TypeError("Collision group id must not be null");
const normalized = String(id).trim();
if (!normalized) throw new Error("Collision group id must not be empty");
return normalized;
}
function isValidGroupId(id) {
return /^MOD@[A-Za-z0-9_]+$/.test(id) || /^[0-9a-f]{32}$/i.test(id);
}
function formatFinding(finding) {
return `${finding.rule}: ${finding.message}`;
}
class CollisionGroupSetBuilder {
constructor(data, options = {}) {
this.data = clone(data);
this._sourcePath = options.sourcePath || null;
this._originalGroups = this._groups().map((group) => clone(group));
this._warned = new Set();
}
static read(filepath) {
return new CollisionGroupSetBuilder(readJsonFile(filepath, "collisiongroupset file"), { sourcePath: filepath });
}
static load(filepath) {
return CollisionGroupSetBuilder.read(filepath);
}
static snapshot(filepath) {
return CollisionGroupSetBuilder.read(filepath).snapshot();
}
_json() {
const json = this.data && this.data.ContentProto && this.data.ContentProto.Json;
if (!json || typeof json !== "object" || Array.isArray(json)) {
throw new Error("Invalid collisiongroupset: missing ContentProto.Json");
}
return json;
}
_groups() {
const groups = this._json().Groups;
if (!Array.isArray(groups)) throw new Error("Invalid collisiongroupset: ContentProto.Json.Groups must be an array");
return groups;
}
_matrix() {
const matrix = this._json().Matrix;
if (!matrix || typeof matrix !== "object" || Array.isArray(matrix)) {
throw new Error("Invalid collisiongroupset: ContentProto.Json.Matrix must be an object");
}
return matrix;
}
_warnOnce(rule, message, key = message) {
const warnKey = `${rule}:${key}`;
if (this._warned.has(warnKey)) return;
this._warned.add(warnKey);
console.warn(`[CollisionGroupSetBuilder] WARNING ${rule}: ${message}`);
}
_findGroup(nameOrId) {
const key = String(nameOrId);
return this._groups().find((group) => group && (group.Id === key || group.Name === key)) || null;
}
_resolveId(nameOrId) {
const group = this._findGroup(nameOrId);
if (!group) throw new Error(`Collision group not found: ${nameOrId}`);
return group.Id;
}
_resolveName(id) {
const group = this._findGroup(id);
return group ? group.Name : id;
}
_ensureRow(id) {
const matrix = this._matrix();
if (!Array.isArray(matrix[id])) matrix[id] = [];
return matrix[id];
}
_removeFromRows(id) {
const matrix = this._matrix();
for (const [rowId, row] of Object.entries(matrix)) {
if (!Array.isArray(row)) continue;
matrix[rowId] = row.filter((targetId) => targetId !== id);
}
}
_dedupeMatrix() {
const matrix = this._matrix();
for (const [rowId, row] of Object.entries(matrix)) {
matrix[rowId] = Array.isArray(row) ? Array.from(new Set(row.map(String))) : [];
}
}
listGroups() {
return this._groups().map((group) => clone(group));
}
listCollisions(nameOrId) {
const id = this._resolveId(nameOrId);
return this._ensureRow(id).map((targetId) => ({
id: targetId,
name: this._resolveName(targetId),
}));
}
hasGroup(nameOrId) {
return Boolean(this._findGroup(nameOrId));
}
getGroup(nameOrId) {
const group = this._findGroup(nameOrId);
return group ? clone(group) : null;
}
getGroupId(nameOrId) {
const group = this._findGroup(nameOrId);
return group ? group.Id : null;
}
addGroup(name, options = {}) {
const groupName = normalizeName(name);
const requestedId = normalizeId(options.id || options.groupId || randomHexId());
const id = requestedId.toLowerCase().startsWith("mod@") ? requestedId : requestedId.toLowerCase();
const existingByName = this._findGroup(groupName);
if (existingByName) {
this._warnOnce("C020", `Group name '${groupName}' already exists; addGroup() left the existing group unchanged.`, groupName);
return this;
}
const existingById = this._findGroup(id);
if (existingById) {
this._warnOnce("C021", `Group id '${id}' already exists on '${existingById.Name}'; addGroup() skipped '${groupName}'.`, id);
return this;
}
if (PROTECTED_NAMES.has(groupName) || PROTECTED_IDS.has(id)) {
this._warnOnce("C030", `Group '${groupName}' uses a protected built-in name or id; avoid redefining built-in collision groups.`, groupName);
}
this._groups().push({ Id: id, Name: groupName });
this._ensureRow(id);
return this;
}
removeGroup(nameOrId) {
const group = this._findGroup(nameOrId);
if (!group) throw new Error(`Collision group not found: ${nameOrId}`);
if (PROTECTED_NAMES.has(group.Name) || PROTECTED_IDS.has(group.Id)) {
this._warnOnce("C031", `Removing protected group '${group.Name}' can break native collision behavior.`, group.Id);
}
const groups = this._groups();
const index = groups.findIndex((candidate) => candidate && candidate.Id === group.Id);
if (index >= 0) groups.splice(index, 1);
delete this._matrix()[group.Id];
this._removeFromRows(group.Id);
return this;
}
renameGroup(nameOrId, newName) {
const group = this._findGroup(nameOrId);
if (!group) throw new Error(`Collision group not found: ${nameOrId}`);
const groupName = normalizeName(newName);
const existing = this._findGroup(groupName);
if (existing && existing.Id !== group.Id) {
this._warnOnce("C022", `Group name '${groupName}' already exists; renameGroup() left '${group.Name}' unchanged.`, groupName);
return this;
}
if (PROTECTED_NAMES.has(group.Name) || PROTECTED_IDS.has(group.Id)) {
this._warnOnce("C032", `Renaming protected group '${group.Name}' can break scripts that use CollisionGroups.${group.Name}.`, group.Id);
}
group.Name = groupName;
return this;
}
setGroupId(nameOrId, newId) {
const group = this._findGroup(nameOrId);
if (!group) throw new Error(`Collision group not found: ${nameOrId}`);
const id = normalizeId(newId);
const existing = this._findGroup(id);
if (existing && existing.Name !== group.Name) {
this._warnOnce("C023", `Group id '${id}' already exists on '${existing.Name}'; setGroupId() left '${group.Name}' unchanged.`, id);
return this;
}
if (PROTECTED_NAMES.has(group.Name) || PROTECTED_IDS.has(group.Id)) {
this._warnOnce("C033", `Changing id for protected group '${group.Name}' can break model CollisionGroup values.`, group.Id);
}
const oldId = group.Id;
group.Id = id;
const matrix = this._matrix();
if (Object.prototype.hasOwnProperty.call(matrix, oldId)) {
matrix[id] = matrix[oldId];
delete matrix[oldId];
}
for (const [rowId, row] of Object.entries(matrix)) {
if (Array.isArray(row)) matrix[rowId] = row.map((targetId) => (targetId === oldId ? id : targetId));
}
this._ensureRow(id);
return this;
}
setCollidable(groupA, groupB, enabled = true, options = {}) {
const idA = this._resolveId(groupA);
const idB = this._resolveId(groupB);
const symmetric = options.symmetric !== false;
this._setDirected(idA, idB, enabled);
if (symmetric && idA !== idB) this._setDirected(idB, idA, enabled);
return this;
}
_setDirected(fromId, toId, enabled) {
const row = this._ensureRow(fromId);
const hasTarget = row.includes(toId);
if (enabled && !hasTarget) row.push(toId);
if (!enabled && hasTarget) this._matrix()[fromId] = row.filter((targetId) => targetId !== toId);
}
setCollidesWith(group, targets, options = {}) {
const id = this._resolveId(group);
const targetIds = (Array.isArray(targets) ? targets : [targets]).map((target) => this._resolveId(target));
this._matrix()[id] = Array.from(new Set(targetIds));
if (options.symmetric) {
const allIds = this._groups().map((candidate) => candidate.Id);
for (const otherId of allIds) {
if (otherId === id) continue;
this._setDirected(otherId, id, targetIds.includes(otherId));
}
}
return this;
}
build() {
this.data.EntryKey = this.data.EntryKey || ENTRY_KEY;
this.data.ContentType = this.data.ContentType || CONTENT_TYPE;
this.data.ContentProto = this.data.ContentProto || {};
this.data.ContentProto.Use = "Json";
this.data.ContentProto.Json = this._json();
this._dedupeMatrix();
return this.data;
}
snapshot() {
const groups = this._groups();
const matrix = this._matrix();
return {
entryKey: this.data.EntryKey,
contentType: this.data.ContentType,
groups: groups.map((group) => ({
id: group.Id,
name: group.Name,
collidesWith: (Array.isArray(matrix[group.Id]) ? matrix[group.Id] : []).map((targetId) => this._resolveName(targetId)),
})),
findings: this.validate(),
};
}
validate() {
const findings = [];
let json;
try {
json = this._json();
} catch (err) {
return [{ severity: "error", rule: "C000", message: err.message }];
}
if (this.data.EntryKey && this.data.EntryKey !== ENTRY_KEY) {
findings.push({ severity: "warn", rule: "C001", message: `EntryKey is '${this.data.EntryKey}', expected '${ENTRY_KEY}'.` });
}
if (this.data.ContentType && this.data.ContentType !== CONTENT_TYPE) {
findings.push({ severity: "warn", rule: "C002", message: `ContentType is '${this.data.ContentType}', expected '${CONTENT_TYPE}'.` });
}
if (!Array.isArray(json.Groups)) {
findings.push({ severity: "error", rule: "C010", message: "ContentProto.Json.Groups must be an array." });
return findings;
}
if (!json.Matrix || typeof json.Matrix !== "object" || Array.isArray(json.Matrix)) {
findings.push({ severity: "error", rule: "C011", message: "ContentProto.Json.Matrix must be an object." });
return findings;
}
const names = new Map();
const ids = new Map();
let userDefinedGroupCount = 0;
for (const group of json.Groups) {
if (!group || typeof group !== "object") {
findings.push({ severity: "error", rule: "C012", message: "Each group must be an object." });
continue;
}
if (!group.Name) findings.push({ severity: "error", rule: "C013", message: `Group '${group.Id || "<missing id>"}' has no Name.` });
if (!group.Id) findings.push({ severity: "error", rule: "C014", message: `Group '${group.Name || "<missing name>"}' has no Id.` });
if (group.Id && !isValidGroupId(group.Id)) {
findings.push({ severity: "warn", rule: "C015", message: `Group '${group.Name}' id '${group.Id}' is not MOD@Name or 32-character hex.` });
}
if (group.Name) {
if (names.has(group.Name)) findings.push({ severity: "warn", rule: "C020", message: `Duplicate group name '${group.Name}'.` });
names.set(group.Name, group);
}
if (group.Id) {
if (ids.has(group.Id)) findings.push({ severity: "error", rule: "C021", message: `Duplicate group id '${group.Id}'.` });
ids.set(group.Id, group);
}
if (group.Id && group.Name && group.Name !== "Default" && !group.Id.startsWith("MOD@")) {
userDefinedGroupCount++;
}
}
if (userDefinedGroupCount > MAX_USER_DEFINED_GROUPS) {
findings.push({
severity: "error",
rule: "C016",
message: `Collision group set has ${userDefinedGroupCount} user-defined groups; the engine supports at most ${MAX_USER_DEFINED_GROUPS}.`,
});
}
for (const original of this._originalGroups) {
if (!original || (!PROTECTED_NAMES.has(original.Name) && !PROTECTED_IDS.has(original.Id))) continue;
const byId = ids.get(original.Id);
const byName = names.get(original.Name);
if (!byId && !byName) {
findings.push({ severity: "warn", rule: "C030", message: `Protected group '${original.Name}' was removed.` });
} else if (byId && byId.Name !== original.Name) {
findings.push({ severity: "warn", rule: "C031", message: `Protected group id '${original.Id}' was renamed from '${original.Name}' to '${byId.Name}'.` });
} else if (byName && byName.Id !== original.Id) {
findings.push({ severity: "warn", rule: "C032", message: `Protected group '${original.Name}' id changed from '${original.Id}' to '${byName.Id}'.` });
}
}
for (const group of json.Groups) {
if (!group || !group.Id) continue;
if (!Object.prototype.hasOwnProperty.call(json.Matrix, group.Id)) {
findings.push({ severity: "error", rule: "C040", message: `Matrix row missing for group '${group.Name}' (${group.Id}).` });
}
}
for (const [rowId, targets] of Object.entries(json.Matrix)) {
if (!ids.has(rowId)) {
findings.push({ severity: "warn", rule: "C041", message: `Matrix row '${rowId}' does not match any group id.` });
}
if (!Array.isArray(targets)) {
findings.push({ severity: "error", rule: "C042", message: `Matrix row '${rowId}' must be an array.` });
continue;
}
const seen = new Set();
for (const targetId of targets) {
if (seen.has(targetId)) findings.push({ severity: "warn", rule: "C043", message: `Matrix row '${rowId}' contains duplicate target '${targetId}'.` });
seen.add(targetId);
if (!ids.has(targetId)) {
findings.push({ severity: "warn", rule: "C044", message: `Matrix row '${rowId}' references unknown group id '${targetId}'.` });
continue;
}
const reverse = json.Matrix[targetId];
if (Array.isArray(reverse) && !reverse.includes(rowId)) {
findings.push({ severity: "warn", rule: "C045", message: `Collision '${this._resolveName(rowId)}' -> '${this._resolveName(targetId)}' is one-way.` });
}
}
}
return findings;
}
write(filepath) {
const findings = this.validate();
for (const warning of findings.filter((finding) => finding.severity === "warn")) {
console.warn(`[CollisionGroupSetBuilder] WARNING ${warning.rule}: ${warning.message}`);
}
const errors = findings.filter((finding) => finding.severity === "error");
if (errors.length) throw new Error(`CollisionGroupSet validation failed: ${errors.map(formatFinding).join("; ")}`);
fs.mkdirSync(path.dirname(filepath), { recursive: true });
fs.writeFileSync(filepath, `${JSON.stringify(this.build(), null, 2)}\n`, "utf8");
console.log(`Written collision group set (${this._groups().length} groups) to ${filepath}`);
return this;
}
}
module.exports = {
CollisionGroupSetBuilder,
CONTENT_TYPE,
ENTRY_KEY,
MAX_USER_DEFINED_GROUPS,
};
scripts/map/msw_map_builder.cjs
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const crypto = require("crypto");
const path = require("path");
const DEFAULT_SPRITE_RUID = "8ef238e0d0ca4bb783aca526cff35d11";
const MAP_TEMPLATE_FILES = Object.freeze({
maple: "TileMapTemplate.map",
tile: "TileMapTemplate.map",
tilemap: "TileMapTemplate.map",
"0": "TileMapTemplate.map",
rect: "RectTileMapTemplate.map",
recttile: "RectTileMapTemplate.map",
"1": "RectTileMapTemplate.map",
sideview: "SideViewRectTileMapTemplate.map",
sideviewrect: "SideViewRectTileMapTemplate.map",
sideviewrecttile: "SideViewRectTileMapTemplate.map",
"2": "SideViewRectTileMapTemplate.map",
});
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function hasExplicitPos(options = {}) {
return options != null && options.pos != null;
}
function readJsonFile(filepath, label) {
try {
return JSON.parse(fs.readFileSync(filepath, "utf8"));
} catch (err) {
if (err && err.code === "ENOENT") throw new Error(`${label} not found: ${filepath}`);
throw new Error(`Invalid JSON in ${label} ${filepath}: ${err.message}`);
}
}
function normalizeMapName(mapName) {
const value = String(mapName || "").trim();
if (!value) throw new Error("Map name must not be empty");
if (value.startsWith("map://")) throw new Error(`Map name must be plain, not an EntryKey: ${value}`);
if (/\.map$/i.test(value)) throw new Error(`Map name must not include the .map extension: ${value}`);
if (/[\\/]/.test(value)) throw new Error(`Map name must not include path separators: ${value}`);
return value;
}
function mapTemplatePath(kind) {
const key = String(kind ?? "").trim().toLowerCase().replace(/[-_\s]/g, "");
const filename = MAP_TEMPLATE_FILES[key];
if (!filename) {
throw new Error(`Unknown map template kind: ${kind}. Use "maple"/0, "rect"/1, or "sideview"/2.`);
}
return path.resolve(__dirname, "../../resources/maps", filename);
}
function remapTemplateValue(value, idMap, oldRootPath, newRootPath) {
if (typeof value === "string") {
if (idMap.has(value)) return idMap.get(value);
if (value === oldRootPath || value.startsWith(`${oldRootPath}/`)) {
return `${newRootPath}${value.slice(oldRootPath.length)}`;
}
return value;
}
if (Array.isArray(value)) return value.map((item) => remapTemplateValue(item, idMap, oldRootPath, newRootPath));
if (value && typeof value === "object") {
for (const [key, item] of Object.entries(value)) {
value[key] = remapTemplateValue(item, idMap, oldRootPath, newRootPath);
}
}
return value;
}
function vector2(x = 0, y = 0) {
if (x && typeof x === "object") {
if (Array.isArray(x)) return { x: Number(x[0] ?? 0), y: Number(x[1] ?? 0) };
return { x: Number(x.x ?? 0), y: Number(x.y ?? 0) };
}
return { x: Number(x), y: Number(y) };
}
function vector3(x = 0, y = 0, z = 0) {
if (x && typeof x === "object") {
if (Array.isArray(x)) return { x: Number(x[0] ?? 0), y: Number(x[1] ?? 0), z: Number(x[2] ?? 0) };
return { x: Number(x.x ?? 0), y: Number(x.y ?? 0), z: Number(x.z ?? 0) };
}
return { x: Number(x), y: Number(y), z: Number(z) };
}
function quaternion(x = 0, y = 0, z = 0, w = 1) {
if (x && typeof x === "object") {
if (Array.isArray(x)) return { x: Number(x[0] ?? 0), y: Number(x[1] ?? 0), z: Number(x[2] ?? 0), w: Number(x[3] ?? 1) };
return { x: Number(x.x ?? 0), y: Number(x.y ?? 0), z: Number(x.z ?? 0), w: Number(x.w ?? 1) };
}
return { x: Number(x), y: Number(y), z: Number(z), w: Number(w) };
}
function color(value, alpha = 1) {
if (value == null) return { r: 1, g: 1, b: 1, a: alpha };
if (typeof value === "string") {
const hex = value.replace(/^#/, "");
if (hex.length !== 6 && hex.length !== 8) throw new Error(`Invalid color hex: ${value}`);
return {
r: parseInt(hex.slice(0, 2), 16) / 255,
g: parseInt(hex.slice(2, 4), 16) / 255,
b: parseInt(hex.slice(4, 6), 16) / 255,
a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : alpha,
};
}
if (Array.isArray(value)) {
return { r: Number(value[0]), g: Number(value[1]), b: Number(value[2]), a: value.length > 3 ? Number(value[3]) : alpha };
}
return { r: Number(value.r ?? 1), g: Number(value.g ?? 1), b: Number(value.b ?? 1), a: Number(value.a ?? alpha) };
}
// >>> BEGIN AUTO-GENERATED: native component catalog + resolver — do not hand-edit; run tools/gen-native-components.cjs
// Native MSW component class names (CoreVersion 26.7.0.0). A bare name in this
// set is auto-qualified to "MOD.Core.<name>"; any other bare name is treated as a
// "script.<name>" custom component, with a one-time advisory on stderr.
const NATIVE_COMPONENTS = new Set([
"AIChaseComponent", "AIComponent", "AIWanderComponent", "AnimationSequenceControllerComponent",
"AreaParticleComponent", "AttackComponent", "AvatarBodyActionSelectorComponent", "AvatarFaceActionSelectorComponent",
"AvatarGUIRendererComponent", "AvatarRendererComponent", "AvatarStateAnimationComponent", "BackgroundComponent",
"BasicParticleComponent", "ButtonComponent", "CameraComponent", "CanvasGroupComponent",
"ChatBalloonComponent", "ChatComponent", "ClimbableComponent", "ClimbableSpriteRendererComponent",
"Component", "CostumeManagerComponent", "CustomFootholdComponent", "DamageSkinComponent",
"DamageSkinSettingComponent", "DamageSkinSpawnerComponent", "DirectionSynchronizerComponent", "DistanceJointComponent",
"FootholdComponent", "GridViewComponent", "HitComponent", "HitEffectSpawnerComponent",
"InteractionComponent", "InventoryComponent", "JoystickComponent", "KinematicbodyComponent",
"LightComponent", "LineGUIRendererComponent", "LineRendererComponent", "MapComponent",
"MapLayerComponent", "MaskComponent", "MissingComponent", "MovementComponent",
"NameTagComponent", "OverlayLightComponent", "PhysicsColliderComponent", "PhysicsRigidbodyComponent",
"PhysicsSimulatorComponent", "PixelGUIRendererComponent", "PixelRendererComponent", "PlayerComponent",
"PlayerControllerComponent", "PolygonGUIRendererComponent", "PolygonRendererComponent", "PortalComponent",
"PrismaticJointComponent", "PulleyJointComponent", "RawImageGUIRendererComponent", "RawImageRendererComponent",
"RectTileMapComponent", "RevoluteJointComponent", "RigidbodyComponent", "ScrollLayoutGroupComponent",
"SideviewbodyComponent", "SkeletonGUIRendererComponent", "SkeletonRendererComponent", "SliderComponent",
"SoundComponent", "SpawnLocationComponent", "SpriteGUIRendererComponent", "SpriteParticleComponent",
"SpriteRendererComponent", "StateAnimationComponent", "StateComponent", "StateStringToAvatarActionComponent",
"StateStringToMonsterActionComponent", "TagComponent", "TextComponent", "TextGUIRendererComponent",
"TextGUIRendererInputComponent", "TextInputComponent", "TextRendererComponent", "TileMapComponent",
"TouchReceiveComponent", "TransformComponent", "TriggerComponent", "TweenCircularComponent",
"TweenFloatingComponent", "TweenLineComponent", "UIAreaParticleComponent", "UIBasicParticleComponent",
"UIGroupComponent", "UISpriteParticleComponent", "UITouchReceiveComponent", "UITransformComponent",
"WebSpriteComponent", "WebViewComponent", "WeldJointComponent", "WheelJointComponent",
"WorldComponent", "YoutubePlayerCommonComponent", "YoutubePlayerGUIComponent", "YoutubePlayerWorldComponent"
]);
const _resolveWarned = new Set();
function _editDistance(a, b) {
const m = a.length, n = b.length;
if (Math.abs(m - n) > 2) return 3;
const prev = new Array(n + 1);
for (let j = 0; j <= n; j++) prev[j] = j;
for (let i = 1; i <= m; i++) {
let diag = prev[0];
prev[0] = i;
for (let j = 1; j <= n; j++) {
const tmp = prev[j];
prev[j] = Math.min(
prev[j] + 1,
prev[j - 1] + 1,
diag + (a[i - 1] === b[j - 1] ? 0 : 1)
);
diag = tmp;
}
}
return prev[n];
}
function _nearestNative(name) {
const limit = name.length <= 6 ? 1 : 2;
let best = null, bestD = limit + 1;
for (const n of NATIVE_COMPONENTS) {
const d = _editDistance(name, n);
if (d < bestD) { bestD = d; best = n; }
}
return bestD <= limit ? best : null;
}
function normalizeComponentName(name) {
if (name == null) throw new TypeError("Component name must not be null");
const value = String(name);
if (value.startsWith("MOD.") || value.startsWith("script.")) return value;
if (NATIVE_COMPONENTS.has(value)) {
const out = "MOD.Core." + value;
if (!_resolveWarned.has(value)) {
_resolveWarned.add(value);
console.warn(`[builder:map] component "${value}" -> ${out} (native; auto-qualified). Pass "${out}" to silence this.`);
}
return out;
}
const near = _nearestNative(value);
const out = "script." + value;
if (!_resolveWarned.has(value)) {
_resolveWarned.add(value);
if (near) {
console.warn(`[builder:map] component "${value}" is not a native component -> treated as ${out}. Looks like a typo of native "MOD.Core.${near}": if you meant the native, pass "MOD.Core.${near}"; if it is your own script component, pass "${out}".`);
} else {
console.warn(`[builder:map] component "${value}" -> ${out} (assumed custom script component). Next time pass "${out}" if it is yours, or "MOD.Core.${value}" if it is native.`);
}
}
return out;
}
// <<< END AUTO-GENERATED
function modelContent(modelJson) {
const content = modelJson && modelJson.ContentProto && modelJson.ContentProto.Json;
if (!content || !Array.isArray(content.Components)) {
throw new Error("Invalid model JSON: missing ContentProto.Json.Components");
}
return content;
}
function modelDefinitionContent(modelJsonOrContent) {
if (modelJsonOrContent && modelJsonOrContent.ContentProto && modelJsonOrContent.ContentProto.Json) {
return modelContent(modelJsonOrContent);
}
if (modelJsonOrContent && Array.isArray(modelJsonOrContent.Components)) {
return modelJsonOrContent;
}
throw new Error("Invalid model JSON: missing model Components");
}
function modelIdFromJson(modelJson) {
const content = modelDefinitionContent(modelJson);
if (content.Id) return String(content.Id);
const entryKey = String(modelJson.EntryKey || "");
if (entryKey.startsWith("model://")) return entryKey.slice("model://".length);
throw new Error("Model Id not found");
}
function targetTypeFromDescriptor(target) {
if (!target || typeof target !== "object") return null;
const raw = String(target.type || "");
const match = raw.match(/(MOD\.Core\.[A-Za-z0-9_]+Component|script\.[A-Za-z0-9_]+)/);
return match ? match[1] : null;
}
function defaultComponent(componentType, pos) {
const type = normalizeComponentName(componentType);
if (type === "MOD.Core.TransformComponent") {
return {
"@type": type,
Position: vector3(pos || [0, 0, 0]),
QuaternionRotation: quaternion(),
Scale: vector3(1, 1, 1),
Enable: true,
};
}
if (type === "MOD.Core.SpriteRendererComponent") {
return {
"@type": type,
SpriteRUID: "",
Color: color(),
DrawMode: 0,
FlipX: false,
FlipY: false,
PlayRate: 1,
OrderInLayer: 2,
StartFrameIndex: 0,
EndFrameIndex: 2147483647,
Enable: true,
};
}
if (type === "MOD.Core.RigidbodyComponent" || type === "MOD.Core.KinematicbodyComponent" || type === "MOD.Core.SideviewbodyComponent") {
return { "@type": type, MoveVelocity: vector2(), RealMoveVelocity: vector2(), Enable: true };
}
if (type === "MOD.Core.AIChaseComponent" || type === "MOD.Core.AIWanderComponent" || type === "MOD.Core.HitComponent") {
return { "@type": type, IsLegacy: false, Enable: true };
}
if (type === "script.Monster") return { "@type": type, Enable: true, IsDead: false };
if (type === "script.MonsterAttack") return { "@type": type, Enable: true, SpriteSize: vector2(), PositionOffset: vector2() };
return { "@type": type, Enable: true };
}
function componentsFromModel(modelJson, pos) {
const content = modelDefinitionContent(modelJson);
const components = content.Components.map((componentType) =>
defaultComponent(componentType, componentType === "MOD.Core.TransformComponent" ? pos : null));
const byType = new Map(components.map((component) => [component["@type"], component]));
const properties = Array.isArray(content.Properties) ? content.Properties : [];
for (const item of content.Values || []) {
let targetType = item.TargetType;
let propertyName = item.Name;
if (targetType == null) {
const prop = properties.find((candidate) => candidate.Name === item.Name);
if (prop && prop.Link) {
targetType = targetTypeFromDescriptor(prop.Link.Target);
propertyName = prop.Link.Property || propertyName;
}
}
if (!targetType || !propertyName) continue;
const component = byType.get(normalizeComponentName(targetType));
if (component) component[propertyName] = clone(item.Value);
}
return components;
}
class MapBuilder {
constructor(mapName = "map01", data = null) {
this.mapName = mapName;
this.rootPath = `/maps/${mapName}`;
this.data = data || {
Id: "",
GameId: "",
EntryKey: `map://${mapName}`,
ContentType: "x-mod/map",
Content: "",
Usage: 0,
UsePublish: 1,
UseService: 0,
CoreVersion: "26.7.0.0",
StudioVersion: "0.1.0.0",
DynamicLoading: 0,
ContentProto: { Use: "Binary", Entities: [] },
};
this.entities = this.data.ContentProto.Entities;
this.displayCounter = this._nextDisplayOrder();
this._lastId = null;
}
static read(filepath) {
return MapBuilder.load(filepath);
}
static load(filepath) {
const data = readJsonFile(filepath, "map");
if (data.ContentType !== "x-mod/map") throw new Error(`Not an x-mod/map file: ${filepath}`);
if (!data.ContentProto || !Array.isArray(data.ContentProto.Entities)) {
throw new Error(`Missing ContentProto.Entities in map file: ${filepath}`);
}
for (const entity of data.ContentProto.Entities) {
if (typeof entity.jsonString === "string") entity.jsonString = JSON.parse(entity.jsonString);
const js = entity.jsonString || {};
if (js.version2 && (js.entityInfos || js.addedComponents || js.modifications)) {
throw new Error("Condensed version2 .map files are not supported by MapBuilder.");
}
}
const rootPath = data.ContentProto.Entities[0] && data.ContentProto.Entities[0].jsonString && data.ContentProto.Entities[0].jsonString.path;
const mapName = rootPath && rootPath.startsWith("/maps/")
? rootPath.split("/")[2]
: String(data.EntryKey || "map://map01").replace(/^map:\/\//, "");
return new MapBuilder(mapName, data);
}
static fromTemplate(templatePath, mapName) {
const targetMapName = normalizeMapName(mapName);
const template = MapBuilder.load(templatePath);
const data = clone(template.data);
const oldRootPath = template.rootPath;
if (!oldRootPath || !oldRootPath.startsWith("/maps/")) {
throw new Error(`Template map root path is invalid: ${oldRootPath || "<missing>"}`);
}
const newRootPath = `/maps/${targetMapName}`;
const idMap = new Map();
data.EntryKey = `map://${targetMapName}`;
data.Content = "";
data.Id = "";
data.GameId = "";
for (const entity of data.ContentProto.Entities) {
if (!entity.id) throw new Error(`Template map entity is missing id: ${entity.path || "<unknown>"}`);
idMap.set(entity.id, crypto.randomUUID());
}
for (const entity of data.ContentProto.Entities) {
entity.id = idMap.get(entity.id);
entity.path = remapTemplateValue(entity.path, idMap, oldRootPath, newRootPath);
entity.jsonString = remapTemplateValue(entity.jsonString, idMap, oldRootPath, newRootPath);
const js = entity.jsonString || {};
if (js.path === newRootPath) js.name = targetMapName;
}
const builder = new MapBuilder(targetMapName, data);
for (const entity of builder.entities) builder._syncComponentNames(entity);
builder.displayCounter = builder._nextDisplayOrder();
builder._lastId = null;
return builder;
}
static snapshot(filepath) {
return MapBuilder.read(filepath).snapshot();
}
static templatePath(kind) {
return mapTemplatePath(kind);
}
build() {
this.data.ContentProto.Entities = this.entities;
return this.data;
}
write(filepath) {
fs.writeFileSync(filepath, `${JSON.stringify(this.build(), null, 2)}\n`, "utf8");
return this;
}
snapshot() {
return { mapName: this.mapName, mapInfo: this.getMapInfo(), entities: this.listEntities() };
}
lastId() {
return this._lastId;
}
_nextDisplayOrder() {
return this.entities.reduce((max, entity) => Math.max(max, Number(this._entityJson(entity).displayOrder ?? -1)), -1) + 1;
}
_entityJson(entity) {
if (!entity.jsonString || typeof entity.jsonString !== "object") entity.jsonString = {};
return entity.jsonString;
}
_normalizePath(identifier) {
const value = String(identifier || "").trim();
if (!value) throw new Error("Entity identifier must not be empty");
if (value.startsWith("/maps/")) {
if (value !== this.rootPath && !value.startsWith(`${this.rootPath}/`)) throw new Error(`Entity path is outside this map: ${value}`);
return value;
}
return `${this.rootPath}/${value.replace(/^\/+/, "")}`;
}
_entityName(path) {
return path === this.rootPath ? this.mapName : path.split("/").pop();
}
_pathConstraints(path) {
return "/".repeat((path.match(/\//g) || []).length);
}
_findIndex(identifier) {
const raw = String(identifier || "").trim();
const target = raw === this.mapName ? this.rootPath : this._normalizePath(identifier);
return this.entities.findIndex((entity) => this._entityJson(entity).path === target || entity.path === target);
}
find(identifier) {
const idx = this._findIndex(identifier);
return idx < 0 ? null : this.entities[idx];
}
component(identifier, componentType) {
const entity = typeof identifier === "object" ? identifier : this.find(identifier);
if (!entity) return null;
const target = normalizeComponentName(componentType);
return (this._entityJson(entity)["@components"] || []).find((component) => component["@type"] === target) || null;
}
_syncComponentNames(entity) {
entity.componentNames = (this._entityJson(entity)["@components"] || [])
.map((component) => component["@type"])
.filter(Boolean)
.join(",");
}
_rootMapEntity() {
return this.entities.find((entity) => this.component(entity, "MOD.Core.MapComponent")) || null;
}
getMapInfo() {
const root = this._rootMapEntity();
const map = root ? this.component(root, "MOD.Core.MapComponent") : null;
return {
TileMapMode: map ? map.TileMapMode : null,
Gravity: map ? map.Gravity : null,
IsInstanceMap: map ? map.IsInstanceMap : null,
entityCount: this.entities.length,
tileCount: this.getTiles().length,
footholdCount: this.getFootholds().length,
};
}
getTileMapMode() {
return this.getMapInfo().TileMapMode;
}
listEntities() {
return this.entities.map((entity) => {
const js = this._entityJson(entity);
return {
id: entity.id,
name: js.name,
path: js.path || entity.path,
modelId: js.modelId ?? null,
displayOrder: js.displayOrder,
componentNames: entity.componentNames || "",
};
}).sort((a, b) => String(a.path).localeCompare(String(b.path)));
}
entity(identifier, components, options = {}, preserveExistingTransform = false) {
const path = this._normalizePath(identifier);
const existingIndex = this._findIndex(path);
const existing = existingIndex >= 0 ? this.entities[existingIndex] : null;
const existingJs = existing ? this._entityJson(existing) : null;
const id = existing ? existing.id : crypto.randomUUID();
const modelId = options.modelId !== undefined ? options.modelId : (existingJs ? existingJs.modelId : null);
let origin;
if (options.origin !== undefined) origin = options.origin;
else if (existingJs && existingJs.origin !== undefined) origin = clone(existingJs.origin);
else if (modelId != null) origin = { type: "Model", entry_id: modelId, sub_entity_id: null, root_entity_id: id, replaced_model_id: null };
else origin = undefined;
if (origin && origin.root_entity_id == null) origin.root_entity_id = id;
let finalComponents = clone(components);
if (preserveExistingTransform && existingJs) {
const existingTransform = (existingJs["@components"] || []).find(
(component) => component["@type"] === "MOD.Core.TransformComponent",
);
if (existingTransform) {
finalComponents = finalComponents.map((component) =>
component["@type"] === "MOD.Core.TransformComponent" ? clone(existingTransform) : component,
);
}
}
const js = {
name: options.name ?? (existingJs ? existingJs.name : this._entityName(path)),
path,
nameEditable: options.nameEditable ?? (existingJs ? existingJs.nameEditable : true),
enable: options.enable ?? (existingJs ? existingJs.enable : true),
visible: options.visible ?? (existingJs ? existingJs.visible : true),
localize: options.localize ?? (existingJs ? existingJs.localize : false),
displayOrder: options.displayOrder ?? (existingJs ? existingJs.displayOrder : this.displayCounter++),
pathConstraints: this._pathConstraints(path),
revision: existingJs ? (existingJs.revision ?? 1) : (options.revision ?? 1),
modelId,
"@components": finalComponents,
"@version": 1,
};
if (origin !== undefined) js.origin = origin;
const entity = { id, path, componentNames: "", jsonString: js };
this._syncComponentNames(entity);
if (existingIndex >= 0) this.entities[existingIndex] = entity;
else this.entities.push(entity);
this._lastId = id;
return this;
}
empty(name, options = {}) {
const components = [defaultComponent("MOD.Core.TransformComponent", options.pos || [0, 0, 0])];
for (const script of options.scripts || []) components.push(defaultComponent(script));
return this.entity(name, components, { modelId: options.modelId ?? "mapempty", origin: options.origin, enable: options.enable }, !hasExplicitPos(options));
}
sprite(name, options = {}) {
const sprite = defaultComponent("MOD.Core.SpriteRendererComponent");
sprite.SpriteRUID = options.ruid === undefined ? DEFAULT_SPRITE_RUID : options.ruid;
sprite.OrderInLayer = options.order ?? sprite.OrderInLayer;
sprite.Color = color(options.color);
return this.entity(name, [defaultComponent("MOD.Core.TransformComponent", options.pos || [0, 0, 0]), sprite], {
modelId: options.modelId ?? "mapobject",
origin: options.origin,
enable: options.enable,
}, !hasExplicitPos(options));
}
placeModel(name, modelFilepathOrJson, options = {}) {
const modelJson = typeof modelFilepathOrJson === "string" ? readJsonFile(modelFilepathOrJson, "model") : clone(modelFilepathOrJson);
const modelId = options.modelId || modelIdFromJson(modelJson);
const components = componentsFromModel(modelJson, options.pos || [0, 0, 0]);
for (const [componentType, updates] of Object.entries(options.componentOverrides || {})) {
const component = components.find((item) => item["@type"] === normalizeComponentName(componentType));
if (!component) throw new Error(`Model ${modelId} has no component ${componentType}`);
Object.assign(component, clone(updates));
}
const path = this._normalizePath(name);
const existing = this.find(path);
if (existing) {
const existingPath = this._entityJson(existing).path || existing.path;
this.entities = this.entities.filter((entity) => {
const currentPath = this._entityJson(entity).path || entity.path;
return !currentPath.startsWith(`${existingPath}/`);
});
this.data.ContentProto.Entities = this.entities;
}
this.entity(name, components, {
modelId,
enable: options.enable,
visible: options.visible,
origin: { type: "Model", entry_id: modelId, sub_entity_id: null, root_entity_id: null, replaced_model_id: null },
}, !hasExplicitPos(options));
const rootId = this._lastId;
const model = modelContent(modelJson);
this._placeModelChildren(path, rootId, modelId, model.Children || [], modelId);
this._lastId = rootId;
return this;
}
_placeModelChildren(parentPath, rootEntityId, rootModelId, children, parentModelId) {
if (!Array.isArray(children) || children.length === 0) return;
const byId = new Map(children.map((child) => [child.Id || (child.Model && child.Model.Id), child]));
const placed = new Set();
const placedPaths = new Map();
const place = (child, fallbackParentPath = parentPath) => {
const childId = child.Id || (child.Model && child.Model.Id);
if (!childId || placed.has(childId)) return;
const parentChild = byId.get(child.ParentId);
let currentParentPath = fallbackParentPath;
if (parentChild && parentChild !== child) {
place(parentChild, fallbackParentPath);
currentParentPath = placedPaths.get(parentChild.Id || (parentChild.Model && parentChild.Model.Id)) || currentParentPath;
}
const model = child.Model || {};
const childName = child.Name || model.Name || childId;
const childPath = `${currentParentPath}/${childName}`;
const childModelId = model.Id || childId;
const origin = child.ModelReplaced
? { type: "Model2", entry_id: childModelId, sub_entity_id: null, root_entity_id: rootEntityId, replaced_model_id: childId }
: { type: "Model", entry_id: parentModelId || rootModelId, sub_entity_id: childId, root_entity_id: rootEntityId, replaced_model_id: null };
this.entity(childPath, componentsFromModel(model), {
name: childName,
modelId: childModelId,
origin,
});
placed.add(childId);
placedPaths.set(childId, childPath);
this._placeModelChildren(childPath, rootEntityId, rootModelId, model.Children || [], childModelId);
};
for (const child of children) place(child);
}
patch(identifier, updates = {}) {
const entity = this.find(identifier);
if (!entity) throw new Error(`Entity not found: ${identifier}`);
const js = this._entityJson(entity);
if (updates.pos) {
const transform = this.component(entity, "MOD.Core.TransformComponent");
if (!transform) throw new Error(`Entity ${identifier} has no TransformComponent`);
transform.Position = vector3(updates.pos);
}
for (const key of ["enable", "visible", "localize", "displayOrder"]) {
if (Object.prototype.hasOwnProperty.call(updates, key)) js[key] = updates[key];
}
if (updates.name) this.rename(identifier, updates.name);
return this;
}
rename(identifier, newName) {
const entity = this.find(identifier);
if (!entity) throw new Error(`Entity not found: ${identifier}`);
const oldPath = this._entityJson(entity).path;
const newPath = `${oldPath.split("/").slice(0, -1).join("/")}/${newName}`;
for (const item of this.entities) {
const js = this._entityJson(item);
const currentPath = js.path || item.path;
if (currentPath === oldPath || currentPath.startsWith(`${oldPath}/`)) {
const updatedPath = newPath + currentPath.slice(oldPath.length);
item.path = updatedPath;
js.path = updatedPath;
js.pathConstraints = this._pathConstraints(updatedPath);
if (currentPath === oldPath) js.name = newName;
}
}
return this;
}
upsertComponent(identifier, componentType, data = null) {
const entity = this.find(identifier);
if (!entity) throw new Error(`Entity not found: ${identifier}`);
const js = this._entityJson(entity);
const component = data ? clone(data) : defaultComponent(componentType);
component["@type"] = normalizeComponentName(component["@type"] || componentType);
const idx = (js["@components"] || []).findIndex((item) => item["@type"] === component["@type"]);
if (idx >= 0) js["@components"][idx] = component;
else js["@components"].push(component);
this._syncComponentNames(entity);
return this;
}
patchComponent(identifier, componentType, updates) {
const component = this.component(identifier, componentType);
if (!component) throw new Error(`Entity ${identifier} has no ${componentType}`);
Object.assign(component, clone(updates));
return this;
}
removeComponent(identifier, componentType) {
const entity = this.find(identifier);
if (!entity) throw new Error(`Entity not found: ${identifier}`);
const js = this._entityJson(entity);
const target = normalizeComponentName(componentType);
if (!(js["@components"] || []).some((component) => component["@type"] === target)) {
throw new Error(`Entity ${identifier} has no ${target}`);
}
js["@components"] = js["@components"].filter((component) => component["@type"] !== target);
this._syncComponentNames(entity);
return this;
}
remove(identifier) {
const target = this._normalizePath(identifier);
const before = this.entities.length;
this.entities = this.entities.filter((entity) => {
const currentPath = this._entityJson(entity).path || entity.path;
return currentPath !== target && !currentPath.startsWith(`${target}/`);
});
this.data.ContentProto.Entities = this.entities;
if (this.entities.length === before) throw new Error(`Entity not found: ${identifier}`);
return this;
}
_tileEntity(name = null) {
if (name) return this.find(name);
return this.entities.find((entity) => this.component(entity, "MOD.Core.TileMapComponent") || this.component(entity, "MOD.Core.RectTileMapComponent")) || null;
}
_tileComponent(name = null) {
const entity = this._tileEntity(name);
if (!entity) return null;
return this.component(entity, "MOD.Core.TileMapComponent") || this.component(entity, "MOD.Core.RectTileMapComponent");
}
getTiles(tilemapName = null) {
const component = this._tileComponent(tilemapName);
if (!component) return [];
return component.Tiles || component.tileMap || [];
}
getTileAt(x, y, tilemapName = null) {
return this.getTiles(tilemapName).find((tile) => tile.position && tile.position.x === x && tile.position.y === y) || null;
}
getTileBounds(tilemapName = null) {
const tiles = this.getTiles(tilemapName);
if (!tiles.length) return null;
const xs = tiles.map((tile) => tile.position.x);
const ys = tiles.map((tile) => tile.position.y);
return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys), count: tiles.length };
}
_footholdComponent() {
const root = this._rootMapEntity();
return root ? this.component(root, "MOD.Core.FootholdComponent") : null;
}
getFootholds(layer = "1") {
const component = this._footholdComponent();
if (!component || !component.FootholdsByLayer) return [];
return component.FootholdsByLayer[String(layer)] || [];
}
getFootholdBounds(layer = "1") {
const footholds = this.getFootholds(layer);
if (!footholds.length) return null;
const xs = [];
const ys = [];
for (const foothold of footholds) {
xs.push(foothold.StartPoint.x, foothold.EndPoint.x);
ys.push(foothold.StartPoint.y, foothold.EndPoint.y);
}
return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys), count: footholds.length };
}
}
module.exports = { MapBuilder, DEFAULT_SPRITE_RUID, MAP_TEMPLATE_FILES, componentsFromModel, defaultComponent, vector2, vector3, quaternion, color };
scripts/model/msw_model_builder.cjs
"use strict";
const fs = require("fs");
const path = require("path");
const MSCORLIB = "mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
const MOD_CORE_VERSION = process.env.MSW_MODEL_BUILDER_MOD_CORE_VERSION || "26.7.0.0";
const MOD_CORE = `MOD.Core, Version=${MOD_CORE_VERSION}, Culture=neutral, PublicKeyToken=null`;
const MOD_CORE_SHORT = "MOD.Core";
const DEFAULT_SPRITE_RUID = "8ef238e0d0ca4bb783aca526cff35d11";
const SPRITE_RENDERER = "MOD.Core.SpriteRendererComponent";
const DEFAULT_DAMAGE_SKIN_ATTACK = "3271c3e79bf04ecba9a107d55495970d";
const DEFAULT_DAMAGE_SKIN_HIT = "02c22d93421b4038b3c413b3e40b57ec";
const DEFAULT_DAMAGE_SKIN_HEAL = "d58b67cf0f3a4eaf9fe1ad87c0ffac8a";
const TYPE_MAP = {
bool: `System.Boolean, ${MSCORLIB}`,
boolean: `System.Boolean, ${MSCORLIB}`,
int: `System.Int32, ${MSCORLIB}`,
integer: `System.Int32, ${MSCORLIB}`,
long: `System.Int64, ${MSCORLIB}`,
float: `System.Single, ${MSCORLIB}`,
single: `System.Single, ${MSCORLIB}`,
double: `System.Double, ${MSCORLIB}`,
string: `System.String, ${MSCORLIB}`,
vector2: `MOD.Core.MODVector2, ${MOD_CORE}`,
Vector2: `MOD.Core.MODVector2, ${MOD_CORE}`,
vector3: `MOD.Core.MODVector3, ${MOD_CORE}`,
Vector3: `MOD.Core.MODVector3, ${MOD_CORE}`,
quaternion: `MOD.Core.MODQuaternion, ${MOD_CORE}`,
Quaternion: `MOD.Core.MODQuaternion, ${MOD_CORE}`,
collision_group: `MOD.Core.Physics.CollisionGroup, ${MOD_CORE}`,
CollisionGroup: `MOD.Core.Physics.CollisionGroup, ${MOD_CORE}`,
collider_type: `MOD.Core.ColliderType, ${MOD_CORE}`,
ColliderType: `MOD.Core.ColliderType, ${MOD_CORE}`,
data_ref: `MOD.Core.MODDataRef, ${MOD_CORE}`,
MODDataRef: `MOD.Core.MODDataRef, ${MOD_CORE}`,
sync_string_dict: `MOD.Core.MODSyncDictionary\`2[[System.String, ${MSCORLIB}],[System.String, ${MSCORLIB}]], ${MOD_CORE}`,
action_sheet: `MOD.Core.MODSyncDictionary\`2[[System.String, ${MSCORLIB}],[System.String, ${MSCORLIB}]], ${MOD_CORE}`,
};
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function typeDescriptor(typeStr) {
return { $type: "MODNativeType", type: typeStr };
}
function componentTypeDescriptor(componentType) {
const target = normalizeTargetType(componentType);
if (target && target.startsWith("MOD.Core.")) return typeDescriptor(`${target}, ${MOD_CORE}`);
return typeDescriptor(target);
}
function normalizeTypeKey(typeKey) {
if (typeKey == null) return null;
const value = String(typeKey);
const canonical = {
Boolean: "bool",
boolean: "bool",
Integer: "int",
integer: "int",
Int32: "int",
Single: "float",
Float: "float",
Vector2: "vector2",
Vector3: "vector3",
Quaternion: "quaternion",
CollisionGroup: "collision_group",
MODDataRef: "data_ref",
}[value];
return canonical || value;
}
function inferType(value) {
if (typeof value === "boolean") return "bool";
if (typeof value === "number") return Number.isInteger(value) ? "int" : "float";
if (typeof value === "string") return "string";
if (value && typeof value === "object") {
const typeName = String(value.$type || "");
if (typeName.includes("MODVector2")) return "vector2";
if (typeName.includes("MODVector3")) return "vector3";
if (typeName.includes("MODQuaternion")) return "quaternion";
if (typeName.includes("CollisionGroup")) return "collision_group";
if (typeName.includes("MODDataRef")) return "data_ref";
const keys = Object.keys(value).sort().join(",");
if (keys === "x,y") return "vector2";
if (keys === "x,y,z") return "vector3";
if (keys === "w,x,y,z") return "quaternion";
}
return "string";
}
function wrapValue(value, typeKey) {
const t = normalizeTypeKey(typeKey);
if (t === "vector2") {
if (Array.isArray(value)) return vector2(value[0], value[1]);
if (value && typeof value === "object" && value.$type == null) return { $type: `MOD.Core.MODVector2, ${MOD_CORE_SHORT}`, ...value };
}
if (t === "vector3") {
if (Array.isArray(value)) return vector3(value[0], value[1], value[2]);
if (value && typeof value === "object" && value.$type == null) return { $type: `MOD.Core.MODVector3, ${MOD_CORE_SHORT}`, ...value };
}
if (t === "quaternion") {
if (Array.isArray(value)) return quaternion(value[0], value[1], value[2], value[3]);
if (value && typeof value === "object" && value.$type == null) return { $type: `MOD.Core.MODQuaternion, ${MOD_CORE_SHORT}`, ...value };
}
if (t === "collision_group" && typeof value === "string") return collisionGroup(value);
if (t === "data_ref" && typeof value === "string") return dataRef(value);
if ((t === "sync_string_dict" || t === "action_sheet") && value && typeof value === "object" && value.$type == null) return actionSheet(value);
return clone(value);
}
function vector2(x = 0, y = 0) {
if (x && typeof x === "object") {
if (Array.isArray(x)) return { $type: `MOD.Core.MODVector2, ${MOD_CORE_SHORT}`, x: Number(x[0] ?? 0), y: Number(x[1] ?? 0) };
return { $type: `MOD.Core.MODVector2, ${MOD_CORE_SHORT}`, x: Number(x.x ?? 0), y: Number(x.y ?? 0) };
}
return { $type: `MOD.Core.MODVector2, ${MOD_CORE_SHORT}`, x: Number(x), y: Number(y) };
}
function vector3(x = 0, y = 0, z = 0) {
if (x && typeof x === "object") {
if (Array.isArray(x)) return { $type: `MOD.Core.MODVector3, ${MOD_CORE_SHORT}`, x: Number(x[0] ?? 0), y: Number(x[1] ?? 0), z: Number(x[2] ?? 0) };
return { $type: `MOD.Core.MODVector3, ${MOD_CORE_SHORT}`, x: Number(x.x ?? 0), y: Number(x.y ?? 0), z: Number(x.z ?? 0) };
}
return { $type: `MOD.Core.MODVector3, ${MOD_CORE_SHORT}`, x: Number(x), y: Number(y), z: Number(z) };
}
function quaternion(x = 0, y = 0, z = 0, w = 1) {
if (x && typeof x === "object") {
if (Array.isArray(x)) return { $type: `MOD.Core.MODQuaternion, ${MOD_CORE_SHORT}`, x: Number(x[0] ?? 0), y: Number(x[1] ?? 0), z: Number(x[2] ?? 0), w: Number(x[3] ?? 1) };
return { $type: `MOD.Core.MODQuaternion, ${MOD_CORE_SHORT}`, x: Number(x.x ?? 0), y: Number(x.y ?? 0), z: Number(x.z ?? 0), w: Number(x.w ?? 1) };
}
return { $type: `MOD.Core.MODQuaternion, ${MOD_CORE_SHORT}`, x: Number(x), y: Number(y), z: Number(z), w: Number(w) };
}
function collisionGroup(groupId) {
return { $type: `MOD.Core.Physics.CollisionGroup, ${MOD_CORE_SHORT}`, Id: String(groupId) };
}
function dataRef(dataId) {
return { $type: `MOD.Core.MODDataRef, ${MOD_CORE_SHORT}`, DataId: String(dataId) };
}
function actionSheet(actions) {
return {
$type: "MOD.Core.MODSyncDictionary`2[[System.String, mscorlib],[System.String, mscorlib]], MOD.Core",
...clone(actions),
};
}
function modelIdFromName(name) {
return String(name)
.trim()
.replace(/[^0-9A-Za-z_]/g, "")
.toLowerCase();
}
function readJsonFile(filepath, label) {
try {
return JSON.parse(fs.readFileSync(filepath, "utf8"));
} catch (err) {
if (err && err.code === "ENOENT") throw new Error(`${label} not found: ${filepath}`);
throw new Error(`Invalid JSON in ${label} ${filepath}: ${err.message}`);
}
}
function findSiblingModelById(sourcePath, modelId) {
if (!sourcePath || !modelId) return null;
const dir = path.dirname(sourcePath);
let entries = [];
try {
entries = fs.readdirSync(dir);
} catch (_) {
return null;
}
const normalizedModelId = normalizeModelId(modelId);
for (const entry of entries) {
if (!entry.toLowerCase().endsWith(".model")) continue;
const candidate = path.join(dir, entry);
try {
const data = readJsonFile(candidate, "base model file");
const model = data.ContentProto && data.ContentProto.Json;
if (!model) continue;
if (normalizeModelId(model.Id) === normalizedModelId) return candidate;
if (normalizeModelId(data.EntryKey) === normalizedModelId) return candidate;
} catch (_) {
// Ignore unrelated or malformed sibling files; the write path remains non-blocking.
}
}
return null;
}
function modelDefinition(modelJsonOrContent, label = "model") {
if (modelJsonOrContent && modelJsonOrContent.ContentProto && modelJsonOrContent.ContentProto.Json) {
return modelJsonOrContent.ContentProto.Json;
}
if (modelJsonOrContent && Array.isArray(modelJsonOrContent.Components)) return modelJsonOrContent;
throw new Error(`Invalid ${label}: missing ContentProto.Json.Components`);
}
function normalizeModelId(value) {
if (value == null) return null;
const modelId = String(value).trim().replace(/^model:\/\//, "");
return modelId === "" ? null : modelId.toLowerCase();
}
// >>> BEGIN AUTO-GENERATED: native component catalog + resolver — do not hand-edit; run tools/gen-native-components.cjs
// Native MSW component class names (CoreVersion 26.7.0.0). A bare name in this
// set is auto-qualified to "MOD.Core.<name>"; any other bare name is treated as a
// "script.<name>" custom component, with a one-time advisory on stderr.
const NATIVE_COMPONENTS = new Set([
"AIChaseComponent", "AIComponent", "AIWanderComponent", "AnimationSequenceControllerComponent",
"AreaParticleComponent", "AttackComponent", "AvatarBodyActionSelectorComponent", "AvatarFaceActionSelectorComponent",
"AvatarGUIRendererComponent", "AvatarRendererComponent", "AvatarStateAnimationComponent", "BackgroundComponent",
"BasicParticleComponent", "ButtonComponent", "CameraComponent", "CanvasGroupComponent",
"ChatBalloonComponent", "ChatComponent", "ClimbableComponent", "ClimbableSpriteRendererComponent",
"Component", "CostumeManagerComponent", "CustomFootholdComponent", "DamageSkinComponent",
"DamageSkinSettingComponent", "DamageSkinSpawnerComponent", "DirectionSynchronizerComponent", "DistanceJointComponent",
"FootholdComponent", "GridViewComponent", "HitComponent", "HitEffectSpawnerComponent",
"InteractionComponent", "InventoryComponent", "JoystickComponent", "KinematicbodyComponent",
"LightComponent", "LineGUIRendererComponent", "LineRendererComponent", "MapComponent",
"MapLayerComponent", "MaskComponent", "MissingComponent", "MovementComponent",
"NameTagComponent", "OverlayLightComponent", "PhysicsColliderComponent", "PhysicsRigidbodyComponent",
"PhysicsSimulatorComponent", "PixelGUIRendererComponent", "PixelRendererComponent", "PlayerComponent",
"PlayerControllerComponent", "PolygonGUIRendererComponent", "PolygonRendererComponent", "PortalComponent",
"PrismaticJointComponent", "PulleyJointComponent", "RawImageGUIRendererComponent", "RawImageRendererComponent",
"RectTileMapComponent", "RevoluteJointComponent", "RigidbodyComponent", "ScrollLayoutGroupComponent",
"SideviewbodyComponent", "SkeletonGUIRendererComponent", "SkeletonRendererComponent", "SliderComponent",
"SoundComponent", "SpawnLocationComponent", "SpriteGUIRendererComponent", "SpriteParticleComponent",
"SpriteRendererComponent", "StateAnimationComponent", "StateComponent", "StateStringToAvatarActionComponent",
"StateStringToMonsterActionComponent", "TagComponent", "TextComponent", "TextGUIRendererComponent",
"TextGUIRendererInputComponent", "TextInputComponent", "TextRendererComponent", "TileMapComponent",
"TouchReceiveComponent", "TransformComponent", "TriggerComponent", "TweenCircularComponent",
"TweenFloatingComponent", "TweenLineComponent", "UIAreaParticleComponent", "UIBasicParticleComponent",
"UIGroupComponent", "UISpriteParticleComponent", "UITouchReceiveComponent", "UITransformComponent",
"WebSpriteComponent", "WebViewComponent", "WeldJointComponent", "WheelJointComponent",
"WorldComponent", "YoutubePlayerCommonComponent", "YoutubePlayerGUIComponent", "YoutubePlayerWorldComponent"
]);
const _resolveWarned = new Set();
function _editDistance(a, b) {
const m = a.length, n = b.length;
if (Math.abs(m - n) > 2) return 3;
const prev = new Array(n + 1);
for (let j = 0; j <= n; j++) prev[j] = j;
for (let i = 1; i <= m; i++) {
let diag = prev[0];
prev[0] = i;
for (let j = 1; j <= n; j++) {
const tmp = prev[j];
prev[j] = Math.min(
prev[j] + 1,
prev[j - 1] + 1,
diag + (a[i - 1] === b[j - 1] ? 0 : 1)
);
diag = tmp;
}
}
return prev[n];
}
function _nearestNative(name) {
const limit = name.length <= 6 ? 1 : 2;
let best = null, bestD = limit + 1;
for (const n of NATIVE_COMPONENTS) {
const d = _editDistance(name, n);
if (d < bestD) { bestD = d; best = n; }
}
return bestD <= limit ? best : null;
}
function normalizeComponentName(name) {
if (name == null) throw new TypeError("Component name must not be null");
const value = String(name);
if (value.startsWith("MOD.") || value.startsWith("script.")) return value;
if (NATIVE_COMPONENTS.has(value)) {
const out = "MOD.Core." + value;
if (!_resolveWarned.has(value)) {
_resolveWarned.add(value);
console.warn(`[builder:model] component "${value}" -> ${out} (native; auto-qualified). Pass "${out}" to silence this.`);
}
return out;
}
const near = _nearestNative(value);
const out = "script." + value;
if (!_resolveWarned.has(value)) {
_resolveWarned.add(value);
if (near) {
console.warn(`[builder:model] component "${value}" is not a native component -> treated as ${out}. Looks like a typo of native "MOD.Core.${near}": if you meant the native, pass "MOD.Core.${near}"; if it is your own script component, pass "${out}".`);
} else {
console.warn(`[builder:model] component "${value}" -> ${out} (assumed custom script component). Next time pass "${out}" if it is yours, or "MOD.Core.${value}" if it is native.`);
}
}
return out;
}
// <<< END AUTO-GENERATED
function normalizeTargetType(targetType) {
return targetType == null ? null : normalizeComponentName(targetType);
}
function shortTarget(targetType) {
return targetType == null ? "<property>" : String(targetType).replace(/^MOD\.Core\./, "");
}
class ModelBuilder {
constructor(name, options = {}) {
if (!name || !String(name).trim()) throw new Error("Model name must not be empty");
this.name = String(name);
this.model_id = options.model_id || options.modelId || modelIdFromName(this.name);
if (!this.model_id) throw new Error(`Model id derived from '${this.name}' is empty`);
this.components = [];
this.properties = [];
this.values = [];
this.event_links = [];
this.children = [];
this.base_model_id = options.base_model_id ?? options.baseModelId ?? null;
this.version = options.version ?? 1;
this._data = null;
this._source_path = options.source_path || options.sourcePath || null;
this._warnedInheritedComponents = new Set();
}
static load(filepath) {
const data = readJsonFile(filepath, "model file");
if (!data.ContentProto || !data.ContentProto.Json) {
throw new Error(`Missing ContentProto.Json in model file: ${filepath}`);
}
const modelJson = data.ContentProto.Json;
const instance = new ModelBuilder(modelJson.Name || "Unnamed", {
model_id: modelJson.Id || undefined,
version: modelJson.Version ?? 1,
});
instance.components = Array.isArray(modelJson.Components) ? clone(modelJson.Components) : [];
instance.properties = Array.isArray(modelJson.Properties) ? clone(modelJson.Properties) : [];
instance.values = Array.isArray(modelJson.Values) ? clone(modelJson.Values) : [];
instance.event_links = Array.isArray(modelJson.EventLinks) ? clone(modelJson.EventLinks) : [];
instance.children = Array.isArray(modelJson.Children) ? clone(modelJson.Children) : [];
instance.base_model_id = modelJson.BaseModelId ?? null;
instance._data = data;
instance._source_path = filepath;
console.log(`Loaded model '${instance.name}': ${instance.components.length} components, ${instance.values.length} values, ${instance.children.length} children`);
return instance;
}
static read(filepath) {
return ModelBuilder.load(filepath);
}
static fromTemplate(templatePath, name, options = {}) {
const instance = ModelBuilder.load(templatePath);
return instance.renameModel(name, options.model_id || options.modelId);
}
static snapshot(filepath) {
return ModelBuilder.load(filepath).snapshot();
}
static normalizeComponentName(name) {
return normalizeComponentName(name);
}
snapshot() {
return {
name: this.name,
model_id: this.model_id,
version: this.version,
base_model_id: this.base_model_id,
components: clone(this.components),
properties: this.properties.map((p) => ({
name: p.Name,
display_name: p.DisplayName,
show_in_inspector: p.ShowInInspector,
link_target: p.Link ? clone(p.Link.Target) : null,
link_property: p.Link ? p.Link.Property : null,
})),
values: this.values.map((v) => ({
target_type: v.TargetType,
name: v.Name,
value: clone(v.Value),
type: v.ValueType && v.ValueType.type ? v.ValueType.type : null,
})),
child_count: this.children.length,
children: this.children.map((child) => childSummary(child)),
};
}
renameModel(name, modelId = null) {
if (!name || !String(name).trim()) throw new Error("Model name must not be empty");
const oldModelId = this.model_id;
this.name = String(name);
this.model_id = modelId || modelIdFromName(this.name);
if (!this.model_id) throw new Error(`Model id derived from '${this.name}' is empty`);
for (const child of this.children) {
if (child.ParentId === oldModelId) child.ParentId = this.model_id;
}
if (this._data) this._data.EntryKey = `model://${this.model_id}`;
return this;
}
setBaseModelId(baseModelId) {
this.base_model_id = normalizeModelId(baseModelId);
return this;
}
component(compName) {
const normalized = normalizeComponentName(compName);
if (normalized.startsWith("script.")) {
console.log(`[ModelBuilder] NOTE: '${normalized}' is a script component. Refresh script .mlua before writing and refreshing this .model.`);
}
if (this._isInheritedComponent(normalized) && !this._warnedInheritedComponents.has(normalized)) {
this._warnedInheritedComponents.add(normalized);
console.warn(`[ModelBuilder] WARNING M040: '${normalized}' is inherited from BaseModelId '${this.base_model_id}'. Prefer value(...) overrides; redeclaring inherited components can create duplicate component definitions.`);
}
if (!this.components.includes(normalized)) this.components.push(normalized);
return this;
}
addComponent(compName) {
return this.component(compName);
}
hasComponent(compName) {
return this.components.includes(normalizeComponentName(compName));
}
removeComponent(compName) {
const normalized = normalizeComponentName(compName);
if (!this.components.includes(normalized)) {
throw new Error(`Component not found: ${normalized}`);
}
this.components = this.components.filter((c) => c !== normalized);
this.values = this.values.filter((v) => v.TargetType !== normalized);
this.properties = this.properties.filter((p) => {
if (!p.Link) return true;
return normalizeLinkTarget(p.Link.Target) !== normalized;
});
return this;
}
value(targetType, name, val, typeKey = null) {
upsertValue(this.values, targetType, name, val, typeKey);
return this;
}
getValue(targetType, name, fallback = undefined) {
return getValueEntry(this.values, targetType, name, fallback);
}
getValueEntry(targetType, name) {
const normalizedTarget = normalizeTargetType(targetType);
const found = this.values.find((v) => v.TargetType === normalizedTarget && v.Name === name);
return found ? clone(found) : null;
}
hasValue(targetType, name) {
const normalizedTarget = normalizeTargetType(targetType);
return this.values.some((v) => v.TargetType === normalizedTarget && v.Name === name);
}
removeValue(targetType, name) {
if (!removeValueEntry(this.values, targetType, name)) {
throw new Error(`Value not found: ${targetType}.${name}`);
}
return this;
}
enable(targetType, enabled = true) {
return this.value(targetType, "Enable", Boolean(enabled), "bool");
}
entityEnable(enabled = true) {
return this.value("MOD.Core.MODEntity", "Enable", Boolean(enabled), "bool");
}
entityVisible(visible = true) {
return this.value("MOD.Core.MODEntity", "Visible", Boolean(visible), "bool");
}
property(name, options = {}) {
upsertProperty(this.properties, this.values, name, options);
return this;
}
removeProperty(name) {
if (!removePropertyEntry(this.properties, name)) {
throw new Error(`Property not found: ${name}`);
}
return this;
}
child(name, componentsOrOptions = null, maybeOptions = {}) {
if (!name || !String(name).trim()) throw new Error("Child name must not be empty");
const options = normalizeChildOptions(componentsOrOptions, maybeOptions);
const parentId = this._childParentId(options.parent ?? options.parentId);
const existing = this._findChild(name, options.parent ?? options.parentId);
if (existing) {
if (options.model != null) {
throw new Error(
`child("${name}"): cannot swap the Model template of an existing child via options.model — ` +
`applyChildOptions does not consume options.model, so a passing call would silently keep the prior template. ` +
`To replace the template, call removeChild("${name}") first, then childFromTemplate/childFromModel. ` +
`To update name/components/enable/visible on the existing child, omit options.model.`,
);
}
ensureChildModelShape(existing, name, parentId);
applyChildOptions(existing, options, name, parentId);
return this;
}
const child = createChildModel(name, parentId, options);
this.children.push(child);
return this;
}
childFromTemplate(name, templatePath, options = {}) {
if (!templatePath || !String(templatePath).trim()) throw new Error("childFromTemplate() requires templatePath");
const model = modelDefinition(readJsonFile(templatePath, "child template"), "child template");
return this.child(name, { preserve_model_id: false, ...options, model });
}
childFromModel(name, modelJsonOrContent, options = {}) {
return this.child(name, { ...options, model: modelDefinition(modelJsonOrContent, "child model") });
}
getChild(name) {
const found = this._findChild(name);
return found ? clone(found) : null;
}
hasChild(name) {
return this._findChild(name) != null;
}
childComponent(childName, compName) {
const child = this._requireChild(childName);
const comp = normalizeComponentName(compName);
if (!child.Model.Components.includes(comp)) child.Model.Components.push(comp);
return this;
}
removeChildComponent(childName, compName) {
const child = this._requireChild(childName);
const comp = normalizeComponentName(compName);
if (!child.Model.Components.includes(comp)) {
throw new Error(`Child ${childName} has no ${comp}`);
}
child.Model.Components = child.Model.Components.filter((c) => c !== comp);
child.Model.Values = child.Model.Values.filter((v) => v.TargetType !== comp);
child.Model.Properties = child.Model.Properties.filter((p) => !p.Link || normalizeLinkTarget(p.Link.Target) !== comp);
return this;
}
childValue(childName, targetType, name, val, typeKey = null) {
const child = this._requireChild(childName);
upsertValue(child.Model.Values, targetType, name, val, typeKey);
return this;
}
getChildValue(childName, targetType, name, fallback = undefined) {
const child = this._requireChild(childName);
return getValueEntry(child.Model.Values, targetType, name, fallback);
}
removeChildValue(childName, targetType, name) {
const child = this._requireChild(childName);
if (!removeValueEntry(child.Model.Values, targetType, name)) {
throw new Error(`Value not found on child '${childName}': ${targetType}.${name}`);
}
return this;
}
childEnable(childName, enabled = true) {
return this.childValue(childName, "MOD.Core.MODEntity", "Enable", Boolean(enabled), "bool");
}
childVisible(childName, visible = true) {
return this.childValue(childName, "MOD.Core.MODEntity", "Visible", Boolean(visible), "bool");
}
childProperty(childName, name, options = {}) {
const child = this._requireChild(childName);
upsertProperty(child.Model.Properties, child.Model.Values, name, options);
return this;
}
removeChildProperty(childName, name) {
const child = this._requireChild(childName);
if (!removePropertyEntry(child.Model.Properties, name)) {
throw new Error(`Property not found on child '${childName}': ${name}`);
}
return this;
}
setChildBaseModelId(childName, baseModelId) {
const child = this._requireChild(childName);
child.Model.BaseModelId = normalizeModelId(baseModelId);
return this;
}
moveChild(childName, parentNameOrId = null) {
const child = this._requireChild(childName);
const parentId = this._childParentId(parentNameOrId);
if (parentId === child.Id) throw new Error(`Child '${childName}' cannot be moved under itself`);
child.ParentId = parentId;
return this;
}
renameChild(childName, newName, options = {}) {
if (!newName || !String(newName).trim()) throw new Error("New child name must not be empty");
const child = this._requireChild(childName);
child.Name = String(newName);
if (options.rename_model !== false && options.renameModel !== false) child.Model.Name = String(newName);
return this;
}
childEventLink(childName, link, options = {}) {
const child = this._requireChild(childName);
upsertEventLink(child.Model.EventLinks, link, options);
return this;
}
removeChildEventLink(childName, key, value = undefined) {
const child = this._requireChild(childName);
if (!removeEventLinkFrom(child.Model.EventLinks, key, value)) {
throw new Error(`EventLink not found on child '${childName}': ${typeof key === "function" ? "<predicate>" : `${key}=${value}`}`);
}
return this;
}
eventLink(link, options = {}) {
upsertEventLink(this.event_links, link, options);
return this;
}
upsertEventLink(link, options = {}) {
return this.eventLink(link, options);
}
removeEventLink(key, value = undefined) {
if (!removeEventLinkFrom(this.event_links, key, value)) {
throw new Error(`EventLink not found: ${typeof key === "function" ? "<predicate>" : `${key}=${value}`}`);
}
return this;
}
listEventLinks() {
return clone(this.event_links);
}
printEventLinks() {
const entries = this.listEventLinks();
entries.forEach((entry, index) => console.log(` [${index}] ${JSON.stringify(entry)}`));
return entries;
}
_requireChild(name) {
const child = this._findChild(name);
if (!child) throw new Error(`Child not found: ${name}`);
ensureChildModelShape(child, child.Name || name, child.ParentId || this.model_id);
return child;
}
_findChild(name, parentNameOrId = undefined) {
const target = String(name);
const parentId = parentNameOrId === undefined ? undefined : this._childParentId(parentNameOrId);
return this.children.find((child) => {
const id = child.Id || (child.Model && child.Model.Id);
const matchesName = child.Name === target || id === target || (child.Model && child.Model.Name === target);
const matchesParent = parentId === undefined || child.ParentId === parentId;
return matchesName && matchesParent;
}) || null;
}
_childParentId(parentNameOrId = null) {
if (parentNameOrId == null) return this.model_id;
const parent = this._findChild(parentNameOrId);
if (parent) return parent.Id || (parent.Model && parent.Model.Id);
const id = String(parentNameOrId);
if (id === this.name || id === this.model_id || id === `model://${this.model_id}`) return this.model_id;
return id.replace(/^model:\/\//, "");
}
removeChild(name) {
const target = this._findChild(name);
if (!target) throw new Error(`Child not found: ${name}`);
const removeIds = new Set([target.Id || (target.Model && target.Model.Id)]);
let changed = true;
while (changed) {
changed = false;
for (const child of this.children) {
const childId = child.Id || (child.Model && child.Model.Id);
if (!removeIds.has(childId) && removeIds.has(child.ParentId)) {
removeIds.add(childId);
changed = true;
}
}
}
this.children = this.children.filter((child) => !removeIds.has(child.Id || (child.Model && child.Model.Id)));
return this;
}
listComponents() {
return clone(this.components);
}
printComponents() {
const items = this.listComponents();
items.forEach((c) => console.log(` ${c}`));
return items;
}
listValues() {
return clone(this.values);
}
printValues() {
const items = this.listValues();
items.forEach((v) => console.log(` ${shortTarget(v.TargetType)}.${v.Name} = ${JSON.stringify(v.Value)}`));
return items;
}
listChildren() {
return clone(this.children);
}
printChildren() {
this.children.forEach((child) => {
const summary = childSummary(child);
console.log(` ${summary.name} id=${summary.id} parent=${summary.parent_id} model=${summary.model_id} (${summary.components.length} components)`);
});
return this.listChildren();
}
build() {
const modelJson = {
Version: this.version,
Name: this.name,
BaseModelId: this.base_model_id,
Id: this.model_id,
Components: clone(this.components),
Properties: clone(this.properties),
Values: clone(this.values),
EventLinks: clone(this.event_links),
Children: clone(this.children),
};
const data = this._data ? clone(this._data) : {
Id: "",
GameId: "",
EntryKey: `model://${this.model_id}`,
ContentType: "x-mod/model",
Content: "",
Usage: 0,
UsePublish: 1,
UseService: 0,
CoreVersion: "",
StudioVersion: "",
DynamicLoading: 0,
ContentProto: { Use: "Json", Json: modelJson },
};
data.EntryKey = `model://${this.model_id}`;
data.ContentProto = data.ContentProto || {};
data.ContentProto.Use = "Json";
data.ContentProto.Json = modelJson;
return data;
}
_baseComponents() {
const basePath = findSiblingModelById(this._source_path, this.base_model_id);
if (!basePath) return [];
try {
const data = readJsonFile(basePath, "base model file");
const model = data.ContentProto && data.ContentProto.Json;
return Array.isArray(model && model.Components) ? model.Components.map(normalizeComponentName) : [];
} catch (_) {
return [];
}
}
_isInheritedComponent(compName) {
if (!this.base_model_id || !this._source_path) return false;
return this._baseComponents().includes(normalizeComponentName(compName));
}
validate() {
const findings = [];
if (!this.name) findings.push({ severity: "error", rule: "M001", message: "Model name is empty" });
if (!this.model_id) findings.push({ severity: "error", rule: "M002", message: "Model id is empty" });
if (this.components.includes(SPRITE_RENDERER) && !this.hasValue(SPRITE_RENDERER, "SpriteRUID")) {
findings.push({ severity: "warn", rule: "M010", message: "SpriteRendererComponent exists but SpriteRUID is missing; write() will inject placeholder" });
}
const inheritedComponents = new Set(this._baseComponents());
for (const component of this.components) {
if (inheritedComponents.has(normalizeComponentName(component))) {
findings.push({
severity: "warn",
rule: "M040",
component,
message: `Component ${component} is inherited from BaseModelId '${this.base_model_id}'; prefer value(...) overrides instead of redeclaring it locally.`,
});
}
}
for (const v of this.values) {
if (!v.ValueType || !v.ValueType.type) {
findings.push({ severity: "error", rule: "M020", message: `Value ${shortTarget(v.TargetType)}.${v.Name} has no ValueType.type` });
}
}
const childIds = new Set([this.model_id]);
for (const child of this.children) {
ensureChildModelShape(child, child.Name || (child.Model && child.Model.Name) || "Child", child.ParentId || this.model_id);
if (!child.Id) findings.push({ severity: "error", rule: "M030", message: `Child ${child.Name || "<unnamed>"} has no Id` });
if (!child.ParentId) findings.push({ severity: "error", rule: "M031", message: `Child ${child.Name || child.Id || "<unnamed>"} has no ParentId` });
if (child.Id && childIds.has(child.Id)) findings.push({ severity: "error", rule: "M032", message: `Duplicate child/model id: ${child.Id}` });
if (child.Id) childIds.add(child.Id);
for (const v of child.Model.Values) {
if (!v.ValueType || !v.ValueType.type) {
findings.push({ severity: "error", rule: "M033", message: `Child ${child.Name || child.Id} value ${shortTarget(v.TargetType)}.${v.Name} has no ValueType.type` });
}
}
}
for (const child of this.children) {
if (child.ParentId && !childIds.has(child.ParentId)) {
findings.push({ severity: "error", rule: "M034", message: `Child ${child.Name || child.Id} ParentId does not point to root or another child: ${child.ParentId}` });
}
if (child.ParentId === child.Id) {
findings.push({ severity: "error", rule: "M035", message: `Child ${child.Name || child.Id} cannot be its own parent` });
}
}
const parentById = new Map(this.children.map((child) => [child.Id, child.ParentId]));
for (const child of this.children) {
const seen = new Set([child.Id]);
let parentId = child.ParentId;
while (parentById.has(parentId)) {
if (seen.has(parentId)) {
findings.push({ severity: "error", rule: "M036", message: `Child parent cycle detected at ${child.Name || child.Id}` });
break;
}
seen.add(parentId);
parentId = parentById.get(parentId);
}
}
return findings;
}
_ensureSpriteRuid() {
if (!this.components.includes(SPRITE_RENDERER)) return;
const existing = this.values.find((v) => v.TargetType === SPRITE_RENDERER && v.Name === "SpriteRUID");
if (!existing) {
console.log(`[ModelBuilder] WARNING: model '${this.name}' declares SpriteRendererComponent but SpriteRUID is unset; injecting placeholder ${DEFAULT_SPRITE_RUID}. Replace it with a real sprite RUID before shipping.`);
this.value(SPRITE_RENDERER, "SpriteRUID", DEFAULT_SPRITE_RUID, "string");
return;
}
if (existing.Value == null || existing.Value === "") {
console.log(`[ModelBuilder] WARNING: model '${this.name}' has empty SpriteRUID. This can fail at load time unless replaced at runtime.`);
}
}
write(filepath, options = {}) {
if (options.ensure_sprite_ruid !== false && options.ensureSpriteRuid !== false) this._ensureSpriteRuid();
const findings = this.validate();
for (const warning of findings.filter((f) => f.severity === "warn")) {
if (warning.rule === "M040" && warning.component && this._warnedInheritedComponents.has(warning.component)) continue;
console.warn(`[ModelBuilder] WARNING ${warning.rule}: ${warning.message}`);
}
const errors = findings.filter((f) => f.severity === "error");
if (errors.length) {
const message = errors.map((f) => `${f.rule}: ${f.message}`).join("; ");
throw new Error(`Model validation failed: ${message}`);
}
fs.mkdirSync(path.dirname(filepath), { recursive: true });
fs.writeFileSync(filepath, `${JSON.stringify(this.build(), null, 2)}\n`, "utf8");
console.log(`Written model '${this.name}' (${this.components.length} components, ${this.values.length} values, ${this.children.length} children) to ${filepath}`);
console.log(` Model ID: ${this.model_id} (use this in SpawnByModelId)`);
return this;
}
}
function normalizeLinkTarget(target) {
if (target == null) return null;
if (typeof target === "string") return target;
if (typeof target === "object" && typeof target.type === "string") return target.type.split(",")[0].trim();
return String(target);
}
function normalizeChildOptions(componentsOrOptions, maybeOptions) {
if (Array.isArray(componentsOrOptions)) return { ...maybeOptions, components: componentsOrOptions };
if (componentsOrOptions == null) return { ...maybeOptions };
if (typeof componentsOrOptions === "object") return { ...componentsOrOptions, ...maybeOptions };
throw new TypeError("child() second argument must be a components array or options object");
}
function normalizeComponentList(components) {
return components == null ? null : components.map((c) => normalizeComponentName(c));
}
function createDefaultChildModel(name, id) {
return {
Version: 1,
Name: String(name),
BaseModelId: null,
Id: id,
Components: [],
Properties: [],
Values: [
{ TargetType: "MOD.Core.MODEntity", Name: "Enable", ValueType: typeDescriptor(TYPE_MAP.bool), Value: true },
{ TargetType: "MOD.Core.MODEntity", Name: "Visible", ValueType: typeDescriptor(TYPE_MAP.bool), Value: true },
],
EventLinks: [],
Children: [],
};
}
function createChildModel(name, parentId, options = {}) {
const childId = String(options.id ?? options.child_id ?? options.childId ?? randomUuid());
const sourceModel = options.model ? clone(modelDefinition(options.model, "child model")) : createDefaultChildModel(name, childId);
const child = {
Id: childId,
ParentId: parentId,
Name: String(options.name ?? name),
Model: sourceModel,
};
ensureChildModelShape(child, child.Name, parentId);
applyChildOptions(child, options, child.Name, parentId, { isNew: true });
return child;
}
function applyChildOptions(child, options = {}, fallbackName, parentId, flags = {}) {
const model = child.Model;
const oldChildId = child.Id;
const oldModelId = model.Id;
child.ParentId = parentId;
child.Name = String(options.name ?? child.Name ?? fallbackName);
if (options.id || options.child_id || options.childId) child.Id = String(options.id ?? options.child_id ?? options.childId);
if (options.modelReplaced !== undefined || options.model_replaced !== undefined) {
child.ModelReplaced = Boolean(options.modelReplaced ?? options.model_replaced);
}
model.Name = String(options.model_name ?? options.modelName ?? model.Name ?? child.Name);
const explicitModelId = options.model_id ?? options.modelId;
if (explicitModelId != null) model.Id = normalizeModelId(explicitModelId);
else if (flags.isNew && (options.model == null || options.preserve_model_id === false || options.preserveModelId === false)) model.Id = child.Id;
else if (child.Id !== oldChildId && oldModelId === oldChildId) model.Id = child.Id;
if (!model.Id) model.Id = child.Id;
model.BaseModelId = normalizeModelId(options.base_model_id ?? options.baseModelId ?? model.BaseModelId);
const normalizedComponents = normalizeComponentList(options.components);
if (normalizedComponents != null) model.Components = normalizedComponents;
if (options.enable !== undefined) upsertValue(model.Values, "MOD.Core.MODEntity", "Enable", Boolean(options.enable), "bool");
if (options.visible !== undefined) upsertValue(model.Values, "MOD.Core.MODEntity", "Visible", Boolean(options.visible), "bool");
}
function childSummary(child) {
const model = child.Model || {};
return {
name: child.Name || model.Name || "",
id: child.Id || model.Id || null,
parent_id: child.ParentId || null,
model_name: model.Name || "",
model_id: model.Id || null,
base_model_id: model.BaseModelId ?? null,
model_replaced: Boolean(child.ModelReplaced),
components: Array.isArray(model.Components) ? clone(model.Components) : clone(child.Components || []),
child_count: Array.isArray(model.Children) ? model.Children.length : 0,
};
}
function upsertValue(values, targetType, name, val, typeKey = null) {
const normalizedTarget = normalizeTargetType(targetType);
const explicitKey = normalizeTypeKey(typeKey);
const inferredKey = explicitKey || inferType(val);
const existing = values.find((v) => v.TargetType === normalizedTarget && v.Name === name);
// Preserve typed metadata (e.g. MODSyncDictionary) when caller passes a dict
// without an explicit typeKey. inferType() returns "string" for object shapes
// it can't classify (anything that isn't vector2/3, quaternion, etc.), which
// would otherwise clobber the ValueType. Also covers the round-trip case
// where the dict carries a $type from a previous getValueEntry() read.
const isDictLike =
val != null && typeof val === "object" && !Array.isArray(val) && inferredKey === "string";
const shouldPreserveExistingType =
!explicitKey &&
isDictLike &&
existing &&
existing.ValueType &&
typeof existing.ValueType.type === "string" &&
!existing.ValueType.type.startsWith("System.String");
if (shouldPreserveExistingType) {
const existingDollar =
existing.Value && typeof existing.Value === "object" ? existing.Value.$type : null;
const stripped = clone(val);
if (stripped && typeof stripped === "object") delete stripped.$type;
existing.Value = existingDollar ? { $type: existingDollar, ...stripped } : stripped;
return;
}
const typeStr = TYPE_MAP[inferredKey] || String(inferredKey);
const wrapped = wrapValue(val, inferredKey);
if (existing) {
existing.ValueType = typeDescriptor(typeStr);
existing.Value = wrapped;
return;
}
values.push({
TargetType: normalizedTarget,
Name: String(name),
ValueType: typeDescriptor(typeStr),
Value: wrapped,
});
}
function getValueEntry(values, targetType, name, fallback = undefined) {
const normalizedTarget = normalizeTargetType(targetType);
const found = values.find((v) => v.TargetType === normalizedTarget && v.Name === name);
return found ? clone(found.Value) : fallback;
}
function removeValueEntry(values, targetType, name) {
const normalizedTarget = normalizeTargetType(targetType);
const before = values.length;
for (let i = values.length - 1; i >= 0; i--) {
if (values[i].TargetType === normalizedTarget && values[i].Name === name) values.splice(i, 1);
}
return values.length !== before;
}
function upsertProperty(properties, values, name, options = {}) {
if (!name || !String(name).trim()) throw new Error("Property name must not be empty");
const target = normalizeTargetType(options.target ?? options.link_target ?? options.linkTarget);
const prop = options.property ?? options.link_property ?? options.linkProperty;
if (!target || !prop) throw new Error("property() requires options.target and options.property");
const typeKey = normalizeTypeKey(options.type_key ?? options.typeKey ?? options.type);
const existingValue = values.find((v) => v.TargetType === target && v.Name === prop && v.ValueType && v.ValueType.type);
const typeStr = options.type_string || options.typeString || TYPE_MAP[typeKey] || typeKey || (existingValue && existingValue.ValueType.type);
if (!typeStr) throw new Error(`property('${name}') needs type/type_key or an existing value for ${target}.${prop}`);
const entry = {
Type: typeDescriptor(typeStr),
Name: String(name),
DisplayName: String(options.display_name ?? options.displayName ?? name),
ShowInInspector: Boolean(options.show_in_inspector ?? options.showInInspector ?? true),
Link: {
Target: componentTypeDescriptor(target),
Property: String(prop),
},
};
const idx = properties.findIndex((p) => p.Name === entry.Name);
if (idx >= 0) properties[idx] = entry;
else properties.push(entry);
}
function removePropertyEntry(properties, name) {
const before = properties.length;
for (let i = properties.length - 1; i >= 0; i--) {
if (properties[i].Name === name) properties.splice(i, 1);
}
return properties.length !== before;
}
function ensureChildModelShape(child, name, parentId) {
child.Name = child.Name || String(name);
child.Id = child.Id || randomUuid();
child.ParentId = child.ParentId || parentId;
child.Model = child.Model || {};
child.Model.Version = child.Model.Version || 1;
child.Model.Name = child.Model.Name || child.Name;
child.Model.BaseModelId = child.Model.BaseModelId ?? null;
child.Model.Id = child.Model.Id || child.Id;
child.Model.Components = Array.isArray(child.Model.Components) ? child.Model.Components : [];
child.Model.Properties = Array.isArray(child.Model.Properties) ? child.Model.Properties : [];
child.Model.Values = Array.isArray(child.Model.Values) ? child.Model.Values : [];
child.Model.EventLinks = Array.isArray(child.Model.EventLinks) ? child.Model.EventLinks : [];
child.Model.Children = Array.isArray(child.Model.Children) ? child.Model.Children : [];
}
function pickEventLinkKey(entry) {
for (const key of ["Id", "id", "Name", "name", "EventName", "eventName"]) {
if (entry[key] != null) return key;
}
return null;
}
function upsertEventLink(list, link, options = {}) {
if (!link || typeof link !== "object" || Array.isArray(link)) {
throw new TypeError("eventLink() requires a link object");
}
const entry = clone(link);
const key = options.key || pickEventLinkKey(entry);
if (key != null) {
const idx = list.findIndex((existing) => existing && existing[key] === entry[key]);
if (idx >= 0) {
list[idx] = entry;
return;
}
}
list.push(entry);
}
function removeEventLinkFrom(list, key, value = undefined) {
if (typeof key === "function") {
const before = list.length;
const kept = list.filter((entry) => !key(clone(entry)));
list.splice(0, list.length, ...kept);
return list.length !== before;
}
if (value === undefined && typeof key === "object" && key != null) {
const matchKey = pickEventLinkKey(key);
if (matchKey == null) return false;
return removeEventLinkFrom(list, matchKey, key[matchKey]);
}
const before = list.length;
const kept = list.filter((entry) => !entry || entry[key] !== value);
list.splice(0, list.length, ...kept);
return list.length !== before;
}
function randomUuid() {
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") return globalThis.crypto.randomUUID();
return require("crypto").randomUUID();
}
module.exports = {
ModelBuilder,
DEFAULT_SPRITE_RUID,
DEFAULT_DAMAGE_SKIN_ATTACK,
DEFAULT_DAMAGE_SKIN_HIT,
DEFAULT_DAMAGE_SKIN_HEAL,
MOD_CORE_VERSION,
TYPE_MAP,
vector2,
vector3,
quaternion,
collision_group: collisionGroup,
collisionGroup,
data_ref: dataRef,
dataRef,
actionSheet,
normalizeComponentName,
};
SKILL.md
---
name: msw-general
description: "Foundation skill for MSW (MapleStory Worlds). Read this FIRST before anything else in MSW."
---
# MSW General — Foundation Skill
The foundation skill for MSW (MapleStory Worlds) creation, integrating **shared tools, domain knowledge, platform rules, and file authoring**. Every other MSW skill depends on it.
---
## Core Principle: Visual Polish
MSW is a **game creation platform**. The goal is not a prototype where logic merely runs — it is a **polished game** players can enjoy.
So whatever entity you create — monster, NPC, tower, item, background object — search for and apply **appropriate resources (sprites, animations, sounds)** that match its role and personality. Do not leave the default sprite in place or leave `SpriteRUID` empty.
**Resource application principle when creating an entity:**
1. After creating the entity, use the **`msw-search` skill** to find sprites/animations that fit it
2. Apply the RUID of the found resource to `SpriteRendererComponent` so the entity is **visually represented**
3. If there is combat, also set hit/explosion effects; if there is interaction, set sound effects
> **Functionality implemented != finished.** A polished game requires appropriate resources plus visual presentation.
---
## When making a `.model` — catalog first
Do not start a new `.model` from an empty file. **The skill-local `models/` folder contains validated templates organized by category** — monsters (`ChaseMonster`/`MoveMonster`/`StaticMonster`), NPC (`StaticNPC`), players (`Player`/`DefaultPlayer`), terrain (`Foothold`/`Ladder`/`Rope`/`Portal`), map objects (`MapObject`/`SkeletonMapObject`/`ItemAsset`), particles (`BasicParticle`/`SpriteParticle`/`AreaParticle`/`AnimationPlayer`), sound (`Sound`/`SoundEffect`), tile map containers (`TileMap`/`RectTileMap`), UI (`UIButton`/`UIText`/`UISprite`/`UIGroup`, etc.), external media (`WebSprite`/`YoutubePlayerWorld`).
**Workflow**: **`Read` [references/model.md](references/model.md) IN FULL FIRST (mandatory — see "Model Work Preflight — MUST" below)** → pick the closest template from the catalog → load it via `ModelBuilder` (call protocol: [`references/builder-protocol.md`](references/builder-protocol.md) core + [`references/builder-protocol-model.md`](references/builder-protocol-model.md)) → replace the 3 identifiers (`EntryKey`, `Id`, `Name`) through the builder → customize `Components`/`Values`/`Properties`/`Children` through the builder → **save under a typed subfolder of `RootDesk/MyDesk/Models/`** (e.g. `Models/Monsters/{Name}.model`, never directly under `MyDesk/`) → `refresh`. Detailed catalog and builder procedure: [references/model.md §2](references/model.md).
> The builder emits the required value metadata, so agents do not need to read or hand-write `.model` format internals.
> **For a monster, first pick a pattern in [references/animation-state.md §0](references/animation-state.md), then follow [references/monster.md §5](references/monster.md) for the recommended path.**
> - **Pattern A (verified working canonical — Soldier reference setup, full source inlined in [`references/monster.md` §7](references/monster.md))**: no template needed; assemble 11 components from scratch with a custom `script.MyMonsterAI` (SoldierAI-style) instead of AIChase/AIWander. `StateComponent.IsLegacy` left at the default.
> - **Pattern B (`MonsterCanonical.model`)**: `AIChaseComponent` + `ActionSheet` pipeline. `StateComponent.IsLegacy=false` mandatory. The other monster templates (`ChaseMonster` / `MoveMonster` / `StaticMonster`) leave `ActionSheet` empty and use defaults that silently fail under Pattern B (uppercase keys, `SortingLayer="Default"`, `IsLegacy=true`).
>
> **For any entity that has `StateAnimationComponent` (monster/NPC) or `AvatarStateAnimationComponent` (player) — or any `.mlua` that calls `ChangeState` / `AddState` / `SetActionSheet` — also read [references/animation-state.md](references/animation-state.md).** The state-machine ↔ animation pipeline, the two-pattern split, default-state registration rules, `[LEA-3005]` cause, and `SetActionSheet` vs `ChangeState` semantics live there (not duplicated into per-entity docs).
---
## Placing multiple entities — model first
If the same entity is going to appear **twice or more in a map** (5 monsters, 10 trees, 3 portals, …), **author a `.model` first and place each instance via `modelId`** rather than copy-pasting inline `@components`.
| Instance count of same composition | Choice |
|---|---|
| **1** (truly one-off decoration in a single map) | inline `@components` is acceptable |
| **≥2** | **`.model` + `modelId` instances (default)** |
| Spawned at runtime (`SpawnByModelId`) | `.model` is required regardless of count |
**Why this is the default:**
- **Edit once, propagate everywhere** — change `SpriteRUID`/HP/`ActionSheet` in the model and every instance updates. Inline copies require touching N entities each time.
- **Smaller, reviewable `.map` diffs** — `modelId` instances carry only `Transform` overrides; inline copies bloat the map by hundreds of lines per entity.
- **Avoids drift** — five inline copies silently diverge (one gets `IsLegacy: true`, another forgets `SortingLayer: "MapLayer0"`). The model anchors the canonical values.
- **Required for `SpawnByModelId`** — without a registered model id, dynamic spawning fails.
**Workflow**:
1. Author `.model` under `RootDesk/MyDesk/Models/{Category}/{Name}.model` (see folder rule above).
2. Place each instance via `MapBuilder` (call protocol: [`references/builder-protocol.md`](references/builder-protocol.md) core + [`references/builder-protocol-map.md`](references/builder-protocol-map.md)) so ids, paths, `componentNames`, origin metadata, and per-instance component overrides stay synchronized.
3. `refresh`.
Details and the inline-vs-modelId comparison: [references/entity.md "Two-Step Map Editing Workflow"](references/entity.md), [references/model.md §1](references/model.md).
---
## Preflight read semantics — applies to every "MUST Read" in this skill
"Read X FIRST" in the preflights and Absolute Principles means: **X must be fully in context before the work starts** — not "re-read X every turn". `Read` the file IN FULL (no `offset`/`limit`, no `cat`/`Get-Content`) **only if** it was never loaded this session or was lost to context compaction. Do **not** re-read a file that is already fully in context — presence in context is the requirement; re-reading is waste. Memory or a summary of a file does **not** count as the file being in context, and a prior turn having loaded it does not exempt this turn from confirming it is still there.
---
## Entity Work Preflight — **MUST**
If the task involves an entity in any way, **you must read [references/entity.md](references/entity.md) first.** No exceptions.
---
## Builder Protocol Preflight — **MUST**
If the task **creates or modifies any `.map` / `.model` / `.ui` file** — directly, or as a side effect of writing `.mlua` that spawns / places / binds — **[references/builder-protocol.md](references/builder-protocol.md) (core) plus the per-builder file for each file type the task mutates — [references/builder-protocol-map.md](references/builder-protocol-map.md) (`.map`) / [references/builder-protocol-model.md](references/builder-protocol-model.md) (`.model`) / [references/builder-protocol-ui.md](references/builder-protocol-ui.md) (`.ui`) — must be fully in context FIRST** (read semantics above). No exceptions.
The protocol is one unified entry point split into a shared core (`builder-protocol.md` — routing, common workflow, chaining contract, §0 pre-flight, §4 cross-flow, §5 checklist) plus per-builder files (`builder-protocol-map.md` §1 / `builder-protocol-model.md` §2 / `builder-protocol-ui.md` §3). **Knowing only one builder's protocol and then invoking another builder's `.cjs` bypasses that builder's write-side contract** (`componentNames` sync, `Values` `typeKey` metadata, write-time auto-lint, `placeModel` component mirroring, child entity invariants) — the three are interlocked through cross-flow (model authoring → map placement → ui binding), so cross-flow work loads every matching per-builder file.
Triggers (intentionally broad — load the missing protocol file(s) whenever any match):
- `.map` changes (entity placement, component patching, tile / foothold inspection)
- `.model` changes (new authoring, value / component / property / child edits)
- `.ui` changes (new build, component CRUD, binding injection)
- Any call to `MapBuilder` / `ModelBuilder` / `UIBuilder`
- Requests shaped like "entity-shaped" work — monster / NPC / projectile / map object / popup / HUD, etc.
- Any code using `_SpawnService` (a spawnable model must be authored and placed first)
The domain refs (`entity.md` / `model.md` / `msw-ui-system` design references) are read **alongside** the protocol files — they are not substitutes (domain context + call protocol are a pair). **Confirm they are still in context on every turn that fires a trigger** — re-read only what was never loaded or was lost to compaction.
---
## Model Work Preflight — **MUST**
If the task involves authoring or editing a `.model` file in **any** way — including any call to `ModelBuilder` (any API), creating a new model from a template, mutating components/values/properties/children/event links on an existing model, or even a one-line tweak — **[references/model.md](references/model.md) must be fully in context FIRST** (read semantics above). No exceptions.
The builder's template catalog, the 3-identifier replacement rule (`EntryKey` / `Id` / `Name`), required value metadata, property/child/event-link API surface, and the typed save-folder layout under `RootDesk/MyDesk/Models/` live only in [`references/model.md`](references/model.md). Calling the builder without reading [`references/model.md`](references/model.md) first silently produces broken models (missing value metadata, mismatched identifiers, wrong save folder, default-value silent failures). Reading scattered template files or guessing the API from memory is **not a substitute** — at the start of every turn that touches a `.model`, confirm it is still in context and re-read only if it was lost.
---
## Map Work Preflight (do this BEFORE any map work)
Before starting **any** map-related task — entity placement, spawn, movement scripts, model authoring, tile edits, etc. — you **must** complete these two steps in order:
1. **Identify the target map** — its path (`./map/{mapname}.map`), its root entity, and its location in the Hierarchy.
2. **Use `MapBuilder` to read `MapComponent.TileMapMode` as a number** (call protocol: [`references/builder-protocol.md`](references/builder-protocol.md) core + [`references/builder-protocol-map.md`](references/builder-protocol-map.md)). Keep the value in mind for the rest of the session.
| Value | Mode | Required Body | Runtime log on mismatch / missing Body |
|:--:|---|---|---|
| `0` | **TileMap** (MapleTile, side-view + Foothold) | `RigidbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'RigidbodyComponent'.` |
| `1` | **RectTileMap** (RectTile, top-down) | `KinematicbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'KinematicbodyComponent'.` |
| `2` | **SideViewRectTileMap** (SideViewRectTile, side-view tile) | `SideviewbodyComponent` | `[LEA-3004] MissingComponent : Entity is missing 'SideviewbodyComponent'.` |
**Never start map work without knowing the current `TileMapMode`.** The three modes differ completely in Body component, gravity, collision, and event stacks. A mismatch is almost never a compile-time error — it shows up either as a silent failure (entity doesn't move / passes through walls / invisible) or as one of the three `[LEA-3004] MissingComponent` runtime logs above. Whenever you see one of those three messages, suspect a **TileMapMode ↔ entity Body mismatch** first.
### Recommending the right mode — **MUST** when starting a new map or when the current mode is clearly wrong for the user's goal
Whenever the user describes what game / map they want to build (new map authoring, "make a side-scroller", "I want a top-down dungeon", or you read the current `.map` and find its `TileMapMode` does **not** fit the user's intended gameplay), you **must explicitly recommend the appropriate `TileMapMode` to the user and explain why** before proceeding with any further entity / model / script work.
Use this decision matrix as the source of truth:
| User's intended game / gameplay | Recommend | Why |
|---|---|---|
| MapleStory-style side-scrolling action · jump · ladder · freely placed footholds (platformer) | **`0` MapleTile** | Side-view + gravity, `FootholdComponent` line-segment platforms — non-grid, freely placed platforms |
| Top-down RPG · maze · board game · dungeon crawler · Bomberman-style · RTS-style · farming sim | **`1` RectTile** | Top-down 4-directional free move, no gravity, square-tile grid |
| Tile-based side-scrolling platformer · Mario-style pixel action · side-view puzzle (square-tile side-view) | **`2` SideViewRectTile** | Side-view + gravity **on a tile grid** (not freely placed footholds) |
**Procedure:**
1. If the user has not yet told you what kind of game they want, **ask** before recommending a mode (one short question is enough — e.g. "Is it top-down, or side-scrolling (jump/ladder)? And is the terrain based on freely placed footholds, or a square tile grid?").
2. Once the intent is clear, **state the recommendation** (mode number + name + one-sentence rationale) and the matching Body / map component the user will need ([`platform.md` §4 mapping table](references/platform.md)).
3. **Lock the choice in early** — switching `TileMapMode` later wipes terrain, forces every Body / movement script to be re-checked, and may force re-painting all tiles ([`platform.md` §4 "Cautions When Switching Map Type"](references/platform.md)).
### Changing `TileMapMode` — **user action in Maker, not an AI file edit**
**The AI must never flip `MapComponent.TileMapMode` by editing the `.map` JSON directly.** Mode switching requires swapping tile components, rebuilding footholds, converting tile-data formats, and resetting terrain — all internal Maker operations.
**Guide the user to do this in the Maker editor:**
1. Open the Maker editor's **Hierarchy** window.
2. **Right-click the target map entity** in the Hierarchy.
3. From the context menu, **choose the "Switch ..." option that matches the target mode** (Switch TileMap / RectTileMap / SideViewRectTileMap). Maker performs the conversion, swaps the tile component, and resets terrain as needed.
4. After the user confirms the switch is complete, call MCP **`refresh`**, then re-read `MapComponent.TileMapMode` to verify and re-check every dynamic entity's Body component against the new mode.
> The AI's role in mode changes: **recommend → wait for user to right-click-switch in Maker Hierarchy → refresh → fix Body components / scripts that no longer match.** Never write a new value to `TileMapMode` from a file edit.
The table above is a summary. **The mode-switch procedure, the post-Body-swap checklist, and the silent-failure symptom dictionary beyond LEA-3004** live only in [`references/platform.md` §4](references/platform.md) (mapping + check protocol + switching policy) and [`references/troubleshooting.md`](references/troubleshooting.md) (full symptom dictionary) — **you must Read them** when changing modes / swapping Body / debugging silent failures. Per-map-type detail patterns: [`platform-maple.md`](references/platform-maple.md) / [`platform-rect.md`](references/platform-rect.md) / [`platform-sideview.md`](references/platform-sideview.md). Tile painting: [`references/tile.md`](references/tile.md). Map Work Preflight: [`references/entity.md`](references/entity.md).
---
## Platform Rules Preflight — **MUST** when any of these triggers fire
If **any** of the following triggers matches your task, `Read` the corresponding reference **before** editing code or proposing a plan. These triggers are intentionally broad — when in doubt, read. The "8 Core Rules" in this SKILL.md are a summary only; the **symptom→cause→fix tables, per-map-type code patterns, the `MovementComponent` conversion formulas, and SortingLayer/SpriteRUID details live only in references**.
| Trigger (keyword / situation) | File to read |
|---|---|
| jump, gravity, movement, `MoveVelocity`, `InputSpeed`, `JumpForce`, `WalkSpeed`, `SpeedFactor`, foothold, patrol | **The matching map type's** [`references/platform-maple.md`](references/platform-maple.md) / [`platform-rect.md`](references/platform-rect.md) / [`platform-sideview.md`](references/platform-sideview.md) (in full) **+** [`references/platform.md`](references/platform.md) §10 |
| spawn / `_SpawnService` / `SpawnByModelId` / `SpawnByEntity` / "summon a monster" / "runtime creation" | [`references/platform.md`](references/platform.md) §8 + §8.5 |
| Screen coordinates / camera range / "is it on screen" / "pixel units" / OrthographicSize / world unit | [`references/platform.md`](references/platform.md) §5 |
| Occluded / invisible / "should render on top" / SortingLayer / OrderInLayer / Z value | [`references/platform.md`](references/platform.md) §6 + §7 |
| `LEA-3004` in the log / "won't move" / "floating in mid-air" / "stuck in wall" / "bouncing off" / "disappears off the map" / "falls off the foothold edge" | **[`references/troubleshooting.md`](references/troubleshooting.md) (in full) first**, then the matching map type's `platform-{type}.md` §7 |
| `LEA-3005 InvalidArgument 'stateName'` / `StateComponent` / `StateType` / `@State` / `ChangeState` / `AddState` / `AddCondition` / `ActionSheet` / `SetActionSheet` / `StateAnimationComponent` / `AvatarStateAnimationComponent` / `StateChangeEvent` / "animation doesn't change" / "stays in stand/idle while moving" / "attack pose never plays" / "hit anim loops" / monster·NPC·player animation state work | **[`references/animation-state.md`](references/animation-state.md) (in full) first**, then [`references/monster.md`](references/monster.md) (or the entity-specific doc) for entity-level composition |
| shader / material / outline / glow / blur / pixelate / rainbow / tint / grayscale / vignette / screen filter / lens distortion / wave / ripple / distortion / dissolve / additive / blend mode / hologram / mask / post-process / `MaterialID` / `MaterialId` / `ChangeMaterial` / `_MaterialService` / `.material` file | **[`references/material.md`](references/material.md) (in full)** — then drive shader catalog / property names / per-component compatibility via `mlua_Document_Retriever` + `mlua_API_Retriever` MCP lookups (do NOT memorize) |
| New map setup / new project / `.config` / CoreVersion verification / sector registration / folder metadata Refresh | [`references/platform.md`](references/platform.md) §2 + §15 + §16 |
| MapleTile (`TileMapMode = 0`) work — Foothold, `Gravity`, `WalkSpeed`, `PredictFootholdEnd` | [`references/platform-maple.md`](references/platform-maple.md) (in full) |
| RectTile (`TileMapMode = 1`) work — `SpeedFactor`, 4-directional movement, Movable tiles, dynamic tiles | [`references/platform-rect.md`](references/platform-rect.md) (in full) |
| SideViewRectTile (`TileMapMode = 2`) work — `JumpSpeed`/`JumpDrag`, wall detection (`Normal`), `EnableDownJump` | [`references/platform-sideview.md`](references/platform-sideview.md) (in full) |
> If two or more triggers match, read **all** of them. "I already saw the 8 Core Rules in SKILL.md" is not an excuse for skipping references.
---
## 8 Core Rules (must memorize)
1. If you don't align **TileMapMode ↔ Body mapping**, the entity will not move (no error) or raises `[LEA-3004] MissingComponent` at runtime → [`references/platform.md` §4](references/platform.md) (or if approaching by symptom, [`references/troubleshooting.md`](references/troubleshooting.md))
2. User scripts only work as a `.mlua` + `.codeblock` **pair** — `.codeblock` is generated by Maker Refresh
3. If `SpriteRUID` is an empty string, the entity is **invisible on screen** (no error)
4. When calling `SpawnByModelId`, not passing a map entity (`self.Entity.CurrentMap`) as `parent` causes a runtime error
5. Coordinates are in **world units** (1 unit = 100 px). Pixel values are off by 100x
6. Maker only scans `RootDesk/` — user files placed in `Global/` will not be recognized
7. **Do not modify** `.d.mlua` or `.codeblock`.
8. CoreVersion is `26.7.0.0` — do not work if there is a mismatch
---
## MCP Tool Quick Reference (msw-maker-mcp)
| Tool | Purpose |
|------|---------|
| **play** / **stop** | Enter / exit play mode |
| **refresh** | Sync Maker after file change (not allowed during play) |
| **logs** / **clear_logs** | Read / clear runtime and build logs |
| **screenshot** | Call only when explicitly requested by the user |
| **keyboard_input** / **mouse_input** | Simulate input in play mode |
> On MCP call failure / "MCP connection" / "API Key" requests → guide the user to the official setup docs: https://maplestoryworlds-creators.nexon.com/ko/docs?postId=1368
---
## Per-task routing — which reference to read
| File | Scope | When to read |
|------|-------|--------------|
| [workspace.md](references/workspace.md) | World instance / Room / DataStorage, folder layout, file paths, Play mode, `refresh`, mid-workflow failure recovery | Workspace / instance / mode-transition work |
| [platform.md](references/platform.md) (core) | 8 core rules, file authority + folder metadata, `.mlua`+`.codeblock` pair, TileMapMode↔Body mapping + LEA-3004, coordinate system / on-screen range, SortingLayer/OrderInLayer, SpriteRUID, `SpawnByModelId` usage / initialization order, `MovementComponent` per-map-type InputSpeed conversion formula, ECS, ID generation, `.config`, CoreVersion | TileMapMode mapping / mode switching / spawn / coordinates / RUID / SortingLayer / `.config` & CoreVersion / folder metadata — **rules common to all map types** |
| [platform-maple.md](references/platform-maple.md) | MapleTile (`TileMapMode = 0`) only — Foothold physics, `Gravity`/`WalkSpeed`/`WalkJump`, `PredictFootholdEnd`, `IsOnGround`, `DownJump`, FootholdEnter/LeaveEvent, MapleTile-only troubleshooting + checklist | Side-scrolling action / jump / ladder / freely placed footholds (MapleStory-style platformer) |
| [platform-rect.md](references/platform-rect.md) | RectTile (`TileMapMode = 1`) only — `KinematicbodyComponent`, `SpeedFactor`, free 4-directional movement, visual-only jump, Movable tile collision, `ToCellPosition`/`ToWorldPosition`, RectTileEnter/LeaveEvent, dynamic tiles (`SetTile`/`BoxFill`), RectTile-only troubleshooting + checklist | Top-down RPG / maze / board game / dungeon crawler / Bomberman-style / RTS / farming sim |
| [platform-sideview.md](references/platform-sideview.md) | SideViewRectTile (`TileMapMode = 2`) only — `SideviewbodyComponent`, `JumpSpeed`/`JumpDrag`, `EnableDownJump`, wall detection (`RectTileCollisionBeginEvent` + `Normal`), `GetUnderfootTile`, SideView-only troubleshooting + checklist | Tile-based side-scrolling platformer / Mario-style pixel action / side-view puzzle |
| [troubleshooting.md](references/troubleshooting.md) | Unified symptom dictionary — `LEA-3004` table / "won't move" / "won't render" / "floating in mid-air" / "stuck in wall" / "disappears off the map" / "falls off the foothold edge" / "100× off" / "doesn't show in Maker" / "client-only sync" and other silent-failure symptom→cause→fix unified index | **Symptom-first debugging** — go here first when the user reports the above or `[LEA-3004]` appears in the log |
| [authoring.md](references/authoring.md) | Shared authoring principles across 5 file types (schema consistency, hand-edit hazards) | Entry point before any file authoring |
| [tile.md](references/tile.md) | Tile painting — Maker UI domain, AI guides only | Tilemap work |
| [**builder-protocol.md**](references/builder-protocol.md) (core) + [builder-protocol-map.md](references/builder-protocol-map.md) / [builder-protocol-model.md](references/builder-protocol-model.md) / [builder-protocol-ui.md](references/builder-protocol-ui.md) | **Unified call protocol for `.map` / `.model` / `.ui` — core: routing, common workflow, chaining contract, §0 pre-flight, §4 cross-flow, §5 checklist; per-builder files: MapBuilder §1 / ModelBuilder §2 / UIBuilder §3 API, coverage gaps, binding injection** | **Core + the file(s) matching the mutated types must be in context on every turn that mutates `.map` / `.model` / `.ui` (Builder Protocol Preflight)** |
| [entity.md](references/entity.md) | `.map` entity domain — Scope, RUID, TileMapMode preflight, `modelId` vs inline decision rule, coordinate / foothold / camera, runtime verification | `.map` editing / entity placement (read together with builder-protocol.md core + builder-protocol-map.md for the call protocol) |
| [model.md](references/model.md) | `.model` authoring domain — when to create, template catalog, component combinations, script-component lifecycle | Writing / editing `.model` (read together with builder-protocol.md core + builder-protocol-model.md for the call protocol) |
| [monster.md](references/monster.md) | Monster canonical components, lowercase ActionSheet keys, mandatory `IsLegacy` / `SortingLayer` overrides, AI choice, HP/respawn, spawn position | Authoring a monster model |
| [animation-state.md](references/animation-state.md) | StateComponent defaults & auto-registration, state-change pipeline, `SetActionSheet` vs `ChangeState`, `StateType` authoring (server-only, `ParentComponent.Entity`), `StateAnimationComponent` (monster/NPC) vs `AvatarStateAnimationComponent` (player), `[LEA-3005]` pitfalls | Any state / animation issue across monster, NPC, or player — read first whenever an entity's animation doesn't match its behavior |
| [material.md](references/material.md) | `.material` file anatomy, shader category index (10+ categories), applying `MaterialID` on renderer components via `.model` / `.map` / runtime `ChangeMaterial`, `_MaterialService:ChangeMaterialProperty` (ClientOnly), and **the MCP-driven lookup loop (`mlua_Document_Retriever` / `mlua_API_Retriever`) that replaces memorizing the per-shader catalog** | Any shader / material / visual effect work — outline, glow, blur, vignette, rainbow, hologram, blend mode, post-process, hit-flash, screen filter, etc. |
| `msw-ui-system` skill (invoke via the `Skill` tool) | **Single UI entry point.** Design judgment (coordinates/anchors/pivot, UIGroup/CanvasGroup, component selection) + component property/method/event API + enum values + layout recipes + mlua runtime patterns (popup/toast/HP/grid/drag) + Runtime UI Caveats + UUID binding + **`.ui` CJS UIBuilder invocation protocol** (panel/text/sprite/button/slider/scroll/script/group/mask/grid/avatar/touch/skeleton/particle, anchor presets, component add/replace/patch/remove, write-time auto lint) | Any UI-related task / creating or editing `.ui` files — **read FIRST** |
| `msw-ui-system/references/templates` files | UI structure pattern templates by complexity (simple popup, minimal HUD, multi-tab, shop/purchase flow) with `.ui` + `.mlua` examples and button handler patterns | Adding new UI groups/popups/HUD, structuring button handlers, or choosing a UI layout pattern (read directly after `msw-ui-system`) |
| [dataset.md](references/dataset.md) | UserDataSet / LocaleDataSet runtime, `.userdataset` + `.csv` pair, **`_LocalizationService` is ClientOnly**, `serveronly` | Datasets / i18n / translation |
---
## Absolute Principles (apply to every task)
0. **If the task involves an entity, read [references/entity.md](references/entity.md) first.** No exceptions.
0-bis. **If the user's request mentions any UI element** (popup, HUD, button, toast, panel, dialog window, menu, tab, layout, screen, bar/gauge, slot) **OR involves writing/editing `.ui` files**, **BEFORE proposing any plan, options, or questions to the user**:
1. **Invoke `msw-ui-system` via the `Skill` tool first** — the single UI entry point (design judgment, component API, enums, layout recipes, runtime patterns, UUID binding, builder invocation protocol unified in one skill).
2. **All `.ui` mutations must go through `msw-ui-system`'s `UIBuilder`** — no direct raw JSON editing or grep. Read existing `.ui` files via `UIBuilder`'s read-side API too. (Call protocol: [`references/builder-protocol.md`](references/builder-protocol.md) core + [`references/builder-protocol-ui.md`](references/builder-protocol-ui.md) §3.)
3. (Optional) If you need UI pattern templates (simple popup, minimal HUD, multi-tab, shop flow), `Read`/`Glob` the files under `msw-ui-system/references/templates/` directly ([`templates.md`](../msw-ui-system/references/templates/templates.md) + `style-N-*/` + [`ruid-map.md`](../msw-ui-system/references/templates/style-1-black/ruid-map.md) + `Popupbutton.mlua`).
No exceptions.
0-ter. **If the task will create, modify, rename, or delete ANY `.mlua` file** — including new scripts, edits to existing scripts, adding/removing a `Component`/`@Logic`/`@Event`/`@State`/`@BTNode`, wiring lifecycle methods (`OnBeginPlay`/`OnUpdate`/...), or even small one-line fixes — you **MUST `Read` BOTH [`msw-scripting/SKILL.md`](../msw-scripting/SKILL.md) AND [`msw-scripting/references/verify-checklist.md`](../msw-scripting/references/verify-checklist.md) IN FULL FIRST** (no `offset`/`limit`, no `cat`/`Get-Content`). Reading `msw-general` plus scattered `.d.mlua` files is **not a substitute**. This applies even when a previous turn already loaded `msw-scripting` — re-confirm at the start of the new turn. **Trigger phrases are intentionally broad**: if there is any chance the turn will touch a `.mlua`, treat it as triggered. No exceptions, no "I already know this", no shortcut via memory.
0-quater. **If the task involves spawning, movement, jump/gravity, coordinate placement, layer/order debugging, MapleTile/RectTile/SideViewRectTile-specific logic, OR you observe any silent-failure symptom (`[LEA-3004]` log, "won't move", "won't render", "floating in mid-air", "stuck in wall", "disappears off the map", "falls off the foothold edge", "100× off", "doesn't show in Maker", "client-only sync"), the matching `references/platform*.md` / [`references/troubleshooting.md`](references/troubleshooting.md) MUST be fully in context FIRST** (Read only if missing — see "Preflight read semantics"). **Additionally, if the symptom is animation/state-related (`[LEA-3005]` log, `'stateName' is not a valid argument`, animation doesn't match behavior, stuck in stand/idle clip while moving, attack pose never plays, hit loops, custom state never animates, anything touching `StateComponent` / `StateType` / `ChangeState` / `AddState` / `ActionSheet` / `SetActionSheet` / `StateAnimationComponent`), [`references/animation-state.md`](references/animation-state.md) MUST be fully in context FIRST** before any code or model edit. The 8 Core Rules in this SKILL.md are summary only — **the symptom→cause→fix tables, per-map-type code patterns (Foothold patrol / RectTile 4-directional movement / SideView wall detection), the `MovementComponent` InputSpeed conversion formula, SpriteRUID, SortingLayer/OrderInLayer detail, `SpawnByModelId` initialization order, folder metadata Refresh policy, and CoreVersion policy live only in references**. **Trigger phrases are intentionally broad**: if there is any chance the turn touches one of these areas, treat as triggered. For the matching `Read` targets, follow the trigger table in the "Platform Rules Preflight — MUST" section above. No exceptions, no "I already know this from the 8 Core Rules", no shortcut via memory.
1. **Visual polish** — never leave `SpriteRUID` empty. Use `msw-search` to find resources.
2. **`refresh` after content file changes that Maker must ingest** (if in play mode, `stop` first). Folder-only changes do not need immediate refresh.
3. **Never modify `Environment/*.d.mlua`** — API definitions are read-only.
4. **Never create `.codeblock` by hand** — Maker `refresh` generates it from `.mlua`. Folder metadata is also generated from real folders during refresh.
5. **Do not create new user files in `Global/`** — Maker will not recognize them. User files belong under `RootDesk/MyDesk/`.
6. **Structured files prefer builders, and the call manual is one unified entry point — a shared core plus per-builder files** — `.model` / `.ui` are builder-only; `.map` is builder-first. **The call protocol for all three builders — `MapBuilder` / `ModelBuilder` / `UIBuilder` — is consolidated in [`references/builder-protocol.md`](references/builder-protocol.md) (core) plus the per-builder files ([`builder-protocol-map.md`](references/builder-protocol-map.md) / [`builder-protocol-model.md`](references/builder-protocol-model.md) / [`builder-protocol-ui.md`](references/builder-protocol-ui.md)). The core + the file(s) matching the mutated types must be in context on every turn that mutates `.map` / `.model` / `.ui`** (see Builder Protocol Preflight). Direct raw JSON edits are allowed only in the coverage-gap areas explicitly listed in the per-builder files — minimal scope + `refresh` + logs verification.
7. **Entity reference binding (Entity/EntityRef property)** — the AI injects the UUID string directly. Do not ask the user to drag in Maker.
8. **Stop work on CoreVersion mismatch** — first verify `CoreVersion` in `Environment/config` is `26.7.0.0`.
9. **Call `screenshot` only when the user explicitly requests it.** Never call it automatically after task completion.
10. **If a workflow step fails mid-flow, stop later steps** — fix the root cause first.
11. **Two-or-more = make a model.** Whenever the same entity composition is placed ≥2 times in a map, author a `.model` first and instance it via `modelId`. Inline `@components` duplication is reserved for genuine one-off entities.
12. **Models live in typed subfolders.** Save new `.model` files under a category subfolder of `RootDesk/MyDesk/Models/` (e.g. `Models/Monsters/`, `Models/NPCs/`, `Models/Terrain/`, `Models/MapObjects/`, `Models/Particles/`, `Models/UI/`) — **never directly under `MyDesk/` or `Models/`**. When a needed subfolder does not exist, create the folder only; Maker Refresh will generate folder metadata later (see [references/platform.md §2](references/platform.md)).
13. **Translation is client-side only.** `_LocalizationService` and `Translator` methods (`GetText` / `GetTextFormat`) are all `ClientOnly`. For server-originated localized messages, send the key over RPC and let the client resolve it.
14. **Cross-platform tool selection — no shell for workspace exploration, use tools.** Use **the `Glob` / `Read` / `Grep` tools** for all workspace file/folder exploration, reading, and search. `Bash` commands like `ls` / `dir` / `Get-ChildItem` / `gci` / `cat` / `type` / `Get-Content` / `gc` / `head` / `tail` / `find` / `where` / `grep` / `findstr` / `Select-String` are **forbidden for workspace exploration** — they are not compatible across Windows (PowerShell/Git Bash) and macOS (bash/zsh), due to shell/path-handling differences (notably, in bash a path like `D:\path\foo` has its backslashes consumed as escapes and collapses to `D:pathfoo`). Use `Bash` only for **actual shell programs (`git` / `npm` / MCP / build scripts)**, and even then: (a) prefer **workspace-relative paths**, (b) if an absolute path is unavoidable, use **forward slashes + double quotes** (`"D:/path/to/map/"`, never pass `D:\...` form), (c) use **POSIX commands** only (`ls` / `mv` / `cp` / `rm`). If you see an error like `ls: cannot access 'D:path...': No such file or directory`, stop immediately and retry via `Glob` / `Read`.
15. **`.model` files are builder-only — and the builder requires [`references/model.md`](references/model.md) (domain) plus [`references/builder-protocol.md`](references/builder-protocol.md) core and [`references/builder-protocol-model.md`](references/builder-protocol-model.md) (call protocol) first.** Do not inspect or edit `.model` JSON directly. **Before using `ModelBuilder`, all of these documents must be fully in context** (read semantics above) — both "Model Work Preflight" and "Builder Protocol Preflight" fire. Identifier / value-metadata / property / child / event-link consistency is only guaranteed when they are read together.
16. **`.map` files are builder-first.** Use `MapBuilder` for covered inspection and mutation so entity ids, paths, component names, origin metadata, and model instance mirrors stay consistent. If `MapBuilder` explicitly does not cover the required operation, make the smallest direct `.map` edit possible, then `refresh` and verify. **Full API / require path / per-operation patterns / coverage gaps / `false`-return handling / cross-flow: [`references/builder-protocol-map.md`](references/builder-protocol-map.md) §1 + [`references/builder-protocol.md`](references/builder-protocol.md) §4** (read alongside [`references/entity.md`](references/entity.md) for domain context).