mcpField
M
msw-scripting

msw-scripting

github.com/msw-git开发3810.0k 次安装3 周前更新
代码仓库

关于 msw-scripting

msw-scripting 是用于开发和验证 MapleStory Worlds .mlua 脚本的技能,面向游戏创作者,涵盖 MSW 专用 API、脚本结构、生命周期及从构建到运行时的调试。

主要功能

  1. 01开发 Component 和 Logic 脚本
  2. 02通过 .d.mlua 定义核对 API
  3. 03按功能目录组织脚本
  4. 04编辑后运行 LSP 诊断
  5. 05结合构建与运行日志验证
  6. 06遵循生命周期和执行空间规则

适用场景

  1. 01为现有脚本添加行为
  2. 02创建功能专属脚本
  3. 03诊断脚本错误和运行时故障
  4. 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 MCP refresh。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 + RigidbodyEntity↔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
在服务端代码中操作 UIUI 仅限客户端使用——服务端→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、OnMapLeaveServerOnly、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-1114 Info(见 §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(不要混淆)

世界触摸——两种方式
方式事件连接到用途
实体触摸 — 实体上的 TouchReceiveComponentTouchEvent(+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 操作前,先 Read builder-protocol.md 和对应构建器的协议文件。

子级遍历

对于“地图中的所有 X” / “名为 Y 的子级”查询,请使用 Entity 的查找工具集:

成员返回值用途
Entity.ChildrenReadOnlyList<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)
_UILogicUI 坐标转换(例如,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 会返回普通 Lua table——直接使用 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. 外部工具

调试顺序:构建日志 → 游戏运行 → 日志 → 停止 → 修复 → 诊断 → 刷新 → 重复。


15. 脚本编写工作流

  1. 搜索现有脚本(§1.1)——如果存在类似脚本,则修改它。
  2. 核实规范(§1.3)——优先查阅 .d.mlua,信息不足时使用 msw-search。
  3. 决定路径(§1.2)——必须放在功能文件夹中;切勿写入 MyDesk/ 根目录。
  4. 编写。
  5. 验证——mlua-diagnose 钩子会自动运行;修复所有问题,直到错误数为零(§1.4)。
  6. 刷新——Maker MCP refresh(§1.5)。
  7. (如有需要) 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 工具、截图 / 日志策略、刷新规则、工作区与层级。

评论

支持 Markdown