lawless/server/internal/battle/engine.go

725 行
19 KiB
Go

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