本篇教程由作者设定使用 CC BY-NC-SA 协议。
注:以下内容均基于咒法学1.19.2发布版(hexcasting-forge-1.19.2-0.10.3.jar),及github内1.19分支
注2:以下js代码段中使用到的咒法学类默认由Java.loadClass以原名称引入
咒法学本体的法术图案注册接口位于PatternRegistry类之中;注意到其中的Action参数为接口而非类,并且基础定义来自于Kotlin而非Java;那如果我们向其中放入由kubejs创建的对象……
理论存在,实际开始:
创建施法动作(Action)
接口分析
在Action.kt内,可以看到一共定义了5个字段,其中可以分为3类:
operate(continuation, stack, ravenmind, ctx): OperationResult,这是执行每个符文内部逻辑的位置,实测传入js的箭头函数也能运行
alwaysProcessGreatSpell、causesBlindDiversion、isGreat:三个均为kotlin getter的字段,仅在kotlin内部访问,传入()=><value>常量箭头函数即可
displayName:第一个坑来了,这个字段在kotlin内部和java侧均有访问,内部访问依然等价为.displayName()即可,但java侧是使用getDisplayName()来获取的;因此需要将两个字段同时定义箭头函数
基于此,可以定义一个基础的JS Action如下:
let myAction={
getDisplayName: () => Text.gold('TEST'),
displayName: () => Text.gold('TEST'),
isGreat: () => false,
alwaysProcessGreatSpell: () => true,
causesBlindDiversion: () => true,
operate: (continuation, stack, ravenmind, ctx) => {
ctx.caster.runCommandSilent('kill @s') // 执行一下原地自爆指令,测试是否已读取
return OperationResult(continuation, stack, ravenmind, [])
}
}
注册施法动作
除了簿记员之策略和数字之精思使用了PatternRegistry.addSpecialHandler外,官方本体的所有静态图案注册均使用了PatternRegistry.mapPattern实现,其中入参pattern由HexPattern.fromAngles创建,所需的序列(只要不涉及原地调头方向)可以在Hex Studio中绘制,并通过左侧菜单File - Export Patterns导出后复制序列
注册动作只可发生一次,因而相应脚本需置于kubejs/startup_scripts目录下,此处从StartupEvents选取一个幸运的事件以调用注册动作:
StartupEvents.postInit(e => {
PatternRegistry.mapPattern(
HexPattern.fromAngles("wwawwawwdwwdww", 0),
ResourceLocation('kubejs', 'tester'),
myAction, false
)
})
进入游戏测试
可以看到,在绘制了对应图案之后,游戏顺利执行了myAction.operate内的逻辑,玩家符合预期地似了(
从HexGloop提供的聊天框展示功能中也能看到图案对应的displayName字段
自此,由游戏内咒法学符文至执行kubejs逻辑的路径已经打通,后续就是想象力的问题了(咒法学爆改新生魔艺可能性微存)
批量创建更多法术图案
以上流程做到了“能用”,但距离“好用”仍有一定的差距,这里列举几点:
每次注册都得创建新的对象,其中包含很多重复内容
已注册的图案无法在游戏内实时修改执行效果,不方便调试开发
哪有符文叫test的,来个正式点的名字
以下将分别对这些方面进行改进
面向对象创建法术
把MyAction的重复部分抽取为MyActionClass类,暂定可变动范围为是否为卓越法术isGreat、执行动作operate;由于kjs不支持class语法故采用function+prototype的古法实现:
function MyActionClass(isGreat, operate) {
this.operate = operate
this.isGreat = () => isGreat
let _displayName = Text.gold('TEST')
this.getDisplayName = this.displayName = () => _displayName
}
MyActionClass.prototype = {
alwaysProcessGreatSpell: () => true,
causesBlindDiversion: () => true,
}
注意到“是否为卓越法术”在action本身和注册部分均有使用,为了便于保持一致和少打字,再对注册函数进行一层封装:
function lazyMapPattern(pattern, id, isGreat, operate) {
PatternRegistry.mapPattern(
pattern, ResourceLocation('kubejs', id),
new MyActionClass(isGreat, operate), isGreat
)
}
对应的注册代码如下,将相同的测试operate函数分别注册为了普通与卓越两个版本;因为卓越法术自带打乱笔画(和检查避免与普通法术冲突),所以可以使用相同的序列进行注册
StartupEvents.postInit(() => {
let operate = (continuation, stack, ravenmind, ctx) => {
ctx.caster.runCommandSilent('kill @s') // 还是原地自爆
return OperationResult(continuation, stack, ravenmind, [])
}
let pattern = HexPattern.fromAngles('wwawwawwdwwdww', 0)
lazyMapPattern(pattern, 'tester', false, operate)
lazyMapPattern(pattern, 'tester_great', true, operate)
})
游戏内展示效果如下:
注:在老存档内自己添加卓越法术与添加新附属相同,需运行如下指令刷新随机卓越法术的缓存
/hexcasting recalcPatterns
支持热重载的法术执行块 & 法术名翻译
此处使用到了一个常见的kubejs小寄巧:将需要重载的功能写进global对象,在使用的时候直接索引
相应的修改后类构造参数不再传入施法动作函数,转而传入对应的索引id;方便起见这里将索引id定为注册id
global.operateMap = {
tester: (continuation, stack, ravenmind, ctx) => {
ctx.caster.runCommandSilent('kill @s') // 天生万物以养人
return OperationResult(continuation, stack, ravenmind, [])
},
tester_great: (continuation, stack, ravenmind, ctx) => {
ctx.caster.tell("I'M GREAT!!!")
ctx.caster.runCommandSilent('kill @s') // 人无一物以报天
return OperationResult(continuation, stack, ravenmind, [])
},
}
function MyActionClass(isGreat, id) {
this.operate = (continuation, stack, ravenmind, ctx) => {
// 此处必须每次执行索引global,不能直接赋值
// 否则等价于上文修改前,会回归无法热重载的状态
return global.operateMap[id](continuation, stack, ravenmind, ctx)
}
this.isGreat = () => isGreat
let _displayName = Text.translate(`hexcasting.spell.kubejs:${id}`).gold()
this.getDisplayName = this.displayName = () => _displayName
}
MyActionClass.prototype = {
alwaysProcessGreatSpell: () => true,
causesBlindDiversion: () => true,
}
来都来了,顺便把显示名称也改成按lang索引的格式吧,顺便去assets/hexcasting/lang/zh_cn.json加一下翻译
{
"hexcasting.spell.kubejs:tester":"测试法术",
"hexcasting.spell.kubejs:tester_great":"测试卓越法术"
}
那么对应的注册代码也修改为如下:
function lazyMapPattern(pattern, id, isGreat) { // 删去执行函数输入
PatternRegistry.mapPattern(
pattern, ResourceLocation('kubejs', id),
new MyActionClass(isGreat, id), isGreat
)
}
StartupEvents.postInit(() => {
let pattern = HexPattern.fromAngles('wwawwawwdwwdww', 0)
lazyMapPattern(pattern, 'tester', false)
lazyMapPattern(pattern, 'tester_great', true)
})
最后是惯例的似一次测试执行结果:
operate执行流程:副作用与异常处理
将视角放回最根基的operate函数,它的四个参数与主要作用(个人理解)如下表:
参数名 | 对应类型 | 主要功能 |
continuation | SpellContinuation | 控制整个符文串的执行和中断,在单个Action内部几乎没作用,绝大多数情况直接传就行 |
stack | MutableList<Iota> | iota栈,下标从小到大对应栈底到栈顶(与渔夫之策略等使用的索引数刚好相反) |
ravenmind | Iota 或 null | 渡鸦之思对应的数据容器 |
ctx | CastingContext | 法术执行位于世界的上下文 可以从中获取玩家caster、施法主副手castingHand、维度世界world等 |
注意到其返回值的构造并未传递ctx,而是使用到了新的变量sideEffects,类型为OperationSideEffect的列表;该列表决定了法术成功施放后按顺序执行的一系列“副作用”包括扣除媒质、释放粒子、引起事故、“真正地”执行法术等;
另一方面,执行过程中形形色色不满足条件而引发的事故,为了及时中断执行逻辑,则以抛出异常的形式实现,所抛的异常在代码中均继承了Mishap类
下面就以一个简单的 实体,数字 ➡ [锤你一拳]法术为例,走一遍从栈读取到执行法术的流程,开始迈入实用法术阶段
读取参数与抛出异常(事故)
省略上文的基础设施,此处只列出operate函数和注册代码
global.operateMap = {
do_punch: (continuation, stack, ravenmind, ctx) => {
if (stack.length < 2) throw MishapNotEnoughArgs(2, stack.length)
// 倒序弹出实体和数字iota
let iotaDamage = stack.pop()
let iotaEntity = stack.pop()
// 理论上可以loadClass再instanceof判断iota类型,摆了直接用对应获取数据方法拿到的是否是undefined判断了
// 注:此处iota.xxx等价于原始方法iota.getXxx(),是kjs自动转换的
let damage = iotaDamage.double,
entity = iotaEntity.entity
if (damage === undefined) throw MishapInvalidIota.of(iotaDamage, 0, 'class.double')
// 因为要锤人,确保被锤的有受击方法,对应方法似乎1.20还是1.21改名成hurt了
if (entity?.attack === undefined) throw MishapInvalidIota.of(iotaEntity, 1, 'class.entity')
// TODO 真的锤
ctx.caster.tell(`将要锤 ${entity.name.string} ${damage} 伤害`)
return OperationResult(continuation, stack, ravenmind, [])
},
}
StartupEvents.postInit(() => {
lazyMapPattern(HexPattern.fromAngles('wwawawwdeeeee', HexDir.SOUTH_WEST), 'do_punch')
})
进游戏:先用简单的射线选定实体+输入数字+执行测试一下
恭喜你读到了这篇教程的第二个坑:kjs在抛异常的时候会自动包装一层JavaScriptException;但是咒法学的源码只能捕获Mishap,于是这个不认识的异常并没有被当做事故处理,反而漏到了更外层被视作了bug
此处我选择的办法是调整一下MyActionClass基类的operate函数,仿照原始逻辑将js层捕获的Mishap异常作为副作用传出
function MyActionClass(isGreat, id) {
this.operate = (continuation, stack, ravenmind, ctx) => {
try {
return global.operateMap[id](continuation, stack, ravenmind, ctx)
?? OperationResult(continuation, stack, ravenmind, [])
// 此默认值是为了以后更多无副作用的符文省去例行公事
} catch (e) {
// 事故则收入副作用列表
if (e instanceof Mishap)
return OperationResult(continuation, stack, ravenmind, [
OperatorSideEffect.DoMishap(e, Mishap.Context(HexPattern(HexDir.WEST, []), null)),
])
// 否则抛出
throw e
}
}
// 下略……
现在根据入参的不同错误类型,对应的事故可以正常抛出显示了
法术实际执行与媒质消耗
首先我们把上文的法术补全:
// global.operateMap.do_punch
// 真的锤
ctx.caster.tell(`锤 ${entity.name} ${damage} 伤害`)
entity.attack(DamageSource.playerAttack(ctx.caster), damage)
let sideEffects = [OperatorSideEffect.Particles(ParticleSpray.burst(entity.position(), damage / 20, damage * 2))]
return OperationResult(continuation, stack, ravenmind, sideEffects)
这里添加了预制的几种副作用类型之一:喷射粒子,为的是使“锤”的动作更有打击感(适当降低了使用的伤害值,以防生成的粒子速度过高而不可见)
所有可用的预制副作用均位于OperatorSideEffect.kt文件中,由于该接口为sealed,不能由kjs简单代替,故在js层只可使用这些预定义的副作用,或附属们继承该接口实现的新类,不可使用js对象直接代替
当然,这些副作用已经涵盖了本体所有的法术执行效果,包含上文涉及过的抛出事故和产生粒子,以及下文将提到的扣除媒质和执行法术
扣除媒质与常消耗动作
常消耗动作(ConstMediaAction)继承了Action基类,以弓箭手之馏化为例,其消耗和执行效果有以下特点:
先执行动作,后消耗媒质
消耗的媒质为固定量
该类的operate实现方式为正常执行对应动作,随后将动作开销以OperatorSideEffect.ConsumeMedia添加至sideEffects列表内;根据这一特点可以初步将上文示例符文改造为“消费伤害值 * 0.01紫水晶粉(100单位)”,代码如下:
// global.operateMap.do_punch,插入于上文定义sideEffects之后
sideEffects.push(OperatorSideEffect.ConsumeMedia(Math.ceil(damage * 100)))
预付款与施法动作
游戏中更多的世界交互法术继承自施法动作(SpellAction),咒术笔记中“法术”与“卓越法术”大部分均属于此类
其在operate的主体部分仅负责计算法术将要施放的内容与消耗,首先计算法术开销并加入OperatorSideEffect.ConsumeMedia,随后(若未在启迪前施放卓越法术)加入包含施法内容的OperatorSideEffect.AttemptSpell;幸运的是AttemptSpell所需的RenderedSpell接口并非sealed,故可以仿照其结构传入相应js对象。
根据该逻辑改造后的函数如下:
// global.operateMap.do_punch,从上文“真的锤”注释开始修改
// 真的锤
let sideEffects = [
// 先扣钱
OperatorSideEffect.ConsumeMedia(Math.ceil(damage * 100)),
// 再施法
OperatorSideEffect.AttemptSpell(
{
cast: (ctx) => {
ctx.caster.tell(`锤 ${entity.name.string} ${damage} 伤害`)
entity.attack(DamageSource.playerAttack(ctx.caster), damage)
},
},
true,
true,
),
// 施法成功再放粒子
OperatorSideEffect.Particles(ParticleSpray.burst(entity.position(), damage / 20, damage * 2)),
]
return OperationResult(continuation, stack, ravenmind, sideEffects)
这样就实现了游戏内普通法术的效果:先扣媒质,如果扣光了就扣血且法术就此中断
结语
至此便是足以添加一个平衡、合理、有代价的自定义法术所需的全部内容,可以根据实际需求自行在代码逻辑与参数设计上进行平衡,以及取舍上述各部分功能,毕竟js是一门很自由骚的语言
后续有缘将更新如何使用Java、JS以及代码阅读结合的一些里技实现更“不平衡”的自定义法术(比如封面这个)