Files
a301_game_server/internal/combat/combat_manager.go
2026-02-26 17:52:48 +09:00

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