package battle import ( "encoding/json" "fmt" "math" "math/rand" "sort" "time" "github.com/honghuang-game/server/config" ) // --------------------------------------------------------------------------- // 配置读取辅助(带本地默认值,避免 Nacos 不可用时崩溃) // --------------------------------------------------------------------------- func cfgGet(key string) (interface{}, bool) { if config.Global == nil { return nil, false } return config.Global.Get(key) } func cfgFloat(key string, def float64) float64 { if config.Global == nil { return def } return config.Global.GetFloat64(key, def) } func cfgInt(key string, def int) int { if config.Global == nil { return def } return config.Global.GetInt(key, def) } func toFloat(v interface{}, def float64) float64 { if v == nil { return def } switch n := v.(type) { case float64: return n case float32: return float64(n) case int: return float64(n) case int64: return float64(n) case string: var f float64 if _, err := fmt.Sscanf(n, "%f", &f); err == nil && f > 0 { return f } } return def } // --------------------------------------------------------------------------- // 战报数据结构(与 GDD-03 文字战报 JSON Schema 对齐) // --------------------------------------------------------------------------- type FighterStatsView struct { HPMax int `json:"hp_max"` ATK int `json:"atk"` DEF int `json:"def"` Speed int `json:"speed"` Spirit int `json:"spirit"` Luck int `json:"luck"` Blood *int `json:"blood"` } type FighterView struct { ID string `json:"id"` Name string `json:"name"` Race string `json:"race"` Job string `json:"job"` AvatarKey string `json:"avatar_key,omitempty"` Stats FighterStatsView `json:"stats"` ActiveSkills []string `json:"active_skills"` PassiveTalents []string `json:"passive_talents"` } type RoundLog struct { Round int `json:"round"` Tick int `json:"tick"` DayPhase string `json:"day_phase"` Actor string `json:"actor"` SkillID string `json:"skill_id"` SkillName string `json:"skill_name"` Damage int `json:"damage"` DamageType string `json:"damage_type"` IsCrit bool `json:"is_crit"` IsEvaded bool `json:"is_evaded"` HPAfter map[string]int `json:"hp_after"` Text string `json:"text"` } type BattleResult struct { Winner string `json:"winner"` EndCondition string `json:"end_condition"` FinalHP map[string]int `json:"final_hp"` } type CurrencyDrop struct { Type string `json:"type"` Amount float64 `json:"amount"` } type ItemDrop struct { ItemID string `json:"item_id"` Quantity int `json:"quantity"` } type DropResult struct { Currency []CurrencyDrop `json:"currency"` Items []ItemDrop `json:"items"` HonorPoints int `json:"honor_points"` Exp int64 `json:"exp"` } type SpecialEvent struct { Round int `json:"round"` Tick int `json:"tick"` Type string `json:"type"` Actor string `json:"actor,omitempty"` Value int `json:"value,omitempty"` Text string `json:"text,omitempty"` } type BattleReport struct { BattleID string `json:"battle_id"` Type string `json:"type"` RealmTier int `json:"realm_tier"` GameTimestamp string `json:"game_timestamp"` RealTimestamp string `json:"real_timestamp"` Attacker FighterView `json:"attacker"` Defender FighterView `json:"defender"` Rounds []RoundLog `json:"rounds"` Result BattleResult `json:"result"` SpecialEvents []SpecialEvent `json:"special_events"` Drops DropResult `json:"drops"` DeathPenaltyApplied bool `json:"death_penalty_applied"` } // --------------------------------------------------------------------------- // ATB 战斗引擎 // --------------------------------------------------------------------------- // Engine 负责按行动速度制(ATB)完整模拟一场战斗 type Engine struct { attacker *Fighter defender *Fighter battleType string realmTier int rng *rand.Rand tick int roundSeq int maxTicks int maxActions int gaugeMax float64 baseCoef float64 logs []RoundLog specialEvents []SpecialEvent } // NewEngine 创建战斗引擎 func NewEngine(attacker, defender *Fighter, rng *rand.Rand, battleType string, realmTier int) *Engine { if rng == nil { rng = rand.New(rand.NewSource(1)) } return &Engine{ attacker: attacker, defender: defender, battleType: battleType, realmTier: realmTier, rng: rng, maxTicks: cfgInt("combat.battle.max_ticks", 3000), maxActions: cfgInt("combat.battle.max_actions", 50), gaugeMax: cfgFloat("combat.atb.gauge_max", 100), baseCoef: cfgFloat("combat.atb.base_coefficient", 0.1), } } // Run 运行完整战斗模拟并返回文字战报 func (e *Engine) Run() *BattleReport { if e.attacker == nil || e.defender == nil { return e.emptyReport("invalid_combatants") } for e.tick < e.maxTicks { e.tick++ e.globalTickUpdate() actor := e.pickActor() if actor == nil { continue } e.processAction(actor) if ended, winner, reason := e.checkEnd(); ended { return e.buildReport(winner, reason) } } // 达到最大 ticks,按剩余 HP 百分比判定胜负(PVE 默认攻击方占优) winner, reason := e.resolveTimeout() return e.buildReport(winner, reason) } // globalTickUpdate 每 tick 更新:内力回复、CD 流逝、行动条填充 func (e *Engine) globalTickUpdate() { for _, f := range []*Fighter{e.attacker, e.defender} { if !f.IsAlive() { continue } f.RegenMana() f.ReduceCooldowns() inc := f.EffectiveSpeed() * e.baseCoef f.Gauge += inc } } // pickActor 选出当前行动条满的单位;多单位同满时按速度+随机扰动排序 func (e *Engine) pickActor() *Fighter { candidates := make([]*Fighter, 0, 2) for _, f := range []*Fighter{e.attacker, e.defender} { if f.IsAlive() && f.Gauge >= e.gaugeMax { candidates = append(candidates, f) } } if len(candidates) == 0 { return nil } sort.Slice(candidates, func(i, j int) bool { vi := candidates[i].EffectiveSpeed() * (0.9 + e.rng.Float64()*0.2) vj := candidates[j].EffectiveSpeed() * (0.9 + e.rng.Float64()*0.2) return vi > vj }) return candidates[0] } // processAction 处理一次行动 func (e *Engine) processAction(actor *Fighter) { target := e.opponent(actor) if target == nil || !target.IsAlive() { return } e.roundSeq++ // 眩晕跳过 if actor.Stunned { actor.Stunned = false actor.StunRemaining = 0 actor.ResetGauge() e.logRound(actor, nil, 0, false, false, "skip") return } normal := DefaultNormalAttack(actor) skill := SelectSkill(actor, target, normal, e.rng) // 进入 CD / 消耗内力 if !skill.IsNormal() { actor.AddCooldown(skill) actor.SpendMana(skill.Cost) } // 命中判定 evaded := false if !skill.IsNormal() || skill.DamageType != DamageTypeTrue { // 真实伤害普攻也允许被闪避(设计可选),这里统一判定 hitBonus := 0.0 if e.rng.Float64() < (target.EvadeRate - hitBonus) { evaded = true } } damage := 0.0 isCrit := false if !evaded { damage, isCrit = e.calcDamage(actor, target, skill) target.TakeDamage(damage) } // 行动后额外内力回复 actor.ManaRegenAfterAction() actor.ActionCount++ actor.ResetGauge() e.logRound(actor, skill, damage, isCrit, evaded, string(skill.DamageType)) if isCrit { e.specialEvents = append(e.specialEvents, SpecialEvent{ Round: e.roundSeq, Tick: e.tick, Type: "crit", Actor: actor.Side, Value: int(damage), }) } } // calcDamage 按 GDD-03 公式计算最终伤害 func (e *Engine) calcDamage(attacker, defender *Fighter, skill *SkillInstance) (float64, bool) { scalingAttr := skill.ScalingAttr if scalingAttr == "" { switch skill.DamageType { case DamageTypeMagical: scalingAttr = "spi" case DamageTypeTrue: scalingAttr = "str" default: scalingAttr = "str" } } var baseAtk float64 switch scalingAttr { case "vit": baseAtk = attacker.Base.Vit case "wis": baseAtk = attacker.Base.Wis case "agi": baseAtk = attacker.Base.Agi case "spi": baseAtk = attacker.Base.Spi case "luk": baseAtk = attacker.Base.Luk case "blood": baseAtk = attacker.Base.Blood default: baseAtk = attacker.Base.Str } coef := skill.BaseCoef if coef <= 0 { coef = cfgFloat("combat.skill.normal_attack_coef", 1.0) } // 亲和 / 共鸣占位(未接入 GDD-15 时视为 0) affinity := 0.0 resonance := 0.0 coef *= (1 + affinity) * (1 + resonance) damage := baseAtk * coef // 暴击 isCrit := false critRate := attacker.CritRate if critRate > 0 && e.rng.Float64() < critRate { multiplier := cfgFloat("combat.crit.multiplier", 1.5) damage *= multiplier isCrit = true } ignoreResist := 0.0 // 减伤 switch skill.DamageType { case DamageTypePhysical: dr := defender.PhysDR - ignoreResist if dr < 0 { dr = 0 } damage *= (1 - dr) case DamageTypeMagical: dr := defender.MagDR - ignoreResist if dr < 0 { dr = 0 } damage *= (1 - dr) case DamageTypeTrue: // 真实伤害无视物/法减伤 } // 阵营伤害修正 factionMod := e.factionModifier(skill.Alignment, defender.Alignment) damage *= factionMod // 元素克制修正(物理技能也可带元素标签) elemMod, ignore := e.elementModifier(skill.Element, defender.Element) damage *= elemMod if ignore > 0 { // 混沌无视部分抗性已在元素处理中体现 } if damage < 0 { damage = 0 } return damage, isCrit } // factionModifier 阵营伤害修正(光明↔暗黑) func (e *Engine) factionModifier(skillAlign, targetAlign string) float64 { cap := cfgFloat("combat.faction.cap", 0.15) if (skillAlign == "light" && targetAlign == "dark") || (skillAlign == "dark" && targetAlign == "light") { return 1 + cap } return 1.0 } // elementModifier 返回元素克制倍率与无视抗性比例 func (e *Engine) elementModifier(atkElem, defElem string) (float64, float64) { if atkElem == "" || atkElem == "none" { return 1.0, 0 } if atkElem == "chaos" { // 混沌无视 15% 抗性,不参与克制环 return 1.0, 0.15 } if advantageOver(atkElem, defElem) { return cfgFloat("combat.element.advantage", 1.2), 0 } if disadvantageTo(atkElem, defElem) { return cfgFloat("combat.element.disadvantage", 0.85), 0 } return 1.0, 0 } func advantageOver(atk, def string) bool { pairs := map[string][]string{ "fire": {"nature", "wood", "ice"}, "water": {"fire"}, "lightning": {"water", "metal"}, "earth": {"lightning"}, "nature": {"earth", "water"}, "wood": {"earth", "water"}, "yin": {"yang"}, "yang": {"yin"}, } for _, d := range pairs[atk] { if d == def { return true } } return false } func disadvantageTo(atk, def string) bool { pairs := map[string][]string{ "fire": {"water"}, "water": {"lightning"}, "lightning": {"earth"}, "earth": {"nature", "wood"}, "nature": {"fire"}, "wood": {"fire"}, "yin": {"yang"}, "yang": {"yin"}, } for _, d := range pairs[atk] { if d == def { return true } } return false } // opponent 返回对手 func (e *Engine) opponent(f *Fighter) *Fighter { if f == e.attacker { return e.defender } return e.attacker } // checkEnd 检查战斗是否结束 func (e *Engine) checkEnd() (bool, string, string) { if !e.attacker.IsAlive() { return true, "defender", "hp_zero" } if !e.defender.IsAlive() { return true, "attacker", "hp_zero" } if e.attacker.ActionCount >= e.maxActions || e.defender.ActionCount >= e.maxActions { winner, _ := e.resolveTimeout() return true, winner, "action_count_limit" } return false, "", "" } // resolveTimeout 超时/行动次数上限判胜负:PVE 攻击方 HP% 高则胜;平局攻击方胜 func (e *Engine) resolveTimeout() (string, string) { aPct := e.attacker.HP / e.attacker.HPMax dPct := e.defender.HP / e.defender.HPMax if aPct >= dPct { return "attacker", "action_time_limit" } return "defender", "action_time_limit" } // logRound 写入一条行动日志 func (e *Engine) logRound(actor *Fighter, skill *SkillInstance, damage float64, isCrit, evaded bool, dmgType string) { if skill == nil { skill = DefaultNormalAttack(actor) } text := e.buildText(actor, skill, int(damage), isCrit, evaded) hpAfter := map[string]int{ "attacker": int(e.attacker.HP), "defender": int(e.defender.HP), } e.logs = append(e.logs, RoundLog{ Round: e.roundSeq, Tick: e.tick, DayPhase: "day", Actor: actor.Side, SkillID: skill.SkillID, SkillName: skill.Name, Damage: int(damage), DamageType: dmgType, IsCrit: isCrit, IsEvaded: evaded, HPAfter: hpAfter, Text: text, }) } func (e *Engine) buildText(actor *Fighter, skill *SkillInstance, damage int, isCrit, evaded bool) string { if evaded { return fmt.Sprintf("〔%s〕的 %s 被闪开了!", actor.Name, skill.Name) } if skill.SkillID == "skip" { return fmt.Sprintf("〔%s〕眩晕中无法行动。", actor.Name) } critText := "" if isCrit { critText = "暴击!" } return fmt.Sprintf("〔%s〕施展 %s,造成 %d 点伤害%s", actor.Name, skill.Name, damage, critText) } // buildReport 组装最终战报 func (e *Engine) buildReport(winner, endCondition string) *BattleReport { drops := GenerateDrops(e.attacker, e.defender, e.realmTier, e.rng) return &BattleReport{ BattleID: "", Type: e.battleType, RealmTier: e.realmTier, GameTimestamp: time.Now().UTC().Format(time.RFC3339), RealTimestamp: time.Now().UTC().Format(time.RFC3339), Attacker: fighterView(e.attacker), Defender: fighterView(e.defender), Rounds: e.logs, Result: BattleResult{ Winner: winner, EndCondition: endCondition, FinalHP: map[string]int{ "attacker": int(e.attacker.HP), "defender": int(e.defender.HP), }, }, SpecialEvents: e.specialEvents, Drops: drops, DeathPenaltyApplied: false, } } func (e *Engine) emptyReport(reason string) *BattleReport { return &BattleReport{ Type: e.battleType, RealmTier: e.realmTier, GameTimestamp: time.Now().UTC().Format(time.RFC3339), RealTimestamp: time.Now().UTC().Format(time.RFC3339), Result: BattleResult{Winner: "draw", EndCondition: reason}, Drops: DropResult{}, } } func fighterView(f *Fighter) FighterView { blood := int(f.Base.Blood) var bloodPtr *int if f.Base.Blood > 0 { bloodPtr = &blood } return FighterView{ ID: f.ID, Name: f.Name, Race: f.RaceID, Job: f.Job, Stats: FighterStatsView{ HPMax: int(f.HPMax), ATK: int(f.PhysAtk), DEF: int(f.PhysDR * 100), Speed: int(f.Speed), Spirit: int(f.MagAtk), Luck: int(f.CritRate * 100), Blood: bloodPtr, }, ActiveSkills: f.ActiveSkillIDs(), PassiveTalents: []string{}, } } // GenerateMonster 根据玩家强度和境界层级生成一只 PVE 怪物(简化版) func GenerateMonster(realmTier, worldTier int, player *Fighter, rng *rand.Rand) *Fighter { if rng == nil { rng = rand.New(rand.NewSource(1)) } names := []string{"幽魂", "铁背苍狼", "山魈", "腐沼毒蟾", "赤焰狼妖"} races := []string{"ghost", "wolf_yao", "shanxiao", "deep_one", "wolf_yao"} elements := []string{"yin", "none", "earth", "yin", "fire"} idx := rng.Intn(len(names)) name := names[idx] race := races[idx] element := elements[idx] scale := 0.75 + rng.Float64()*0.15 base := BaseStats{ Str: player.Base.Str * scale, Vit: player.Base.Vit * scale, Wis: player.Base.Wis * scale, Agi: player.Base.Agi * scale * (0.8 + rng.Float64()*0.3), Spi: player.Base.Spi * scale, Luk: player.Base.Luk * scale, } monster := NewFighter("npc-"+name, name, race, "defender", worldTier, realmTier, base, "dark", element, false) monster.Side = "defender" // 给怪物配置 1~2 个简单技能 small := &SkillInstance{ InstanceID: "m_small", SkillID: "monster_claw", Name: "撕咬", DamageType: DamageTypePhysical, Element: element, Alignment: "dark", ScalingAttr: "str", BaseCoef: cfgFloat("combat.skill.small_coef_max", 2.5), CD: cfgInt("combat.skill.small_cd", 225), Cost: cfgFloat("combat.mana.small_skill_cost", 20), TriggerRate: cfgFloat("combat.skill.small_trigger_rate", 1.25), TargetType: TargetSingle, } monster.AddSkill(small) if rng.Float64() < 0.4 { big := &SkillInstance{ InstanceID: "m_big", SkillID: "monster_frenzy", Name: "狂暴扑击", DamageType: DamageTypePhysical, Element: element, Alignment: "dark", ScalingAttr: "str", BaseCoef: cfgFloat("combat.skill.big_coef_min", 2.5), CD: cfgInt("combat.skill.big_cd", 800), Cost: cfgFloat("combat.mana.big_skill_cost", 40), TriggerRate: cfgFloat("combat.skill.big_trigger_rate", 2.0), TargetType: TargetSingle, } monster.AddSkill(big) } return monster } // GenerateDrops 根据战斗结果生成奖励(货币 + 经验 + 概率材料) func GenerateDrops(winner, loser *Fighter, realmTier int, rng *rand.Rand) DropResult { if rng == nil { rng = rand.New(rand.NewSource(1)) } if winner == nil || !winner.IsPlayer { return DropResult{} } currencyCode, baseAmount := dropCurrencyForTier(realmTier) amount := baseAmount * (0.8 + rng.Float64()*0.4) drop := DropResult{ Currency: []CurrencyDrop{{Type: currencyCode, Amount: math.Round(amount*100) / 100}}, Exp: int64(50 * realmTier), } // 材料概率掉落 materialRate := 0.05 + float64(realmTier)*0.03 if rng.Float64() < materialRate { itemID := fmt.Sprintf("monster_material_tier%d", realmTier) drop.Items = append(drop.Items, ItemDrop{ItemID: itemID, Quantity: 1}) } return drop } func dropCurrencyForTier(realmTier int) (string, float64) { defaults := map[int]struct { code string amount float64 }{ 1: {"copper", 125}, 2: {"spirit_stone_low", 2}, 3: {"spirit_stone_mid", 10}, 4: {"soul_crystal", 20}, 5: {"immortal_crystal", 55}, 6: {"chaos_crystal", 140}, } key := fmt.Sprintf("combat.drop.currency_tier%d", realmTier) if v, ok := cfgGet(key); ok { if m, ok2 := v.(map[string]interface{}); ok2 { code, _ := m["code"].(string) amount := toFloat(m["amount"], 0) if code != "" && amount > 0 { return code, amount } } } if d, ok := defaults[realmTier]; ok { return d.code, d.amount } return "copper", 10 } // MarshalReport 将战报序列化为 JSONB 字节 func (r *BattleReport) MarshalReport() ([]byte, error) { return json.Marshal(r) }