first commit
This commit is contained in:
86
internal/combat/buff.go
Normal file
86
internal/combat/buff.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package combat
|
||||
|
||||
import "time"
|
||||
|
||||
// BuffDef defines a buff/debuff type.
|
||||
type BuffDef struct {
|
||||
ID uint32
|
||||
Name string
|
||||
IsDebuff bool
|
||||
DamagePerTick int32 // for DoTs (debuff); heal per tick for HoTs (buff)
|
||||
StatModifier StatMod
|
||||
}
|
||||
|
||||
// StatMod is a temporary stat modification from a buff.
|
||||
type StatMod struct {
|
||||
StrBonus int32
|
||||
DexBonus int32
|
||||
IntBonus int32
|
||||
}
|
||||
|
||||
// ActiveBuff is an active buff/debuff on an entity.
|
||||
type ActiveBuff struct {
|
||||
Def *BuffDef
|
||||
CasterID uint64
|
||||
Remaining time.Duration
|
||||
TickInterval time.Duration
|
||||
NextTick time.Duration // time until next tick
|
||||
}
|
||||
|
||||
// Tick advances the buff by dt. Returns damage/heal to apply this tick (0 if no tick).
|
||||
func (b *ActiveBuff) Tick(dt time.Duration) int32 {
|
||||
b.Remaining -= dt
|
||||
var tickValue int32
|
||||
|
||||
if b.TickInterval > 0 {
|
||||
b.NextTick -= dt
|
||||
if b.NextTick <= 0 {
|
||||
tickValue = b.Def.DamagePerTick
|
||||
b.NextTick += b.TickInterval
|
||||
}
|
||||
}
|
||||
|
||||
return tickValue
|
||||
}
|
||||
|
||||
// IsExpired returns true if the buff has no remaining duration.
|
||||
func (b *ActiveBuff) IsExpired() bool {
|
||||
return b.Remaining <= 0
|
||||
}
|
||||
|
||||
// BuffRegistry holds all buff/debuff definitions.
|
||||
type BuffRegistry struct {
|
||||
buffs map[uint32]*BuffDef
|
||||
}
|
||||
|
||||
// NewBuffRegistry creates a registry with default buffs.
|
||||
func NewBuffRegistry() *BuffRegistry {
|
||||
r := &BuffRegistry{buffs: make(map[uint32]*BuffDef)}
|
||||
r.registerDefaults()
|
||||
return r
|
||||
}
|
||||
|
||||
// Get returns a buff definition.
|
||||
func (r *BuffRegistry) Get(id uint32) *BuffDef {
|
||||
return r.buffs[id]
|
||||
}
|
||||
|
||||
func (r *BuffRegistry) registerDefaults() {
|
||||
// Poison DoT (referenced by skill ID 5, effect Value=1)
|
||||
r.buffs[1] = &BuffDef{
|
||||
ID: 1,
|
||||
Name: "Poison",
|
||||
IsDebuff: true,
|
||||
DamagePerTick: 8,
|
||||
}
|
||||
|
||||
// Power Up buff (referenced by skill ID 6, effect Value=2)
|
||||
r.buffs[2] = &BuffDef{
|
||||
ID: 2,
|
||||
Name: "Power Up",
|
||||
IsDebuff: false,
|
||||
StatModifier: StatMod{
|
||||
StrBonus: 20,
|
||||
},
|
||||
}
|
||||
}
|
||||
368
internal/combat/combat_manager.go
Normal file
368
internal/combat/combat_manager.go
Normal file
@@ -0,0 +1,368 @@
|
||||
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)
|
||||
}
|
||||
239
internal/combat/combat_test.go
Normal file
239
internal/combat/combat_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package combat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
pb "a301_game_server/proto/gen/pb"
|
||||
)
|
||||
|
||||
// mockCombatant implements Combatant for testing.
|
||||
type mockCombatant struct {
|
||||
id uint64
|
||||
pos mathutil.Vec3
|
||||
hp, maxHP int32
|
||||
mp, maxMP int32
|
||||
str, dex, intStat int32
|
||||
}
|
||||
|
||||
func (m *mockCombatant) EntityID() uint64 { return m.id }
|
||||
func (m *mockCombatant) EntityType() entity.Type { return entity.TypePlayer }
|
||||
func (m *mockCombatant) Position() mathutil.Vec3 { return m.pos }
|
||||
func (m *mockCombatant) SetPosition(p mathutil.Vec3) { m.pos = p }
|
||||
func (m *mockCombatant) Rotation() float32 { return 0 }
|
||||
func (m *mockCombatant) SetRotation(float32) {}
|
||||
func (m *mockCombatant) ToProto() *pb.EntityState { return &pb.EntityState{EntityId: m.id} }
|
||||
func (m *mockCombatant) HP() int32 { return m.hp }
|
||||
func (m *mockCombatant) SetHP(hp int32) {
|
||||
if hp < 0 { hp = 0 }
|
||||
if hp > m.maxHP { hp = m.maxHP }
|
||||
m.hp = hp
|
||||
}
|
||||
func (m *mockCombatant) MaxHP() int32 { return m.maxHP }
|
||||
func (m *mockCombatant) MP() int32 { return m.mp }
|
||||
func (m *mockCombatant) SetMP(mp int32) {
|
||||
if mp < 0 { mp = 0 }
|
||||
m.mp = mp
|
||||
}
|
||||
func (m *mockCombatant) IsAlive() bool { return m.hp > 0 }
|
||||
func (m *mockCombatant) Stats() CombatStats {
|
||||
return CombatStats{Str: m.str, Dex: m.dex, Int: m.intStat, Level: 1}
|
||||
}
|
||||
|
||||
func newMock(id uint64, x, z float32) *mockCombatant {
|
||||
return &mockCombatant{
|
||||
id: id, pos: mathutil.NewVec3(x, 0, z),
|
||||
hp: 100, maxHP: 100, mp: 100, maxMP: 100,
|
||||
str: 10, dex: 10, intStat: 10,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAttack(t *testing.T) {
|
||||
mgr := NewManager()
|
||||
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
|
||||
|
||||
attacker := newMock(1, 0, 0)
|
||||
target := newMock(2, 2, 0) // within range (3.0)
|
||||
|
||||
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
|
||||
|
||||
ok, errMsg := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{},
|
||||
func(id uint64) entity.Entity {
|
||||
if e, ok := entities[id]; ok { return e }
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
if !ok {
|
||||
t.Fatalf("expected success, got error: %s", errMsg)
|
||||
}
|
||||
|
||||
if target.HP() >= 100 {
|
||||
t.Error("target should have taken damage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutOfRange(t *testing.T) {
|
||||
mgr := NewManager()
|
||||
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
|
||||
|
||||
attacker := newMock(1, 0, 0)
|
||||
target := newMock(2, 100, 0) // far away
|
||||
|
||||
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
|
||||
|
||||
ok, _ := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{},
|
||||
func(id uint64) entity.Entity {
|
||||
if e, ok := entities[id]; ok { return e }
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
if ok {
|
||||
t.Error("expected failure due to range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCooldown(t *testing.T) {
|
||||
mgr := NewManager()
|
||||
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
|
||||
|
||||
attacker := newMock(1, 0, 0)
|
||||
target := newMock(2, 2, 0)
|
||||
|
||||
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
|
||||
getEnt := func(id uint64) entity.Entity {
|
||||
if e, ok := entities[id]; ok { return e }
|
||||
return nil
|
||||
}
|
||||
|
||||
// First use should succeed.
|
||||
ok, _ := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{}, getEnt, nil)
|
||||
if !ok {
|
||||
t.Fatal("first use should succeed")
|
||||
}
|
||||
|
||||
// Immediate second use should fail (cooldown).
|
||||
ok, errMsg := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{}, getEnt, nil)
|
||||
if ok {
|
||||
t.Error("expected cooldown failure")
|
||||
}
|
||||
if errMsg != "skill on cooldown" {
|
||||
t.Errorf("expected 'skill on cooldown', got '%s'", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManaConsumption(t *testing.T) {
|
||||
mgr := NewManager()
|
||||
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
|
||||
|
||||
caster := newMock(1, 0, 0)
|
||||
target := newMock(2, 5, 0)
|
||||
caster.mp = 10 // not enough for Fireball (20 mana)
|
||||
|
||||
entities := map[uint64]*mockCombatant{1: caster, 2: target}
|
||||
|
||||
ok, errMsg := mgr.UseSkill(caster, 2, 2, mathutil.Vec3{},
|
||||
func(id uint64) entity.Entity {
|
||||
if e, ok := entities[id]; ok { return e }
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
if ok {
|
||||
t.Error("expected mana failure")
|
||||
}
|
||||
if errMsg != "not enough mana" {
|
||||
t.Errorf("expected 'not enough mana', got '%s'", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealSelf(t *testing.T) {
|
||||
mgr := NewManager()
|
||||
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
|
||||
|
||||
caster := newMock(1, 0, 0)
|
||||
caster.hp = 30
|
||||
|
||||
ok, _ := mgr.UseSkill(caster, 3, 0, mathutil.Vec3{},
|
||||
func(id uint64) entity.Entity { return nil },
|
||||
nil,
|
||||
)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("heal should succeed")
|
||||
}
|
||||
if caster.HP() <= 30 {
|
||||
t.Error("HP should have increased")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoisonDoT(t *testing.T) {
|
||||
mgr := NewManager()
|
||||
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
|
||||
|
||||
attacker := newMock(1, 0, 0)
|
||||
target := newMock(2, 5, 0)
|
||||
|
||||
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
|
||||
|
||||
// Apply poison (skill 5).
|
||||
ok, _ := mgr.UseSkill(attacker, 5, 2, mathutil.Vec3{},
|
||||
func(id uint64) entity.Entity {
|
||||
if e, ok := entities[id]; ok { return e }
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("poison should succeed")
|
||||
}
|
||||
|
||||
initialHP := target.HP()
|
||||
|
||||
// Simulate ticks until first DoT tick (2s at 50ms/tick = 40 ticks).
|
||||
for i := 0; i < 40; i++ {
|
||||
mgr.UpdateBuffs(50*time.Millisecond, func(id uint64) Combatant {
|
||||
if e, ok := entities[id]; ok { return e }
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if target.HP() >= initialHP {
|
||||
t.Error("poison DoT should have dealt damage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDamageFormula(t *testing.T) {
|
||||
// Base 15, attacker STR 10 (1.1x), defender DEX 10 (0.95x)
|
||||
// Expected ~15.675 (without crit).
|
||||
result := CalcDamage(15, 10, 10)
|
||||
if result.FinalDamage < 1 {
|
||||
t.Error("damage should be at least 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespawn(t *testing.T) {
|
||||
mgr := NewManager()
|
||||
|
||||
ent := newMock(1, 50, 50)
|
||||
ent.hp = 0
|
||||
|
||||
spawn := mathutil.NewVec3(0, 0, 0)
|
||||
mgr.Respawn(ent, spawn)
|
||||
|
||||
if !ent.IsAlive() {
|
||||
t.Error("should be alive after respawn")
|
||||
}
|
||||
if ent.HP() != ent.MaxHP() {
|
||||
t.Error("should have full HP after respawn")
|
||||
}
|
||||
if ent.Position() != spawn {
|
||||
t.Error("should be at spawn position")
|
||||
}
|
||||
}
|
||||
51
internal/combat/damage.go
Normal file
51
internal/combat/damage.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package combat
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
const (
|
||||
critChance = 0.15 // 15% crit chance
|
||||
critMultiplier = 1.5
|
||||
)
|
||||
|
||||
// DamageResult holds the outcome of a damage calculation.
|
||||
type DamageResult struct {
|
||||
FinalDamage int32
|
||||
IsCritical bool
|
||||
}
|
||||
|
||||
// CalcDamage computes final damage from base damage and attacker/defender stats.
|
||||
// Formula: base * (1 + attackerStr/100) * (1 - defenderDex/200)
|
||||
// Then roll for crit.
|
||||
func CalcDamage(baseDamage int32, attackerStr, defenderDex int32) DamageResult {
|
||||
attack := float64(baseDamage) * (1.0 + float64(attackerStr)/100.0)
|
||||
defense := 1.0 - float64(defenderDex)/200.0
|
||||
if defense < 0.1 {
|
||||
defense = 0.1 // minimum 10% damage
|
||||
}
|
||||
|
||||
dmg := attack * defense
|
||||
isCrit := rand.Float64() < critChance
|
||||
if isCrit {
|
||||
dmg *= critMultiplier
|
||||
}
|
||||
|
||||
final := int32(dmg)
|
||||
if final < 1 {
|
||||
final = 1
|
||||
}
|
||||
|
||||
return DamageResult{FinalDamage: final, IsCritical: isCrit}
|
||||
}
|
||||
|
||||
// CalcHeal computes final healing.
|
||||
// Formula: base * (1 + casterInt/100)
|
||||
func CalcHeal(baseHeal int32, casterInt int32) int32 {
|
||||
heal := float64(baseHeal) * (1.0 + float64(casterInt)/100.0)
|
||||
result := int32(heal)
|
||||
if result < 1 {
|
||||
result = 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
148
internal/combat/skill.go
Normal file
148
internal/combat/skill.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package combat
|
||||
|
||||
import "time"
|
||||
|
||||
// TargetType determines how a skill selects its target.
|
||||
type TargetType int
|
||||
|
||||
const (
|
||||
TargetSelf TargetType = iota
|
||||
TargetSingleEnemy // requires a valid enemy entity ID
|
||||
TargetSingleAlly // requires a valid ally entity ID
|
||||
TargetAoEGround // requires a ground position
|
||||
TargetAoETarget // AoE centered on target entity
|
||||
)
|
||||
|
||||
// EffectType determines what a skill effect does.
|
||||
type EffectType int
|
||||
|
||||
const (
|
||||
EffectDamage EffectType = iota
|
||||
EffectHeal
|
||||
EffectBuff
|
||||
EffectDebuff
|
||||
)
|
||||
|
||||
// Effect is a single outcome of a skill.
|
||||
type Effect struct {
|
||||
Type EffectType
|
||||
Value int32 // damage amount, heal amount, or buff/debuff ID
|
||||
Duration time.Duration // 0 for instant effects
|
||||
TickInterval time.Duration // for DoT/HoT; 0 means apply once
|
||||
}
|
||||
|
||||
// SkillDef defines a skill's properties (loaded from data).
|
||||
type SkillDef struct {
|
||||
ID uint32
|
||||
Name string
|
||||
Cooldown time.Duration
|
||||
ManaCost int32
|
||||
Range float32 // max distance to target (0 = self only)
|
||||
TargetType TargetType
|
||||
AoERadius float32 // for AoE skills
|
||||
CastTime time.Duration
|
||||
Effects []Effect
|
||||
}
|
||||
|
||||
// SkillRegistry holds all skill definitions.
|
||||
type SkillRegistry struct {
|
||||
skills map[uint32]*SkillDef
|
||||
}
|
||||
|
||||
// NewSkillRegistry creates a registry with default skills.
|
||||
func NewSkillRegistry() *SkillRegistry {
|
||||
r := &SkillRegistry{skills: make(map[uint32]*SkillDef)}
|
||||
r.registerDefaults()
|
||||
return r
|
||||
}
|
||||
|
||||
// Get returns a skill definition by ID.
|
||||
func (r *SkillRegistry) Get(id uint32) *SkillDef {
|
||||
return r.skills[id]
|
||||
}
|
||||
|
||||
// Register adds a skill definition.
|
||||
func (r *SkillRegistry) Register(s *SkillDef) {
|
||||
r.skills[s.ID] = s
|
||||
}
|
||||
|
||||
func (r *SkillRegistry) registerDefaults() {
|
||||
// Basic attack
|
||||
r.Register(&SkillDef{
|
||||
ID: 1,
|
||||
Name: "Basic Attack",
|
||||
Cooldown: 1 * time.Second,
|
||||
ManaCost: 0,
|
||||
Range: 3.0,
|
||||
TargetType: TargetSingleEnemy,
|
||||
Effects: []Effect{
|
||||
{Type: EffectDamage, Value: 15},
|
||||
},
|
||||
})
|
||||
|
||||
// Fireball - ranged damage
|
||||
r.Register(&SkillDef{
|
||||
ID: 2,
|
||||
Name: "Fireball",
|
||||
Cooldown: 3 * time.Second,
|
||||
ManaCost: 20,
|
||||
Range: 15.0,
|
||||
TargetType: TargetSingleEnemy,
|
||||
Effects: []Effect{
|
||||
{Type: EffectDamage, Value: 40},
|
||||
},
|
||||
})
|
||||
|
||||
// Heal
|
||||
r.Register(&SkillDef{
|
||||
ID: 3,
|
||||
Name: "Heal",
|
||||
Cooldown: 5 * time.Second,
|
||||
ManaCost: 30,
|
||||
Range: 0,
|
||||
TargetType: TargetSelf,
|
||||
Effects: []Effect{
|
||||
{Type: EffectHeal, Value: 50},
|
||||
},
|
||||
})
|
||||
|
||||
// AoE - Flame Strike
|
||||
r.Register(&SkillDef{
|
||||
ID: 4,
|
||||
Name: "Flame Strike",
|
||||
Cooldown: 8 * time.Second,
|
||||
ManaCost: 40,
|
||||
Range: 12.0,
|
||||
TargetType: TargetAoEGround,
|
||||
AoERadius: 5.0,
|
||||
Effects: []Effect{
|
||||
{Type: EffectDamage, Value: 30},
|
||||
},
|
||||
})
|
||||
|
||||
// Poison (DoT debuff)
|
||||
r.Register(&SkillDef{
|
||||
ID: 5,
|
||||
Name: "Poison",
|
||||
Cooldown: 6 * time.Second,
|
||||
ManaCost: 15,
|
||||
Range: 8.0,
|
||||
TargetType: TargetSingleEnemy,
|
||||
Effects: []Effect{
|
||||
{Type: EffectDebuff, Value: 1, Duration: 10 * time.Second, TickInterval: 2 * time.Second},
|
||||
},
|
||||
})
|
||||
|
||||
// Power Buff (self buff - increases damage)
|
||||
r.Register(&SkillDef{
|
||||
ID: 6,
|
||||
Name: "Power Up",
|
||||
Cooldown: 15 * time.Second,
|
||||
ManaCost: 25,
|
||||
Range: 0,
|
||||
TargetType: TargetSelf,
|
||||
Effects: []Effect{
|
||||
{Type: EffectBuff, Value: 2, Duration: 10 * time.Second},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user