ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Torque3D脚本加载顺序、exec启动链路与排错指南

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.cs;main.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++ 一样。多行语句可以连续输入,引擎按分号分行执行,比如$a=1;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 表示 mount0,1 是 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的三个参数是延迟毫秒数、执行对象 ID(0 表示不绑对象)、要调的函数名。延迟时间要根据初始化耗时长调整,2000 毫秒是常见起点,改小容易在对象还没建好时跑导致空结果,改大则等太久。返回值存起来,需要取消时用cancel($dumpSched);。这段自检的价值在于:createPlayer中途失败时,日志里往往什么都没有,但玩家列表一打就露馅,比断点还直接。

下面这张清单是我每次改完启动链路或新增模块后固定过一遍的,比凭感觉点一遍快:

检查项预期结果不通过时先看哪
exec返回值每个模块都是 1路径、语法、.dso
$modcount与模块数一致哪个模块加载失败
控制台启动日志无error(输出首个报错行往上三条
dumpPlayers输出每个 client 都有 playercreatePlayer是否被调用
退出后偏好文件prefs.cs时间戳更新onExit是否漏Parent::

刚开始看这份文档,我以为重点是语法那几章;后来发现真正省时间的是框架结构和启动链路那两块,因为出错概率最高的永远是「该加载的没加载」。从那以后我每次动main.cs或者加新模块,都强制先把exec返回值和控制台日志过一遍再往下写功能,宁可多花五分钟,也不要在联机阶段靠猜。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表