本篇教程由作者设定使用 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类:

  1. operate(continuation, stack, ravenmind, ctx): OperationResult,这是执行每个符文内部逻辑的位置,实测传入js的箭头函数也能运行

  2. alwaysProcessGreatSpell、causesBlindDiversion、isGreat:三个均为kotlin getter的字段,仅在kotlin内部访问,传入()=><value>常量箭头函数即可

  3. 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添加自定义法术(表篇)-第1张图片

注册动作只可发生一次,因而相应脚本需置于kubejs/startup_scripts目录下,此处从StartupEvents选取一个幸运的事件以调用注册动作:

StartupEvents.postInit(e => {
    PatternRegistry.mapPattern(
        HexPattern.fromAngles("wwawwawwdwwdww", 0), 
        ResourceLocation('kubejs', 'tester'), 
        myAction, false
    )
})

进入游戏测试

使用KubeJS添加自定义法术(表篇)-第2张图片使用KubeJS添加自定义法术(表篇)-第3张图片

可以看到,在绘制了对应图案之后,游戏顺利执行了myAction.operate内的逻辑,玩家符合预期地似了(

使用KubeJS添加自定义法术(表篇)-第4张图片


从HexGloop提供的聊天框展示功能中也能看到图案对应的displayName字段

自此,由游戏内咒法学符文至执行kubejs逻辑的路径已经打通,后续就是想象力的问题了(咒法学爆改新生魔艺可能性微存)


批量创建更多法术图案

以上流程做到了“能用”,但距离“好用”仍有一定的差距,这里列举几点:

  1. 每次注册都得创建新的对象,其中包含很多重复内容

  2. 已注册的图案无法在游戏内实时修改执行效果,不方便调试开发

  3. 哪有符文叫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)
})

游戏内展示效果如下:

使用KubeJS添加自定义法术(表篇)-第5张图片注:在老存档内自己添加卓越法术与添加新附属相同,需运行如下指令刷新随机卓越法术的缓存

/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)
})

最后是惯例的似一次测试执行结果:


使用KubeJS添加自定义法术(表篇)-第6张图片

使用KubeJS添加自定义法术(表篇)-第7张图片


operate执行流程:副作用与异常处理

将视角放回最根基的operate函数,它的四个参数与主要作用(个人理解)如下表:

参数名对应类型主要功能

continuation

SpellContinuation
控制整个符文串的执行和中断,在单个Action内部几乎没作用,绝大多数情况直接传就行
stack

MutableList<Iota>

iota栈,下标从小到大对应栈底到栈顶(与渔夫之策略等使用的索引数刚好相反)
ravenmindIota 或 null渡鸦之思对应的数据容器
ctxCastingContext

法术执行位于世界的上下文

可以从中获取玩家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')
})

进游戏:先用简单的射线选定实体+输入数字+执行测试一下

使用KubeJS添加自定义法术(表篇)-第8张图片

恭喜你读到了这篇教程的第二个坑: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
        }
    }
    // 下略……

现在根据入参的不同错误类型,对应的事故可以正常抛出显示了

使用KubeJS添加自定义法术(表篇)-第9张图片

法术实际执行与媒质消耗

首先我们把上文的法术补全:

// 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)

这里添加了预制的几种副作用类型之一:喷射粒子,为的是使“锤”的动作更有打击感(适当降低了使用的伤害值,以防生成的粒子速度过高而不可见)

使用KubeJS添加自定义法术(表篇)-第10张图片

所有可用的预制副作用均位于OperatorSideEffect.kt文件中,由于该接口为sealed,不能由kjs简单代替,故在js层只可使用这些预定义的副作用,或附属们继承该接口实现的新类,不可使用js对象直接代替

当然,这些副作用已经涵盖了本体所有的法术执行效果,包含上文涉及过的抛出事故和产生粒子,以及下文将提到的扣除媒质和执行法术

扣除媒质与常消耗动作

常消耗动作(ConstMediaAction)继承了Action基类,以弓箭手之馏化为例,其消耗和执行效果有以下特点:

  1. 先执行动作,后消耗媒质

  2. 消耗的媒质为固定量

该类的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以及代码阅读结合的一些里技实现更“不平衡”的自定义法术(比如封面这个)