725 行
19 KiB
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)
|
|||
|
|
}
|