first commit

This commit is contained in:
2026-02-26 17:52:48 +09:00
commit dabf1f3ba9
49 changed files with 14883 additions and 0 deletions

86
internal/combat/buff.go Normal file
View 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,
},
}
}

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

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