feat: 增强 Boss 脚本 HookAction 接入能力
All checks were successful
ci/woodpecker/push/my-first-workflow Pipeline was successful

引入 BossHookActionContext 封装战斗上下文,并支持脚本调用 useSkill 和 switchPet 函数控制战斗行为。
This commit is contained in:
xinian
2026-04-05 22:27:38 +08:00
committed by cnb
parent c021b40fbe
commit 24b463f0aa
4 changed files with 396 additions and 43 deletions

View File

@@ -0,0 +1,173 @@
# Boss ScriptHookAction接入说明
日期2026-04-05
## 1. 执行流程
1. 先执行战斗效果链 `HookAction()`
2. 执行脚本 `hookAction(hookaction)`
3. 用脚本返回值决定是否继续出手
4. 脚本可直接调用 Go 绑定函数`useSkill()``switchPet()`
## 2. JS 可调用的 Go 函数
1. `useSkill(skillId: number)`
- 作用指定本回合使用技能
- 示例`useSkill(5001)`
2. `switchPet(catchTime: number)`
- 作用指定本回合切换精灵
- 示例`switchPet(2)`
## 3. `hookaction` 参数字段
1. `hookaction.hookaction: boolean`
- effect 链原始判定
2. `hookaction.round: number`
- 当前回合数
3. `hookaction.is_first: boolean`
- 是否先手
4. `hookaction.our: { pet_id, catch_time, hp, max_hp } | null`
- 我方当前精灵
5. `hookaction.opp: { pet_id, catch_time, hp, max_hp } | null`
- 对方当前精灵
6. `hookaction.skills: Array<{ skill_id, pp, can_use }>`
- 我方当前技能快照
返回值
- `true`继续行动
- `false`阻止行动
- 不返回默认回退到 `hookaction.hookaction`
## 4. 脚本示例
### 4.1 第3回合固定放技能
```js
function hookAction(hookaction) {
if (!hookaction.hookaction) return false;
if (hookaction.round === 3) {
useSkill(5001);
}
return true;
}
```
### 4.2 低血切宠
```js
function hookAction(hookaction) {
if (!hookaction.hookaction) return false;
if (!hookaction.our) return true;
var hpRate = hookaction.our.max_hp > 0 ? hookaction.our.hp / hookaction.our.max_hp : 1;
if (hpRate < 0.3) {
switchPet(2);
}
return true;
}
```
### 4.3 读取技能可用性后出招
```js
function hookAction(hookaction) {
if (!hookaction.hookaction) return false;
for (var i = 0; i < hookaction.skills.length; i++) {
var s = hookaction.skills[i];
if (s.can_use && s.skill_id === 5001) {
useSkill(s.skill_id);
break;
}
}
return true;
}
```
## 5. Monaco 类型提示
```ts
import * as monaco from "monaco-editor";
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
allowNonTsExtensions: true,
checkJs: true,
target: monaco.languages.typescript.ScriptTarget.ES2020,
});
monaco.languages.typescript.javascriptDefaults.addExtraLib(
`
interface BossHookPetContext {
pet_id: number;
catch_time: number;
hp: number;
max_hp: number;
}
interface BossHookSkillContext {
skill_id: number;
pp: number;
can_use: boolean;
}
interface BossHookActionContext {
hookaction: boolean;
round: number;
is_first: boolean;
our: BossHookPetContext | null;
opp: BossHookPetContext | null;
skills: BossHookSkillContext[];
}
declare function hookAction(hookaction: BossHookActionContext): boolean;
declare function HookAction(hookaction: BossHookActionContext): boolean;
declare function hookaction(hookaction: BossHookActionContext): boolean;
declare function useSkill(skillId: number): void;
declare function switchPet(catchTime: number): void;
`,
"ts:boss-script.d.ts"
);
```
## 6. Monaco 补全可选
```ts
monaco.languages.registerCompletionItemProvider("javascript", {
provideCompletionItems() {
return {
suggestions: [
{
label: "boss hookAction",
kind: monaco.languages.CompletionItemKind.Snippet,
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
insertText: [
"function hookAction(hookaction) {",
" if (!hookaction.hookaction) return false;",
" if (hookaction.round >= 3) useSkill(5001);",
" return true;",
"}",
].join("\\n"),
documentation: "Boss脚本模板可读取回合/血量并调用 useSkill/switchPet。",
},
],
};
},
});
```
## 7. 后端代码
- 脚本执行器与函数绑定`modules/config/model/boss_pet.go`
- AI 出手转发`logic/service/fight/input/ai.go`
- BOSS 脚本注入
- `logic/controller/fight_boss野怪和地图怪.go`
- `logic/controller/fight_塔.go`

View File

@@ -4,30 +4,28 @@ import (
"blazing/logic/service/fight/info" "blazing/logic/service/fight/info"
"blazing/logic/service/player" "blazing/logic/service/player"
configmodel "blazing/modules/config/model" configmodel "blazing/modules/config/model"
"strings"
"github.com/gogf/gf/v2/util/grand" "github.com/gogf/gf/v2/util/grand"
) )
// Shuffle 打乱切片顺序,使用 Fisher-Yates 洗牌算法,泛型支持任意类型 // Shuffle 打乱切片顺序,使用 Fisher-Yates 洗牌算法,泛型支持任意类型
func Shuffle[T any](slice []T) { func Shuffle[T any](slice []T) {
// 从后往前遍历,逐个交换
for i := len(slice) - 1; i > 0; i-- { for i := len(slice) - 1; i > 0; i-- {
// 生成 0 到 i 之间的随机索引
j := grand.Intn(i + 1) j := grand.Intn(i + 1)
// 交换 i 和 j 位置的元素
slice[i], slice[j] = slice[j], slice[i] slice[i], slice[j] = slice[j], slice[i]
} }
} }
func (our *Input) GetAction() { func (our *Input) GetAction() {
next := our.Exec(func(t Effect) bool { next := our.Exec(func(t Effect) bool {
return t.HookAction() return t.HookAction()
}) })
scriptCtx := buildBossHookActionContext(our, next)
if aiPlayer, ok := our.Player.(*player.AI_player); ok && aiPlayer.BossScript != "" { if aiPlayer, ok := our.Player.(*player.AI_player); ok && aiPlayer.BossScript != "" {
scriptBoss := &configmodel.BossConfig{Script: aiPlayer.BossScript} scriptBoss := &configmodel.BossConfig{Script: aiPlayer.BossScript}
nextByScript, err := scriptBoss.RunHookActionScript(next) nextByScript, err := scriptBoss.RunHookActionScript(scriptCtx)
if err != nil { if err != nil {
return return
} }
@@ -37,28 +35,30 @@ func (our *Input) GetAction() {
if !next { if !next {
return return
} }
// 获取己方当前宠物和对方当前宠物
if applyBossScriptAction(our, scriptCtx) {
return
}
selfPet := our.FightC.GetCurrPET(our.Player) selfPet := our.FightC.GetCurrPET(our.Player)
//没血就切换精灵 if selfPet == nil {
return
}
if selfPet.Info.Hp <= 0 { if selfPet.Info.Hp <= 0 {
for _, v := range our.AllPet { for _, v := range our.AllPet {
if v.Info.Hp > 0 { if v.Info.Hp > 0 {
// println("AI出手,切换宠物")
our.FightC.ChangePet(our.Player, v.Info.CatchTime) our.FightC.ChangePet(our.Player, v.Info.CatchTime)
return return
} }
} }
// 如果没有可用宠物,则直接返回,不执行任何操作
return return
} }
//oppPet := opp.FightC.GetCurrPET(opp.Player)
skills := selfPet.Skills
// 空技能列表直接返回,避免错误 skills := selfPet.Skills
if len(skills) == 0 { if len(skills) == 0 {
return return
} }
//println("AI出手,选择技能")
var usedskill *info.SkillEntity var usedskill *info.SkillEntity
for _, s := range skills { for _, s := range skills {
if s == nil { if s == nil {
@@ -67,30 +67,25 @@ func (our *Input) GetAction() {
if !s.CanUse() { if !s.CanUse() {
continue continue
} }
// 计算技能对对方的伤害假设CalculatePower返回伤害值或需从技能中获取
s.DamageValue = our.CalculatePower(our.Opp, s) s.DamageValue = our.CalculatePower(our.Opp, s)
oppPet := our.Opp.CurrentPet() oppPet := our.Opp.CurrentPet()
if oppPet == nil { if oppPet == nil {
continue continue
} }
// 判断是否能秒杀(伤害 >= 对方当前生命值) if s.DamageValue.Cmp(oppPet.GetHP()) != -1 {
if s.DamageValue.Cmp(oppPet.GetHP()) != -1 { // 假设oppPet.HP为对方当前剩余生命值
if usedskill != nil { if usedskill != nil {
if s.DamageValue.Cmp(usedskill.DamageValue) != -1 { if s.DamageValue.Cmp(usedskill.DamageValue) != -1 {
usedskill = s usedskill = s
} }
} else { } else {
usedskill = s usedskill = s
} }
} }
} }
Shuffle(skills) Shuffle(skills)
if usedskill == nil { if usedskill == nil {
for _, s := range skills { for _, s := range skills {
if s == nil { if s == nil {
continue continue
@@ -99,14 +94,87 @@ func (our *Input) GetAction() {
continue continue
} }
usedskill = s usedskill = s
} }
} }
if usedskill != nil { if usedskill != nil {
our.FightC.UseSkill(our.Player, uint32(usedskill.XML.ID)) our.FightC.UseSkill(our.Player, uint32(usedskill.XML.ID))
} else { } else {
our.FightC.UseSkill(our.Player, 0) our.FightC.UseSkill(our.Player, 0)
} }
}
func buildBossHookActionContext(our *Input, hookAction bool) *configmodel.BossHookActionContext {
ctx := &configmodel.BossHookActionContext{
HookAction: hookAction,
Action: "auto",
}
ctx.UseSkillFn = func(skillID uint32) {
ctx.Action = "skill"
ctx.SkillID = skillID
}
ctx.SwitchPetFn = func(catchTime uint32) {
ctx.Action = "switch"
ctx.CatchTime = catchTime
}
if our == nil || our.FightC == nil || our.Player == nil {
return ctx
}
overInfo := our.FightC.GetOverInfo()
ctx.Round = overInfo.Round
ctx.IsFirst = our.FightC.IsFirst(our.Player)
if selfPet := our.CurrentPet(); selfPet != nil {
ctx.Our = &configmodel.BossHookPetContext{
PetID: selfPet.Info.ID,
CatchTime: selfPet.Info.CatchTime,
Hp: selfPet.Info.Hp,
MaxHp: selfPet.Info.MaxHp,
}
ctx.Skills = make([]configmodel.BossHookSkillContext, 0, len(selfPet.Skills))
for _, s := range selfPet.Skills {
if s == nil || s.Info == nil {
continue
}
ctx.Skills = append(ctx.Skills, configmodel.BossHookSkillContext{
SkillID: s.Info.ID,
PP: s.Info.PP,
CanUse: s.CanUse(),
})
}
}
if our.Opp != nil {
if oppPet := our.Opp.CurrentPet(); oppPet != nil {
ctx.Opp = &configmodel.BossHookPetContext{
PetID: oppPet.Info.ID,
CatchTime: oppPet.Info.CatchTime,
Hp: oppPet.Info.Hp,
MaxHp: oppPet.Info.MaxHp,
}
}
}
return ctx
}
func applyBossScriptAction(our *Input, ctx *configmodel.BossHookActionContext) bool {
if our == nil || ctx == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(ctx.Action)) {
case "", "auto":
return false
case "skill", "use_skill", "useskill":
our.FightC.UseSkill(our.Player, ctx.SkillID)
return true
case "switch", "change_pet", "changepet":
our.FightC.ChangePet(our.Player, ctx.CatchTime)
return true
default:
return false
}
} }

View File

@@ -28,6 +28,37 @@ type BossConfig struct {
Rule []uint32 `gorm:"type:jsonb; ;comment:'战胜规则'" json:"rule"` Rule []uint32 `gorm:"type:jsonb; ;comment:'战胜规则'" json:"rule"`
} }
// BossHookSkillContext 为脚本暴露当前精灵技能可用信息。
type BossHookSkillContext struct {
SkillID uint32 `json:"skill_id"`
PP uint32 `json:"pp"`
CanUse bool `json:"can_use"`
}
// BossHookPetContext 为脚本暴露战斗中双方精灵简要信息。
type BossHookPetContext struct {
PetID uint32 `json:"pet_id"`
CatchTime uint32 `json:"catch_time"`
Hp uint32 `json:"hp"`
MaxHp uint32 `json:"max_hp"`
}
// BossHookActionContext 为 boss 脚本提供可读写的出手上下文。
type BossHookActionContext struct {
HookAction bool `json:"hookaction"` // effect 链原始 HookAction 判定
Round uint32 `json:"round"` // 当前回合数
IsFirst bool `json:"is_first"` // 是否先手
Our *BossHookPetContext `json:"our"` // 我方当前精灵
Opp *BossHookPetContext `json:"opp"` // 对方当前精灵
Skills []BossHookSkillContext `json:"skills"` // 我方技能
Action string `json:"action"` // auto/skill/switch
SkillID uint32 `json:"skill_id"` // action=skill
CatchTime uint32 `json:"catch_time"` // action=switch
UseSkillFn func(skillID uint32) `json:"-"`
SwitchPetFn func(catchTime uint32) `json:"-"`
}
// TableName 指定BossConfig对应的数据库表名 // TableName 指定BossConfig对应的数据库表名
func (*BossConfig) TableName() string { func (*BossConfig) TableName() string {
return TableNameBossConfig return TableNameBossConfig
@@ -39,11 +70,8 @@ func (*BossConfig) GroupName() string {
} }
// NewBossConfig 创建一个新的BossConfig实例初始化通用Model字段+所有默认值) // NewBossConfig 创建一个新的BossConfig实例初始化通用Model字段+所有默认值)
func NewBossConfig() *BossConfig { func NewBossConfig() *BossConfig {
return &BossConfig{ return &BossConfig{Model: cool.NewModel()}
Model: cool.NewModel(),
}
} }
// init 程序启动时自动创建/同步boss_config表结构 // init 程序启动时自动创建/同步boss_config表结构
@@ -51,8 +79,7 @@ func init() {
cool.CreateTable(&BossConfig{}) cool.CreateTable(&BossConfig{})
} }
// RunHookActionScript 执行BOSS脚本中的 hookAction,并传入 fight 的 hookaction 参数 // RunHookActionScript 执行 BOSS 脚本 hookAction。
// 返回值遵循 HookAction 语义true 允许继续出手false 阻止继续出手。
func (b *BossConfig) RunHookActionScript(hookAction any) (bool, error) { func (b *BossConfig) RunHookActionScript(hookAction any) (bool, error) {
if b == nil || strings.TrimSpace(b.Script) == "" { if b == nil || strings.TrimSpace(b.Script) == "" {
return true, nil return true, nil
@@ -64,6 +91,9 @@ func (b *BossConfig) RunHookActionScript(hookAction any) (bool, error) {
} }
vm := goja.New() vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
bindBossScriptFunctions(vm, hookAction)
if _, err = vm.RunProgram(program); err != nil { if _, err = vm.RunProgram(program); err != nil {
return false, fmt.Errorf("run boss script: %w", err) return false, fmt.Errorf("run boss script: %w", err)
} }
@@ -87,10 +117,49 @@ func (b *BossConfig) RunHookActionScript(hookAction any) (bool, error) {
return false, fmt.Errorf("execute boss hookAction: %w", err) return false, fmt.Errorf("execute boss hookAction: %w", err)
} }
// 与既有HookAction默认行为保持一致未显式返回时视为允许继续出手。
if goja.IsUndefined(result) || goja.IsNull(result) { if goja.IsUndefined(result) || goja.IsNull(result) {
return true, nil return defaultHookActionResult(hookAction), nil
} }
return result.ToBoolean(), nil return result.ToBoolean(), nil
} }
func bindBossScriptFunctions(vm *goja.Runtime, hookAction any) {
ctx, ok := hookAction.(*BossHookActionContext)
if !ok || ctx == nil {
return
}
_ = vm.Set("useSkill", func(call goja.FunctionCall) goja.Value {
if ctx.UseSkillFn == nil || len(call.Arguments) == 0 {
return goja.Undefined()
}
skillID := call.Arguments[0].ToInteger()
if skillID < 0 {
return goja.Undefined()
}
ctx.UseSkillFn(uint32(skillID))
return goja.Undefined()
})
_ = vm.Set("switchPet", func(call goja.FunctionCall) goja.Value {
if ctx.SwitchPetFn == nil || len(call.Arguments) == 0 {
return goja.Undefined()
}
catchTime := call.Arguments[0].ToInteger()
if catchTime < 0 {
return goja.Undefined()
}
ctx.SwitchPetFn(uint32(catchTime))
return goja.Undefined()
})
}
func defaultHookActionResult(hookAction any) bool {
if ctx, ok := hookAction.(*BossHookActionContext); ok {
return ctx.HookAction
}
if val, ok := hookAction.(bool); ok {
return val
}
return true
}

View File

@@ -2,21 +2,17 @@ package model
import "testing" import "testing"
type testHookAction struct {
Allow bool
Round int
}
func TestBossConfigRunHookActionScript(t *testing.T) { func TestBossConfigRunHookActionScript(t *testing.T) {
boss := &BossConfig{ boss := &BossConfig{
Script: ` Script: `
function hookAction(hookaction) { function hookAction(hookaction) {
return hookaction.Allow && hookaction.Round >= 2; return hookaction.hookaction === true;
} }
`, `,
} }
ok, err := boss.RunHookActionScript(testHookAction{Allow: true, Round: 2}) ctx := &BossHookActionContext{HookAction: true}
ok, err := boss.RunHookActionScript(ctx)
if err != nil { if err != nil {
t.Fatalf("RunHookActionScript returned error: %v", err) t.Fatalf("RunHookActionScript returned error: %v", err)
} }
@@ -25,20 +21,67 @@ func TestBossConfigRunHookActionScript(t *testing.T) {
} }
} }
func TestBossConfigRunHookActionScriptEmptyReturnDefaultsTrue(t *testing.T) { func TestBossConfigRunHookActionScriptCallUseSkillFn(t *testing.T) {
boss := &BossConfig{ boss := &BossConfig{
Script: ` Script: `
function hookAction(hookaction) { function hookAction(hookaction) {
var _ = hookaction; if (hookaction.round >= 2) {
useSkill(5001);
}
return true;
} }
`, `,
} }
ok, err := boss.RunHookActionScript(testHookAction{Allow: false, Round: 1}) ctx := &BossHookActionContext{
HookAction: true,
Round: 2,
Action: "auto",
}
ctx.UseSkillFn = func(skillID uint32) {
ctx.Action = "skill"
ctx.SkillID = skillID
}
ok, err := boss.RunHookActionScript(ctx)
if err != nil { if err != nil {
t.Fatalf("RunHookActionScript returned error: %v", err) t.Fatalf("RunHookActionScript returned error: %v", err)
} }
if !ok { if !ok {
t.Fatalf("RunHookActionScript = false, want true") t.Fatalf("RunHookActionScript = false, want true")
} }
if ctx.Action != "skill" || ctx.SkillID != 5001 {
t.Fatalf("useSkill not applied, got action=%q skill_id=%d", ctx.Action, ctx.SkillID)
}
}
func TestBossConfigRunHookActionScriptCallSwitchPetFn(t *testing.T) {
boss := &BossConfig{
Script: `
function hookAction(hookaction) {
switchPet(3);
return true;
}
`,
}
ctx := &BossHookActionContext{
HookAction: true,
Action: "auto",
}
ctx.SwitchPetFn = func(catchTime uint32) {
ctx.Action = "switch"
ctx.CatchTime = catchTime
}
ok, err := boss.RunHookActionScript(ctx)
if err != nil {
t.Fatalf("RunHookActionScript returned error: %v", err)
}
if !ok {
t.Fatalf("RunHookActionScript = false, want true")
}
if ctx.Action != "switch" || ctx.CatchTime != 3 {
t.Fatalf("switchPet not applied, got action=%q catch_time=%d", ctx.Action, ctx.CatchTime)
}
} }