package combat import ( "time" "a301_game_server/internal/entity" "a301_game_server/internal/network" "a301_game_server/pkg/mathutil" pb "a301_game_server/proto/gen/pb" ) // Combatant is an entity that can participate in combat. type Combatant interface { entity.Entity HP() int32 SetHP(int32) MaxHP() int32 MP() int32 SetMP(int32) IsAlive() bool Stats() CombatStats } // CombatStats provides stat access for damage calculation. type CombatStats struct { Str int32 Dex int32 Int int32 Level int32 } // CombatantWithConn is a combatant that can receive messages (player). type CombatantWithConn interface { Combatant Connection() *network.Connection } // Manager handles all combat logic for a zone. type Manager struct { skills *SkillRegistry buffs *BuffRegistry // Per-entity state cooldowns map[uint64]map[uint32]time.Time // entityID -> skillID -> ready time activeBuffs map[uint64][]*ActiveBuff // entityID -> active buffs // Broadcast function (set by zone to send to AOI) broadcastToNearby func(ent entity.Entity, msgType uint16, msg interface{}) sendToEntity func(entityID uint64, msgType uint16, msg interface{}) } // NewManager creates a combat manager. func NewManager() *Manager { return &Manager{ skills: NewSkillRegistry(), buffs: NewBuffRegistry(), cooldowns: make(map[uint64]map[uint32]time.Time), activeBuffs: make(map[uint64][]*ActiveBuff), } } // Skills returns the skill registry. func (m *Manager) Skills() *SkillRegistry { return m.skills } // SetBroadcast configures the broadcast callback. func (m *Manager) SetBroadcast( broadcast func(ent entity.Entity, msgType uint16, msg interface{}), send func(entityID uint64, msgType uint16, msg interface{}), ) { m.broadcastToNearby = broadcast m.sendToEntity = send } // UseSkill attempts to execute a skill. func (m *Manager) UseSkill( caster Combatant, skillID uint32, targetID uint64, targetPos mathutil.Vec3, getEntity func(uint64) entity.Entity, getEntitiesInRadius func(center mathutil.Vec3, radius float32) []entity.Entity, ) (bool, string) { if !caster.IsAlive() { return false, "you are dead" } skill := m.skills.Get(skillID) if skill == nil { return false, "unknown skill" } // Cooldown check. if cd, ok := m.cooldowns[caster.EntityID()]; ok { if readyAt, ok := cd[skillID]; ok && time.Now().Before(readyAt) { return false, "skill on cooldown" } } // Mana check. if caster.MP() < skill.ManaCost { return false, "not enough mana" } // Resolve targets. var targets []Combatant switch skill.TargetType { case TargetSelf: targets = []Combatant{caster} case TargetSingleEnemy: ent := getEntity(targetID) if ent == nil { return false, "target not found" } target, ok := ent.(Combatant) if !ok || !target.IsAlive() { return false, "invalid target" } if caster.Position().DistanceXZ(target.Position()) > skill.Range { return false, "target out of range" } targets = []Combatant{target} case TargetSingleAlly: ent := getEntity(targetID) if ent == nil { return false, "target not found" } target, ok := ent.(Combatant) if !ok || !target.IsAlive() { return false, "invalid target" } if caster.Position().DistanceXZ(target.Position()) > skill.Range { return false, "target out of range" } targets = []Combatant{target} case TargetAoEGround: if caster.Position().DistanceXZ(targetPos) > skill.Range { return false, "target position out of range" } entities := getEntitiesInRadius(targetPos, skill.AoERadius) for _, e := range entities { if c, ok := e.(Combatant); ok && c.IsAlive() && c.EntityID() != caster.EntityID() { targets = append(targets, c) } } case TargetAoETarget: ent := getEntity(targetID) if ent == nil { return false, "target not found" } if caster.Position().DistanceXZ(ent.Position()) > skill.Range { return false, "target out of range" } entities := getEntitiesInRadius(ent.Position(), skill.AoERadius) for _, e := range entities { if c, ok := e.(Combatant); ok && c.IsAlive() && c.EntityID() != caster.EntityID() { targets = append(targets, c) } } } // Consume mana. caster.SetMP(caster.MP() - skill.ManaCost) // Set cooldown. if m.cooldowns[caster.EntityID()] == nil { m.cooldowns[caster.EntityID()] = make(map[uint32]time.Time) } m.cooldowns[caster.EntityID()][skillID] = time.Now().Add(skill.Cooldown) // Calculate effective stats (base + buff modifiers). casterStats := m.effectiveStats(caster) // Apply effects to each target. for _, target := range targets { m.applyEffects(caster, target, skill, casterStats) } return true, "" } func (m *Manager) applyEffects(caster, target Combatant, skill *SkillDef, casterStats CombatStats) { for _, effect := range skill.Effects { switch effect.Type { case EffectDamage: targetStats := m.effectiveStats(target) result := CalcDamage(effect.Value, casterStats.Str, targetStats.Dex) target.SetHP(target.HP() - result.FinalDamage) died := !target.IsAlive() evt := &pb.CombatEvent{ CasterId: caster.EntityID(), TargetId: target.EntityID(), SkillId: skill.ID, Damage: result.FinalDamage, IsCritical: result.IsCritical, TargetDied: died, TargetHp: target.HP(), TargetMaxHp: target.MaxHP(), EventType: pb.CombatEventType_COMBAT_EVENT_DAMAGE, } if m.broadcastToNearby != nil { m.broadcastToNearby(target, network.MsgCombatEvent, evt) } if died { deathEvt := &pb.CombatEvent{ CasterId: caster.EntityID(), TargetId: target.EntityID(), EventType: pb.CombatEventType_COMBAT_EVENT_DEATH, } if m.broadcastToNearby != nil { m.broadcastToNearby(target, network.MsgCombatEvent, deathEvt) } } case EffectHeal: heal := CalcHeal(effect.Value, casterStats.Int) target.SetHP(target.HP() + heal) evt := &pb.CombatEvent{ CasterId: caster.EntityID(), TargetId: target.EntityID(), SkillId: skill.ID, Heal: heal, TargetHp: target.HP(), TargetMaxHp: target.MaxHP(), EventType: pb.CombatEventType_COMBAT_EVENT_HEAL, } if m.broadcastToNearby != nil { m.broadcastToNearby(target, network.MsgCombatEvent, evt) } case EffectBuff, EffectDebuff: buffDef := m.buffs.Get(uint32(effect.Value)) if buffDef == nil { continue } ab := &ActiveBuff{ Def: buffDef, CasterID: caster.EntityID(), Remaining: effect.Duration, TickInterval: effect.TickInterval, NextTick: effect.TickInterval, } m.activeBuffs[target.EntityID()] = append(m.activeBuffs[target.EntityID()], ab) evt := &pb.BuffApplied{ TargetId: target.EntityID(), BuffId: buffDef.ID, BuffName: buffDef.Name, Duration: float32(effect.Duration.Seconds()), IsDebuff: buffDef.IsDebuff, } if m.broadcastToNearby != nil { m.broadcastToNearby(target, network.MsgBuffApplied, evt) } } } } // UpdateBuffs processes active buffs each tick. Call once per zone tick. func (m *Manager) UpdateBuffs(dt time.Duration, getEntity func(uint64) Combatant) { for entityID, buffs := range m.activeBuffs { target := getEntity(entityID) if target == nil { delete(m.activeBuffs, entityID) continue } var remaining []*ActiveBuff for _, b := range buffs { tickValue := b.Tick(dt) if tickValue != 0 && target.IsAlive() { if b.Def.IsDebuff { // DoT damage. target.SetHP(target.HP() - tickValue) evt := &pb.CombatEvent{ CasterId: b.CasterID, TargetId: entityID, Damage: tickValue, TargetHp: target.HP(), TargetMaxHp: target.MaxHP(), EventType: pb.CombatEventType_COMBAT_EVENT_DAMAGE, } if m.broadcastToNearby != nil { m.broadcastToNearby(target, network.MsgCombatEvent, evt) } } else { // HoT heal. target.SetHP(target.HP() + tickValue) evt := &pb.CombatEvent{ CasterId: b.CasterID, TargetId: entityID, Heal: tickValue, TargetHp: target.HP(), TargetMaxHp: target.MaxHP(), EventType: pb.CombatEventType_COMBAT_EVENT_HEAL, } if m.broadcastToNearby != nil { m.broadcastToNearby(target, network.MsgCombatEvent, evt) } } } if !b.IsExpired() { remaining = append(remaining, b) } else { // Notify buff removed. evt := &pb.BuffRemoved{ TargetId: entityID, BuffId: b.Def.ID, } if m.broadcastToNearby != nil { m.broadcastToNearby(target, network.MsgBuffRemoved, evt) } } } if len(remaining) == 0 { delete(m.activeBuffs, entityID) } else { m.activeBuffs[entityID] = remaining } } } // Respawn resets a dead entity to full HP at a spawn position. func (m *Manager) Respawn(ent Combatant, spawnPos mathutil.Vec3) { ent.SetHP(ent.MaxHP()) ent.SetPosition(spawnPos) m.clearCooldowns(ent.EntityID()) m.clearBuffs(ent.EntityID()) } // RemoveEntity cleans up combat state for a removed entity. func (m *Manager) RemoveEntity(entityID uint64) { m.clearCooldowns(entityID) m.clearBuffs(entityID) } // effectiveStats returns stats with buff modifiers applied. func (m *Manager) effectiveStats(c Combatant) CombatStats { base := c.Stats() buffs := m.activeBuffs[c.EntityID()] for _, b := range buffs { base.Str += b.Def.StatModifier.StrBonus base.Dex += b.Def.StatModifier.DexBonus base.Int += b.Def.StatModifier.IntBonus } return base } func (m *Manager) clearCooldowns(entityID uint64) { delete(m.cooldowns, entityID) } func (m *Manager) clearBuffs(entityID uint64) { delete(m.activeBuffs, entityID) }