Torque3D脚本加载顺序、exec启动链路与排错指南 简介《Torque3D规范及说明书》是一份面向Torque 3D游戏引擎开发者与脚本初学者的技术文档适合希望系统了解引擎框架、补齐脚本语法基础并快速上手3D项目原型的读者。文档围绕游戏框架结构展开分别说明服务器端与客户端的职责划分、通信机制以及游戏从启动到帧更新的运行调用过程并进一步讲解UI编辑、世界地图编辑和游戏脚本编辑三类实际开发场景。语法部分覆盖常用命令、变量声明与类型、物体定义等内容可帮助读者理解脚本组织方式减少编写与调试中的常见错误。压缩包内为1个doc文件约442KB篇幅紧凑但目录层次清晰便于按章节检索和对照查阅。目前已有246人学习适合作为Torque 3D入门阶段的框架索引与语法速查材料。1. Torque3D 脚本层的真正门槛不是语法是加载顺序刚接手 Torque3D 项目的人多数会在同一个地方停住脚本明明写了运行时却像没加载过。问题往往不在if/while写错而在于不清楚哪个文件在哪个阶段被哪一层exec拉起来。这份《Torque3D 规范及说明书》的组织方式就是围绕加载顺序先摆 Server/Client 双端模块清单再拆main.cs的调用链路然后才讲脚本编辑三个入口F10 的 GUI 编辑器、F11 的世界地图编辑器、手写脚本、$与%作用域、字符串连接符最后落到datablock与new Object的实例化。它适合两类人手里有跑不起来的 Torque3D demo 想查断点的和要在旧工程上改 UI、改地图、加角色属性的。2. Server 与 Client 双端骨架六个服务端模块、七个客户端模块怎么对上号Torque3D 的框架图看起来像两张互不相干的模块表实际是同一套运行时的两个视角。服务器端负责权威计算客户端负责呈现与输入两边各自有一套地图和消息通道理解这种「镜像关系」比背模块名有用得多因为后面所有排错都落在「这条逻辑归哪端」上。写脚本时先问自己这段代码要在权威端跑还是只要在本地看到效果就行答案决定了它进server/init.cs还是client/init.cs。2.1 服务器端六个模块的职责切分文档把服务端拆成 Net Work Server、World Map、Database Connection、ServerCommand、Message、Object Data 六块。网络服务模块管连接与版本下发世界地图模块在服务端维持一份地图镜像主要用来算碰撞和判定保证所有客户机看到的位置随时间收敛数据库连接模块负责数据一致与更新服务器执行命令模块处理第一个客户机发来的可执行命令比如攻击、移动消息模块做转发与记录聊天走这里对象数据处理模块统管 Player、AIPlayer、物品的动作、碰撞、特效。服务端模块主要职责脚本里通常接触的位置Net Work Server客户机连接、数据传输、版本升级连接回调、GameConnection::系列函数World Map服务端地图镜像、碰撞与判定地图加载、MissionCleanup相关Database Connection与服务端数据保持一致与更新存档、偏好导出ServerCommand执行客户机上报的可执行命令命令处理函数Message消息转发记录聊天为主消息回调Object DataPlayer、AIPlayer、物品的动作碰撞特效datablock与被控对象这张表的作用不是让你背下来而是排错时能快速定位。角色能走动但打不中东西八成在 Object Data 的碰撞判定聊天发出去对方收不到去看 Message 转发。新人不做这个区分容易把所有问题都往客户端塞。2.2 客户端七个模块Canvas 到底管到哪一步客户端侧是 Canvas、Gui、Action Map、ServerConnection、ClientCommand、Message、World Map 七块。Canvas 负责整个游戏的画面窗口设置包括显示驱动类型、分辨率、色深Gui 是所有可见画面和按钮从启动界面到游戏内 HUD 都在这里Action Map 是键位动作映射功能键和操作方法的对应关系就写在它下面ServerConnection 是与服务器的连接与数据交换通道ClientCommand 接收服务器下发的命令比如让角色坐下Message 把收到的服务器消息显示出来World Map 载入服务器指定的地图并做呈现。这里的实用价值在于划界。改分辨率、改色深找 Canvas 的初始化时机不要在某个 GUI 控件里硬编码改按键先去 Action Map 看映射有没有绑上再怀疑逻辑函数改 HUD 布局GUI 编辑器按 F10 就能实时调改完存盘再回脚本里补功能。很多「改了没反应」的情况是改在了错误的那一层。2.3 两端同名模块不要重复造轮子服务端和客户端都有 World Map也都有 Message但职责不同服务端那份是权威镜像用来算碰撞判定客户端那份是呈现层负责显示。同理服务端的 Message 做转发记录客户端的 Message 做显示提示。常见误用是把服务端的判定逻辑抄一份到客户端看着能用实际上两边状态会漂移尤其是多人场景下位置和伤害对不上排查起来极其费劲。我一般会这么处理任何涉及「谁赢了、谁掉血、谁在什么位置」的判断一律只在服务端写客户端只做输入采集和表现。需要本地预测的地方明确标注这是表现层并且保证服务端的最终结果能覆盖回去。文档里强调服务端地图镜像「保证所有客户机与服务器信息保持时间的一致」说的就是这件事。分不清这一层写出来的东西单机测试全对联机必翻车。3. 从 main.cs 到 onStart启动链路四层调用与 exec 顺序Torque3D 的启动不是「双击 exe 就跑」而是一层层exec递归拉起来的。执行游戏可执行文件后先调用主目录下的main.csmain.cs再装载游戏目录里的main.cs游戏目录的main.cs先装载common目录那里放的是基本命令、函数、GUI最后通过一个package把client、server、data三块的init.cs依次拉起来。整条链路上每一层都可能静默失败这是最坑的地方。所有被exec的脚本会编译成对应的.dso文件运行时实际调用的是这些编译产物这一点决定了改脚本后行为不一致的成因。3.1 loadDir 与 loadMods递归加载的写法与失败判定先看主目录main.cs里两个关键函数这是整条链路的第一层// main.cs 片段目录装载与模块递归加载 $defaultGame starter.fps; // 默认游戏目录名 function loadDir(%dir) { setModPaths(pushback($userMods, %dir, ;)); // 把目录压进模块搜索路径 exec(%dir /main.cs); // 执行该目录下的入口脚本 } function loadMods(%modPath) { %modPath nextToken(%modPath, token, ;); // 取出第一个 ; 分隔的路径 if (%modPath !$ ) loadMods(%modPath); // 递归处理剩余路径 if (exec(%token /main.cs) ! true) $modcount--; // 加载失败就回退计数 } loadMods($userMods); // 启动时把游戏目录里的 main.cs 拉起来逻辑上是「先递归处理剩余路径再执行当前路径」也就是后进先出保证依赖在前的模块最后被加载。pushback把目录追加进$userMods这个以分号分隔的字符串nextToken每次切下一个 token%token是被切出来的目录名。exec返回布尔值写! true而不是!exec(...)是因为部分exec失败会返回非 0 非 1 的值用严格比较更稳。参数上要改的通常只有$defaultGame换成你自己的游戏目录名。$userMods一般由启动参数或上一层脚本拼接不要手动改成绝对路径后面第 5 章会讲为什么。$modcount是模块计数加载失败时减一某些工程会用它判断是否有模块没起来接手老项目时可以先在控制台echo($modcount);看一眼。3.2 package StarterKit 里的 onStart 与 onExit第二层到第四层的收口在package StarterKit这个包里。包里包了两个引擎自调的函数onStart()做初始化onExit()做保存与释放。这两个函数全部由引擎自行调用你自己不要主动去调。package StarterKit { function onStart() { Parent::onStart(); // 必写引擎侧初始化与资源准备 exec(./client/init.cs); // 客户端脚本与 GUI exec(./server/init.cs); // 服务端脚本 exec(./data/init.cs); // 数据与资源定义 initServer(); // 先起 server 层客户机可内置服务器 if ($Server::Dedicated) initDedicated(); // --- Server --- server/init.cs else initClient(); // --- Client --- client/init.cs } function onExit() { export($pref::*, ./client/prefs.cs, False); // 存客户端偏好 if (isObject(moveMap)) moveMap.save(./client/config.cs, false); // 存地图配置 export($Pref::Server::*, ./server/prefs.cs, False); BanList::Export(./server/banlist.cs); // 存封禁列表 Parent::onExit(); // 必写释资源 } };Parent::onStart()和Parent::onExit()是对引擎内部实现的调用前者做引擎侧初始化后者释放资源两个都不能省。省了Parent::onStart()的典型表现是初始化走到一半就停窗口能出来但鼠标、GUI 状态不对省了Parent::onExit()则是退出时偏好不落盘下次进去设置全丢。这类问题单看脚本逻辑看不出来因为函数本身没报错。注意initServer()是在initClient()之前调用的注释里写得很清楚客户端可以在游戏内充当服务器所以 server 层对所有会话都要加载再由$Server::Dedicated分流到专用服务器模式还是客户端模式。改造启动流程时这个顺序不能反反了会出现客户端先初始化、再回头找 server 对象找不到的情况。3.3 相对路径 ./ 与 ~/移动目录后第一个崩的就是它脚本里资源路径写错报错往往很晚才出现甚至不报错只是模型不显示。文档里给了两个关键字./表示当前调用文件所在目录~/表示当前主游戏文件main.cs的目录。这两者混用是新手最常翻的车。判据很简单写exec加载同级脚本用./写shapeFile、debrisShapeName这类资源引用用~/。上面PlayerData里shapeFile ~/data/shapes/player/player.dts;用的就是~/因为资源是相对主main.cs固定的目录树而脚本之间的相互引用是相对当前文件走的。主目录改名或整体移动时~/写法的资源路径会跟着变./写法的脚本引用也不受影响而写死绝对路径的工程会当场全崩。提示老工程里如果混着写死的盘符路径先全局搜一遍再动手改别边跑边改容易把能跑的模块也带崩。4. 脚本语法、变量与 datablock$ 和 % 的边界 和 \c 的细节Torque 脚本和 C 长得像坑却都长在「像」的地方变量不用预定义、类型灵活、名字大小写不敏感、每条语句必须用分号收尾。控制台是这条路上最好用的工具运行后按~键其实是反引号键不用按 shift激活echo()是最简单的调试手段echo(hello!);必须带分号和 C 一样。多行语句可以连续输入引擎按分号分行执行比如$a1;echo($a);会输出 1。4.1 $ 与 % 的边界全局、局部与数据块变量$开头是全局变量%开头是局部变量局部变量一般用在function内部。这里有个容易误解的点网络环境下的$全局变量只表示本客户端的全局不是整个网络共享的全局跨客户端的数据必须走服务端。把「全局」理解成「所有玩家共享」写出来的东西单机对、联机错。数据块里增加局部变量的写法是在new datablock(){...}里直接写新变量名 初始化值;这些自定义属性只在脚本里可用不改变引擎内置属性。我一般会加个前缀做命名空间比如my_xxx避免和引擎已有的属性重名重名时引擎属性优先你会看到设了值但不生效。$a 2; // 全局变量 $a[0] 3; // 数组元素与 $a 各自独立 echo($a); // 输出 2 echo($a[0]); // 输出 3 echo($a0); // 输出 3$a0 是指向 $a[0] 的另一种写法 echo($a0_0); // 等价于 $a[0,0]多维数组的另一种指向方式最后两行是 Torque 数组的下标折叠规则$a0被解析成$a[0]$a0_0被解析成$a[0,0]。这个特性在动态拼变量名时非常好用比如按编号批量取配置但也是命名冲突的高发区$a和$a0是两个东西别看着像就当成一个。数组的三种写法$AA[n]、$AA[M,N]、$AA[M_N]都合法选一种在项目里统一混着用迟早要对不上。4.2 字符串、标记字符串与连接符数字型有整数1234、浮点1.234、科学计数1234e-3、十六进制0xc001。字符型要区分abcd字符串和abcd标记字符串两者完全不同别混用。逻辑型只有true(1)和false(0)。矢量格式是带空格的字符串比如1.0 1.0 1.0 1.0在 3D 坐标、动力、粒子、声效里大量使用。最反直觉的是加法1 2输出 3 而不是 12因为引擎做了数值转换真正的字符串拼接要用。除了还有TAB、SPC、NL三个连接符分别插入制表符、空格、换行而且必须大写写小写不生效且不报错。echo(1 2); // 输出 3走数值相加 echo(1 2); // 输出 12 才是拼接 echo(abc NL def); // 换行输出两行 echo(abc SPC def); // 中间插一个空格 echo(\c2hello!\c0hello!); // 前半段变色后半段恢复默认色格式化字符里\n换行、\r回车、\t制表符、\\反斜杠颜色相关的\c0到\c9设置后续文字颜色\cr回默认色\cp把当前色压栈\co从颜色栈取色。颜色操作只对当前行有效不影响默认字体色所以调试信息打红、正常信息打黑这种区分很好做。字符串比较用$判等、!$判不等。控制语句和 C 基本一致if-else、switch、while都能直接用switch里break;必不可少漏了会贯穿到下一个 case。4.3 控制台 echo 调试法把黑匣子撬开一条缝脚本没有断点器可用时echo()就是唯一的光。我习惯在三个位置固定打点函数入口打参数、关键分支前打条件值、exec之后打返回值。exec返回非 true 就说明这个文件没进来比猜快得多。function myLoadScripts() { %ok exec(./server/scripts/player.cs); echo([load] player.cs - %ok); // 返回 1 才算真进来 if (%ok ! true) echo([warn] player.cs 加载失败检查路径与语法); }注意exec失败不总是抛错语法错的脚本有时只是不生效。看到%ok是 0 或空先去查路径再用控制台手动exec一次同一条命令把报错捞出来。另一点是.dso缓存脚本被编译成.dso后运行改源码但.dso没重新生成时行为会和源码不一致删掉对应的.dso重新跑一次基本能排除这类玄学。4.4 datablock 与 new Object属性定义和实例化是两步Torque 里所有东西都是「物体」人物、汽车、场景物件都能用脚本访问属性在引擎里定义。datablock负责定义属性new负责创建实例两步不能合并理解。下面这段是角色属性定义的典型写法datablock PlayerData(PlayerBody) { renderFirstPerson false; className Armor; shapeFile ~/data/shapes/player/player.dts; // ~/ 指主 main.cs 所在目录 cameraMaxDist 3; cameraDefaultFov 90.0; cameraMinFov 5.0; cameraMaxFov 120.0; debrisShapeName ~/data/shapes/player/debris_player.dts; debris playerDebris; };datablock只是把属性写进内存用exec(./player.cs);载入时并没有真正创建角色。真正创建发生在GameConnection::createPlayer里这段代码信息量很大值得逐行看function GameConnection::createPlayer(%this, %spawnPoint) { if (%this.player 0) error(Attempting to create a ghost player!); // 一个连接只挂一个玩家 %player new Player() { dataBlock PlayerBody; // 必须绑定上面定义的属性块 client %this; // 必须归属到连接 }; MissionCleanup.add(%player); // 放进场景清理组 %player.setTransform(%spawnPoint); // 出生点来自场景编辑器里定义的点 %player.setShapeName(%this.name); %player.setInventory(Crossbow, 1); // 初始武器 %player.setInventory(CrossbowAmmo, 10); // 初始弹药 %player.mountImage(CrossbowImage, 0); // 0 表示 mount01 是 mount1依次类推 %this.camera.setTransform(%player.getEyeTransform()); %this.player %player; // 把控制权交出去 %this.setControlObject(%player); }创建物体的通用写法就是%句柄 new 物体类型(名称) { datablock 属性块; 内置属性 值; 自定义属性 值; };datablock是必填项可以是自定义的也可以是引擎内置的自定义属性只在脚本里生效不影响引擎行为结尾那个分号不能少。setTransform的出生点来自场景编辑器里定义的点再经pickSpawnPoint()产生随机点所以角色出现位置不对时先去看编辑器里的出生点标记别急着改脚本。5. 避坑与排查五条踩过的坑每条都有现象、原因和解法这一章是整份文档里最容易被人跳过、事后最后悔的部分。下面五条都是实际会撞上的按「现象 → 原因 → 解决」写遇到同类问题直接对号入座比重读语法快。5.1 改完脚本没生效先怀疑 .dso 缓存现象脚本改完保存重启游戏行为还是旧的echo也没打出来。原因Torque 会把脚本编译成.dso文件再执行源码改了但.dso没重新生成或者生成失败却沿用了旧文件运行时用的还是旧逻辑。解决找到对应脚本同目录下的.dso删掉重新启动让它重编译同时用控制台手动exec一次源码路径看有没有语法报错。语法错误会导致编译中断.dso停留在上一版这就是典型的黑匣子行为。5.2 相对路径和标记字符串混用资源静默不显示现象模型不显示、特效不出来控制台没明显报错。原因./和~/混用或者该用双引号字符串的地方写成了单引号标记字符串。...是字符串...是标记字符串两者完全不同传给资源加载函数时后者可能被当成别的类型。解决资源引用统一用~/加双引号脚本互相exec用./。改完在控制台echo出实际拼出来的路径对照文件系统确认。接手老项目时先全局搜一遍盘符路径一次性替换。5.3 漏写 Parent::onStart()初始化走一半就停现象窗口能出来但 GUI 状态不对、鼠标异常或者某些模块看起来加载了但功能不全。原因package里覆盖onStart()时忘了先调Parent::onStart();。这个函数是引擎侧初始化入口跳过它会丢掉一部分底层准备脚本层还照样往下走所以不报错。解决onStart()第一行固定写Parent::onStart();onExit()最后一行固定写Parent::onExit();。把它当成模板别每次现想。5.4 datablock 先用后定义拿到的属性全是默认值现象角色或物件外观、参数不对比如视角距离、模型文件全走默认。原因datablock的exec加载顺序在实例化之后或者属性名和引擎内置属性重名脚本里设了值但没覆盖掉引擎默认值。解决确认exec(./player.cs)在创建对象之前执行加载顺序按data→server→ 创建对象来排。自定义属性加项目前缀避免和内置属性重名重名时内置属性优先这一点不写在文档里很难猜到。5.5 句柄当网络 ID 用联机后对象对不上现象单机测试正常联机后客户端拿到的对象 ID 和服务器不一致操作错位。原因new返回的句柄在客户端内唯一但网络环境下还有一套网络 ID两者不同。一个物体存在两个 ID本地 ID 只在本地有效网络 ID 才在整网唯一。解决凡是跨客户端传递对象引用一律用网络 ID 那一套别把本地句柄存进要同步的数据里。写代码时在变量名上区分比如%localId和%netId比事后排查省事得多。做网络编程这一块先确认 ID 语义再动手写逻辑。注意这五条里有三条5.1、5.4、5.5不报错、只表现为行为异常靠读代码很难发现必须靠打点和实际运行验证。6. 进阶验证用 eval 和 schedule 把脚本变成可检查的运行时脚本写对不对光看代码不算数。文档里提到两个很实用但容易被忽略的函数eval(Command String)可以把拼接出来的命令字符串当代码执行适合按名称动态取函数或取配置schedule()可以做延时和循环调度。把这两个用起来就能在游戏里给自己搭一套轻量自检不用每次都靠肉眼盯。// 运行时自检把当前场景里每个客户端挂的玩家对象挨个打出来 function dumpPlayers() { %count ClientGroup.getCount(); for (%i 0; %i %count; %i) { %client ClientGroup.getObject(%i); %player %client.player; if (isObject(%player)) echo(client SPC %i SPC - SPC %player SPC id %player.getId()); else echo(client SPC %i SPC 没有挂玩家对象); // 常见于 createPlayer 没走到 } } // 延迟 2 秒执行一次避开 onStart 阶段对象尚未创建的时刻 $dumpSched schedule(2000, 0, dumpPlayers);schedule的三个参数是延迟毫秒数、执行对象 ID0 表示不绑对象、要调的函数名。延迟时间要根据初始化耗时长调整2000 毫秒是常见起点改小容易在对象还没建好时跑导致空结果改大则等太久。返回值存起来需要取消时用cancel($dumpSched);。这段自检的价值在于createPlayer中途失败时日志里往往什么都没有但玩家列表一打就露馅比断点还直接。下面这张清单是我每次改完启动链路或新增模块后固定过一遍的比凭感觉点一遍快检查项预期结果不通过时先看哪exec返回值每个模块都是 1路径、语法、.dso$modcount与模块数一致哪个模块加载失败控制台启动日志无error(输出首个报错行往上三条dumpPlayers输出每个 client 都有 playercreatePlayer是否被调用退出后偏好文件prefs.cs时间戳更新onExit是否漏Parent::刚开始看这份文档我以为重点是语法那几章后来发现真正省时间的是框架结构和启动链路那两块因为出错概率最高的永远是「该加载的没加载」。从那以后我每次动main.cs或者加新模块都强制先把exec返回值和控制台日志过一遍再往下写功能宁可多花五分钟也不要在联机阶段靠猜。希望帮到你。本文还有配套的精品资源点击获取