369 lines
9.5 KiB
Go
369 lines
9.5 KiB
Go
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)
|
|
}
|