关于 msw-scripting
msw-scripting 是用于开发和验证 MapleStory Worlds .mlua 脚本的技能,面向游戏创作者,涵盖 MSW 专用 API、脚本结构、生命周期及从构建到运行时的调试。
主要功能
- 01开发 Component 和 Logic 脚本
- 02通过 .d.mlua 定义核对 API
- 03按功能目录组织脚本
- 04编辑后运行 LSP 诊断
- 05结合构建与运行日志验证
- 06遵循生命周期和执行空间规则
适用场景
- 01为现有脚本添加行为
- 02创建功能专属脚本
- 03诊断脚本错误和运行时故障
- 04实现事件与生命周期处理
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ msw-git/ msw-ai-coding-plugins-official --skill msw-scripting安装后在 Claude Code 中输入/msw-scripting确认已加载。
附带文件references/datastorage.mdreferences/verify-checklist.md
详细介绍
MSW 脚本(.mlua)——框架 + 文件工作流 + Playtest 与调试
mlua 基于 Lua,但它有 MSW 专属注解、生命周期和执行空间模型。 仅凭 Lua 通用知识无法编写出可运行的代码。所有工作都通过直接编辑工作区中的文件完成, 代码按构建日志 → 运行时日志的顺序进行验证。
1. 核心原则(必须遵守)
1.1 优先使用现有脚本
创建新的 .mlua 之前,先在 ./RootDesk/MyDesk/ 下通过 glob/关键词搜索查找用途相同的现有脚本——始终优先扩展现有文件。重复实现会增加维护成本和冲突风险。
1.2 新脚本的文件夹结构——切勿将文件散放在根目录
无法避免创建新的 .mlua 时,应将其放在功能/类别子文件夹下。必需的路径形式:./RootDesk/MyDesk/<FeatureFolder>/<ScriptName>.mlua。
- 如果现有子文件夹合适,则复用(
Player/、UI/、Combat/、Inventory/……);先 glob./RootDesk/MyDesk/。 - 否则创建一个以功能命名的文件夹(PascalCase)。同一功能的所有相关脚本(Component/Logic/Event/Struct)都放在一起。即使功能只有一个文件,也要为其创建专属文件夹。
- 禁止使用
Scripts/、Misc/、Common/、New/、temp/等兜底文件夹。根目录平铺会导致无法遵守 §1.1 的规则(创建前先搜索)。
示例:Inventory/InventoryManager.mlua、Combat/MeleeAttackComponent.mlua、UI/Popup/RewardPopupLogic.mlua。
1.3 切勿猜测 API——写代码前先验证
猜测 MSW API 的名称/参数/返回类型会导致其在运行时静默失败。必需顺序:先查 .d.mlua 中的签名 → 如有需要,再用 msw-search 查找语义/示例 → 编写 → 通过 LSP 诊断(自动运行)。
引擎 API 位于 ./Environment/NativeScripts/ 下:
| 文件夹 | 内容 | 数量 |
|---|---|---|
Component/ | 引擎组件 | 104 |
Service/ | 系统服务 | 46 |
Event/ | 事件类型 | 202 |
Logic/ | 内置逻辑 | 9 |
Enum/ | 枚举 | 118 |
Misc/ | 实用类型(Vector2 等) | 140 |
已知名称 → Read ./Environment/NativeScripts/{folder}/{name}.d.mlua。未知名称 → 在这些文件夹中 Grep 搜索关键词。
1.4 Lint(LSP 诊断)
mlua-diagnose 钩子会在每次创建/修改 .mlua 后自动运行 LSP diagnose。反复修复并重新编辑,直到错误级别的诊断数量归零。
1.5 .codeblock 与 Refresh
.codeblock文件由 Maker Refresh 生成——切勿手动创建/编辑/删除。- 创建/修改/重命名/删除任何
.mlua后,都要调用 Maker MCPrefresh。Refresh 需要编辑模式——如果正在播放,请先stop。
1.6 MSW ≠ Unity — 不要凭直觉推理
直接套用 Unity/通用模式,虽然可以顺利编译,却会在运行时静默失败。常见误解:
| Unity 中的直觉 | MSW 的实际情况/相关说明 |
|---|---|
从全局管理器访问 gameObject/transform | @Logic 没有 self.Entity——参见 §3.2(使用属性注入/_EntityService) |
使用 OnMouseDown/BoxCollider2D 处理点击 | 物理碰撞体不会触发 TouchEvent——World 使用 TouchReceiveComponent(§10);UI 使用 ButtonComponent/UITouchReceiveComponent |
OnCollisionEnter + Rigidbody | Entity↔entity 碰撞需要 TriggerComponent + TriggerEnter/Leave/Stay 事件 |
UI 字段名称(interactable/text/color) | MSW 使用特定名称——请查阅 msw-ui-system/references/component-api.md。常见对应关系:禁用→Enable,文本→Text,文本颜色→FontColor,色调→Color。ButtonComponent.Interactable 不存在。 |
| 随意附加多个 Rigidbody/Collider | 每种地图类型只能有一个 Body——参见 msw-general/references/platform.md §4 |
| 在服务端代码中操作 UI | UI 仅限客户端使用——服务端→UI 需通过 @ExecSpace("Client") RPC。将 Server/ServerOnly/Multicast/@Sync 放在附加到 UI 的 Component 上,会静默无效,并产生运行时警告。参见 msw-ui-system/references/runtime-patterns.md |
在任何地方调用 Instantiate(prefab) | _SpawnService:SpawnByModelId(id, name, pos, parent)——parent 为必填项,且仅限服务端调用——参见 §11 |
static 类/手动编写的单例 | @Logic 本身就是单例——调用方式为 _ScriptName:Method(),切勿实例化——参见 §3.2 |
规则:若想套用 Unity 模式,请先停下来,对照 Environment/NativeScripts/*.d.mlua 核实。
1.7 Builder Protocol 预检 — 必须
如果本轮涉及 .map/.model/.ui(直接涉及,或通过 .mlua 中的 spawn/实体放置/UI 绑定代码间接涉及),则必须先将**../msw-general/references/builder-protocol.md(核心文档)以及所涉及每种类型对应的 builder 文件(builder-protocol-map.md/builder-protocol-model.md/builder-protocol-ui.md)完整加载到上下文中**(仅当本会话中从未加载过这些文件或内容已因压缩而丢失时,才需要通过 Read 读取完整文件——仅凭记忆中的摘要不算已加载到上下文中)。核心文档包含共用的写入端契约和跨流程说明;每个 builder 文件则包含对应 builder 的 API、typeKey 元数据、自动 lint、子实体不变量以及 placeModel 镜像逻辑;了解一个 builder 并不代表了解其他 builder。
触发条件(刻意涵盖广泛):_SpawnService/SpawnByModelId/SpawnByEntity;任何 .map/.model/.ui 变更;调用 msw_map_builder.cjs/msw_model_builder.cjs/msw_ui_builder.cjs;任何“新增怪物/NPC/弹窗/地图物件”的请求;§11 或 §16 相关工作。
1.8 方法文档注释 — 写在方法体内
每个 method(生命周期方法、RPC、事件处理程序、自定义方法)都必须在方法体内的第一行写描述性注释,绝不能写在声明上方。mlua 的解析器会将前置注释绑定到前一个声明,因此写在“上方”的注释并不可靠。
-- ✅ Correct
method void ApplyDamage(Entity target, number amount)
-- Applies damage and triggers hit VFX.
target:TakeDamage(amount)
end
-- ❌ Wrong — comment above the method
-- Applies damage...
method void ApplyDamage(Entity target, number amount)
target:TakeDamage(amount)
end
2. 路径和文件角色
| 目标 | 路径 | Agent 操作 |
|---|---|---|
| 用户脚本 | ./RootDesk/MyDesk/**/*.mlua | 直接创建 / 读取 / 修改 / 删除 |
| 自动生成的构件 | *.codeblock | 不要触碰(由 Refresh 管理) |
| 引擎 API 定义 | ./Environment/NativeScripts/** | 只读(不要修改) |
| 模型(组件列表) | ./RootDesk/MyDesk/**/*.model 以及现有的 ./Global/*.model 文件 | 附加脚本时编辑 Components |
| 地图实例 | ./map/*.map | 为仅存在于地图中的实体附加脚本时编辑 |
3. 脚本类型和声明
3.1 组件脚本(@Component)
附加到实体上的脚本。使用 self.Entity 访问所属实体。
@Component
script MyScript extends Component
property number Speed = 5.0
@ExecSpace("ServerOnly")
method void OnBeginPlay()
-- initialization (also: OnUpdate(delta), OnEndPlay)
end
end
允许的父类:
Component— 通用组件AttackComponent— 攻击系统(Shape、AttackFast、OnAttack)HitComponent— 受击系统(OnHit、HandleHitEvent)
3.2 逻辑脚本(@Logic)
全局单例。独立运行,不依赖实体。用于游戏管理器、UI 管理器、实用工具等。
@Logic
script GameManager extends Logic
@Sync property integer Score = 0
@ExecSpace("ServerOnly")
method void OnBeginPlay()
-- global initialization (also: OnUpdate, OnEndPlay)
end
end
- 每个世界一个(单例)
- 通过
_<ExactScriptName>访问——不会去除后缀。TDHUDLogic.mlua→_TDHUDLogic(不是_TDHUD);TowerDefenseConfig.mlua→_TowerDefenseConfig。启发式去除后缀会静默返回nil。 - 支持
@Sync属性(服务器→客户端) - Logic 的
OnUpdate在 Components 的OnUpdate之前运行。
⚠️
@Logic没有self.Entity——Logic 父类仅提供ConnectEvent/DisconnectEvent/IsClient/IsServer/SendEvent。self.Entity.xxx可以编译通过,但运行时会发生 nil 访问。若要绑定世界实体,请通过属性注入(property Entity x = "uuid"/property EntityRef x = ""),或使用_EntityService:GetEntityByPath(...)/:FindEntityByName(...)查找实体。优先使用属性注入(UUID 字面量)。参见 §7。⚠️
OnMapEnter/OnMapLeave不会在@Logic上触发——它们仅适用于 Component(参见 §5)。在 Logic 上声明这些函数不会报错,但代码不会执行。要执行每张地图的初始化 / 清理,请选择以下方式之一:(1)将逻辑移至地图实体上的@Component(首选,也是唯一适用于服务器的方式);或(2)仅限客户端:从OnUpdate轮询_UserService.LocalPlayer.CurrentMap——LocalPlayer仅在客户端可用(服务器上为 nil)。Logic 会在地图切换时继续存在,因此任何应在每张地图重置的计时器 / 事件处理程序 / 可变状态,都必须通过上述方式之一清除——没有自动钩子。
选择:根据生命周期而非“是否全局”决定使用
@Component还是@Logic
范围 选择 原因 整个世界范围,在每次地图切换后仍然存在(账号状态、世界事件总线、全局 UI 管理器) @Logic引擎单例;在整个世界会话期间存在。 地图范围——仅在一张地图内有意义(任务控制器、波次生成器、谜题) 地图实体上的 @Component地图卸载时清理。将其放在 @Logic中会导致状态 / 计时器跨地图泄漏。单个角色(怪物 AI、物品拾取、玩家技能) 该实体上的 @Component请思考:“玩家走到另一张地图后,它还会继续运行吗?”——会 ⇒
@Logic;不会(仅限这张地图)⇒ 地图实体上的@Component;不会(仅限这个角色)⇒ 角色上的@Component。
3.3 扩展脚本
@Component
script PlayerAttack extends AttackComponent
-- Override parent methods; call parent via __base:MethodName()
end
3.4 其他脚本类型
@Event(自定义事件) · @Item(物品栏) · @BTNode(行为树) · @State(状态机) · @Struct(复合数据类型)。
4. mlua 语言扩展(与纯 Lua 的区别)
基于 Lua 5.3,但存在以下差异:
新增语法:
continue— 跳过当前循环,进入下一次迭代。- 复合赋值:
+=、-=、*=、/=、//=、%=、^=、..=(以及位运算符&=、|=、<<=、>>=)。多重赋值(a, b += 1, 2)和在函数参数中使用(print(a += 1))均无效。 - 位运算符:
&、|、<<、>>。
限制:
- 不支持全局变量(禁止使用
global关键字)——通过 Properties 共享值。 - 不支持协程(
coroutine.*)。 - 父级调用使用
__base:MethodName(),而不是super。
内置实用函数:
| 函数 | 用途 |
|---|---|
log() / log_warning() / log_error() | 按不同严重级别记录日志 |
wait(seconds) | 暂停脚本执行 |
isvalid(obj) → boolean | 检查有效性(处理已删除对象和 nil) |
enum(t) → table | 交换键和值 |
beginscope(name) / endscope() | 性能分析范围 |
5. 生命周期
OnInitialize → OnBeginPlay → OnUpdate(delta) → OnEndPlay → OnDestroy
↑
OnMapEnter / OnMapLeave (Component only, per transition)
| 方法 | 时机 | 位置 | 用途 |
|---|---|---|---|
OnInitialize | 创建后 | Component + Logic | 初始化内部变量(很少使用) |
OnBeginPlay | 游戏开始时 | Component + Logic | 连接事件、启动计时器、初始设置 |
OnUpdate(delta) | 每帧 | Component + Logic(Logic 优先) | 移动、动画、输入 |
OnMapEnter / OnMapLeave | 地图切换时 | 仅 Component(在 Logic 上静默无操作) | 按地图进行初始化/清理 |
OnEndPlay | 游戏结束时 | Component + Logic | 断开事件连接、清除计时器(必须执行!) |
OnDestroy | 移除时 | Component + Logic | 最终清理(很少使用) |
在客户端,地图中已存在的其他玩家也会触发 OnMapEnter,而不只是本地玩家进入地图时触发。
必需模式:在 OnBeginPlay 中连接的所有内容,都必须在 OnEndPlay 中释放(事件、计时器)。
property any eventHandler = nil -- EventHandlerBase (must be 'any'; not integer)
property integer timerId = 0
method void OnBeginPlay()
self.eventHandler = self.Entity:ConnectEvent(SomeEvent, self.OnSomeEvent)
self.timerId = _TimerService:SetTimerRepeat(self.Tick, 1/60)
end
method void OnEndPlay()
if self.eventHandler then self.Entity:DisconnectEvent(SomeEvent, self.eventHandler) end
if self.timerId then _TimerService:ClearTimer(self.timerId) end
end
OnMapEnter 与 OnMapLeave 成对使用——避免地图切换时的计时器泄漏。 每次重新进入地图时都会触发 OnMapEnter,而在切换地图时仍存续的实体(玩家、持久对象)所关联的 Component 不会被销毁——因此,在 OnMapEnter 中启动的计时器/处理程序每次重新进入都会多出一个仍在运行的副本。请在 OnMapLeave 中清除它,而不是在 OnEndPlay 中清除(OnEndPlay 只会在最终移除时触发,因此不会在地图切换之间运行)。未注销的重复计时器(SetTimerRepeat 或 SetTimer(..., isRepeat = true))会导致引擎在下次启动游戏模式时报告 [LEA-3051] MemoryLeak,并指出创建它的代码行;一次性计时器(SetTimerOnce)会在触发后自动清除,因此永远不会被标记。规则:OnMapEnter 中启动的内容,必须由 OnMapLeave 清除。
6. 执行空间(ExecSpace)
MSW 采用服务器-客户端架构。每个方法都必须声明其运行位置。
| ExecSpace | 运行于 | 方向 | 使用场景 |
|---|---|---|---|
ServerOnly | 服务器 | 仅服务器内部 | 伤害计算、状态变更、生成对象 |
ClientOnly | 客户端 | 仅客户端内部 | UI 更新、特效、音效 |
Server | 服务器 | 客户端→服务器 RPC | 客户端向服务器发起请求(攻击、使用物品) |
Client | 客户端 | 服务器→客户端 RPC | 服务器通知客户端(结果 UI、特效) |
Multicast | 所有客户端 | 服务器→所有客户端 | 全局事件(公告、Boss 生成) |
| (未指定) | 调用方所在端 | 服务器→服务器,客户端→客户端 | 在任一端本地执行的共享函数 |
生命周期方法的 ExecSpace 限制
| 方法 | 允许的 ExecSpace |
|---|---|
OnSyncProperty | 仅限 ClientOnly |
OnInitialize、OnBeginPlay、OnUpdate、OnEndPlay、OnDestroy、OnMapEnter、OnMapLeave | ServerOnly、ClientOnly 或未指定 |
| 所有事件处理程序 | ServerOnly、ClientOnly 或未指定 |
| 自定义用户方法 | Server、Client、ServerOnly、ClientOnly、Multicast 中任意一种 |
典型的服务端-客户端模式
[Client] input (ClientOnly) ──Request()──→ [Server] validate (ServerOnly)
├─ state auto-syncs via @Sync
[Client] UI update (ClientOnly) ←──Show()──────┘ (Client RPC)
ServerOnly:客户端调用会被静默忽略(不会报错)。Server:客户端→服务端 RPC(存在网络延迟)。Client:服务端→客户端 RPC;要指定一个客户端,请将 UserId 作为调用位置的最后一个参数传入(不要将其添加到声明中)。
senderUserId——验证请求方
在 @ExecSpace("Server") 主体中,本地的 senderUserId 保存调用方客户端的 UserId(由服务端分配,客户端无法修改)。请用它进行安全检查。
@ExecSpace("Server")
method void RequestBuyItem(integer itemId)
if senderUserId ~= self.Entity.PlayerComponent.UserId then return end
self:ProcessPurchase(itemId)
end
保留的参数名——name is unavailable
有四个参数名为 RPC 编组器保留,不能用作任何 @ExecSpace(...) 方法的自定义参数名。LSP 会阻止脚本运行,并提示 '<name>' name is unavailable.:
| 保留名称 | 引擎用途 |
|---|---|
self | 方法接收者 |
senderUserId | @ExecSpace("Server") 主体中调用方客户端的 UserId |
targetUserId | 接收方客户端的 UserId(@ExecSpace("Client") 主体的调用位置最后一个参数——不要声明此参数;引擎会追加它) |
messageOwnerEntity | 某些服务回调的来源实体 |
如果自定义参数与这些名称冲突,请重命名(targetUserId → forUserId,senderUserId → fromUserId)。self 是接收者,无法使用别名——如果它与无关参数冲突,请为该参数选择其他名称。
手动分支——IsServer() / IsClient() 是方法,不是属性
如果一个方法没有 @ExecSpace(会在调用它的任意一侧运行),且需要针对不同侧使用不同逻辑,请用 self:IsServer() / self:IsClient() 进行分支。Component 和 Logic 上都声明了 method boolean IsServer() / method boolean IsClient()——必须调用它们,不能读取它们。
if self:IsServer() then ... end -- ✅ method call → boolean
if self.IsServer then ... end -- ❌ method object itself → always truthy
使用不带括号的点号形式会造成隐蔽错误:LSP 不会标记,脚本也能编译;由于方法对象为真,if 总会进入,因此仅在客户端运行的代码也会在服务端运行(反之亦然)。表现为“两个分支都在两侧执行”,而不是崩溃。请始终使用冒号调用(self:IsServer())。
跨边界参数类型
服务端↔客户端 RPC 允许使用:string、integer、number、boolean、table、Vector2/3/4、Color、Entity、Component、EntityRef、ComponentRef。不允许使用 any。 引擎枚举也无法跨边界传递——无论是直接传递(LSP 会拒绝将引擎枚举类型作为参数),还是通过 any 偷渡(运行时会报错 LEA-3036 InvalidCast)。常用的解决方法是:发送方将选项编码为 string 键,接收方根据该键进行分支,再在本地转换回枚举。SyncTable<k,v> 泛型也必须使用允许的类型。
7. 属性系统
基本类型
number(浮点数/双精度数——整数使用单独的类型 integer)、string、boolean、Vector2 / Vector3、Color(r、g、b、a 的取值范围为 0.0~1.0)、any。
property number Speed = 5.0
property integer Count = 0
property Vector2 Direction = Vector2(0, 0)
property Color Tint = Color(1, 1, 1, 1)
Entity / Component 引用属性
property Entity targetEntity = "94a274e4-4111-40f1-924d-c95a3a1f14d5" -- UUID string literal
property ButtonComponent btnOk = "uuid-string" -- typed component ref
AI 必须直接注入 UUID——从 .map / .ui 读取 id,并将其硬编码为字符串字面量。切勿要求用户在编辑器中拖拽绑定(这是为了方便人工作者而设计的操作)。
Entity 与 EntityRef
Entity / Component 引用会在地图切换时丢失。EntityRef / ComponentRef 可在地图切换后保留——多地图游戏优先使用它们。
同步注解
@Sync——服务端 → 所有客户端。单向;客户端上的更改不会传播回服务端。存在网络延迟。@TargetUserSync——服务端 → 仅所属用户的客户端。适用于每个玩家的私有数据(货币、成就)。如果用于非 PlayerEntity,则会回退为普通的@Sync。- 无法同步:
any、table——请改用SyncTable。 - 两者均不接受参数。
@Sync property number CurrentHp = 100
@TargetUserSync property number PrivateScore = 0
@Sync property SyncTable<number> Scores -- array form, NO default literal
@Sync property SyncTable<string, number> Stats -- dict form, NO default literal
SyncTable<...> 属性——不写默认字面量
声明 SyncTable<V>(数组形式)或 SyncTable<K, V>(字典形式)时,不要使用 = ... 初始化器。引擎会将 SyncTable 属性的 = 位置保留给自身进行类型记录,并在运行时自动将属性初始化为空集合。你写入的任何字面量(= {}、= { key = val }、= nil)都会被静默丢弃——它们只是会造成误导的噪声,并非真正的默认值;代码块往返处理后也会将其删除。
在 OnInitialize / OnBeginPlay 中填入初始条目:
@Sync property SyncTable<string, number> Stats -- empty at construction
@Sync property SyncTable<number> Scores -- empty at construction
method void OnBeginPlay()
if self:IsServer() then
self.Stats["hp"] = 100
self.Stats["mp"] = 50
self.Scores:Add(0)
end
end
运行时将普通 Lua 表赋给 SyncTable 属性也会被拒绝——该属性只接受自身的代理对象。逐字段修改(self.Stats[k] = v),或调用其方法(self.Scores:Add(v) / :Remove(v) / :Clear())。
SyncList<V> 不是用户属性类型
SyncList<V> 仅作为原生引擎组件上的只读属性公开(例如 TagComponent.Tags、PhysicsColliderComponent.PolygonPoints、SkeletonRendererComponent.AnimationNames 以及各种 JointComponent.Joints)。用户脚本可以读取这些属性并调用其方法(:Add(v)、:Remove(v)、:Clear()、.Count、:ToTable()),但不能在自己的 @Component / @Logic 上声明 property SyncList<...> X,也不能实例化 SyncList(...)。
要在自己的脚本中使用同步集合,请使用 SyncTable<V>(数组形式)或 SyncTable<K, V>(字典形式)——见上文。
临时属性(_T)
self._T.<name> 是无需声明的非同步临时状态。服务器和客户端各自保留自己的值;它不会显示在检查器中。不能标记为 @Sync。
⚠️
_T是唯一无需声明的字段。 给任何其他未声明的self.<name>赋值都会导致运行时错误——cannot set <name>, no such field——并终止调用的方法(通常是整个OnBeginPlay)。你设置的每个self.<name>都必须是已声明的property,或通过self._T.<name>访问。构建日志仍保持干净,但会有一条容易忽略的LIA-1114Info(见 §17.2)。
OnSyncProperty 回调
客户端钩子,在 @Sync 属性完成同步时触发。必须是 ClientOnly(不能更改)。Component 和 Logic 均可使用。同步设置为 None 的属性不会触发此回调。
@ExecSpace("ClientOnly")
method void OnSyncProperty(string name, any value)
if name == "CurrentHp" then self:UpdateHpBar(value) end
end
属性编辑器特性
@DisplayName("...") @Description("...") @MaxLength(20) @HideFromInspector
@MinValue(0) @MaxValue(999) @Delta(5) -- Delta = mobile +/- step
8. 事件系统 / RPC
静态订阅——@EventSender + handler
@EventSender("Self") handler HandleHitEvent(HitEvent event) ... end
@EventSender("Service", "InputService") handler HandleKeyDown(KeyDownEvent event) ... end
@EventSender 第 1 个参数:"Self" / "LocalPlayer"(不带第 2 个参数)· "Entity",id / "Model",id / "Service",typeName / "Logic",typeName。
动态订阅——ConnectEvent / DisconnectEvent
property any clickHandler = nil
self.clickHandler = entity:ConnectEvent(ButtonClickEvent, self.OnClick) -- OnBeginPlay
entity:DisconnectEvent(ButtonClickEvent, self.clickHandler) -- OnEndPlay (mandatory)
对于需要捕获逐元素状态(卡片 ID、槽位索引)的情况,请使用闭包处理程序;将返回的 EventHandlerBase 存入表中,并在 OnEndPlay 中逐个断开连接。
property table clickHandlers = {}
for _, id in ipairs(cardIds) do
local capturedId = id
local h = e:ConnectEvent(ButtonClickEvent, function() self:OnCardClicked(capturedId) end)
table.insert(self.clickHandlers, { entity = e, handler = h })
end
⚠️
ConnectEvent属于 Entity / Logic / Service——不属于 Component。 Component 只能发送事件;请在所属的 Entity(或_InputService/_<LogicName>)上订阅。self.Entity.ButtonComponent:ConnectEvent(...)会在运行时得到 nil。property any clickHandler = nil property any keyHandler = nil self.clickHandler = self.Entity:ConnectEvent(ButtonClickEvent, self.OnClick) self.keyHandler = _InputService:ConnectEvent(KeyDownEvent, self.OnKeyDown)
⚠️
handler与method void的区别——handler Name(Ev e)与@EventSender(...)配套,并通过声明进行绑定。method void Name(Ev e)是通过ConnectEvent(EvType, self.Name)绑定的动态回调。混用两者可以通过编译,但不会触发(E-V1-5)。如果使用了@EventSender→ 用handler;如果要调用ConnectEvent→ 用method void。
CustomEvent——类型化类风格
编写此类事件的唯一方式是使用 @Event + extends EventType,并包含 property 字段。没有内联工厂。
@Event
script DamageDealtEvent extends EventType
property number amount = 0
end
local dmg = DamageDealtEvent(); dmg.amount = 50
self.Entity:SendEvent(dmg) -- via Entity / Logic / Service
self.Entity:ConnectEvent(DamageDealtEvent, self.OnDamage) -- first arg = event Type
method void OnDamage(DamageDealtEvent event) log(event.amount) end
NativeEvent(由引擎提供,例如 HitEvent.TotalDamage/.AttackerEntity、ButtonClickEvent、StateChangedEvent.PrevState/.CurState)——参见 Environment/NativeScripts/Event/。
9. 有效性检查和方法重写
有效性检查
访问已删除的实体会导致运行时错误——务必先调用 isvalid()。
if isvalid(entity) then ... end
if isvalid(self.Entity.SomeComponent) then ... end
方法重写
在使用 extends 的脚本中,与父类具有相同签名的 method 会重写父类方法。标记为 ---@sealed 的引擎内置方法不可重写。通过 __base:MethodName(args) 调用父类的原始方法。
⚠️ LEA-3014 SignatureMismatch — ExecSpace 必须与父类一致
“相同签名”包括 @ExecSpace。重写方法必须与父类的注解块逐字节一致——包括父类没有注解的情况。在父类没有注解时,添加 @ExecSpace("ServerOnly") 来“让它在服务器端运行” → 运行时出现 LEA-3014。
常见问题方法:AttackComponent / HitComponent 的伤害钩子(CalcDamage、CalcCritical、GetCriticalDamageRate、GetDisplayHitCount、IsAttackTarget、IsHitTarget、OnAttack)都声明时没有 @ExecSpace。重写时不要添加注解——它们仍然是安全的,因为服务器端命中流程是唯一的调用方。
工作流:在 .d.mlua(§1.3)中查看父类,并逐字复制其注解块。修复 LEA-3014 时,应让子类的 @ExecSpace 与父类一致,绝不要反过来修改父类。
10. 输入 / 点击事件——世界与 UI(不要混淆)
世界触摸——两种方式
| 方式 | 事件 | 连接到 | 用途 |
|---|---|---|---|
实体触摸 — 实体上的 TouchReceiveComponent | TouchEvent(+Hold/Release) | entity:ConnectEvent(...) | “触摸了哪个实体”——NPC、物品 |
| 屏幕触摸 — 无需组件 | ScreenTouchEvent | _InputService:ConnectEvent(...) | “屏幕上的哪个位置”——放置、移动目标 |
两个事件都携带 TouchId(int32)和 TouchPoint(Vector2 屏幕坐标)。要获取世界坐标,请使用 _UILogic:ScreenToWorldPosition(event.TouchPoint)。使用 _InputService:IsPointerOverUI() 过滤 UI 点击。如果 TouchEvent 未触发,ScreenTouchEvent + ScreenToWorldPosition 是无需配置的备用方案。
⚠️ 物理碰撞体不会发出
TouchEvent——BoxCollider2D、CircleCollider2D、Rigidbody/Kinematicbody 和TriggerComponent都不会传递触摸输入。只有TouchReceiveComponent会发出TouchEvent/TouchHoldEvent/TouchReleaseEvent。设置:
AutoFitToSize = true(自动将 TouchArea 调整为与 Sprite/Avatar 相同的缩放)是最简单的方式。手动设置TouchArea时,应比精灵多留 10–20% 的余量。RelayEventToBehind = true(默认值)会将事件转发到后方;只有需要阻止转发时才设为false。未触发?请按顺序检查:(1) 是否确实已附加
TouchReceiveComponent(在.map/.model中);(2)TouchArea是否非零且实体在屏幕上可见;(3) 前方是否有实体通过RelayEventToBehind = false阻挡事件;(4) 处理程序是否存储在property any中(否则会被 GC 回收)。
选择规则:“触摸了哪个实体” →
TouchEvent;“屏幕上的哪个位置” →ScreenTouchEvent。
PC 鼠标按键(左键 / 右键 / 中键)——使用 KeyDownEvent,而不是 ScreenTouchEvent.TouchId == 2
在 PC 上,ScreenTouchEvent 只会针对左键触发(TouchId == 1);TouchId == 2 表示移动设备上的双指触摸——通过它读取右键点击在 Maker 模拟器中可行,但在真实 PC 上不会有任何输入。对于 PC 鼠标按键,请使用 _InputService:ConnectEvent(KeyDownEvent, ...),并根据 event.key == KeyboardKey.Mouse0 / Mouse1 / Mouse2 分支处理(左键 = 323,右键 = 324,中键 = 325)。要同时支持移动设备多点触控和 PC,请同时连接 ScreenTouchEvent 和 KeyDownEvent——两者不会重复触发(移动设备没有右键点击;PC 上没有 TouchId == 2)。
UI 点击
对于 UI 实体(./ui/*.ui、ui 树),请使用 ButtonComponent + ButtonClickEvent。将 UI 事件放在世界对象上(或将世界事件放在 UI 对象上)会静默无效——先判断目标是世界对象还是 UI 面板按钮。
11. 地图上下文与实体生成
§1.7 触发条件 — 在进行任何生成 /
.map/.model操作前,先Readbuilder-protocol.md 和对应构建器的协议文件。
子级遍历
对于“地图中的所有 X” / “名为 Y 的子级”查询,请使用 Entity 的查找工具集:
| 成员 | 返回值 | 用途 |
|---|---|---|
Entity.Children | ReadOnlyList<Entity>(调用 :ToTable() 进行迭代) | 直接子级 |
Entity:GetChildByName(name, recursive=false) | Entity | 按名称查找 |
Entity:GetChild(id, recursive=false) | Entity | 按 UUID 查找 |
Entity:GetChildComponentsByTypeName(typename, recursive=false) | table<Component> | 所有匹配的后代 |
Entity:GetFirstChildComponentByTypeName(typename, recursive=false) | Component | 第一个匹配项 |
local map = self.Entity.CurrentMap -- prefer this over service lookup
local units = map:GetChildComponentsByTypeName("script.MyUnit", false)
for _, child in ipairs(self.Entity.Children:ToTable()) do log(child.Name) end
集合名称是 Children——ChildList/Childs/GetChildren() 都不正确(编译错误、运行时返回 nil、LIA-1114 Info)。运行时生成的实体必须设为 CurrentMap 的子级,才能被查找到。
原生组件与用户组件的访问方式
| 访问方式 | 适用对象 |
|---|---|
entity.SomeComponent(点号) | 仅限引擎原生组件(TransformComponent、ButtonComponent 等) |
entity:GetComponent("script.MyUnit") | 用户 @Component(任意类型) |
entity:GetFirstChildComponentByTypeName("script.MyUnit", true) | 后代上的用户 @Component |
用户 @Component 的 typename 始终为 "script.<FileBaseName>"——MyUnit.mlua → "script.MyUnit",无论其嵌套在何种功能文件夹中。entity.MyUnit(点号)会返回 nil,并触发 LIA-1114。要在脚本间传递用户组件引用,请声明一个类型化属性(property MyUnit unit = "")并注入 UUID。
⚠ 该方法名为
GetComponent(重载为GetComponent(Type)和GetComponent(string typename)——参见Environment/NativeScripts/Misc/Entity.d.mlua)。*ByTypeName后缀仅用于 child 变体(GetChildComponentsByTypeName/GetFirstChildComponentByTypeName)。
GetComponent(string) 返回抽象类型 Component,因此访问其成员时会退化为动态分派,LSP 会发出 LIA-1114 Info(如果将该值传给签名要求具体用户 @Component 的函数,还会出现 type mismatch Error)。使用 ---@type 进行类型转换,以恢复静态类型检查:
---@type MyUnit
local unit = self.Entity:GetComponent("script.MyUnit")
unit:DoSomething() -- LSP now type-checks against MyUnit
运行时生成
- 使用
_SpawnService:SpawnByModelId(id, name, pos, parent)——必须提供parent(没有默认值)。传入self.Entity.CurrentMap。SpawnByEntity不同——允许parent = nil。 - 必须预先存在
.model模板。新对象的流程:编写.model→ 在地图上生成或放置。
Body 组件与直接写入 Position
带有 Body(Kinematic/Rigid/Sideview)的实体会忽略直接写入 TransformComponent.WorldPosition 的操作——物理系统会在下一帧覆盖它们。请改用:
- 每帧移动:
MovementComponent:MoveToDirection(dir, dt) - 瞬移(局部):
MovementComponent:SetPosition(pos)或body:SetPosition(Vector2) - 瞬移(世界):
body:SetWorldPosition(Vector2)——这是 RectTile 地图上 Kinematicbody 的标准绝对定位调用 - 只有不带 Body 的实体(装饰物、效果)才可以直接写入 Transform。
切勿将 Body 移除作为变通方案——这会禁用瓦片碰撞和进入 / 离开事件(NativeIssue_MissingComponent)。
12. 常用服务 / 逻辑
所有服务和逻辑都通过 _Name(下划线 + 类型名称)访问。这里只列出最常用的几个。
| 服务 / 逻辑 | 用途 |
|---|---|
_SpawnService | 生成实体(SpawnByModelId、SpawnByEntity)。没有 Despawn 方法——通过 Entity:Destroy() / Entity:Destroy(delaySeconds) 移除生成的实体(两者均为 ControlOnly)。 |
_TimerService | 计时器(SetTimer、SetTimerRepeat、ClearTimer) |
_EntityService | 查找实体(GetEntity、GetEntities、GetEntitiesByPath) |
_UserService | 查找玩家(GetUsersByMapComponent(map.MapComponent) 返回当前位于指定地图上的所有玩家——这是规范的“查找这张地图上的玩家”调用,Soldier 的 FindNearestPlayer 会使用它)。没有用户时返回 nil。 |
_InputService | 查询输入状态;接收 ScreenTouchEvent |
_ResourceService | 查找资源 RUID;LoadAnimationClipAndWait(ruid) 会同步加载 AnimationClip(会阻塞一帧——请缓存结果;如果想避免阻塞,可将其封装在 _ResourceService:PreloadAsync({ruid}, function() ... end) 中) |
_DataStorageService | 持久化数据(玩家存档)——⚠️ 按调用计费。不要在 OnUpdate / 短计时器中调用;循环中请使用 Batch*。详情:references/datastorage.md |
_UtilLogic | 随机、时间、字符串和数学工具 |
_TweenLogic | 补间动画(MoveTo、ScaleTo、RotateTo) |
_UILogic | UI 坐标转换(例如,ScreenToWorldPosition)——仅客户端 |
如需完整列表,请直接查看
.d.mlua文件:./Environment/NativeScripts/Service/(46 个文件)和./Environment/NativeScripts/Logic/(9 个文件)。如需了解领域相关详情,请通过msw-search搜索。
不带 _ 前缀访问的内置全局变量
上述 _Name 规则仅适用于服务和逻辑。少数内置对象以普通全局变量的形式提供——访问时加上前导下划线会导致运行时错误(引用为 nil)。
| 全局变量(正确) | 错误写法 | 用途 |
|---|---|---|
Environment | ❌ _Environment | 执行环境查询——Environment:IsMakerPlay() / IsMakerEdit() / IsPlay() / IsPublishedPlay() / IsMobilePlatform() / IsPCPlatform(),以及 WorldId 属性。GetApplicationVersion() 仅客户端可用(服务器上为 nil)。 |
CollisionGroups | ❌ _CollisionGroups | 以组名为键、CollisionGroup 对象为值的表——CollisionGroups.HitBox、.Monster、.Player 等。内置组:Default / TriggerBox / HitBox / Interaction / Portal / Climbable,以及项目自定义的组。每个条目都有 .Id(字符串)和 :GetCollideGroups()。 |
13. 数学、工具、保留字、类型注解
数学 / 工具示例
_UtilLogic:RandomDouble() -- 0.0~1.0
_UtilLogic:RandomIntegerRange(1, 10) -- inclusive
-- ElapsedSeconds / ServerElapsedSeconds: world-instance lifetime, NOT reset on OnBeginPlay.
-- They keep ticking across repeated play sessions in the Maker editor.
-- For per-session countdowns, see "Per-session timers" below.
_UtilLogic.ElapsedSeconds
_UtilLogic.ServerElapsedSeconds
mlua 工具类
Lua 标准库之外的集合:List / ReadOnlyList / SyncList、Dictionary / ReadOnlyDictionary / SyncDictionary(Sync* 变体会自动同步服务器↔客户端)。其他工具类型:DateTime、TimeSpan、Regex、Translator、Quaternion、Vector2Int、FastVector2/3 / FastColor(就地运算,提升性能)、Item(库存)。
Dictionary/ReadOnlyDictionary/SyncDictionary上的.Values/.Keys会返回普通 Luatable——直接使用ipairs遍历。无需:GetValues()/:ToTable()/pairs(dict)封装。列表也类似,但需要先调用
:ToTable()(ReadOnlyList<T>不是 Lua 表)。-- 所有已连接的玩家(服务器端扇出) for _, user in ipairs(_UserService.UserEntities.Values) do if isvalid(user) then ... end end
详细 API 请参阅
Environment/NativeScripts/,或通过msw-search查询。
每个会话的计时器——不要以 ElapsedSeconds 为基准
陷阱:在 OnBeginPlay 中使用 self.deadline = _UtilLogic.ElapsedSeconds + 15。World 实例会在多个 Maker 游戏会话中持续存在,因此下次游戏时,保存的截止时间已经过去,会立即触发。
对于每个会话的倒计时,在 OnUpdate 中递减由 delta 驱动的属性:
property number waveCountdown = 0
method void OnBeginPlay() self.waveCountdown = 15 end
method void OnUpdate(number delta)
if self.waveCountdown > 0 then
self.waveCountdown = self.waveCountdown - delta
if self.waveCountdown <= 0 then self:StartWave() end
end
end
对于相对于会话的已用时间,在 OnBeginPlay 中设定基准(self.startTime = _UtilLogic.ElapsedSeconds),然后进行相减。切勿跨会话比较原始的 ElapsedSeconds。
类型注解(代码提示)
---@type T / ---@param / ---@return 仅提供编辑器自动补全——不会影响运行时。
保留字
禁止用作标识符:handler、property、method、script、end、extends、self、nil、true、false。
适用于局部变量、参数、属性、方法、点字段名(rec.handler)和未加引号的表键({ handler = ... })。可以用方括号引用外部字符串键(rec["handler"]),但建议重命名内部键(例如,eventHandler)。
14. 外部工具
- Maker MCP(
refresh/logs/play/stop/screenshot/…):msw-general技能。 .d.mlua中没有的 API 描述/示例/指南:msw-search技能。- MCP 配置 /
.mcp.json/ API 密钥设置:分享 https://maplestoryworlds-creators.nexon.com/ko/docs?postId=1368
调试顺序:构建日志 → 游戏运行 → 日志 → 停止 → 修复 → 诊断 → 刷新 → 重复。
15. 脚本编写工作流
- 搜索现有脚本(§1.1)——如果存在类似脚本,则修改它。
- 核实规范(§1.3)——优先查阅
.d.mlua,信息不足时使用msw-search。 - 决定路径(§1.2)——必须放在功能文件夹中;切勿写入
MyDesk/根目录。 - 编写。
- 验证——
mlua-diagnose钩子会自动运行;修复所有问题,直到错误数为零(§1.4)。 - 刷新——Maker MCP
refresh(§1.5)。 - (如有需要)
play→logs→stop(§17)。
删除/重命名也需要执行 refresh,并清理 .model / .map 中的引用。
16. 将脚本(组件)附加到实体
§1.7 触发条件——首先阅读 builder-protocol.md 和匹配的构建器协议文件。切勿将
Components数组作为原始 JSON 编辑。
- 附加到
.model(首选):使用ModelBuilder.addComponent()/upsertComponent()。地图实例会继承。 - 仅附加到一个地图实例:
MapBuilder.upsertComponent(name, "script.XXX", body)。 - 全局模型(
./Global/*.model):现有全局模板会影响整个项目,应通过ModelBuilder原地编辑,并使用 Maker Refresh。不要在Global/下创建新文件;请在RootDesk/MyDesk/Models/下创建新的自定义模型。
17. 游戏测试与调试
说明如何在 Maker 的游戏模式中验证行为,然后通过运行时日志、截图和模拟输入缩小问题范围。
有关 MCP 工具列表、游戏模式限制和刷新规则,请参阅
msw-general。
17.1 始终先检查构建日志
每次执行 play 前,都要运行 logs(kind="build")。构建错误会导致脚本完全无法加载(组件/逻辑表现得就像不存在一样),而且它们通常不会出现在运行时日志中——大多数“代码看起来没问题,但就是不工作”的情况,追根溯源都是遗漏了构建错误。修复 → 刷新 → 重新检查,直到错误数为零,然后再执行 play。
⚠️ 构建日志为空 ≠ 构建正常。 刷新阶段的 mlua 转换错误(Maker 弹窗 "An error occurred during mlua conversion")会绕过整个 Build Console:
refresh仍会报告成功,logs(kind="build")仍显示 0,而错误文本只会出现在logs(kind="normal")中。如果构建日志为空,但脚本仍无法加载——或有人报告了该弹窗——接下来应读取logs(kind="normal"),不要反复执行刷新→检查构建日志。在捕获原因前,不要执行clear_logs:清除操作会清空普通日志类别,也就是转换错误的唯一副本。
⚠️ 重新运行
refresh不会清除过期的构建错误。 刷新只会重新处理修改时间已更新的文件,因此,如果两次refresh之间没有编辑,第二次刷新不会重新编译任何内容——构建日志时间戳相同,错误也相同。跨文件签名变更是典型诱因(一个脚本中更改了参数列表,另一个脚本中的调用点未更新 → 针对旧签名的参数不匹配错误LEA-1102持续存在)。强制执行一次完整周期:在调用方文件中进行无害编辑(添加注释),然后执行refresh,并根据结果判断。
17.2 错误分类
| 类别 | 特征 | 检查位置 |
|---|---|---|
| 脚本错误 | 堆栈跟踪包含文件名和行号 | 确切的 .mlua 行;事件/时序顺序 |
| nil 引用 | attempt to index a nil value | 初始化顺序、isvalid、Spawn 后延迟 1 帧 |
| 缺少组件 | 组件为 nil / GetComponent 失败 | .model 中的 Components 数组;名称拼写错误 |
| 同步 / 网络 | 只有客户端出错,数值不匹配或收敛延迟 | @Sync、ExecSpace、RPC 流程 |
Info LIA 1113/1114/1115(读取/调用位置上的误报) | 静态分析无法解析用户跨脚本引用(_LogicName、用户 @Component 点号/方法调用)。构建仍会通过(errors=0/warnings=0) | 视为噪声;使用 log() 验证。例外:赋值目标上的 LIA-1114(self.<name> = ... 中的 <name> 未声明)是真实的运行时错误信号——执行 play 时会出现 cannot set <name>, no such field;请声明 property 或使用 self._T(§7)。如果这类日志淹没了真实问题,请提高下一次 logs 调用的严重性筛选级别。 |
用户类型 Symbol not found / type not found | 编写使用位置时,用户类型主体 .mlua 尚不存在。 | 先编写主体 .mlua,再在 Maker 中执行 refresh 以重新生成 .codeblock。构建日志缓存可能会保留一个过期周期——以接下来的诊断结果为准(文件未更改时执行 refresh 不会开始新周期,§17.1)。 |
如果日志无法明确判断问题,可在 .mlua 中添加带 [VRF] 前缀的 log(),检查实体/组件/属性状态(使用统一前缀,便于后续读取日志时筛选)。
17.3 测试结果报告
简要总结:场景(一行) · 环境(地图,是否已刷新?) · 步骤(输入/Lua) · 结果(通过/失败/受阻) · 证据(1–2 行日志;如有要求则附截图) · 后续操作。
有多个发现时,按严重程度列出(阻止执行 play > 行为错误 > 外观问题),并为每项附上复现步骤及其对应的 §17.2 类别。
17.4 工作流
每种游戏测试场景都遵循同一套流程:
edit → refresh → logs(kind="build") ──┐
↓ (errors? fix and refresh again)
clear_logs (optional) → play
↓
reproduce (one maker_execute_script probe, or simulated input)
↓
logs(kind="normal") → classify with §17.2 table
↓ (insufficient? add log() in .mlua, refresh, replay)
stop → fix → loop
复现成本 — 每次工具调用都需要完整往返一次大模型,且其输出会在本次会话的剩余时间内保留在上下文中。优先使用一个 maker_execute_script 探测来驱动场景并输出 [VRF] 日志,而不是串联大量 keyboard_input / mouse_input 调用;只有在测试输入路径本身时,才模拟真实输入。开始操作前,为本轮的所有变更设置 [VRF] 标记,这样一个周期就能验证全部变更。
变体 — 流程相同,入口条件不同:
| 场景 | 特别步骤 |
|---|---|
| 首次游戏测试 | 从编辑开始 → 刷新。 |
| 回归 / 修复循环 | 在 play 前运行 clear_logs,以便干净地复现问题。 |
| mlua 转换错误(弹窗,或构建日志为空但脚本始终未加载) | 先运行 logs(kind="normal") — 转换错误不会出现在构建日志中(§17.1)。捕获错误前不要运行 clear_logs。 |
| 错误分析 | 收集运行时日志后,先对照 §17.2 进行分类;只有分类结果不明确时才添加 log()。 |
| 运行时值检查 | 添加 log() 调用;如果 API 未知,先根据规范(§1.3)进行确认,再添加调用。 |
17.5 最终验证(通过 / 失败)
“没有错误 ≠ 通过。” 报告完成前,收集能证明预期逻辑确实已执行的正向 [VRF] log() 证据。完整清单见 references/verify-checklist.md(运行时 → 代码审查 → 日志证据 → 通过 / 失败)— 每轮仅运行一次,用一个运行时周期覆盖本轮的所有变更。
17.6 相关技能
msw-general — MCP 工具、截图 / 日志策略、刷新规则、工作区与层级。