Skip to content

Heli-Lab/relert.js-browser

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

50 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

relert.js-browser 0.1 说明文档(2021.8.30未完成)

警告:本版本为尚未完成的开发版,许多功能不完善且未经验证。请在使用前对你的地图做好备份。

简介

relert.js是什么?

relert.js是由Heli开发的系列JavaScript库,它允许地图制作者使用原生JavaScript直接对游戏《红色警戒2》的地图文件(.map等)进行操作,从而更方便的编写脚本处理地图。

FinalAlert的“工具脚本”功能使用了自带的类似汇编语言的低级脚本语言,它过于底层的语法不能清晰地表述逻辑,代码编写困难且不可读,其运行效率也是相当低。

而有了relert.js,你就可以解放JavaScript作为一门高级语言完全的编程能力,用更少的代码,以更高的运行效率,来实现更复杂的逻辑。

relert.js-browser是什么?

最初版本的relert.js运行在node.js环境下,虽然我自己使用它感到十分满意,但大部分地图制作者并没有node.js的运行环境。

为了实现对大多数人的“开箱即用”,我决定将relert.js的运行环境移入浏览器——浏览器嘛,没有人的电脑里面没有这个。

这就促成了relert.js-browser这一分支项目。

当然,除了运行环境的移植以外,我还希望通过一些新技术的使用,进一步简化逻辑,让使用者在调用接口的时候可以得到更加符合直觉的结果。为此我也将代码进行了大规模的重构,希望这些重构是值得的。

运行需求

relert.js-browser主要通过网页加载的方式运行。

需要浏览器支持ECMAScript6标准和一些比较新的特性,具体版本为:

  • Chrome 71 版本及以上
  • Edge 79 版本及以上
  • Firefox 65 版本及以上
  • Safari 12.1 版本及以上
  • Opera 58 版本及以上
  • 各种双核浏览器请使用“极速模式”
  • 不支持IE所有版本,及各种IE内核的套壳浏览器

一句话概述:除IE以外主流浏览器的最新版本均可使用。

当然除了relert.js-browser框架本身的运行需求以外,用户编写出来的的脚本也有自己的运行需求——如果你使用了更加激进的新特性来编写脚本,那么使用的范围就会更窄。

不过,鉴于relert.js-browser是一件有效的生产力工具,没有必要牺牲开发的便捷度而去追求对远古浏览器的兼容性,还是请诸位mapper把自己的浏览器升级到最新版为宜。

除了浏览器之外,你还可以通过node.js加载relert.js,以纯命令行的方式执行。

快速上手

如同一个网站一样,你可以使用index.html作为relert.js-browser的入口,它会为你提供一个浏览器窗口,其中包含一个简单易用的文本编辑器界面。

但你也可以在自己编写网页的环境中运行,或者在node.js环境中使用。在文档的后续章节会提供相关的参考。

“快速上手”部分的教程都以默认的index.html作为规范。

用户界面结构

relert.js-browser的用户界面总体分为左右两栏,而右侧有上下两个区域,它们的功能如此划分:

  • 左栏:地图快照列表。relert.js-browser用“快照”这一概念来管理地图在各个时刻的状态。你可以上传一张地图,分别执行多个脚本,生成多个“快照”,并随意在这些“快照”之间切换。
  • 右栏上半部分:脚本编辑区。这里有一个简易的多标签文本编辑器界面,可以在这里查看并编写需要在地图上执行的JavaScript脚本。
  • 右栏下半部分:调试信息区。脚本执行时输出的调试信息、警告信息、报错信息都会显示在这个区域。

HelloWorld

下面,我们试着编写并运行一些脚本。

在右栏上半部分的脚本编辑区键入以下代码:

relert.log('helloworld');

然后点击脚本编辑区上方的“运行”按钮。

你可以看到,在右栏下半部分的调试信息区出现了类似这样的内容:

[2021.08.16 20:30:17] - helloworld

前面方括号内的内容是程序执行时的时间,而在后面,我们可以看到,字符串“helloworld”被成功输出了。It works!

本文档不会讲述JavaScript语言的语法,只会描述relert.js提供的接口——毕竟relert.js只是一个JavaScript的库而已,它并不是一个脚本引擎。也就是说,只要是你的浏览器支持的JavaScript语法,都可以在relert.js-browser之中运行!

JavaScript语言本身的教程很容易搜索到。或者照着本文档末尾的“常用逻辑示例”照葫芦画瓢,也可以很快写出能用的代码。

打开地图,并在地图上执行代码

relert.log(info: String)这个JavaScript函数用于在调试信息区输出内容,这个函数不需要在某一张地图上就可以执行。

如果想要使用脚本具体操作地图上的物件,我们需要先加载一张地图。

加载地图的按钮在整个网页的左上角,左栏地图快照列表的最上端。按下它,会弹出一个选择文件的对话框。

选择你自己的地图文件以后,你会发现地图快照列表里面多了一项,它展示了你刚才打开地图的名称、打开时间,下面还用斜体字展示了“打开文件”四个字——这就是一个“快照”。(注:为了接下来的示例脚本顺利运行,选中的地图上应该有平民建筑物)

快照代表了一张地图在某时某刻的状态,以下三种操作会生成新的快照:

  • 打开地图文件

  • 在一个快照上执行脚本。“执行脚本”的过程其实就是:脚本在当前快照的状态上进行操作,操作的结果会存入下一个状态,也就是新的快照。

  • 刚刚打开relert.js-browser时,从缓存中恢复上一次关闭之前的最后一个快照。

接下来我们尝试通过执行脚本的方式生成快照。在右栏上半部分的脚本编辑区键入以下代码:

relert.Structure.forEach((structure) => {
    if (structure.House == 'Neutral House') {
        structure.Strength = relert.randomStrength(0.15, 0.24);
        relert.log(`${structure.Type}, ${structure.Strength}`);
    }
});

然后点击脚本编辑区上方的“运行”按钮。

这次在右栏下半部分的调试信息区出现了更多的内容,而且我们看到,左侧边栏的地图快照列表中新增了一个快照——它就是这段脚本执行以后地图的状态。而在这个快照的上方,地图刚打开时的原始状态仍然保留在快照列表中。

你可以通过快照列表里面的“载入”按钮随时切换哪一个快照为当前快照(在列表中使用彩色显示)。脚本的执行都是在当前快照上执行。

保存”按钮允许你把任何一个快照以文件的形式下载。

删除”按钮允许你删除某个快照。注意,删除快照的操作是不可恢复的。

遍历对象,操作其属性

我们回到上面的那段测试代码。它具体做了什么呢?

作为relert.js这个库的入口,我们提供了relert这一全局对象作为所有函数的挂载点。也就是说,我们调用relert.js的所有接口时,都以relert.XXXX的格式调用。

relert.Structure提供了“建筑(Structure)”这一数据代理。数据代理这一概念在后面文档中会详细的讲,但在这里,你可以暂时理解为:我们通过relert.Structure这一接口,来更加方便直观的操作建筑对象。

有了relert.Structure以后,我们可以通过relert.Structure.forEach函数这一遍历器来遍历所有的建筑。

relert.Structure.forEach((structure) => {
    // 相当于从relert.Structure中,取出每一个structure,进行这里面的操作
    // 中间写的代码都是针对structure的操作
    // structure只是一个变量名,你可以任意的自定义,比如用i都行
});

中间部分的代码就是对单个Structure子数据代理进行的操作了。之所以叫“子数据代理”,是因为数据代理Structure下面还有另外一层数据代理,可以让你十分舒服的对单个的某一座建筑进行操作,比如,形如JavaScript的赋值语句XXXX = XXXX的形式:

    // 如果这个structure的House属性(所属方属性)等于'Neutral House',即单人任务中的平民作战方
	if (structure.House == 'Neutral House') {
        // 那么,设置这个structure的生命值,即Strength属性,为15%~24%之间的随机值
        // relert.randomStrength这一函数也由relert.js库提供
        structure.Strength = relert.randomStrength(0.15, 0.24);
        // 输出这个建筑的类型,以及它的当前生命值
        relert.log(`${structure.Type}, ${structure.Strength}`);
    }

完整的拼起来就是:

// 相当于从relert.Structure中,取出每一个i,进行这里面的操作
relert.Structure.forEach((i) => {
    // 如果这个建筑i的House(所属方)属性等于'Neutral House'
    if (i.House == 'Neutral House') {
        // 那么,设置这个建筑i的Strength(生命值)属性为15%~24%之间的随机值
        i.Strength = relert.randomStrength(0.15, 0.24);
        // 输出这个建筑i的类型,以及它的当前生命值
        relert.log(`${i.Type}, ${i.Strength}`);
    }
});

以上脚本中,每一条代码都带有强烈的语义性,这是接口的优化带来的正面效果。希望这样的代码能读起来能像流程图一样清晰明确,并帮助你构建更加复杂的逻辑。

“快速上手”部分到此为止。想要完成更多的功能,其实你需要的只是在这篇文档中,找到相应的接口,然后用JavaScript的方式合理的调用它们。

祝Scripting愉快!

数据接口

由于relert.jsrelert.js-browser暴露的接口几乎完全一致,下文如不特殊说明,均使用relert.js统一指代。

直接操作INI

relert.js会将整张地图的INI结构,转化为JavaScript Object类型的数据对象,并通过relert.INI这一属性对外暴露。

结构

INI文件格式是[Section] key = value的两层结构,转成JavaScript Object以后也是一个两层的结构,如同这样:

{
    Section: {
        key: 'value',
    },
}

我们来举一个稍微复杂点的例子。

比如说一段INI的结构是这样的:

[SectionA]
keyA1 = valueA1
keyA2 = valueA2

[SectionB]
keyB1 = valueB1
keyB2 = valueB2

把它转化成JavaScript Object就是:

{
    SectionA : {
        keyA1 : 'valueA1',
        keyA2 : 'valueA2',
    },
    SectionB : {
        keyB1 : 'valueB1',
        keyB2 : 'valueB2',
    },
}

在这个例子中,我们在relert.js中想访问SectionA中的keyA1属性,可以这样写:

relert.INI['SectionA']['keyA1']

对这个属性进行直接的取值或者赋值都是可以的。

这就是对INI的直接操作。

注意relert.INI['SectionA']不一定存在,有时候你的代码需要考虑它不存在、 即读取出来的值是undefined的情况,以避免程序出错)

多属性操作

如果想要同时对多个属性进行操作,可以考虑使用我们在relert.Toolbox模块为Object引入的原型方法:Object.prototype.assign

下面展示一个实用性的例子:

// 本段程序作用:把列表中的平民单位的属性,都改为“可被自动攻击、生命值50点”

// 需要修改属性的平民单位列表
let civList = ['CIV1', 'CIV2', 'CIV3', 'CIVA', 'CIVB', 'CIVC'];

for (let i in civList) {
    // 判断对应的平民单位字段是否存在
    if (!relert.INI[civList[i]]) {
        // 如果不存在就新建一个空的
        relert.INI[civList[i]] = {};
    }
    // 使用assign()方法将后面的对象合并入前面的relert.INI[civList[i]]
    relert.INI[civList[i]].assign({
        // 这样利用合并机制,可以同时修改多个属性
        Insignificant: 'yes',
        Strength: 50,
    });
}

总结

relert.INI提供了最基本的底层接口。其实它已经允许我们直接修改地图相关的任何底层数据(毕竟红色警戒2的地图本质上就是一个INI文件),但是这种修改方式只对修改内置rules显得比较友好,进行其它地图相关的操作就显得非常繁琐了——当然,relert.js一定会有让你满意的办法!这就要等后面章节慢慢介绍了。

数据代理

除了直接操作INI以外,你还可以通过relert.js抽象出的数据代理接口,以一种人类可读的方式,对一些在游戏中有明确意义的属性进行操作。

为什么称之为“数据代理”呢?因为它只是对relert.INI的操作进行转化的中间层,并没有在relert.INI以外的地方存储额外的数据——这意味着,你对数据代理进行任何操作后,其背后的实际数据,relert.INI中的对应字段,也会进行实时的更新。

数据代理的具体实现使用了JavaScriptProxy对象:Proxy对象允许我们接管对一个对象所有的操作,包括对它属性的赋值等基础操作。比如,我们想要修改某座建筑物的生命值,通过数据代理,只需要做以下操作:

  • 通过在建筑列表的数据代理relert.Structure之中,(通过索引或者遍历器),找到这个具体建筑的数据代理a
  • 直接把a.Strength赋值为你需要的值。

这样就完成了“修改生命值”的过程。其余的操作,包括解析建筑数据的编码解码、导入INI等操作,relert.js提供的数据代理都会在内部帮你完成。

接下来,本文档将按照大类,具体罗列relert.js中提供的所有数据代理。

物体 Object

物体Object描述这样一类对象:它们被放在地图上的某个位置。即,每一个物体Item,都有明确的位置坐标(X, Y),对应属性Item.XItem.Y

由于它们都具有XY属性,因此它们都可以被当成“坐标”,传入需要输入坐标的函数接口。

属于物体Object的对象有以下几类:

INI中的注册位置 relert.js中的访问接口 描述
Structure relert.Structure 建筑物
Infantry relert.Infantry 步兵
Units relert.Unit 车辆单位
Aircraft relert.Aircraft 飞行器
Terrain relert.Terrain 地形对象
Smudge relert.Smudge 污染
Waypoint relert.Waypoint 路径点
各作战方注册表下 relert.BaseNode 基地节点
CellTags relert.CellTag 单元标记

注意 :覆盖物Overlay在地图中的存储方式有点特殊——它是以Base64编码的二维数据进行存储的。因此在relert.js中,并没有把它归类于物体Object,而是归类于后面介绍的MapData类型。

公共操作

下面章节将会介绍一些适用于所有Object“物体”数据代理的公共操作。

虽然它们在地图中的存储形式不尽相同,但是relert.js尽量把它们包装成了相同的接口。接下来的部分都使用建筑Structure来举例。

按注册号访问

几乎所有的物体都是以一个列表来存储的,因此均可以以数据代理作为入口,按照注册号进行访问,得到表示单个物体的子代理:

relert.Structure[0] //表示地图上的0号建筑
relert.Structure[1] //表示地图上的1号建筑

你也可以通过count或者length属性,来获得一类物体的总数量:

relert.Structure.count //地图上建筑的总数量
relert.Structure.length //同上一条
遍历

对于一个Object类型数据代理,relert.js提供了两种接口来方便的遍历它:forEach函数和for ... of循环。

// forEach遍历接口
relert.Structure.forEach((item, index) => {
    //item为Structure之中一个元素的数据代理
    //index为该元素的注册号(也可以不使用index)
});
// for ... of遍历接口
for (item of relert.Structure) {
    //item为Structure之中一个元素的数据代理
}

虽然通过countlength获得所有注册号,然后通过for循环按照注册号进行访问是可行的,但还是推荐使用上文的两种接口——它们内部对删除做了特殊处理,允许方便的边遍历边删除。

// 遍历的同时进行删除
relert.Structure.forEach((item) => {
    if (...) { //如果满足了一定的条件
        item.delete(); //此时删除正在被访问的item,遍历器仍然保证以后可以访问到每一个元素
    }
});
新增

当我们需要新增一个物体的时候,只需要调用它相应代理下的add接口:

// 添加一个新建筑
relert.Structure.add({
    X: 12, //要增加的新建筑的属性,详见各个子代理的参数列表
    Y: 34,
    ...//未指定的参数会被设置为默认值,详见各个子代理的参数列表
});

add接口会返回新增物体的子代理。

属性设置

通过注册号或者遍历器获得子代理以后,相当于直接对某一个物体的属性进行操作了。

可以把物体的属性当作对象属性,直接进行赋值和取值操作:

// 赋值和取值操作
relert.Structure.forEach((item) => { //此时item就表示了单独的某座建筑
    relert.log(item.Type); //直接输出item.Type的值
    item.Strength = 255; //直接给item.Strength赋值255
});

这些数据操作都会即时的反馈在INI的变化中。

关于物体的属性,详见各个子代理的参数列表。

同时修改多个属性建议使用assign接口:

// assign接口同时设置多个属性的值
relert.Structure.forEach((item) => { //此时item就表示了单独的某座建筑
    item.assign({ //使用子代理的assign接口
       Strength: 255,
       AIRepair: 1, //同时设置多个属性的值
    });
});
批量删除

除了在遍历器中通过item.delete()删除特定的某个物体,主代理还提供了批量删除接口delete。该接口提供了两种使用方式,分别为输入一个对象和输入一个函数

输入对象,则是遍历其中的每一个物体,其属性与对象完全匹配时才执行删除:

// delete接口的第一种使用方式:输入一个对象
relert.Structure.delete({
	House: 'Americans House',
    Strength: 255,
}); //从Structure中,删除所有House属性为Americans House且Strength为255的物体。
//有多个属性必须完全匹配才会删除

输入函数,则是把每一个物体输入函数,返回true的时候会执行删除。

// delete接口的第二种使用方式:输入一个函数
relert.Structure.delete((item, index) => { //和forEach遍历器接口相同
    if ((item.House == 'Americans House') && (item.Strength == '255')) {
        return true; //函数返回值为true,该对象就会被删除
    } else {
        return false; //函数返回值为false就会删除
    }
});
查找类型

对于一个未知类型的物体,我们可以通过读取它的$category属性,来确定它属于哪个数据代理:

// 比如,structure是一个建筑子代理
structure.$category == 'Structure';
// 再比如,waypoint是一个路径点子代理
waypoint.$category == 'Waypoint';
建筑 Structure
属性 描述 默认值
House 所属方 'Neutral House'
Type 注册名 'GAPOWR'
Strength 生命值 '255'
X x坐标 '0'
Y y坐标 '0'
Facing 面向 '0'
Tag 关联标签 'none'
Sellable 可变卖(无用属性) '0'
Rebuild 重建(无用属性) '0'
Enabled 启用 '1'
UpgradesCount 加载物数量 '0'
SpotLight 聚光灯 '0'
Upgrade1 加载物1 'none'
Upgrade2 加载物2 'none'
Upgrade3 加载物3 'none'
AIRepair AI修复 '1'
ShowName 显示名称 '0'
步兵 Infantry
车辆 Unit
飞行器 Aircraft
地形对象 Terrain
属性 描述 默认值
Type 注册名 'TREE01'
X x坐标 '0'
Y y坐标 '0'

注意:同一个位置上只有一个地形对象。这意味着你在修改地形对象的X或者Y属性的时候,可能会覆盖掉原有位置上的地形对象。

污染 Smudge
属性 描述 默认值
Type 注册名 'CRATER01'
X x坐标 '0'
Y y坐标 '0'
Ignore 是否被忽略(填非0的值都会被忽略) '0'
路径点 Waypoint

(施工中)

基地节点 BaseNode

(施工中)

地表数据 MapData

地表MapData描述这样一类对象:它由覆盖整张地图的Base64编码+lzo压缩数据组成。在使用之前,需要先对整个区段进行解码解压缩,才能得到可直接操作的数据。

解码/解压缩是一个费时费力的操作。relert.js在设计时为了避免重复做无用功,亦或在不需要解码/解压缩的时候浪费计算资源,采用了“需要时调用接口进行手动解码”的方式。

公共操作

这里介绍一些MapData数据代理所共有的接口。为了演示方便,接下来的操作都以地形MapPack为例。

访问入口

MapData型的数据代理本身被看做一个函数:

// 在函数被调用时进行解包,需要访问MapData类型代理的操作全都在这个函数内进行
relert.MapPack((data) => {
    data({X: 12, Y: 12}) // 获取坐标(12, 12)处的MapPack对象数据
});
地形 MapPack

(施工中)

覆盖物 OverlayPack

(施工中)

逻辑 Logic

逻辑Logic描述这样一类对象:

特遣部队 TaskForce

(施工中)

动作脚本 Script

(施工中)

作战小队 TeamType

(施工中)

触发 Trigger

(施工中)

AI触发 AITrigger

(施工中)

局部变量 LocalVariable

(施工中)

注册表 Register

注册表Register描述这样一类对象:由一系列INI结构的Section组成,但是存在一个Section,其它的Section名字必须注册在它下面。

选择器 Picker

选择器对象Picker提供了对代理的另一个访问接口。

位置坐标选择器

(施工中)

静态模块

“静态模块”指没有自己独立入口的模块,它们在被导入以后,会在全局对象relert上直接附加一些属性或者方法。

静态模块都由relert.Static类继承而来。

环境变量 Environment

环境变量模块relert.Static.Environment导出了几个非常基本的纯静态属性:

  • relert.isNode: Boolean:当前脚本是否在node.js下运行。

  • relert.isBrowser: Boolean:当前脚本是否在浏览器下运行。

  • relert.version: String:当前relert.js的版本号。

需要时直接使用即可,没什么好说的。

打点计时器 Tick

JavaScript是一种单线程的语言,用户调用relert.js执行JavaScript代码的时候,只能把用户编写的脚本注入到主线程中执行。在执行的过程中,主线程(含用户界面)会被阻塞,浏览器窗口会进入一种“假死”状态,直到脚本执行结束。

但这就引发了一个重大的安全隐患:万一用户编写的脚本中含有死循环无限递归等导致脚本不能执行结束的问题,整个浏览器窗口就无法再使用了。用户可能会收到浏览器的报错:“喔唷,崩溃了!”(Chrome的提示如此)然后被迫关闭整个页面,从而丢失工作区中的所有未保存的内容,至少绝大部分内容都不可恢复。

而由于JavaScript的单线程特性,我们无法方便的在代码段的外部,对代码本身的执行状况进行监听——所有操作都在主线程之中排队进行,就连用于监听的函数自己也会被堵死在JavaScript事件队列中。

为了在这种情况下仍然能保护主线程,我开发了relert.Static.Tick模块来解决这一问题。由于它采用了“注入脚本内部,不断唤起自身”的方式对用户脚本的运行时间进行监测,因此我称其为“打点计时器”。

基本使用方法

relert.js-browser的环境下,一般来说,不用你做任何事情,relert.Static.Tick模块就已经在保护你的主线程了。

在一张地图上尝试以下脚本:

// 死循环,不断的在地图上添加单位
while (true) {
  relert.Unit.add({
     Type: 'MTNK',
     X: 40,
     Y: 40,
  });
}

运行数秒之后,CPU狂转,你会观察到主界面恢复了响应,并输出了类似如下报错信息:

Time Limit Exceeded: [3000.100000023842ms > 3000ms]
  The script process has already been killed.
  Please check if there is an INFINITE LOOP in your script,
  or, manually increase the time limit with method <relert.tickTimeOut(number[in millseconds])>.

于是,我们的脚本在3000毫秒内没有执行完毕,就成功的抛出了一个异常并终止了进程。

设置等待时间

relert.Static.Tick默认的超时时间为3000毫秒3秒

不像效率低下的fscript,对于relert.js来说,这些时间已经足以应付大多数的状况。但如果你要处理的数据量真的很多,你可能需要自行更改这个阈值,让relert.Static.Tick容忍更长的运行时间,说不定再运行几秒就能出结果了呢

为此,relert.Static.Tick提供了一个挂载在全局对象relert的接口:

relert.tickTimeOut(timeOut: Number): Number;

接收数值类型输入,并返回一个数值,其含义均代表当前relert.Static.Tick模块的全局等待时间,单位为毫秒。

设置等待时间:

relert.tickTimeOut(5000); //将等待时间设为5000毫秒,即5秒

获取并打印当前的等待时间:

relert.log(relert.tickTimeOut()); //输出当前的等待时间

手动打点

你可能会注意到,relert.Static.Tick模块无法对一些没有调用relert相关接口的耗时工作进行监听,比如说你尝试在relert.js中写一个空的死循环:

//一个死循环
while (true) { }

或者不那么明显的,在一个复杂的计算过程中:

//一个有错误的验证角谷猜想的程序
let collatz = function(start) {
    let step = 0;
    let number = start;
	while (number != 7) { //这里不小心把终止条件1写成了7,导致部分条件下程序无法停止
        if ((number % 2) == 0) {
            number = parseInt(number / 2);
        } else {
            number = number * 3 + 1;
        }
        step ++;
    }
    return step;
}

// 调用函数以后,就会进入死循环
relert.log(`Steps: ${collatz(180352746940718527, 0)}`);

这段代码仍然会引起整个浏览器的崩溃,relert.Static.Tick模块你干什么吃的?

这要从relert.Static.Tick模块的原理讲起。

该模块中导出了一个全局函数relert.tick()。每当relert.tick()被调用,都相当于脚本向监视者relert.Static.Tick主动报告状态,此时该模块就可以暂时接管主线程,快速的做时间计算、异常处理等操作。而relert.Static.Tick模块在自身被加载时,就将自身的relert.tick()函数注入到INI访问的底层接口中——也就是说,你每次使用relert.js提供的其它模块的接口时,relert.tick()函数都会自动插入执行。(不必担心这个操作耗费大量时间,relert.tick()被设计为可以快速多次调用,消耗性能极低。)

所以,当一个耗时操作之中完全没有使用relert.js提供的数据代理接口时,relert.Static.Tick模块就监视不到它了。在我们编写操作地图对象的脚本时,这样的耗时操作真的少见……但偶尔真的需要监视这样一个纯计算函数的时候,就需要我们手动去调用了,比如这样:

//一个死循环2.0
while (true) {
    relert.tick();
}

或者这样:

//一个有错误的验证角谷猜想的程序 2.0
let collatz = function(start) {
    let step = 0;
    let number = start;
	while (number != 7) { //这里不小心把终止条件1写成了7,导致部分条件下程序无法停止
        if ((number % 2) == 0) {
            number = parseInt(number / 2);
        } else {
            number = number * 3 + 1;
        }
        step ++;
        relert.tick(); //把 relert.tick() 放到最底层的循环之内,进行打点操作
    }
    return step;
}

// 调用函数以后,relert.Tick模块会正确的监视脚本超时并抛出异常
relert.log(`Steps: ${collatz(180352746940718527, 0)}`);

相比之前完全自动的“打点计时器”,这样的操作被我称为“手动打点”。

自定义监听

除了尽职尽责的保护你的主界面不被卡死以外,relert.Static.Tick模块还提供了简明易用的自定义监听功能:它可以监听任何一个函数执行了多久,也可以对任何一个函数开启超时保护,抛出异常,并允许脚本的其他部分捕获异常进行处理。

relert.Static.Tick模块提供了这样的一个静态函数入口:

relert.tickProcess(process: Function, [processId: Any, timeOut: Number]);
  • process:必需,被监听的函数;
  • processId:监听任务的唯一ID,用于区分不同的监听起点与等待时间。具体是什么值无所谓,只要唯一对应即可。缺省值为全局监听ID Symbol
  • timeOut:监听任务的等待时间,单位为毫秒。缺省值为全局等待时间(即relert.tickTimeOut()的返回值)。

而我们的“打点”操作relert.tick()函数除了可以无参数调用以表示全局打点以外,它也可以有自己的参数和返回值:

relert.tick([processId: Any]): number;
  • processId:监听任务的唯一ID,与relert.tickProcess()的参数对应。缺省值为全局监听ID。
  • 返回值:当前监听任务已经执行的时间。如果在指定ID的监听任务之外被调用,则返回0。

**自定义监听必须使用自定义打点。**也就是说,relert.tickProcess()所监听的函数之中,必须有对应ID的relert.tick( ID )打点。

下面的范例中,使用字符串'proc'作为了监听任务的唯一ID,等待时间100毫秒,超时则抛出异常:

relert.tickProcess(() => {
    while (true) {
        relert.tick('proc');
    }
}, 'proc', 100);

当然最适合做唯一ID的还是Symbol类型:

let procId = Symbol();
relert.tickProcess(() => {
    while (true) {
        relert.tick(procId);
    }
}, procId, 100);

可以读取relert.tick()的返回值,获取“这个被监听的函数已经执行了多久”:

let procId2 = Symbol();
relert.tickProcess(() => {
    while (true) {
        relert.log(relert.tick(procId2));
    }
}, procId2, 100);

除了relert.tickProcess()方法来监听一个函数以外,我们还提供了另一种方式:把两个函数relert.tickStart()relert.tickEnd()分别放到需要监听代码段的开头和末尾:

relert.tickStart([processId: Any, timeOut: Number]);
relert.tickEnd([processId: Any]);

比如这样:

let procId3 = Symbol();
relert.tickStart(procId3, 100);
while (true) {
    relert.log(relert.tick(procId3));
}
relert.tickEnd(procId3);

异常捕获

自定义监听功能抛出的异常是可以被try..catch语句捕获的,像下面这样:

let procId4 = Symbol();
relert.tickStart(procId4, 100);
try {
    while (true) {
        relert.log(relert.tick(procId4));
    }
} catch(e) {
    //捕获异常并进行处理
    
} finally {
    //结束处理
	relert.tickEnd(procId4);
}

这就使得我们可以在程序的局部,给一些可能会无法终止的步骤(比如说尝试寻找某个数学问题的可行解)加一层保险:如果它运行超过一定时间,就判定它无法成功,从而避免程序整体崩溃,方便进行后续的处理。

在node.js环境中使用

node.js环境下的relert.Static.Tick模块的行为和在浏览器中行为稍有不同。

node.js下。relert.Static.Tick不会自动启动全局的监听,需要手动执行relert.tickStart()来开启监听。毕竟node.js下不存在崩掉整个工作区的问题,即便是出现了死循环也可以通过Ctrl+C组合键来打断。另外,默认关闭也方便了在node.js的交互式执行界面进行操作——如果监听开着,那么你每敲入一行代码,都会产生超时异常!

调试输出 Log

调试输出是一个最基本的功能。relert.Static.Log模块在node.js环境和浏览器环境下均可使用,但它在node.js环境下和自带的console对象差别不大。

导出接口

relert.Static.Log模块提供的接口有如下几个:

relert.log(...info: Any);

输出调试信息。没什么好说的,就是在调试区显示一段文字。

如果要输出多个变量的值,可以使用逗号分隔,也使用模板字符串(即类似`${var}`的格式)。

relert.warn(...warning: Any);

输出警告调试信息。以黄色为主色调显示,其余同上。

relert.error(...error: Any);

输出错误信息,以红色为主色调显示。注意这里并没有真的引发异常使程序终止。

relert.cls();

清空输出区域。在浏览器环境下就是编辑器下方的调试区,在node.js环境下就是整个console窗口。

为什么使用relert.log而不是console.log

relert.Static.Log模块在浏览器中运行时,其内部做了缓存操作。这使得一瞬间连续输出几十万条信息,也不会让浏览器当场崩溃(虽然这仍然是很不好的习惯,过多的调试信息可能会让浏览器变卡)。

使用relert.log代替console.log的一个另好处是,脚本在浏览器端的行为和在node.js端的行为被统一了起来,使得我们更容易写出能在两个环境下通用的脚本。

编码解码 GB2312

中文文本编码问题一直是远古软件的一大积弊,FinalAlert也是一样。基于旧地图编辑器FinalAlert制作的地图,一律以GB2312格式进行中文文本编码;而JavaScript的字符串只支持UTF-8编码;一些新开发的地图编辑器如RelertSharp则采用纯UTF-8格式……如此种种不统一的问题造成了中文读写时常出现乱码,为此我开发了relert.Static.GB2312模块。

relert.Static.GB2312模块基于iconv-lite库的改写、打包与再封装,为relert.js在浏览器环境和node.js环境提供了基础的编码解码支持。正是在这个模块的加持下,relert.jsUTF-8GB2312模式下均可工作,并且可以把文件在这两种格式之间任意转换。

基本使用

relert.Static.GB2312模块已经与relert.Static.FileSys模块做了深度的整合,也就是说你不用做任何操作,只是正常的读写文件,relert.Static.GB2312库就会自动帮你完成编码与解码。

编码转换

导出接口

文件接口 FileSys

基本使用

文件改名

导出接口

注册号 RegKey

该模块提供了几种不同格式的注册号生成函数,可以自动生成地图中没有使用过的注册号。

地图初始化 Init

该模块提供了一个函数relert.init()。一旦你在一个空的relert对象上执行这个函数,它就会被写入必要的数据,从而使其成为一张合法的空白地图。

当你在node.js环境中生成了一个新的relert对象而不打算打开文件的时候,或者是在浏览器环境中打开了一个空白文件(真·空白文件)的时候,可能会需要用它来初始化地图。

relert.init()只会尝试添加属性,它不会覆盖任何已经设置的属性。

工具箱 Toolbox

relert.Static.Toolbox“工具箱”是一个独立的纯静态模块。

该模块一旦加载,就会将一些可能被高频率使用的纯静态函数挂载在relert全局对象下,或者添加到Object等原型上。适当使用这些函数可以让代码更加方便可读。

下面分类对这些函数进行介绍:

坐标转换相关

relert.js中的“坐标”类型的数据有poscoord两种格式:

  • pos格式:使用一个对象表示坐标,对象的XY属性代表xy坐标值。任何一个“物体”的子代理都是合法的pos格式坐标,因为它们都有XY属性。
  • coord格式:使用一个4~6位由数码组成的字符串表示坐标,后3位表示x坐标(缺位用0补齐),后3位之前表示y坐标。(地图内部结构中多使用这种坐标,而且这种坐标表示便于使用1维结构进行编码)

虽然relert.js提供的大部分接口都使用了pos格式的坐标,但总有不得不使用coord格式的坐标的时候。

relert.static.Toolbox提供了在两种坐标格式之间转换的函数:

relert.posToCoord(pos: Object): String;

pos格式转化为coord格式坐标。

relert.coordToPos(coord: String): Object;

coord格式转化为pos格式坐标。

另外还可以通过下面的方法判断是否是合法的pos坐标:

relert.isPos(pos: Object): Boolean;

如果输入合法的pos坐标,会返回true,否则返回false。注意这个函数只判断XY属性,不会判断这个坐标是否在地图合法区域内。

地图边界相关

relert.mapWidth(): Integer;

返回地图的宽度。

relert.mapHeight(): Integer;

返回地图的高度。

relert.posInnerMap(pos: Object): Boolean;

接受一个pos坐标对象(一个带有XY属性的对象,代表它的X坐标和Y坐标),返回它的坐标是否在地图内。

可以将relert.js提供的,数据代理层的“物体”对象直接传入,因为它们都有XY属性。

也可以输入形如{X: 12, Y: 34}这样的对象,直接指定坐标。

几何相关

relert.posInnerCircle(pos: Object, center: Object, r: Number): Boolean;

接受一个pos坐标对象(一个带有XY属性的对象,代表它的X坐标和Y坐标),返回它的坐标是否在圆心为pos坐标center、半径为r的圆内。

随机数相关

relert.randomBetween(a: Integer, b: Integer): Integer;

接受2个整数ab,返回介于ab之间的随机整数。

relert.randomFacing(): Integer;

返回随机朝向数值。朝向数值有0, 32, 64, 96, 128, 160, 192, 224八个,分别对应“右上, 右, 右下, 下, 左下, 左, 左上, 上”。

relert.randomStrength(a: Float, b: Float): Integer;

接受两个0~1之间的实数ab(代表生命值百分比的上下限,但是此函数不会检查输入),返回随机的生命值数字(255为满生命值的整数)。

relert.randomPosInnerMap(): Object {X: Integer, Y: Integer}

返回地图内的随机坐标。(暂未实装)

返回值为一个pos坐标对象。

relert.randomPosOnLine(pos1: Object, pos2: Object): Object {X: Integer, Y: Integer}

返回pos1pos2两个坐标之间连线上的随机一格坐标。(暂未实装)

pos1pos2两个对象为pos坐标。

可以将relert.js提供的,数据代理层的“物体”对象直接传入,因为它们都有XY属性,都是合法的pos坐标。

也可以输入形如{X: 12, Y: 34}这样的对象,直接指定坐标。

返回值为一个pos坐标对象。

relert.randomPosInnerCircle(center: Object, r: Number): Object {X: Integer, Y: Integer}

返回以pos坐标center为圆心、半径r的圆范围内随机一格的坐标。(暂未实装)

返回值为一个pos坐标对象。

返回结果保证一定落在地图内。如果给的条件不足以产生落在地图内的随机坐标,会抛出异常。

relert.randomSelect(list: Array): Any;

接受一个数组list,返回数组中的随机一项。

原型方法

为了使用方便,一些常用函数除了挂载在relert上以外,还直接写进了原型方法。这些原型方法在下面列出:

Object.prototype.assign(obj: Object): Object;

对于对象obj1obj1.assign(obj2)obj2的属性合并到obj1中(obj1本身会发生改变),并返回改变过的obj1

浏览器环境模块

时光机 Timeline

(施工中)

编辑器 Editor

(施工中)

运行时沙盒 Sandbox

(施工中)

在node.js环境使用

relert.js最早是一个基于node.js的脚本库。在移植到浏览器端以后,在合适的兼容处理下,它仍然能够无缝的在node.js环境中使用。

程序入口

在浏览器环境中,浏览器已经自动在环境中生成了一个relert对象。但在node.js环境中我们无法预先指定运行环境上下文,故需要自己在脚本中引入relert对象。

首先通过require语句,从relert.js中获取用于创建relert对象工厂函数,然后再调用这个函数:

const relertCreater = require('./script/relert.js'); //应为relert.js存储的位置
const relert = relertCreater();

这样我们就获取了一个和浏览器端几乎完全一致的relert对象,可以使用它上面的各种方法。

以上两条语句也可以合并为一条(注意require语句末尾多了一个空括号):

const relert = require('./script/relert.js')();

当然,你也可以给relert全局对象换个名字,接口也会发生相应的改变。下文如不特殊说明,仍默认全局对象的命名仍然是relert

文件接口

在浏览器环境中,我们执行脚本的直接作用对象是快照;而在node.js环境下,没有这个东西。

但是鉴于node.js有对文件系统的完全访问权限,我们写在node.js中的脚本,可以直接读写本地文件——这不比快照更好用?

首先,这带来的好处就是,relert.jsnode.js端所有和“文件名”有关的接口,其“文件名”都可以指定为“文件路径”,即直接指向本地的某个文件。

其次,在node.js端还多了一个可以直接使用的文件读取接口:

relert.load(filename: String);

指定一个文件,将其读取并加载入relert实例。

有读就有写,我们原有的relert.save接口仍然能正常的发挥作用。不带任何参数就是存到原有打开的文件,但甚至能存到任意位置的本地文件:

relert.save([filename: String, [content: Buffer]]);

node.js环境中,通常我们需要自己手动完成读写文件的操作——脚本开始初始化relert示例以后手动读取文件,脚本执行结束后手动保存文件。

但是有一个例外,就是命令行执行的时候带上需要读取的文件名:

node [scriptFileName] [mapFileName]

这样scriptFileName的脚本执行的时候,其中的relert实例都会默认加载mapFileName表示的文件。你也可以通过relert.args来读取命令行参数。

通用脚本编写

本章讨论如何使用relert.js写出在浏览器和node.js都可以正常发挥作用的地图脚本。

判断入口

由于在relert实例初始化之前,没有relert.Environment模块可用,我们不能在最开头直接使用relert.isNode来判断脚本是在哪种环境中执行的。但由于浏览器环境中会预先实例化一个relert对象,我们可以通过判断relert对象的存在性来辨别运行环境:

// 程序开头判断入口
if (typeof relert == 'undefined') {
    relert = require('./script/relert.js')(); //在node.js环境下,需要自己实例化relert对象
}

文件读写

避免使用relert.load接口,而是使用命令行参数来读取文件。

node.js环境下,需要自己手动保存文件:

// 程序结尾保存文件
if (relert.isNode) { //此时已经有relert实例,可以使用isNode来判断了
    relert.save();
}

环境差异

在正确的实例化relert对象以后,使用relert.isNoderelert.isBrowser主动判断脚本的运行环境,来做出差异化的处理。

使用relert.log

使用relert.log而非console.log确保对两个运行环境的兼容。

多个relert实例

node.js环境中还有一个好处,就是你可以通过工厂函数,创建多个relert实例。这在浏览器环境中是做不到的

const relert1 = require('./script/relert.js')();
const relert2 = require('./script/relert.js')();

此时relert1relert2就是两个不同的relert实例。可以读取不同的文件,也可以在二者之间传递数据。你甚至可以做出“把一张地图拆成两张”或者“把两张地图合并为一张”等操作,具体如何使用就要发挥想象力了。

定制开发

本章讨论relert.js-browser在浏览器端的定制开发问题——比如说,我想要丢弃原有的index.html,重新开发一个网页客户端,需要做什么呢?

(施工中)

关于

relert.js-browser版本0.1,2021年9月

开发及贡献者:

  • heli-lab

附录:relert.js代码风格约定

  • relert.js遵循ES6标准,其代码风格也以ES6推荐的代码风格为基础。
  • 大括号不换行,且空1个空格。
  • 标识符命名基本使用大驼峰(PascalCase)和小驼峰(camelCase)两种命名规则:
    • Proxy对象,即各级数据代理,使用大驼峰规则命名。例:relert.BaseNode
    • INI属性遵循原版rules的拼写风格,也使用大驼峰规则命名。例:structure.UpgradesCount
    • 内部常量使用全大写字母+下划线分隔命名。例:ENCODING
    • 对于内部标识符,在其标识符前加双下划线“__”。例:__RelertObject
    • 其余标识符使用小驼峰规则命名。

附录:常用逻辑示例代码

INI导入

// relert.js范例A-1:平民单位INI导入
// * 用途:将所有平民步兵全都设置为不随机走动、受主动攻击、血量50点
// * 运行环境:浏览器

// 需要修改属性的平民单位列表
let civList = ['CIV1', 'CIV2', 'CIV3', 'CIVA', 'CIVB', 'CIVC'];

for (let i in civList) {
    // 判断对应的平民单位字段是否存在
    if (!relert.INI[civList[i]]) {
        // 如果不存在就新建一个空的
        relert.INI[civList[i]] = {};
    }
    relert.INI[civList[i]].assign({
        Insignificant: 'yes',
        Strength: 50,
    });
}

遍历物体,调整属性

// relert.js范例B-1:平民建筑生命值调整
// * 用途:将地图上特定作战方(平民方)的特定建筑物,生命值在一个范围内随机调整
// * 运行环境:浏览器

let ignoreList = ['CAARMY01', 'CAARMY02', 'CAARMY03', 'CAARMY04', 'CATS01', 'CAEURO05', 'CAWASH18',
 'CASANF15', 'CAFRMB', 'CAWT01', 'CAMSC01', 'CAMSC02', 'CAMSC03', 'CAMSC04', 'CAMSC05', 'CAMSC11', 
 'CAPARK01', 'CAPARK02', 'CAPARK03', 'CAPARK04', 'CAPARK05', 'CAPARK06', 'CAMISC04', 'CAPARS07', 
 'CAMSC06', 'CAURB01', 'CAURB02', 'CABARR01', 'CABARR02', 'CASIN03E', 'CASIN03S', 'CAMISC03',
 'CAMISC05', 'CAMISC11', 'CASTRT05', 'INBLULMP', 'INREDLMP', 'INGALITE', 'INGRNLMP', 'INYELWLAMP',
 'INORANLAMP', 'INPURPLAMP', 'CAOILD']; //定义一个ignoreList“忽略列表”,表示不想被此脚本处理的建筑物类型列表

relert.Structure.forEach((item) => { //对于从Structure中取出每一个item
	if ((item.House == 'Neutral House') && (!ignoreList.includes(item.Type))) { //如果其所属为Neutral House,且类型不在ignoreList内
        item.assign({
           Strength: relert.randomStrength(0.15, 0.25), //设置其生命值在15%~25%之间
           AIRepair: 0, //设置其AI修复属性为0
        });
    }
});
// relert.js范例B-2:均匀分布树木类型
// * 用途:地图上已有的树木位置不变,但类型重新安排,使其尽可能保证平均分布,重复的树木不挨的太近
// * 运行环境:浏览器

//计算量可能很大,多给点时间吧
relert.tickTimeOut(10000);

//希望出现的树木类型
let treeType = ['TREE05', 'TREE06', 'TREE07', 'TREE08', 'TREE10', 'TREE11', 'TREE12', 'TREE14', 'TREE15'];

//判断Terrain是否应该被替换的条件
let isTree = (item) => {
    return (item.Type.substring(0, 4)) == 'TREE';
}

//树木分布权重图
//每放置一棵树,在其周围都会产生一圈递减的权重
let heatMap = {};
//在权重图上放置树木
let heatPlace = (item) => {
    let r = 11; //每棵树木的影响半径/格
    for (let i = -r; i <= r; i++) {
        for (let j = -r; j <= r; j++) {
            let distance = Math.hypot(i, j);
            let coord = relert.posToCoord({
                X: parseInt(item.X) + i,
                Y: parseInt(item.Y) + j,
            });
            if ((distance > r) || (distance < Number.EPSILON)) {
                continue;
            }
            if (!heatMap[coord]) {
                heatMap[coord] = {};
            }
            if (!heatMap[coord][item.Type]) {
                heatMap[coord][item.Type] = 0;
            }
            heatMap[coord][item.Type] += Math.exp(-distance);
        }
    }
}

//获取某个点位权重最低的树木
let getWeakHeat = (pos) => {
    let coord = relert.posToCoord(pos);
    if (!heatMap[coord]) {
        return relert.randomSelect(treeType);
    }
    let minHeat = 9999;
    let minHeatType = '';
    for (let i in treeType) {
        if (!heatMap[coord][treeType[i]]) {
            return treeType[i];
        } else {
            if (heatMap[coord][treeType[i]] < minHeat) {
                minHeatType = treeType[i];
                minHeat = heatMap[coord][treeType[i]];
            }
        }
    }
    return minHeatType;
}

//遍历树木并重新排布
relert.Terrain.forEach((item) => {
    if (isTree(item)) {
        item.Type = getWeakHeat(item);
        heatPlace(item);
    }
});

随机生成

// relert.js范例C-1:随机生成污染
// * 用途:在地图上随机生成污染
// * 运行环境:浏览器

let density = 0.01; //随机生成污染的密度(个/格)
let smudgeList = []; //污染种类

//此示例未完成

批量删除物体

// relert.js范例D-1:删除地图外物体
// * 用途:批量删除地图边界区域以外的物体
// * 运行环境:浏览器

relert.Structure.delete((item) => {
    return !relert.posInnerMap(item);
});

relert.Infantry.delete((item) => {
    return !relert.posInnerMap(item);
});

relert.Unit.delete((item) => {
    return !relert.posInnerMap(item);
});

relert.Aircraft.delete((item) => {
    return !relert.posInnerMap(item);
});

relert.Terrain.delete((item) => {
    return !relert.posInnerMap(item);
});

relert.Smudge.delete((item) => {
    return !relert.posInnerMap(item);
});

触发组制作

(施工中)

修改地形与覆盖物

(施工中)

About

A JavaScript scripting interface for editing Maps for Red Alert 2.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published