first commit
This commit is contained in:
184
internal/ai/behavior.go
Normal file
184
internal/ai/behavior.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
)
|
||||
|
||||
// AIState represents the mob's behavioral state.
|
||||
type AIState int
|
||||
|
||||
const (
|
||||
StateIdle AIState = iota // Standing at spawn, doing nothing
|
||||
StatePatrol // Wandering near spawn
|
||||
StateChase // Moving toward a target
|
||||
StateAttack // In attack range, using skills
|
||||
StateReturn // Walking back to spawn (leash)
|
||||
StateDead // Dead, waiting for respawn
|
||||
)
|
||||
|
||||
// EntityProvider gives the AI access to the game world.
|
||||
type EntityProvider interface {
|
||||
GetEntity(id uint64) entity.Entity
|
||||
GetPlayersInRange(center mathutil.Vec3, radius float32) []entity.Entity
|
||||
}
|
||||
|
||||
// SkillUser allows the AI to use combat skills.
|
||||
type SkillUser interface {
|
||||
UseSkill(casterID uint64, skillID uint32, targetID uint64, targetPos mathutil.Vec3) (bool, string)
|
||||
}
|
||||
|
||||
// UpdateMob advances one mob's AI by one tick.
|
||||
func UpdateMob(m *Mob, dt time.Duration, provider EntityProvider, skills SkillUser) {
|
||||
if !m.IsAlive() {
|
||||
m.SetState(StateDead)
|
||||
return
|
||||
}
|
||||
|
||||
switch m.State() {
|
||||
case StateIdle:
|
||||
updateIdle(m, provider)
|
||||
case StateChase:
|
||||
updateChase(m, dt, provider)
|
||||
case StateAttack:
|
||||
updateAttack(m, provider, skills)
|
||||
case StateReturn:
|
||||
updateReturn(m, dt)
|
||||
case StateDead:
|
||||
// handled by spawner
|
||||
}
|
||||
}
|
||||
|
||||
func updateIdle(m *Mob, provider EntityProvider) {
|
||||
// Scan for players in aggro range.
|
||||
target := findNearestPlayer(m, provider, m.Def().AggroRange)
|
||||
if target != nil {
|
||||
m.SetTargetID(target.EntityID())
|
||||
m.SetState(StateChase)
|
||||
}
|
||||
}
|
||||
|
||||
func updateChase(m *Mob, dt time.Duration, provider EntityProvider) {
|
||||
target := provider.GetEntity(m.TargetID())
|
||||
if target == nil || !isAlive(target) {
|
||||
m.SetTargetID(0)
|
||||
m.SetState(StateReturn)
|
||||
return
|
||||
}
|
||||
|
||||
// Leash check: too far from spawn?
|
||||
if m.Position().DistanceXZ(m.SpawnPos()) > m.Def().LeashRange {
|
||||
m.SetTargetID(0)
|
||||
m.SetState(StateReturn)
|
||||
return
|
||||
}
|
||||
|
||||
dist := m.Position().DistanceXZ(target.Position())
|
||||
|
||||
// Close enough to attack?
|
||||
if dist <= m.Def().AttackRange {
|
||||
m.SetState(StateAttack)
|
||||
return
|
||||
}
|
||||
|
||||
// Move toward target.
|
||||
moveToward(m, target.Position(), dt)
|
||||
}
|
||||
|
||||
func updateAttack(m *Mob, provider EntityProvider, skills SkillUser) {
|
||||
target := provider.GetEntity(m.TargetID())
|
||||
if target == nil || !isAlive(target) {
|
||||
m.SetTargetID(0)
|
||||
m.SetState(StateReturn)
|
||||
return
|
||||
}
|
||||
|
||||
dist := m.Position().DistanceXZ(target.Position())
|
||||
|
||||
// Target moved out of attack range? Chase again.
|
||||
if dist > m.Def().AttackRange*1.2 { // 20% buffer to prevent flickering
|
||||
m.SetState(StateChase)
|
||||
return
|
||||
}
|
||||
|
||||
// Leash check.
|
||||
if m.Position().DistanceXZ(m.SpawnPos()) > m.Def().LeashRange {
|
||||
m.SetTargetID(0)
|
||||
m.SetState(StateReturn)
|
||||
return
|
||||
}
|
||||
|
||||
// Face target.
|
||||
dir := target.Position().Sub(m.Position())
|
||||
m.SetRotation(float32(math.Atan2(float64(dir.X), float64(dir.Z))))
|
||||
|
||||
// Use attack skill.
|
||||
skills.UseSkill(m.EntityID(), m.Def().AttackSkill, target.EntityID(), mathutil.Vec3{})
|
||||
}
|
||||
|
||||
func updateReturn(m *Mob, dt time.Duration) {
|
||||
dist := m.Position().DistanceXZ(m.SpawnPos())
|
||||
if dist < 0.5 {
|
||||
m.SetPosition(m.SpawnPos())
|
||||
m.SetState(StateIdle)
|
||||
// Heal to full when returning.
|
||||
m.SetHP(m.MaxHP())
|
||||
return
|
||||
}
|
||||
moveToward(m, m.SpawnPos(), dt)
|
||||
}
|
||||
|
||||
// moveToward moves the mob toward a target position at its move speed.
|
||||
func moveToward(m *Mob, target mathutil.Vec3, dt time.Duration) {
|
||||
dir := target.Sub(m.Position())
|
||||
dir.Y = 0
|
||||
dist := dir.Length()
|
||||
if dist < 0.01 {
|
||||
return
|
||||
}
|
||||
|
||||
step := m.Def().MoveSpeed * float32(dt.Seconds())
|
||||
if step > dist {
|
||||
step = dist
|
||||
}
|
||||
|
||||
move := dir.Normalize().Scale(step)
|
||||
m.SetPosition(m.Position().Add(move))
|
||||
|
||||
// Face movement direction.
|
||||
m.SetRotation(float32(math.Atan2(float64(dir.X), float64(dir.Z))))
|
||||
}
|
||||
|
||||
func findNearestPlayer(m *Mob, provider EntityProvider, radius float32) entity.Entity {
|
||||
players := provider.GetPlayersInRange(m.Position(), radius)
|
||||
if len(players) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var nearest entity.Entity
|
||||
minDist := float32(math.MaxFloat32)
|
||||
for _, p := range players {
|
||||
if !isAlive(p) {
|
||||
continue
|
||||
}
|
||||
d := m.Position().DistanceXZ(p.Position())
|
||||
if d < minDist {
|
||||
minDist = d
|
||||
nearest = p
|
||||
}
|
||||
}
|
||||
return nearest
|
||||
}
|
||||
|
||||
func isAlive(e entity.Entity) bool {
|
||||
type aliveChecker interface {
|
||||
IsAlive() bool
|
||||
}
|
||||
if a, ok := e.(aliveChecker); ok {
|
||||
return a.IsAlive()
|
||||
}
|
||||
return true
|
||||
}
|
||||
221
internal/ai/behavior_test.go
Normal file
221
internal/ai/behavior_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"a301_game_server/internal/combat"
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
pb "a301_game_server/proto/gen/pb"
|
||||
)
|
||||
|
||||
type mockPlayer struct {
|
||||
id uint64
|
||||
pos mathutil.Vec3
|
||||
hp int32
|
||||
maxHP int32
|
||||
alive bool
|
||||
}
|
||||
|
||||
func (m *mockPlayer) EntityID() uint64 { return m.id }
|
||||
func (m *mockPlayer) EntityType() entity.Type { return entity.TypePlayer }
|
||||
func (m *mockPlayer) Position() mathutil.Vec3 { return m.pos }
|
||||
func (m *mockPlayer) SetPosition(p mathutil.Vec3) { m.pos = p }
|
||||
func (m *mockPlayer) Rotation() float32 { return 0 }
|
||||
func (m *mockPlayer) SetRotation(float32) {}
|
||||
func (m *mockPlayer) ToProto() *pb.EntityState { return &pb.EntityState{EntityId: m.id} }
|
||||
func (m *mockPlayer) HP() int32 { return m.hp }
|
||||
func (m *mockPlayer) MaxHP() int32 { return m.maxHP }
|
||||
func (m *mockPlayer) SetHP(hp int32) { m.hp = hp; m.alive = hp > 0 }
|
||||
func (m *mockPlayer) MP() int32 { return 100 }
|
||||
func (m *mockPlayer) SetMP(int32) {}
|
||||
func (m *mockPlayer) IsAlive() bool { return m.alive }
|
||||
func (m *mockPlayer) Stats() combat.CombatStats { return combat.CombatStats{Str: 10, Dex: 10, Int: 10, Level: 1} }
|
||||
|
||||
type mockProvider struct {
|
||||
entities map[uint64]entity.Entity
|
||||
}
|
||||
|
||||
func (p *mockProvider) GetEntity(id uint64) entity.Entity {
|
||||
return p.entities[id]
|
||||
}
|
||||
|
||||
func (p *mockProvider) GetPlayersInRange(center mathutil.Vec3, radius float32) []entity.Entity {
|
||||
radiusSq := radius * radius
|
||||
var result []entity.Entity
|
||||
for _, e := range p.entities {
|
||||
if e.EntityType() == entity.TypePlayer {
|
||||
if e.Position().DistanceSqTo(center) <= radiusSq {
|
||||
result = append(result, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type mockSkillUser struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (s *mockSkillUser) UseSkill(casterID uint64, skillID uint32, targetID uint64, targetPos mathutil.Vec3) (bool, string) {
|
||||
s.called = true
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func newTestMob() *Mob {
|
||||
def := &MobDef{
|
||||
ID: 1,
|
||||
Name: "TestMob",
|
||||
Level: 1,
|
||||
HP: 100,
|
||||
MP: 0,
|
||||
Str: 10,
|
||||
Dex: 5,
|
||||
Int: 3,
|
||||
MoveSpeed: 5.0,
|
||||
AggroRange: 10.0,
|
||||
AttackRange: 2.5,
|
||||
AttackSkill: 1,
|
||||
LeashRange: 30.0,
|
||||
}
|
||||
return NewMob(1000, def, mathutil.NewVec3(50, 0, 50))
|
||||
}
|
||||
|
||||
func TestIdleToChase(t *testing.T) {
|
||||
mob := newTestMob()
|
||||
player := &mockPlayer{id: 1, pos: mathutil.NewVec3(55, 0, 50), hp: 100, maxHP: 100, alive: true}
|
||||
|
||||
provider := &mockProvider{
|
||||
entities: map[uint64]entity.Entity{1: player, 1000: mob},
|
||||
}
|
||||
skills := &mockSkillUser{}
|
||||
|
||||
// Player is within aggro range (5 units < 10 aggro range).
|
||||
UpdateMob(mob, 50*time.Millisecond, provider, skills)
|
||||
|
||||
if mob.State() != StateChase {
|
||||
t.Errorf("expected StateChase, got %d", mob.State())
|
||||
}
|
||||
if mob.TargetID() != 1 {
|
||||
t.Errorf("expected target 1, got %d", mob.TargetID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChaseToAttack(t *testing.T) {
|
||||
mob := newTestMob()
|
||||
mob.SetState(StateChase)
|
||||
mob.SetTargetID(1)
|
||||
|
||||
// Place player within attack range.
|
||||
player := &mockPlayer{id: 1, pos: mathutil.NewVec3(52, 0, 50), hp: 100, maxHP: 100, alive: true}
|
||||
|
||||
provider := &mockProvider{
|
||||
entities: map[uint64]entity.Entity{1: player, 1000: mob},
|
||||
}
|
||||
skills := &mockSkillUser{}
|
||||
|
||||
UpdateMob(mob, 50*time.Millisecond, provider, skills)
|
||||
|
||||
if mob.State() != StateAttack {
|
||||
t.Errorf("expected StateAttack, got %d", mob.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttackUsesSkill(t *testing.T) {
|
||||
mob := newTestMob()
|
||||
mob.SetState(StateAttack)
|
||||
mob.SetTargetID(1)
|
||||
|
||||
player := &mockPlayer{id: 1, pos: mathutil.NewVec3(52, 0, 50), hp: 100, maxHP: 100, alive: true}
|
||||
|
||||
provider := &mockProvider{
|
||||
entities: map[uint64]entity.Entity{1: player, 1000: mob},
|
||||
}
|
||||
skills := &mockSkillUser{}
|
||||
|
||||
UpdateMob(mob, 50*time.Millisecond, provider, skills)
|
||||
|
||||
if !skills.called {
|
||||
t.Error("expected skill to be used")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeashReturn(t *testing.T) {
|
||||
mob := newTestMob()
|
||||
mob.SetState(StateChase)
|
||||
mob.SetTargetID(1)
|
||||
|
||||
// Move mob far from spawn.
|
||||
mob.SetPosition(mathutil.NewVec3(100, 0, 100)) // >30 units from spawn(50,0,50)
|
||||
|
||||
player := &mockPlayer{id: 1, pos: mathutil.NewVec3(110, 0, 110), hp: 100, maxHP: 100, alive: true}
|
||||
|
||||
provider := &mockProvider{
|
||||
entities: map[uint64]entity.Entity{1: player, 1000: mob},
|
||||
}
|
||||
skills := &mockSkillUser{}
|
||||
|
||||
UpdateMob(mob, 50*time.Millisecond, provider, skills)
|
||||
|
||||
if mob.State() != StateReturn {
|
||||
t.Errorf("expected StateReturn (leash), got %d", mob.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReturnToIdle(t *testing.T) {
|
||||
mob := newTestMob()
|
||||
mob.SetState(StateReturn)
|
||||
mob.SetPosition(mathutil.NewVec3(50.1, 0, 50.1)) // very close to spawn
|
||||
|
||||
skills := &mockSkillUser{}
|
||||
provider := &mockProvider{entities: map[uint64]entity.Entity{1000: mob}}
|
||||
|
||||
UpdateMob(mob, 50*time.Millisecond, provider, skills)
|
||||
|
||||
if mob.State() != StateIdle {
|
||||
t.Errorf("expected StateIdle after return, got %d", mob.State())
|
||||
}
|
||||
if mob.HP() != mob.MaxHP() {
|
||||
t.Error("mob should heal to full on return")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetDiesReturnToSpawn(t *testing.T) {
|
||||
mob := newTestMob()
|
||||
mob.SetState(StateChase)
|
||||
mob.SetTargetID(1)
|
||||
|
||||
// Target is dead.
|
||||
player := &mockPlayer{id: 1, pos: mathutil.NewVec3(55, 0, 50), hp: 0, maxHP: 100, alive: false}
|
||||
|
||||
provider := &mockProvider{
|
||||
entities: map[uint64]entity.Entity{1: player, 1000: mob},
|
||||
}
|
||||
skills := &mockSkillUser{}
|
||||
|
||||
UpdateMob(mob, 50*time.Millisecond, provider, skills)
|
||||
|
||||
if mob.State() != StateReturn {
|
||||
t.Errorf("expected StateReturn when target dies, got %d", mob.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobReset(t *testing.T) {
|
||||
mob := newTestMob()
|
||||
mob.SetHP(0)
|
||||
mob.SetPosition(mathutil.NewVec3(100, 0, 100))
|
||||
mob.SetState(StateDead)
|
||||
|
||||
mob.Reset()
|
||||
|
||||
if mob.HP() != mob.MaxHP() {
|
||||
t.Error("HP should be full after reset")
|
||||
}
|
||||
if mob.Position() != mob.SpawnPos() {
|
||||
t.Error("position should be spawn pos after reset")
|
||||
}
|
||||
if mob.State() != StateIdle {
|
||||
t.Error("state should be Idle after reset")
|
||||
}
|
||||
}
|
||||
137
internal/ai/mob.go
Normal file
137
internal/ai/mob.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"a301_game_server/internal/combat"
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
pb "a301_game_server/proto/gen/pb"
|
||||
)
|
||||
|
||||
// MobDef defines a mob template loaded from data.
|
||||
type MobDef struct {
|
||||
ID uint32
|
||||
Name string
|
||||
Level int32
|
||||
HP int32
|
||||
MP int32
|
||||
Str int32
|
||||
Dex int32
|
||||
Int int32
|
||||
MoveSpeed float32 // units per second
|
||||
AggroRange float32 // distance to start chasing
|
||||
AttackRange float32
|
||||
AttackSkill uint32 // skill ID used for auto-attack
|
||||
LeashRange float32 // max distance from spawn before returning
|
||||
ExpReward int64
|
||||
LootTable []LootEntry
|
||||
}
|
||||
|
||||
// LootEntry defines a possible drop.
|
||||
type LootEntry struct {
|
||||
ItemID uint32
|
||||
Quantity int32
|
||||
Chance float32 // 0.0 - 1.0
|
||||
}
|
||||
|
||||
// Mob is a server-controlled enemy entity.
|
||||
type Mob struct {
|
||||
id uint64
|
||||
def *MobDef
|
||||
position mathutil.Vec3
|
||||
rotation float32
|
||||
hp int32
|
||||
maxHP int32
|
||||
mp int32
|
||||
maxMP int32
|
||||
spawnPos mathutil.Vec3
|
||||
alive bool
|
||||
|
||||
// AI state
|
||||
state AIState
|
||||
targetID uint64 // entity being chased/attacked
|
||||
}
|
||||
|
||||
// NewMob creates a mob from a definition at the given position.
|
||||
func NewMob(id uint64, def *MobDef, spawnPos mathutil.Vec3) *Mob {
|
||||
return &Mob{
|
||||
id: id,
|
||||
def: def,
|
||||
position: spawnPos,
|
||||
spawnPos: spawnPos,
|
||||
hp: def.HP,
|
||||
maxHP: def.HP,
|
||||
mp: def.MP,
|
||||
maxMP: def.MP,
|
||||
alive: true,
|
||||
state: StateIdle,
|
||||
}
|
||||
}
|
||||
|
||||
// Entity interface
|
||||
func (m *Mob) EntityID() uint64 { return m.id }
|
||||
func (m *Mob) EntityType() entity.Type { return entity.TypeMob }
|
||||
|
||||
func (m *Mob) Position() mathutil.Vec3 { return m.position }
|
||||
func (m *Mob) SetPosition(p mathutil.Vec3) { m.position = p }
|
||||
func (m *Mob) Rotation() float32 { return m.rotation }
|
||||
func (m *Mob) SetRotation(r float32) { m.rotation = r }
|
||||
|
||||
// Combatant interface
|
||||
func (m *Mob) HP() int32 { return m.hp }
|
||||
func (m *Mob) MaxHP() int32 { return m.maxHP }
|
||||
func (m *Mob) SetHP(hp int32) {
|
||||
if hp < 0 {
|
||||
hp = 0
|
||||
}
|
||||
if hp > m.maxHP {
|
||||
hp = m.maxHP
|
||||
}
|
||||
m.hp = hp
|
||||
m.alive = hp > 0
|
||||
}
|
||||
func (m *Mob) MP() int32 { return m.mp }
|
||||
func (m *Mob) SetMP(mp int32) {
|
||||
if mp < 0 {
|
||||
mp = 0
|
||||
}
|
||||
m.mp = mp
|
||||
}
|
||||
func (m *Mob) IsAlive() bool { return m.alive }
|
||||
func (m *Mob) Stats() combat.CombatStats {
|
||||
return combat.CombatStats{
|
||||
Str: m.def.Str,
|
||||
Dex: m.def.Dex,
|
||||
Int: m.def.Int,
|
||||
Level: m.def.Level,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mob) Def() *MobDef { return m.def }
|
||||
func (m *Mob) SpawnPos() mathutil.Vec3 { return m.spawnPos }
|
||||
func (m *Mob) State() AIState { return m.state }
|
||||
func (m *Mob) SetState(s AIState) { m.state = s }
|
||||
func (m *Mob) TargetID() uint64 { return m.targetID }
|
||||
func (m *Mob) SetTargetID(id uint64) { m.targetID = id }
|
||||
|
||||
func (m *Mob) ToProto() *pb.EntityState {
|
||||
return &pb.EntityState{
|
||||
EntityId: m.id,
|
||||
Name: m.def.Name,
|
||||
Position: &pb.Vector3{X: m.position.X, Y: m.position.Y, Z: m.position.Z},
|
||||
Rotation: m.rotation,
|
||||
Hp: m.hp,
|
||||
MaxHp: m.maxHP,
|
||||
Level: m.def.Level,
|
||||
EntityType: pb.EntityType_ENTITY_TYPE_MOB,
|
||||
}
|
||||
}
|
||||
|
||||
// Reset restores the mob to full health at its spawn position.
|
||||
func (m *Mob) Reset() {
|
||||
m.hp = m.maxHP
|
||||
m.mp = m.maxMP
|
||||
m.position = m.spawnPos
|
||||
m.alive = true
|
||||
m.state = StateIdle
|
||||
m.targetID = 0
|
||||
}
|
||||
130
internal/ai/registry.go
Normal file
130
internal/ai/registry.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package ai
|
||||
|
||||
import "time"
|
||||
|
||||
// MobRegistry holds all mob definitions.
|
||||
type MobRegistry struct {
|
||||
defs map[uint32]*MobDef
|
||||
}
|
||||
|
||||
// NewMobRegistry creates a registry with default mob definitions.
|
||||
func NewMobRegistry() *MobRegistry {
|
||||
r := &MobRegistry{defs: make(map[uint32]*MobDef)}
|
||||
r.registerDefaults()
|
||||
return r
|
||||
}
|
||||
|
||||
// Get returns a mob definition by ID.
|
||||
func (r *MobRegistry) Get(id uint32) *MobDef {
|
||||
return r.defs[id]
|
||||
}
|
||||
|
||||
func (r *MobRegistry) registerDefaults() {
|
||||
r.defs[1] = &MobDef{
|
||||
ID: 1,
|
||||
Name: "Goblin",
|
||||
Level: 1,
|
||||
HP: 60,
|
||||
MP: 0,
|
||||
Str: 8,
|
||||
Dex: 6,
|
||||
Int: 3,
|
||||
MoveSpeed: 4.0,
|
||||
AggroRange: 10.0,
|
||||
AttackRange: 2.5,
|
||||
AttackSkill: 1, // basic attack
|
||||
LeashRange: 30.0,
|
||||
ExpReward: 20,
|
||||
LootTable: []LootEntry{
|
||||
{ItemID: 101, Quantity: 1, Chance: 0.5},
|
||||
},
|
||||
}
|
||||
|
||||
r.defs[2] = &MobDef{
|
||||
ID: 2,
|
||||
Name: "Wolf",
|
||||
Level: 2,
|
||||
HP: 80,
|
||||
MP: 0,
|
||||
Str: 12,
|
||||
Dex: 10,
|
||||
Int: 2,
|
||||
MoveSpeed: 6.0,
|
||||
AggroRange: 12.0,
|
||||
AttackRange: 2.0,
|
||||
AttackSkill: 1,
|
||||
LeashRange: 35.0,
|
||||
ExpReward: 35,
|
||||
LootTable: []LootEntry{
|
||||
{ItemID: 102, Quantity: 1, Chance: 0.4},
|
||||
{ItemID: 103, Quantity: 1, Chance: 0.1},
|
||||
},
|
||||
}
|
||||
|
||||
r.defs[3] = &MobDef{
|
||||
ID: 3,
|
||||
Name: "Forest Troll",
|
||||
Level: 5,
|
||||
HP: 200,
|
||||
MP: 30,
|
||||
Str: 25,
|
||||
Dex: 8,
|
||||
Int: 5,
|
||||
MoveSpeed: 3.0,
|
||||
AggroRange: 8.0,
|
||||
AttackRange: 3.0,
|
||||
AttackSkill: 1,
|
||||
LeashRange: 25.0,
|
||||
ExpReward: 80,
|
||||
LootTable: []LootEntry{
|
||||
{ItemID: 104, Quantity: 1, Chance: 0.6},
|
||||
{ItemID: 105, Quantity: 1, Chance: 0.15},
|
||||
},
|
||||
}
|
||||
|
||||
r.defs[4] = &MobDef{
|
||||
ID: 4,
|
||||
Name: "Fire Elemental",
|
||||
Level: 8,
|
||||
HP: 350,
|
||||
MP: 100,
|
||||
Str: 15,
|
||||
Dex: 12,
|
||||
Int: 30,
|
||||
MoveSpeed: 3.5,
|
||||
AggroRange: 15.0,
|
||||
AttackRange: 10.0,
|
||||
AttackSkill: 2, // fireball
|
||||
LeashRange: 40.0,
|
||||
ExpReward: 150,
|
||||
LootTable: []LootEntry{
|
||||
{ItemID: 106, Quantity: 1, Chance: 0.3},
|
||||
{ItemID: 107, Quantity: 1, Chance: 0.05},
|
||||
},
|
||||
}
|
||||
|
||||
r.defs[5] = &MobDef{
|
||||
ID: 5,
|
||||
Name: "Dragon Whelp",
|
||||
Level: 12,
|
||||
HP: 800,
|
||||
MP: 200,
|
||||
Str: 35,
|
||||
Dex: 20,
|
||||
Int: 25,
|
||||
MoveSpeed: 5.0,
|
||||
AggroRange: 20.0,
|
||||
AttackRange: 4.0,
|
||||
AttackSkill: 2,
|
||||
LeashRange: 50.0,
|
||||
ExpReward: 350,
|
||||
LootTable: []LootEntry{
|
||||
{ItemID: 108, Quantity: 1, Chance: 0.4},
|
||||
{ItemID: 109, Quantity: 1, Chance: 0.02},
|
||||
},
|
||||
}
|
||||
|
||||
// Adjust respawn-related values (these go in SpawnPoints, not MobDef, but
|
||||
// set sensible AttackSkill cooldowns via the existing combat skill system)
|
||||
_ = time.Second // reference for documentation
|
||||
}
|
||||
152
internal/ai/spawner.go
Normal file
152
internal/ai/spawner.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"a301_game_server/pkg/logger"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
)
|
||||
|
||||
// SpawnPoint defines where and what to spawn.
|
||||
type SpawnPoint struct {
|
||||
MobDef *MobDef
|
||||
Position mathutil.Vec3
|
||||
RespawnDelay time.Duration
|
||||
MaxCount int // max alive at this point
|
||||
}
|
||||
|
||||
// spawnEntry tracks a single spawned mob.
|
||||
type spawnEntry struct {
|
||||
mob *Mob
|
||||
alive bool
|
||||
diedAt time.Time
|
||||
}
|
||||
|
||||
// Spawner manages mob spawning and respawning for a zone.
|
||||
type Spawner struct {
|
||||
points []SpawnPoint
|
||||
mobs map[uint64]*spawnEntry // mobID -> entry
|
||||
pointMobs map[int][]*spawnEntry // spawnPointIndex -> entries
|
||||
nextID *atomic.Uint64
|
||||
|
||||
// Callbacks
|
||||
onSpawn func(m *Mob)
|
||||
onRemove func(mobID uint64)
|
||||
}
|
||||
|
||||
// NewSpawner creates a mob spawner.
|
||||
func NewSpawner(nextID *atomic.Uint64, onSpawn func(*Mob), onRemove func(uint64)) *Spawner {
|
||||
return &Spawner{
|
||||
mobs: make(map[uint64]*spawnEntry),
|
||||
pointMobs: make(map[int][]*spawnEntry),
|
||||
nextID: nextID,
|
||||
onSpawn: onSpawn,
|
||||
onRemove: onRemove,
|
||||
}
|
||||
}
|
||||
|
||||
// AddSpawnPoint registers a spawn point.
|
||||
func (s *Spawner) AddSpawnPoint(sp SpawnPoint) {
|
||||
s.points = append(s.points, sp)
|
||||
}
|
||||
|
||||
// InitialSpawn spawns all mobs at startup.
|
||||
func (s *Spawner) InitialSpawn() {
|
||||
for i, sp := range s.points {
|
||||
for j := 0; j < sp.MaxCount; j++ {
|
||||
s.spawnMob(i, &sp)
|
||||
}
|
||||
}
|
||||
logger.Info("initial spawn complete", "totalMobs", len(s.mobs))
|
||||
}
|
||||
|
||||
// Update checks for mobs that need respawning.
|
||||
func (s *Spawner) Update(now time.Time) {
|
||||
for i, sp := range s.points {
|
||||
entries := s.pointMobs[i]
|
||||
aliveCount := 0
|
||||
for _, e := range entries {
|
||||
if e.alive {
|
||||
aliveCount++
|
||||
continue
|
||||
}
|
||||
// Check if it's time to respawn.
|
||||
if !e.diedAt.IsZero() && now.Sub(e.diedAt) >= sp.RespawnDelay {
|
||||
s.respawnMob(e)
|
||||
aliveCount++
|
||||
}
|
||||
}
|
||||
// Spawn new if below max count.
|
||||
for aliveCount < sp.MaxCount {
|
||||
s.spawnMob(i, &sp)
|
||||
aliveCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyDeath marks a mob as dead and starts the respawn timer.
|
||||
func (s *Spawner) NotifyDeath(mobID uint64) {
|
||||
entry, ok := s.mobs[mobID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry.alive = false
|
||||
entry.diedAt = time.Now()
|
||||
|
||||
// Remove from zone (despawn).
|
||||
if s.onRemove != nil {
|
||||
s.onRemove(mobID)
|
||||
}
|
||||
}
|
||||
|
||||
// GetMob returns a mob by ID.
|
||||
func (s *Spawner) GetMob(id uint64) *Mob {
|
||||
if e, ok := s.mobs[id]; ok {
|
||||
return e.mob
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllMobs returns all tracked mobs.
|
||||
func (s *Spawner) AllMobs() []*Mob {
|
||||
result := make([]*Mob, 0, len(s.mobs))
|
||||
for _, e := range s.mobs {
|
||||
result = append(result, e.mob)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AliveMobs returns all alive mobs.
|
||||
func (s *Spawner) AliveMobs() []*Mob {
|
||||
var result []*Mob
|
||||
for _, e := range s.mobs {
|
||||
if e.alive {
|
||||
result = append(result, e.mob)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Spawner) spawnMob(pointIdx int, sp *SpawnPoint) {
|
||||
id := s.nextID.Add(1) + 100000 // offset to avoid collision with player IDs
|
||||
mob := NewMob(id, sp.MobDef, sp.Position)
|
||||
|
||||
entry := &spawnEntry{mob: mob, alive: true}
|
||||
s.mobs[id] = entry
|
||||
s.pointMobs[pointIdx] = append(s.pointMobs[pointIdx], entry)
|
||||
|
||||
if s.onSpawn != nil {
|
||||
s.onSpawn(mob)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Spawner) respawnMob(entry *spawnEntry) {
|
||||
entry.mob.Reset()
|
||||
entry.alive = true
|
||||
entry.diedAt = time.Time{}
|
||||
|
||||
if s.onSpawn != nil {
|
||||
s.onSpawn(entry.mob)
|
||||
}
|
||||
}
|
||||
68
internal/auth/auth.go
Normal file
68
internal/auth/auth.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"a301_game_server/internal/db"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUsernameTaken = errors.New("username already taken")
|
||||
)
|
||||
|
||||
// Service handles account registration and authentication.
|
||||
type Service struct {
|
||||
pool *db.Pool
|
||||
}
|
||||
|
||||
// NewService creates a new auth service.
|
||||
func NewService(pool *db.Pool) *Service {
|
||||
return &Service{pool: pool}
|
||||
}
|
||||
|
||||
// Register creates a new account. Returns the account ID.
|
||||
func (s *Service) Register(ctx context.Context, username, password string) (int64, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
var id int64
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`INSERT INTO accounts (username, password) VALUES ($1, $2)
|
||||
ON CONFLICT (username) DO NOTHING
|
||||
RETURNING id`,
|
||||
username, string(hash),
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
return 0, ErrUsernameTaken
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Login validates credentials and returns the account ID.
|
||||
func (s *Service) Login(ctx context.Context, username, password string) (int64, error) {
|
||||
var id int64
|
||||
var hash string
|
||||
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, password FROM accounts WHERE username = $1`, username,
|
||||
).Scan(&id, &hash)
|
||||
|
||||
if err != nil {
|
||||
return 0, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return 0, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
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},
|
||||
},
|
||||
})
|
||||
}
|
||||
47
internal/db/migrations.go
Normal file
47
internal/db/migrations.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package db
|
||||
|
||||
var migrations = []string{
|
||||
// 0: accounts table
|
||||
`CREATE TABLE IF NOT EXISTS accounts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(32) UNIQUE NOT NULL,
|
||||
password VARCHAR(128) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// 1: characters table
|
||||
`CREATE TABLE IF NOT EXISTS characters (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||
name VARCHAR(32) UNIQUE NOT NULL,
|
||||
level INT NOT NULL DEFAULT 1,
|
||||
exp BIGINT NOT NULL DEFAULT 0,
|
||||
hp INT NOT NULL DEFAULT 100,
|
||||
max_hp INT NOT NULL DEFAULT 100,
|
||||
mp INT NOT NULL DEFAULT 50,
|
||||
max_mp INT NOT NULL DEFAULT 50,
|
||||
str INT NOT NULL DEFAULT 10,
|
||||
dex INT NOT NULL DEFAULT 10,
|
||||
int_stat INT NOT NULL DEFAULT 10,
|
||||
zone_id INT NOT NULL DEFAULT 1,
|
||||
pos_x REAL NOT NULL DEFAULT 0,
|
||||
pos_y REAL NOT NULL DEFAULT 0,
|
||||
pos_z REAL NOT NULL DEFAULT 0,
|
||||
rotation REAL NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// 2: inventory table
|
||||
`CREATE TABLE IF NOT EXISTS inventory (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
character_id BIGINT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
slot INT NOT NULL,
|
||||
item_id INT NOT NULL,
|
||||
quantity INT NOT NULL DEFAULT 1,
|
||||
UNIQUE(character_id, slot)
|
||||
)`,
|
||||
|
||||
// 3: index for character lookups by account
|
||||
`CREATE INDEX IF NOT EXISTS idx_characters_account_id ON characters(account_id)`,
|
||||
}
|
||||
51
internal/db/postgres.go
Normal file
51
internal/db/postgres.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"a301_game_server/config"
|
||||
"a301_game_server/pkg/logger"
|
||||
)
|
||||
|
||||
// Pool wraps a pgx connection pool.
|
||||
type Pool struct {
|
||||
*pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPool creates a connection pool to PostgreSQL.
|
||||
func NewPool(ctx context.Context, cfg *config.DatabaseConfig) (*Pool, error) {
|
||||
poolCfg, err := pgxpool.ParseConfig(cfg.DSN())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dsn: %w", err)
|
||||
}
|
||||
|
||||
poolCfg.MaxConns = cfg.MaxConns
|
||||
poolCfg.MinConns = cfg.MinConns
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("database connected", "host", cfg.Host, "db", cfg.DBName)
|
||||
return &Pool{pool}, nil
|
||||
}
|
||||
|
||||
// RunMigrations executes all schema migrations.
|
||||
func (p *Pool) RunMigrations(ctx context.Context) error {
|
||||
for i, m := range migrations {
|
||||
if _, err := p.Exec(ctx, m); err != nil {
|
||||
return fmt.Errorf("migration %d failed: %w", i, err)
|
||||
}
|
||||
}
|
||||
logger.Info("database migrations completed", "count", len(migrations))
|
||||
return nil
|
||||
}
|
||||
166
internal/db/repository/character.go
Normal file
166
internal/db/repository/character.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"a301_game_server/internal/db"
|
||||
)
|
||||
|
||||
// CharacterData holds persisted character state.
|
||||
type CharacterData struct {
|
||||
ID int64
|
||||
AccountID int64
|
||||
Name string
|
||||
Level int32
|
||||
Exp int64
|
||||
HP int32
|
||||
MaxHP int32
|
||||
MP int32
|
||||
MaxMP int32
|
||||
Str int32
|
||||
Dex int32
|
||||
IntStat int32
|
||||
ZoneID int32
|
||||
PosX float32
|
||||
PosY float32
|
||||
PosZ float32
|
||||
Rotation float32
|
||||
}
|
||||
|
||||
// CharacterRepo handles character persistence.
|
||||
type CharacterRepo struct {
|
||||
pool *db.Pool
|
||||
}
|
||||
|
||||
// NewCharacterRepo creates a new character repository.
|
||||
func NewCharacterRepo(pool *db.Pool) *CharacterRepo {
|
||||
return &CharacterRepo{pool: pool}
|
||||
}
|
||||
|
||||
// Create inserts a new character.
|
||||
func (r *CharacterRepo) Create(ctx context.Context, accountID int64, name string) (*CharacterData, error) {
|
||||
c := &CharacterData{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Level: 1,
|
||||
HP: 100,
|
||||
MaxHP: 100,
|
||||
MP: 50,
|
||||
MaxMP: 50,
|
||||
Str: 10,
|
||||
Dex: 10,
|
||||
IntStat: 10,
|
||||
ZoneID: 1,
|
||||
}
|
||||
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO characters (account_id, name, level, hp, max_hp, mp, max_mp, str, dex, int_stat, zone_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`,
|
||||
c.AccountID, c.Name, c.Level, c.HP, c.MaxHP, c.MP, c.MaxMP, c.Str, c.Dex, c.IntStat, c.ZoneID,
|
||||
).Scan(&c.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create character: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// GetByAccountID returns all characters for an account.
|
||||
func (r *CharacterRepo) GetByAccountID(ctx context.Context, accountID int64) ([]*CharacterData, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, account_id, name, level, exp, hp, max_hp, mp, max_mp, str, dex, int_stat,
|
||||
zone_id, pos_x, pos_y, pos_z, rotation
|
||||
FROM characters WHERE account_id = $1`, accountID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query characters: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var chars []*CharacterData
|
||||
for rows.Next() {
|
||||
c := &CharacterData{}
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.AccountID, &c.Name, &c.Level, &c.Exp,
|
||||
&c.HP, &c.MaxHP, &c.MP, &c.MaxMP,
|
||||
&c.Str, &c.Dex, &c.IntStat,
|
||||
&c.ZoneID, &c.PosX, &c.PosY, &c.PosZ, &c.Rotation,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan character: %w", err)
|
||||
}
|
||||
chars = append(chars, c)
|
||||
}
|
||||
return chars, nil
|
||||
}
|
||||
|
||||
// GetByID loads a single character.
|
||||
func (r *CharacterRepo) GetByID(ctx context.Context, id int64) (*CharacterData, error) {
|
||||
c := &CharacterData{}
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, account_id, name, level, exp, hp, max_hp, mp, max_mp, str, dex, int_stat,
|
||||
zone_id, pos_x, pos_y, pos_z, rotation
|
||||
FROM characters WHERE id = $1`, id,
|
||||
).Scan(
|
||||
&c.ID, &c.AccountID, &c.Name, &c.Level, &c.Exp,
|
||||
&c.HP, &c.MaxHP, &c.MP, &c.MaxMP,
|
||||
&c.Str, &c.Dex, &c.IntStat,
|
||||
&c.ZoneID, &c.PosX, &c.PosY, &c.PosZ, &c.Rotation,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get character %d: %w", id, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Save persists the current character state.
|
||||
func (r *CharacterRepo) Save(ctx context.Context, c *CharacterData) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`UPDATE characters SET
|
||||
level = $2, exp = $3, hp = $4, max_hp = $5, mp = $6, max_mp = $7,
|
||||
str = $8, dex = $9, int_stat = $10,
|
||||
zone_id = $11, pos_x = $12, pos_y = $13, pos_z = $14, rotation = $15,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
c.ID, c.Level, c.Exp, c.HP, c.MaxHP, c.MP, c.MaxMP,
|
||||
c.Str, c.Dex, c.IntStat,
|
||||
c.ZoneID, c.PosX, c.PosY, c.PosZ, c.Rotation,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save character %d: %w", c.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveBatch saves multiple characters in a single transaction.
|
||||
func (r *CharacterRepo) SaveBatch(ctx context.Context, chars []*CharacterData) error {
|
||||
if len(chars) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
for _, c := range chars {
|
||||
_, err := tx.Exec(ctx,
|
||||
`UPDATE characters SET
|
||||
level = $2, exp = $3, hp = $4, max_hp = $5, mp = $6, max_mp = $7,
|
||||
str = $8, dex = $9, int_stat = $10,
|
||||
zone_id = $11, pos_x = $12, pos_y = $13, pos_z = $14, rotation = $15,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
c.ID, c.Level, c.Exp, c.HP, c.MaxHP, c.MP, c.MaxMP,
|
||||
c.Str, c.Dex, c.IntStat,
|
||||
c.ZoneID, c.PosX, c.PosY, c.PosZ, c.Rotation,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save character %d in batch: %w", c.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
26
internal/entity/entity.go
Normal file
26
internal/entity/entity.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
pb "a301_game_server/proto/gen/pb"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
)
|
||||
|
||||
// Type identifies the kind of entity.
|
||||
type Type int
|
||||
|
||||
const (
|
||||
TypePlayer Type = iota
|
||||
TypeMob
|
||||
TypeNPC
|
||||
)
|
||||
|
||||
// Entity is anything that exists in the game world.
|
||||
type Entity interface {
|
||||
EntityID() uint64
|
||||
EntityType() Type
|
||||
Position() mathutil.Vec3
|
||||
SetPosition(pos mathutil.Vec3)
|
||||
Rotation() float32
|
||||
SetRotation(rot float32)
|
||||
ToProto() *pb.EntityState
|
||||
}
|
||||
462
internal/game/game_server.go
Normal file
462
internal/game/game_server.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"a301_game_server/config"
|
||||
"a301_game_server/internal/ai"
|
||||
"a301_game_server/internal/auth"
|
||||
"a301_game_server/internal/db"
|
||||
"a301_game_server/internal/db/repository"
|
||||
"a301_game_server/internal/network"
|
||||
"a301_game_server/internal/player"
|
||||
"a301_game_server/internal/world"
|
||||
"a301_game_server/pkg/logger"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
pb "a301_game_server/proto/gen/pb"
|
||||
)
|
||||
|
||||
const defaultZoneID uint32 = 1
|
||||
|
||||
// GameServer is the top-level orchestrator that connects networking with game logic.
|
||||
type GameServer struct {
|
||||
cfg *config.Config
|
||||
world *World
|
||||
sessions *player.SessionManager
|
||||
dbPool *db.Pool
|
||||
authSvc *auth.Service
|
||||
charRepo *repository.CharacterRepo
|
||||
|
||||
mu sync.RWMutex
|
||||
connPlayer map[uint64]*player.Player // connID -> player
|
||||
playerConn map[uint64]uint64 // playerID -> connID
|
||||
|
||||
nextPlayerID atomic.Uint64
|
||||
cancelSave context.CancelFunc
|
||||
}
|
||||
|
||||
// NewGameServer creates the game server.
|
||||
func NewGameServer(cfg *config.Config, dbPool *db.Pool) *GameServer {
|
||||
gs := &GameServer{
|
||||
cfg: cfg,
|
||||
world: NewWorld(cfg),
|
||||
sessions: player.NewSessionManager(),
|
||||
dbPool: dbPool,
|
||||
authSvc: auth.NewService(dbPool),
|
||||
charRepo: repository.NewCharacterRepo(dbPool),
|
||||
connPlayer: make(map[uint64]*player.Player),
|
||||
playerConn: make(map[uint64]uint64),
|
||||
}
|
||||
|
||||
// Create zones, portals, and mobs.
|
||||
gs.setupWorld()
|
||||
|
||||
return gs
|
||||
}
|
||||
|
||||
// World returns the game world.
|
||||
func (gs *GameServer) World() *World { return gs.world }
|
||||
|
||||
// Start launches all zone game loops and periodic save.
|
||||
func (gs *GameServer) Start() {
|
||||
gs.world.mu.RLock()
|
||||
for _, zone := range gs.world.zones {
|
||||
zone.SetMessageHandler(gs)
|
||||
zone.SetZoneTransferCallback(gs.handleZoneTransfer)
|
||||
}
|
||||
gs.world.mu.RUnlock()
|
||||
|
||||
gs.world.StartAll()
|
||||
|
||||
// Start periodic character save.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
gs.cancelSave = cancel
|
||||
go gs.periodicSave(ctx)
|
||||
}
|
||||
|
||||
// Stop shuts down all zone game loops and saves all players.
|
||||
func (gs *GameServer) Stop() {
|
||||
if gs.cancelSave != nil {
|
||||
gs.cancelSave()
|
||||
}
|
||||
|
||||
// Final save of all online players.
|
||||
gs.saveAllPlayers()
|
||||
|
||||
gs.world.StopAll()
|
||||
}
|
||||
|
||||
// OnPacket handles incoming packets from a connection.
|
||||
func (gs *GameServer) OnPacket(conn *network.Connection, pkt *network.Packet) {
|
||||
switch pkt.Type {
|
||||
case network.MsgLoginRequest:
|
||||
gs.handleLogin(conn, pkt)
|
||||
case network.MsgEnterWorldRequest:
|
||||
gs.handleEnterWorld(conn, pkt)
|
||||
default:
|
||||
gs.mu.RLock()
|
||||
p, ok := gs.connPlayer[conn.ID()]
|
||||
gs.mu.RUnlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
zone, err := gs.world.GetZone(p.ZoneID())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
zone.EnqueueMessage(PlayerMessage{PlayerID: p.EntityID(), Packet: pkt})
|
||||
}
|
||||
}
|
||||
|
||||
// OnDisconnect handles a connection closing.
|
||||
func (gs *GameServer) OnDisconnect(conn *network.Connection) {
|
||||
gs.mu.Lock()
|
||||
p, ok := gs.connPlayer[conn.ID()]
|
||||
if !ok {
|
||||
gs.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(gs.connPlayer, conn.ID())
|
||||
delete(gs.playerConn, p.EntityID())
|
||||
gs.mu.Unlock()
|
||||
|
||||
// Save character to DB on disconnect.
|
||||
if p.CharID() != 0 {
|
||||
if err := gs.charRepo.Save(context.Background(), p.ToCharacterData()); err != nil {
|
||||
logger.Error("failed to save player on disconnect", "playerID", p.EntityID(), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
zone, err := gs.world.GetZone(p.ZoneID())
|
||||
if err == nil {
|
||||
zone.EnqueueMessage(PlayerMessage{
|
||||
PlayerID: p.EntityID(),
|
||||
Packet: &network.Packet{Type: msgPlayerDisconnect},
|
||||
})
|
||||
}
|
||||
|
||||
logger.Info("player disconnected", "connID", conn.ID(), "playerID", p.EntityID())
|
||||
}
|
||||
|
||||
// Internal message types.
|
||||
const (
|
||||
msgPlayerDisconnect uint16 = 0xFFFF
|
||||
msgPlayerEnterWorld uint16 = 0xFFFE
|
||||
)
|
||||
|
||||
// HandleZoneMessage implements ZoneMessageHandler.
|
||||
func (gs *GameServer) HandleZoneMessage(zone *Zone, msg PlayerMessage) bool {
|
||||
switch msg.Packet.Type {
|
||||
case msgPlayerDisconnect:
|
||||
zone.RemovePlayer(msg.PlayerID)
|
||||
return true
|
||||
case msgPlayerEnterWorld:
|
||||
gs.mu.RLock()
|
||||
var found *player.Player
|
||||
for _, p := range gs.connPlayer {
|
||||
if p.EntityID() == msg.PlayerID {
|
||||
found = p
|
||||
break
|
||||
}
|
||||
}
|
||||
gs.mu.RUnlock()
|
||||
if found != nil {
|
||||
zone.AddPlayer(found)
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *GameServer) handleLogin(conn *network.Connection, pkt *network.Packet) {
|
||||
req := pkt.Payload.(*pb.LoginRequest)
|
||||
ctx := context.Background()
|
||||
|
||||
// Try login first.
|
||||
accountID, err := gs.authSvc.Login(ctx, req.Username, req.Password)
|
||||
if err != nil {
|
||||
// Auto-register if account doesn't exist.
|
||||
accountID, err = gs.authSvc.Register(ctx, req.Username, req.Password)
|
||||
if err != nil {
|
||||
conn.Send(network.MsgLoginResponse, &pb.LoginResponse{
|
||||
Success: false,
|
||||
ErrorMessage: "login failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create default character on first registration.
|
||||
_, charErr := gs.charRepo.Create(ctx, accountID, req.Username)
|
||||
if charErr != nil {
|
||||
logger.Error("failed to create default character", "accountID", accountID, "error", charErr)
|
||||
}
|
||||
}
|
||||
|
||||
session := gs.sessions.Create(uint64(accountID), req.Username)
|
||||
|
||||
conn.Send(network.MsgLoginResponse, &pb.LoginResponse{
|
||||
Success: true,
|
||||
SessionToken: session.Token,
|
||||
PlayerId: uint64(accountID),
|
||||
})
|
||||
|
||||
logger.Info("player logged in", "username", req.Username, "accountID", accountID)
|
||||
}
|
||||
|
||||
func (gs *GameServer) handleEnterWorld(conn *network.Connection, pkt *network.Packet) {
|
||||
req := pkt.Payload.(*pb.EnterWorldRequest)
|
||||
|
||||
session := gs.sessions.Get(req.SessionToken)
|
||||
if session == nil {
|
||||
conn.Send(network.MsgEnterWorldResponse, &pb.EnterWorldResponse{
|
||||
Success: false,
|
||||
ErrorMessage: "invalid session",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Load character from DB.
|
||||
chars, err := gs.charRepo.GetByAccountID(ctx, int64(session.PlayerID))
|
||||
if err != nil || len(chars) == 0 {
|
||||
conn.Send(network.MsgEnterWorldResponse, &pb.EnterWorldResponse{
|
||||
Success: false,
|
||||
ErrorMessage: "no character found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
charData := chars[0] // Use first character for now.
|
||||
p := player.NewPlayerFromDB(charData, conn)
|
||||
|
||||
// Register connection-player mapping.
|
||||
gs.mu.Lock()
|
||||
gs.connPlayer[conn.ID()] = p
|
||||
gs.playerConn[p.EntityID()] = conn.ID()
|
||||
gs.mu.Unlock()
|
||||
|
||||
zoneID := p.ZoneID()
|
||||
zone, err := gs.world.GetZone(zoneID)
|
||||
if err != nil {
|
||||
// Fall back to default zone.
|
||||
zoneID = defaultZoneID
|
||||
p.SetZoneID(defaultZoneID)
|
||||
zone, _ = gs.world.GetZone(defaultZoneID)
|
||||
}
|
||||
|
||||
zone.EnqueueMessage(PlayerMessage{
|
||||
PlayerID: p.EntityID(),
|
||||
Packet: &network.Packet{Type: msgPlayerEnterWorld},
|
||||
})
|
||||
|
||||
conn.Send(network.MsgEnterWorldResponse, &pb.EnterWorldResponse{
|
||||
Success: true,
|
||||
Self: p.ToProto(),
|
||||
ZoneId: zoneID,
|
||||
})
|
||||
|
||||
logger.Info("player entered world", "playerID", p.EntityID(), "charID", charData.ID, "zone", zoneID)
|
||||
}
|
||||
|
||||
// periodicSave saves all dirty player data to DB at configured intervals.
|
||||
func (gs *GameServer) periodicSave(ctx context.Context) {
|
||||
ticker := time.NewTicker(gs.cfg.Database.SaveInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
gs.saveAllPlayers()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *GameServer) saveAllPlayers() {
|
||||
gs.mu.RLock()
|
||||
var dirty []*player.Player
|
||||
for _, p := range gs.connPlayer {
|
||||
if p.IsDirty() && p.CharID() != 0 {
|
||||
dirty = append(dirty, p)
|
||||
}
|
||||
}
|
||||
gs.mu.RUnlock()
|
||||
|
||||
if len(dirty) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
chars := make([]*repository.CharacterData, 0, len(dirty))
|
||||
for _, p := range dirty {
|
||||
chars = append(chars, p.ToCharacterData())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := gs.charRepo.SaveBatch(ctx, chars); err != nil {
|
||||
logger.Error("periodic save failed", "count", len(chars), "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, p := range dirty {
|
||||
p.ClearDirty()
|
||||
}
|
||||
|
||||
logger.Debug("periodic save completed", "count", len(chars))
|
||||
}
|
||||
|
||||
// setupWorld creates all zones, portals, and mob spawn points.
|
||||
func (gs *GameServer) setupWorld() {
|
||||
zone1 := gs.world.CreateZone(1) // Starting zone - plains
|
||||
zone2 := gs.world.CreateZone(2) // Forest zone - medium difficulty
|
||||
zone3 := gs.world.CreateZone(3) // Volcano zone - hard
|
||||
|
||||
// Set spawn positions.
|
||||
zone1.spawnPos = mathutil.NewVec3(0, 0, 0)
|
||||
zone2.spawnPos = mathutil.NewVec3(5, 0, 5)
|
||||
zone3.spawnPos = mathutil.NewVec3(10, 0, 10)
|
||||
|
||||
// Portals: Zone 1 <-> Zone 2
|
||||
zone1.AddPortal(world.ZonePortal{
|
||||
SourceZoneID: 1,
|
||||
TriggerPos: mathutil.NewVec3(300, 0, 150),
|
||||
TriggerRadius: 5.0,
|
||||
TargetZoneID: 2,
|
||||
TargetPos: mathutil.NewVec3(5, 0, 5),
|
||||
})
|
||||
zone2.AddPortal(world.ZonePortal{
|
||||
SourceZoneID: 2,
|
||||
TriggerPos: mathutil.NewVec3(0, 0, 0),
|
||||
TriggerRadius: 5.0,
|
||||
TargetZoneID: 1,
|
||||
TargetPos: mathutil.NewVec3(295, 0, 150),
|
||||
})
|
||||
|
||||
// Portals: Zone 2 <-> Zone 3
|
||||
zone2.AddPortal(world.ZonePortal{
|
||||
SourceZoneID: 2,
|
||||
TriggerPos: mathutil.NewVec3(300, 0, 300),
|
||||
TriggerRadius: 5.0,
|
||||
TargetZoneID: 3,
|
||||
TargetPos: mathutil.NewVec3(10, 0, 10),
|
||||
})
|
||||
zone3.AddPortal(world.ZonePortal{
|
||||
SourceZoneID: 3,
|
||||
TriggerPos: mathutil.NewVec3(0, 0, 0),
|
||||
TriggerRadius: 5.0,
|
||||
TargetZoneID: 2,
|
||||
TargetPos: mathutil.NewVec3(295, 0, 295),
|
||||
})
|
||||
|
||||
// Populate zones with mobs.
|
||||
gs.setupZoneMobs(zone1, []mobSpawnConfig{
|
||||
{mobID: 1, count: 3, baseX: 20, baseZ: 30, spacing: 15}, // Goblins
|
||||
{mobID: 2, count: 2, baseX: 80, baseZ: 80, spacing: 12}, // Wolves
|
||||
})
|
||||
gs.setupZoneMobs(zone2, []mobSpawnConfig{
|
||||
{mobID: 2, count: 4, baseX: 50, baseZ: 50, spacing: 15}, // Wolves
|
||||
{mobID: 3, count: 2, baseX: 150, baseZ: 150, spacing: 20}, // Trolls
|
||||
{mobID: 4, count: 1, baseX: 200, baseZ: 50, spacing: 0}, // Fire Elemental
|
||||
})
|
||||
gs.setupZoneMobs(zone3, []mobSpawnConfig{
|
||||
{mobID: 4, count: 3, baseX: 80, baseZ: 80, spacing: 25}, // Fire Elementals
|
||||
{mobID: 5, count: 1, baseX: 200, baseZ: 200, spacing: 0}, // Dragon Whelp
|
||||
})
|
||||
}
|
||||
|
||||
type mobSpawnConfig struct {
|
||||
mobID uint32
|
||||
count int
|
||||
baseX float32
|
||||
baseZ float32
|
||||
spacing float32
|
||||
}
|
||||
|
||||
// setupZoneMobs configures mob spawn points for a zone.
|
||||
func (gs *GameServer) setupZoneMobs(zone *Zone, configs []mobSpawnConfig) {
|
||||
registry := ai.NewMobRegistry()
|
||||
spawner := zone.Spawner()
|
||||
|
||||
for _, cfg := range configs {
|
||||
def := registry.Get(cfg.mobID)
|
||||
if def == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
respawn := time.Duration(15+def.Level*3) * time.Second
|
||||
|
||||
for i := 0; i < cfg.count; i++ {
|
||||
spawner.AddSpawnPoint(ai.SpawnPoint{
|
||||
MobDef: def,
|
||||
Position: mathutil.NewVec3(cfg.baseX+float32(i)*cfg.spacing, 0, cfg.baseZ+float32(i)*cfg.spacing),
|
||||
RespawnDelay: respawn,
|
||||
MaxCount: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
spawner.InitialSpawn()
|
||||
}
|
||||
|
||||
// handleZoneTransfer moves a player between zones.
|
||||
func (gs *GameServer) handleZoneTransfer(playerID uint64, targetZoneID uint32, targetPos mathutil.Vec3) {
|
||||
gs.mu.RLock()
|
||||
var p *player.Player
|
||||
var connID uint64
|
||||
for cid, pl := range gs.connPlayer {
|
||||
if pl.EntityID() == playerID {
|
||||
p = pl
|
||||
connID = cid
|
||||
break
|
||||
}
|
||||
}
|
||||
gs.mu.RUnlock()
|
||||
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
_ = connID
|
||||
|
||||
sourceZone, err := gs.world.GetZone(p.ZoneID())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
targetZone, err := gs.world.GetZone(targetZoneID)
|
||||
if err != nil {
|
||||
logger.Warn("zone transfer target not found", "targetZone", targetZoneID)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from source zone.
|
||||
sourceZone.RemovePlayer(playerID)
|
||||
|
||||
// Update player state.
|
||||
p.SetPosition(targetPos)
|
||||
p.SetZoneID(targetZoneID)
|
||||
|
||||
// Add to target zone via message queue.
|
||||
targetZone.EnqueueMessage(PlayerMessage{
|
||||
PlayerID: playerID,
|
||||
Packet: &network.Packet{Type: msgPlayerEnterWorld},
|
||||
})
|
||||
|
||||
// Notify client of zone change.
|
||||
p.Connection().Send(network.MsgZoneTransferNotify, &pb.ZoneTransferNotify{
|
||||
NewZoneId: targetZoneID,
|
||||
Self: p.ToProto(),
|
||||
})
|
||||
|
||||
logger.Info("zone transfer",
|
||||
"playerID", playerID,
|
||||
"from", sourceZone.ID(),
|
||||
"to", targetZoneID,
|
||||
)
|
||||
}
|
||||
94
internal/game/world.go
Normal file
94
internal/game/world.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"a301_game_server/config"
|
||||
"a301_game_server/pkg/logger"
|
||||
)
|
||||
|
||||
// World manages all zones.
|
||||
type World struct {
|
||||
mu sync.RWMutex
|
||||
zones map[uint32]*Zone
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewWorld creates a new world.
|
||||
func NewWorld(cfg *config.Config) *World {
|
||||
return &World{
|
||||
zones: make(map[uint32]*Zone),
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateZone creates and registers a new zone.
|
||||
func (w *World) CreateZone(id uint32) *Zone {
|
||||
zone := NewZone(id, w.cfg)
|
||||
|
||||
w.mu.Lock()
|
||||
w.zones[id] = zone
|
||||
w.mu.Unlock()
|
||||
|
||||
logger.Info("zone created", "zoneID", id)
|
||||
return zone
|
||||
}
|
||||
|
||||
// GetZone returns a zone by ID.
|
||||
func (w *World) GetZone(id uint32) (*Zone, error) {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
|
||||
zone, ok := w.zones[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("zone %d not found", id)
|
||||
}
|
||||
return zone, nil
|
||||
}
|
||||
|
||||
// StartAll launches all zone game loops.
|
||||
func (w *World) StartAll() {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
|
||||
for _, zone := range w.zones {
|
||||
go zone.Run()
|
||||
}
|
||||
logger.Info("all zones started", "count", len(w.zones))
|
||||
}
|
||||
|
||||
// StopAll stops all zone game loops.
|
||||
func (w *World) StopAll() {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
|
||||
for _, zone := range w.zones {
|
||||
zone.Stop()
|
||||
}
|
||||
logger.Info("all zones stopped")
|
||||
}
|
||||
|
||||
// TotalPlayers returns the total number of online players across all zones.
|
||||
func (w *World) TotalPlayers() int {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
|
||||
total := 0
|
||||
for _, zone := range w.zones {
|
||||
total += zone.PlayerCount()
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// TotalEntities returns the total number of entities across all zones.
|
||||
func (w *World) TotalEntities() int {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
|
||||
total := 0
|
||||
for _, zone := range w.zones {
|
||||
total += zone.EntityCount()
|
||||
}
|
||||
return total
|
||||
}
|
||||
665
internal/game/zone.go
Normal file
665
internal/game/zone.go
Normal file
@@ -0,0 +1,665 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"a301_game_server/config"
|
||||
"a301_game_server/internal/ai"
|
||||
"a301_game_server/internal/combat"
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/internal/network"
|
||||
"a301_game_server/internal/world"
|
||||
"a301_game_server/pkg/logger"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
pb "a301_game_server/proto/gen/pb"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
maxMoveSpeed float32 = 10.0 // units per second
|
||||
)
|
||||
|
||||
// PlayerMessage is a message from a player connection queued for zone processing.
|
||||
type PlayerMessage struct {
|
||||
PlayerID uint64
|
||||
Packet *network.Packet
|
||||
}
|
||||
|
||||
// PlayerEntity wraps an entity.Entity that is also a connected player.
|
||||
type PlayerEntity interface {
|
||||
entity.Entity
|
||||
Connection() *network.Connection
|
||||
Velocity() mathutil.Vec3
|
||||
SetVelocity(vel mathutil.Vec3)
|
||||
}
|
||||
|
||||
// ZoneMessageHandler provides an extension point for handling custom message types in a zone.
|
||||
type ZoneMessageHandler interface {
|
||||
HandleZoneMessage(zone *Zone, msg PlayerMessage) bool // returns true if handled
|
||||
}
|
||||
|
||||
// Zone is a self-contained game area with its own game loop.
|
||||
type Zone struct {
|
||||
id uint32
|
||||
cfg *config.Config
|
||||
entities map[uint64]entity.Entity
|
||||
players map[uint64]PlayerEntity
|
||||
aoi world.AOIManager
|
||||
incoming chan PlayerMessage
|
||||
stopCh chan struct{}
|
||||
tick int64
|
||||
|
||||
// Metrics
|
||||
lastTickDuration atomic.Int64
|
||||
|
||||
// AOI toggle support
|
||||
aoiEnabled bool
|
||||
gridAOI *world.GridAOI
|
||||
broadcastAOI *world.BroadcastAllAOI
|
||||
|
||||
// Combat
|
||||
combatMgr *combat.Manager
|
||||
|
||||
// AI / Mobs
|
||||
spawner *ai.Spawner
|
||||
nextEntityID atomic.Uint64
|
||||
|
||||
// External message handler for custom/internal messages.
|
||||
extHandler ZoneMessageHandler
|
||||
|
||||
// Respawn position
|
||||
spawnPos mathutil.Vec3
|
||||
|
||||
// Zone portals
|
||||
portals []world.ZonePortal
|
||||
|
||||
// Zone transfer callback (set by GameServer)
|
||||
onZoneTransfer func(playerID uint64, targetZoneID uint32, targetPos mathutil.Vec3)
|
||||
}
|
||||
|
||||
// NewZone creates a new zone with the given configuration.
|
||||
func NewZone(id uint32, cfg *config.Config) *Zone {
|
||||
gridAOI := world.NewGridAOI(cfg.World.AOI.CellSize, cfg.World.AOI.ViewRange)
|
||||
broadcastAOI := world.NewBroadcastAllAOI()
|
||||
|
||||
var activeAOI world.AOIManager
|
||||
if cfg.World.AOI.Enabled {
|
||||
activeAOI = gridAOI
|
||||
} else {
|
||||
activeAOI = broadcastAOI
|
||||
}
|
||||
|
||||
cm := combat.NewManager()
|
||||
|
||||
z := &Zone{
|
||||
id: id,
|
||||
cfg: cfg,
|
||||
entities: make(map[uint64]entity.Entity),
|
||||
players: make(map[uint64]PlayerEntity),
|
||||
aoi: activeAOI,
|
||||
incoming: make(chan PlayerMessage, 4096),
|
||||
stopCh: make(chan struct{}),
|
||||
aoiEnabled: cfg.World.AOI.Enabled,
|
||||
gridAOI: gridAOI,
|
||||
broadcastAOI: broadcastAOI,
|
||||
combatMgr: cm,
|
||||
spawnPos: mathutil.NewVec3(0, 0, 0),
|
||||
}
|
||||
|
||||
// Wire combat manager broadcast to zone AOI.
|
||||
cm.SetBroadcast(z.broadcastCombatEvent, z.sendToEntity)
|
||||
|
||||
// Create mob spawner.
|
||||
z.spawner = ai.NewSpawner(
|
||||
&z.nextEntityID,
|
||||
func(m *ai.Mob) { z.addMob(m) },
|
||||
func(mobID uint64) { z.removeMob(mobID) },
|
||||
)
|
||||
|
||||
return z
|
||||
}
|
||||
|
||||
// ID returns the zone identifier.
|
||||
func (z *Zone) ID() uint32 { return z.id }
|
||||
|
||||
// AddPortal registers a zone portal.
|
||||
func (z *Zone) AddPortal(portal world.ZonePortal) {
|
||||
z.portals = append(z.portals, portal)
|
||||
}
|
||||
|
||||
// SetZoneTransferCallback sets the function called when a player enters a portal.
|
||||
func (z *Zone) SetZoneTransferCallback(fn func(playerID uint64, targetZoneID uint32, targetPos mathutil.Vec3)) {
|
||||
z.onZoneTransfer = fn
|
||||
}
|
||||
|
||||
// PlayerCount returns the current number of players in this zone.
|
||||
func (z *Zone) PlayerCount() int { return len(z.players) }
|
||||
|
||||
// EntityCount returns the current number of entities in this zone.
|
||||
func (z *Zone) EntityCount() int { return len(z.entities) }
|
||||
|
||||
// LastTickDuration returns the duration of the last tick in microseconds.
|
||||
func (z *Zone) LastTickDuration() int64 { return z.lastTickDuration.Load() }
|
||||
|
||||
// AOIEnabled returns whether grid-based AOI is currently active.
|
||||
func (z *Zone) AOIEnabled() bool { return z.aoiEnabled }
|
||||
|
||||
// EnqueueMessage queues a player message for processing in the next tick.
|
||||
func (z *Zone) EnqueueMessage(msg PlayerMessage) {
|
||||
select {
|
||||
case z.incoming <- msg:
|
||||
default:
|
||||
logger.Warn("zone message queue full, dropping", "zoneID", z.id, "playerID", msg.PlayerID)
|
||||
}
|
||||
}
|
||||
|
||||
// AddPlayer adds a player to the zone.
|
||||
// Must be called from the zone's goroutine or before Run() starts.
|
||||
func (z *Zone) AddPlayer(p PlayerEntity) {
|
||||
z.entities[p.EntityID()] = p
|
||||
z.players[p.EntityID()] = p
|
||||
z.aoi.Add(p)
|
||||
|
||||
// Notify existing nearby players about the new player.
|
||||
spawnData, _ := network.Encode(network.MsgSpawnEntity, &pb.SpawnEntity{Entity: p.ToProto()})
|
||||
for _, nearby := range z.aoi.GetNearby(p) {
|
||||
if np, ok := z.players[nearby.EntityID()]; ok {
|
||||
np.Connection().SendRaw(spawnData)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("player added to zone", "zoneID", z.id, "playerID", p.EntityID(), "players", len(z.players))
|
||||
}
|
||||
|
||||
// RemovePlayer removes a player from the zone.
|
||||
func (z *Zone) RemovePlayer(playerID uint64) {
|
||||
entity, ok := z.entities[playerID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
events := z.aoi.Remove(entity)
|
||||
z.handleAOIEvents(events)
|
||||
|
||||
z.combatMgr.RemoveEntity(playerID)
|
||||
delete(z.entities, playerID)
|
||||
delete(z.players, playerID)
|
||||
|
||||
logger.Info("player removed from zone", "zoneID", z.id, "playerID", playerID, "players", len(z.players))
|
||||
}
|
||||
|
||||
// ToggleAOI switches between grid-based and broadcast-all AOI at runtime.
|
||||
func (z *Zone) ToggleAOI(enabled bool) {
|
||||
if z.aoiEnabled == enabled {
|
||||
return
|
||||
}
|
||||
|
||||
z.aoiEnabled = enabled
|
||||
|
||||
// Rebuild the target AOI manager with current entities.
|
||||
var newAOI world.AOIManager
|
||||
if enabled {
|
||||
g := world.NewGridAOI(z.cfg.World.AOI.CellSize, z.cfg.World.AOI.ViewRange)
|
||||
for _, e := range z.entities {
|
||||
g.Add(e)
|
||||
}
|
||||
z.gridAOI = g
|
||||
newAOI = g
|
||||
} else {
|
||||
b := world.NewBroadcastAllAOI()
|
||||
for _, e := range z.entities {
|
||||
b.Add(e)
|
||||
}
|
||||
z.broadcastAOI = b
|
||||
newAOI = b
|
||||
}
|
||||
|
||||
z.aoi = newAOI
|
||||
|
||||
// After toggle, send full spawn list to all players so they see the correct set.
|
||||
for _, p := range z.players {
|
||||
z.sendNearbySnapshot(p)
|
||||
}
|
||||
|
||||
logger.Info("AOI toggled", "zoneID", z.id, "enabled", enabled)
|
||||
}
|
||||
|
||||
// Run starts the zone's game loop. Blocks until Stop() is called.
|
||||
func (z *Zone) Run() {
|
||||
interval := z.cfg.TickInterval()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
logger.Info("zone started", "zoneID", z.id, "tickInterval", interval)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
start := time.Now()
|
||||
z.processTick()
|
||||
z.lastTickDuration.Store(time.Since(start).Microseconds())
|
||||
case <-z.stopCh:
|
||||
logger.Info("zone stopped", "zoneID", z.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop signals the zone's game loop to exit.
|
||||
func (z *Zone) Stop() {
|
||||
close(z.stopCh)
|
||||
}
|
||||
|
||||
func (z *Zone) processTick() {
|
||||
z.tick++
|
||||
z.processInputQueue()
|
||||
z.updateMovement()
|
||||
z.updateAI()
|
||||
z.updateCombat()
|
||||
z.checkDeaths()
|
||||
z.spawner.Update(time.Now())
|
||||
z.broadcastState()
|
||||
}
|
||||
|
||||
func (z *Zone) updateCombat() {
|
||||
dt := z.cfg.TickInterval()
|
||||
z.combatMgr.UpdateBuffs(dt, func(id uint64) combat.Combatant {
|
||||
if p, ok := z.players[id]; ok {
|
||||
if c, ok := p.(combat.Combatant); ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (z *Zone) processInputQueue() {
|
||||
for {
|
||||
select {
|
||||
case msg := <-z.incoming:
|
||||
z.handleMessage(msg)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetMessageHandler sets an external handler for custom message types.
|
||||
func (z *Zone) SetMessageHandler(h ZoneMessageHandler) {
|
||||
z.extHandler = h
|
||||
}
|
||||
|
||||
func (z *Zone) handleMessage(msg PlayerMessage) {
|
||||
// Try external handler first (for internal messages like disconnect/enter).
|
||||
if z.extHandler != nil && z.extHandler.HandleZoneMessage(z, msg) {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Packet.Type {
|
||||
case network.MsgMoveRequest:
|
||||
z.handleMoveRequest(msg)
|
||||
case network.MsgUseSkillRequest:
|
||||
z.handleUseSkill(msg)
|
||||
case network.MsgRespawnRequest:
|
||||
z.handleRespawn(msg)
|
||||
case network.MsgPing:
|
||||
z.handlePing(msg)
|
||||
case network.MsgAOIToggleRequest:
|
||||
z.handleAOIToggle(msg)
|
||||
case network.MsgMetricsRequest:
|
||||
z.handleMetrics(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (z *Zone) handleMoveRequest(msg PlayerMessage) {
|
||||
p, ok := z.players[msg.PlayerID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
req := msg.Packet.Payload.(*pb.MoveRequest)
|
||||
|
||||
newPos := mathutil.NewVec3(req.Position.X, req.Position.Y, req.Position.Z)
|
||||
vel := mathutil.NewVec3(req.Velocity.X, req.Velocity.Y, req.Velocity.Z)
|
||||
|
||||
// Server-side speed validation.
|
||||
if vel.Length() > maxMoveSpeed*1.1 { // 10% tolerance
|
||||
vel = vel.Normalize().Scale(maxMoveSpeed)
|
||||
}
|
||||
|
||||
oldPos := p.Position()
|
||||
p.SetPosition(newPos)
|
||||
p.SetRotation(req.Rotation)
|
||||
p.SetVelocity(vel)
|
||||
|
||||
// Update AOI and handle events.
|
||||
events := z.aoi.UpdatePosition(p, oldPos, newPos)
|
||||
z.handleAOIEvents(events)
|
||||
|
||||
// Check portal triggers.
|
||||
z.checkPortals(p, newPos)
|
||||
}
|
||||
|
||||
func (z *Zone) handlePing(msg PlayerMessage) {
|
||||
p, ok := z.players[msg.PlayerID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ping := msg.Packet.Payload.(*pb.Ping)
|
||||
p.Connection().Send(network.MsgPong, &pb.Pong{
|
||||
ClientTime: ping.ClientTime,
|
||||
ServerTime: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
func (z *Zone) handleAOIToggle(msg PlayerMessage) {
|
||||
p, ok := z.players[msg.PlayerID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req := msg.Packet.Payload.(*pb.AOIToggleRequest)
|
||||
z.ToggleAOI(req.Enabled)
|
||||
|
||||
status := "disabled"
|
||||
if req.Enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
p.Connection().Send(network.MsgAOIToggleResponse, &pb.AOIToggleResponse{
|
||||
Enabled: req.Enabled,
|
||||
Message: "AOI " + status,
|
||||
})
|
||||
}
|
||||
|
||||
func (z *Zone) handleMetrics(msg PlayerMessage) {
|
||||
p, ok := z.players[msg.PlayerID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.Connection().Send(network.MsgServerMetrics, &pb.ServerMetrics{
|
||||
OnlinePlayers: int32(len(z.players)),
|
||||
TotalEntities: int32(len(z.entities)),
|
||||
TickDurationUs: z.lastTickDuration.Load(),
|
||||
AoiEnabled: z.aoiEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
func (z *Zone) updateMovement() {
|
||||
// Movement is applied immediately in handleMoveRequest (client-authoritative position
|
||||
// with server validation). Future: add server-side physics/collision here.
|
||||
}
|
||||
|
||||
func (z *Zone) broadcastState() {
|
||||
if len(z.players) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// For each player, send state updates of nearby entities.
|
||||
for _, p := range z.players {
|
||||
nearby := z.aoi.GetNearby(p)
|
||||
if len(nearby) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
states := make([]*pb.EntityState, 0, len(nearby))
|
||||
for _, e := range nearby {
|
||||
states = append(states, e.ToProto())
|
||||
}
|
||||
|
||||
p.Connection().Send(network.MsgStateUpdate, &pb.StateUpdate{
|
||||
Entities: states,
|
||||
ServerTick: z.tick,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (z *Zone) handleAOIEvents(events []world.AOIEvent) {
|
||||
for _, evt := range events {
|
||||
observerPlayer, ok := z.players[evt.Observer.EntityID()]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch evt.Type {
|
||||
case world.AOIEnter:
|
||||
observerPlayer.Connection().Send(network.MsgSpawnEntity, &pb.SpawnEntity{
|
||||
Entity: evt.Target.ToProto(),
|
||||
})
|
||||
case world.AOILeave:
|
||||
observerPlayer.Connection().Send(network.MsgDespawnEntity, &pb.DespawnEntity{
|
||||
EntityId: evt.Target.EntityID(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (z *Zone) sendNearbySnapshot(p PlayerEntity) {
|
||||
nearby := z.aoi.GetNearby(p)
|
||||
states := make([]*pb.EntityState, 0, len(nearby))
|
||||
for _, e := range nearby {
|
||||
states = append(states, e.ToProto())
|
||||
}
|
||||
p.Connection().Send(network.MsgStateUpdate, &pb.StateUpdate{
|
||||
Entities: states,
|
||||
ServerTick: z.tick,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Combat Handlers ────────────────────────────────────────
|
||||
|
||||
func (z *Zone) handleUseSkill(msg PlayerMessage) {
|
||||
p, ok := z.players[msg.PlayerID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
req := msg.Packet.Payload.(*pb.UseSkillRequest)
|
||||
|
||||
var targetPos mathutil.Vec3
|
||||
if req.TargetPos != nil {
|
||||
targetPos = mathutil.NewVec3(req.TargetPos.X, req.TargetPos.Y, req.TargetPos.Z)
|
||||
}
|
||||
|
||||
caster, ok := p.(combat.Combatant)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
success, errMsg := z.combatMgr.UseSkill(
|
||||
caster,
|
||||
req.SkillId,
|
||||
req.TargetId,
|
||||
targetPos,
|
||||
func(id uint64) entity.Entity { return z.entities[id] },
|
||||
z.getEntitiesInRadius,
|
||||
)
|
||||
|
||||
p.Connection().Send(network.MsgUseSkillResponse, &pb.UseSkillResponse{
|
||||
Success: success,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
func (z *Zone) handleRespawn(msg PlayerMessage) {
|
||||
p, ok := z.players[msg.PlayerID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c, ok := p.(combat.Combatant)
|
||||
if !ok || c.IsAlive() {
|
||||
return
|
||||
}
|
||||
|
||||
oldPos := p.Position()
|
||||
z.combatMgr.Respawn(c, z.spawnPos)
|
||||
|
||||
// Update AOI for the new position.
|
||||
events := z.aoi.UpdatePosition(p, oldPos, z.spawnPos)
|
||||
z.handleAOIEvents(events)
|
||||
|
||||
// Notify respawn.
|
||||
respawnEvt := &pb.CombatEvent{
|
||||
TargetId: p.EntityID(),
|
||||
TargetHp: c.HP(),
|
||||
TargetMaxHp: c.MaxHP(),
|
||||
EventType: pb.CombatEventType_COMBAT_EVENT_RESPAWN,
|
||||
}
|
||||
z.broadcastCombatEvent(p, network.MsgCombatEvent, respawnEvt)
|
||||
|
||||
p.Connection().Send(network.MsgRespawnResponse, &pb.RespawnResponse{
|
||||
Self: p.ToProto(),
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastCombatEvent sends a combat event to all players who can see the entity.
|
||||
func (z *Zone) broadcastCombatEvent(ent entity.Entity, msgType uint16, msg interface{}) {
|
||||
protoMsg, ok := msg.(proto.Message)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := network.Encode(msgType, protoMsg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Send to the entity itself if it's a player.
|
||||
if p, ok := z.players[ent.EntityID()]; ok {
|
||||
p.Connection().SendRaw(data)
|
||||
}
|
||||
|
||||
// Send to nearby players.
|
||||
for _, nearby := range z.aoi.GetNearby(ent) {
|
||||
if p, ok := z.players[nearby.EntityID()]; ok {
|
||||
p.Connection().SendRaw(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendToEntity sends a message to a specific entity (if it's a player).
|
||||
func (z *Zone) sendToEntity(entityID uint64, msgType uint16, msg interface{}) {
|
||||
p, ok := z.players[entityID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
protoMsg, ok := msg.(proto.Message)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.Connection().Send(msgType, protoMsg)
|
||||
}
|
||||
|
||||
// getEntitiesInRadius returns all entities within a radius of a point.
|
||||
func (z *Zone) getEntitiesInRadius(center mathutil.Vec3, radius float32) []entity.Entity {
|
||||
radiusSq := radius * radius
|
||||
var result []entity.Entity
|
||||
for _, e := range z.entities {
|
||||
if e.Position().DistanceSqTo(center) <= radiusSq {
|
||||
result = append(result, e)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── AI / Mob Management ────────────────────────────────────
|
||||
|
||||
// Spawner returns the zone's mob spawner for external configuration.
|
||||
func (z *Zone) Spawner() *ai.Spawner { return z.spawner }
|
||||
|
||||
func (z *Zone) addMob(m *ai.Mob) {
|
||||
z.entities[m.EntityID()] = m
|
||||
z.aoi.Add(m)
|
||||
|
||||
// Notify nearby players about the new mob.
|
||||
spawnData, _ := network.Encode(network.MsgSpawnEntity, &pb.SpawnEntity{Entity: m.ToProto()})
|
||||
for _, nearby := range z.aoi.GetNearby(m) {
|
||||
if p, ok := z.players[nearby.EntityID()]; ok {
|
||||
p.Connection().SendRaw(spawnData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (z *Zone) removeMob(mobID uint64) {
|
||||
ent, ok := z.entities[mobID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
events := z.aoi.Remove(ent)
|
||||
z.handleAOIEvents(events)
|
||||
z.combatMgr.RemoveEntity(mobID)
|
||||
delete(z.entities, mobID)
|
||||
}
|
||||
|
||||
func (z *Zone) updateAI() {
|
||||
dt := z.cfg.TickInterval()
|
||||
for _, m := range z.spawner.AliveMobs() {
|
||||
oldPos := m.Position()
|
||||
ai.UpdateMob(m, dt, z, z)
|
||||
newPos := m.Position()
|
||||
|
||||
// Update AOI if mob moved.
|
||||
if oldPos != newPos {
|
||||
events := z.aoi.UpdatePosition(m, oldPos, newPos)
|
||||
z.handleAOIEvents(events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (z *Zone) checkDeaths() {
|
||||
for _, m := range z.spawner.AliveMobs() {
|
||||
if !m.IsAlive() {
|
||||
z.spawner.NotifyDeath(m.EntityID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ai.EntityProvider implementation ───────────────────────
|
||||
|
||||
func (z *Zone) GetEntity(id uint64) entity.Entity {
|
||||
return z.entities[id]
|
||||
}
|
||||
|
||||
func (z *Zone) GetPlayersInRange(center mathutil.Vec3, radius float32) []entity.Entity {
|
||||
radiusSq := radius * radius
|
||||
var result []entity.Entity
|
||||
for _, p := range z.players {
|
||||
if p.Position().DistanceSqTo(center) <= radiusSq {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── ai.SkillUser implementation ────────────────────────────
|
||||
|
||||
func (z *Zone) UseSkill(casterID uint64, skillID uint32, targetID uint64, targetPos mathutil.Vec3) (bool, string) {
|
||||
ent := z.entities[casterID]
|
||||
if ent == nil {
|
||||
return false, "caster not found"
|
||||
}
|
||||
caster, ok := ent.(combat.Combatant)
|
||||
if !ok {
|
||||
return false, "caster cannot fight"
|
||||
}
|
||||
|
||||
return z.combatMgr.UseSkill(
|
||||
caster, skillID, targetID, targetPos,
|
||||
func(id uint64) entity.Entity { return z.entities[id] },
|
||||
z.getEntitiesInRadius,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Zone Portals ───────────────────────────────────────────
|
||||
|
||||
func (z *Zone) checkPortals(p PlayerEntity, pos mathutil.Vec3) {
|
||||
if z.onZoneTransfer == nil || len(z.portals) == 0 {
|
||||
return
|
||||
}
|
||||
for _, portal := range z.portals {
|
||||
if portal.IsInRange(pos) {
|
||||
z.onZoneTransfer(p.EntityID(), portal.TargetZoneID, portal.TargetPos)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
174
internal/network/connection.go
Normal file
174
internal/network/connection.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"a301_game_server/pkg/logger"
|
||||
)
|
||||
|
||||
// ConnState represents the lifecycle state of a connection.
|
||||
type ConnState int32
|
||||
|
||||
const (
|
||||
ConnStateActive ConnState = iota
|
||||
ConnStateClosed
|
||||
)
|
||||
|
||||
// Connection wraps a WebSocket connection with send buffering and lifecycle management.
|
||||
type Connection struct {
|
||||
id uint64
|
||||
ws *websocket.Conn
|
||||
sendCh chan []byte
|
||||
handler PacketHandler
|
||||
state atomic.Int32
|
||||
closeOnce sync.Once
|
||||
|
||||
maxMessageSize int64
|
||||
heartbeatInterval time.Duration
|
||||
heartbeatTimeout time.Duration
|
||||
}
|
||||
|
||||
// PacketHandler processes incoming packets from a connection.
|
||||
type PacketHandler interface {
|
||||
OnPacket(conn *Connection, pkt *Packet)
|
||||
OnDisconnect(conn *Connection)
|
||||
}
|
||||
|
||||
// NewConnection creates a new Connection wrapping the given WebSocket.
|
||||
func NewConnection(id uint64, ws *websocket.Conn, handler PacketHandler, sendChSize int, maxMsgSize int64, hbInterval, hbTimeout time.Duration) *Connection {
|
||||
c := &Connection{
|
||||
id: id,
|
||||
ws: ws,
|
||||
sendCh: make(chan []byte, sendChSize),
|
||||
handler: handler,
|
||||
maxMessageSize: maxMsgSize,
|
||||
heartbeatInterval: hbInterval,
|
||||
heartbeatTimeout: hbTimeout,
|
||||
}
|
||||
c.state.Store(int32(ConnStateActive))
|
||||
return c
|
||||
}
|
||||
|
||||
// ID returns the connection's unique identifier.
|
||||
func (c *Connection) ID() uint64 { return c.id }
|
||||
|
||||
// Start launches the read and write goroutines.
|
||||
func (c *Connection) Start() {
|
||||
go c.readLoop()
|
||||
go c.writeLoop()
|
||||
}
|
||||
|
||||
// Send encodes and queues a message for sending. Non-blocking: drops if buffer is full.
|
||||
func (c *Connection) Send(msgType uint16, msg proto.Message) {
|
||||
if c.IsClosed() {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := Encode(msgType, msg)
|
||||
if err != nil {
|
||||
logger.Error("encode failed", "connID", c.id, "msgType", msgType, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case c.sendCh <- data:
|
||||
default:
|
||||
logger.Warn("send buffer full, dropping message", "connID", c.id, "msgType", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
// SendRaw queues pre-encoded data for sending. Non-blocking.
|
||||
func (c *Connection) SendRaw(data []byte) {
|
||||
if c.IsClosed() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.sendCh <- data:
|
||||
default:
|
||||
logger.Warn("send buffer full, dropping raw message", "connID", c.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Close terminates the connection.
|
||||
func (c *Connection) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
c.state.Store(int32(ConnStateClosed))
|
||||
close(c.sendCh)
|
||||
_ = c.ws.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// IsClosed returns true if the connection has been closed.
|
||||
func (c *Connection) IsClosed() bool {
|
||||
return ConnState(c.state.Load()) == ConnStateClosed
|
||||
}
|
||||
|
||||
func (c *Connection) readLoop() {
|
||||
defer func() {
|
||||
c.handler.OnDisconnect(c)
|
||||
c.Close()
|
||||
}()
|
||||
|
||||
c.ws.SetReadLimit(c.maxMessageSize)
|
||||
_ = c.ws.SetReadDeadline(time.Now().Add(c.heartbeatTimeout))
|
||||
|
||||
c.ws.SetPongHandler(func(string) error {
|
||||
_ = c.ws.SetReadDeadline(time.Now().Add(c.heartbeatTimeout))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
msgType, data, err := c.ws.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
logger.Debug("read error", "connID", c.id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if msgType != websocket.BinaryMessage {
|
||||
continue
|
||||
}
|
||||
|
||||
pkt, err := Decode(data)
|
||||
if err != nil {
|
||||
logger.Warn("decode error", "connID", c.id, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
c.handler.OnPacket(c, pkt)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Connection) writeLoop() {
|
||||
ticker := time.NewTicker(c.heartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case data, ok := <-c.sendCh:
|
||||
if !ok {
|
||||
_ = c.ws.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
return
|
||||
}
|
||||
|
||||
_ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.ws.WriteMessage(websocket.BinaryMessage, data); err != nil {
|
||||
logger.Debug("write error", "connID", c.id, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
_ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.ws.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
internal/network/packet.go
Normal file
121
internal/network/packet.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
pb "a301_game_server/proto/gen/pb"
|
||||
)
|
||||
|
||||
// Message type IDs — the wire protocol uses 2-byte type prefixes.
|
||||
const (
|
||||
// Auth
|
||||
MsgLoginRequest uint16 = 0x0001
|
||||
MsgLoginResponse uint16 = 0x0002
|
||||
MsgEnterWorldRequest uint16 = 0x0003
|
||||
MsgEnterWorldResponse uint16 = 0x0004
|
||||
|
||||
// Movement
|
||||
MsgMoveRequest uint16 = 0x0010
|
||||
MsgStateUpdate uint16 = 0x0011
|
||||
MsgSpawnEntity uint16 = 0x0012
|
||||
MsgDespawnEntity uint16 = 0x0013
|
||||
|
||||
// Zone Transfer
|
||||
MsgZoneTransferNotify uint16 = 0x0014
|
||||
|
||||
// System
|
||||
MsgPing uint16 = 0x0020
|
||||
MsgPong uint16 = 0x0021
|
||||
|
||||
// Combat
|
||||
MsgUseSkillRequest uint16 = 0x0040
|
||||
MsgUseSkillResponse uint16 = 0x0041
|
||||
MsgCombatEvent uint16 = 0x0042
|
||||
MsgBuffApplied uint16 = 0x0043
|
||||
MsgBuffRemoved uint16 = 0x0044
|
||||
MsgRespawnRequest uint16 = 0x0045
|
||||
MsgRespawnResponse uint16 = 0x0046
|
||||
|
||||
// Admin / Debug
|
||||
MsgAOIToggleRequest uint16 = 0x0030
|
||||
MsgAOIToggleResponse uint16 = 0x0031
|
||||
MsgMetricsRequest uint16 = 0x0032
|
||||
MsgServerMetrics uint16 = 0x0033
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnknownMessageType = errors.New("unknown message type")
|
||||
ErrMessageTooShort = errors.New("message too short")
|
||||
)
|
||||
|
||||
// Packet is a decoded network message.
|
||||
type Packet struct {
|
||||
Type uint16
|
||||
Payload proto.Message
|
||||
}
|
||||
|
||||
// messageFactory maps type IDs to protobuf message constructors.
|
||||
var messageFactory = map[uint16]func() proto.Message{
|
||||
MsgLoginRequest: func() proto.Message { return &pb.LoginRequest{} },
|
||||
MsgLoginResponse: func() proto.Message { return &pb.LoginResponse{} },
|
||||
MsgEnterWorldRequest: func() proto.Message { return &pb.EnterWorldRequest{} },
|
||||
MsgEnterWorldResponse: func() proto.Message { return &pb.EnterWorldResponse{} },
|
||||
MsgMoveRequest: func() proto.Message { return &pb.MoveRequest{} },
|
||||
MsgStateUpdate: func() proto.Message { return &pb.StateUpdate{} },
|
||||
MsgSpawnEntity: func() proto.Message { return &pb.SpawnEntity{} },
|
||||
MsgDespawnEntity: func() proto.Message { return &pb.DespawnEntity{} },
|
||||
MsgPing: func() proto.Message { return &pb.Ping{} },
|
||||
MsgPong: func() proto.Message { return &pb.Pong{} },
|
||||
MsgZoneTransferNotify: func() proto.Message { return &pb.ZoneTransferNotify{} },
|
||||
MsgUseSkillRequest: func() proto.Message { return &pb.UseSkillRequest{} },
|
||||
MsgUseSkillResponse: func() proto.Message { return &pb.UseSkillResponse{} },
|
||||
MsgCombatEvent: func() proto.Message { return &pb.CombatEvent{} },
|
||||
MsgBuffApplied: func() proto.Message { return &pb.BuffApplied{} },
|
||||
MsgBuffRemoved: func() proto.Message { return &pb.BuffRemoved{} },
|
||||
MsgRespawnRequest: func() proto.Message { return &pb.RespawnRequest{} },
|
||||
MsgRespawnResponse: func() proto.Message { return &pb.RespawnResponse{} },
|
||||
MsgAOIToggleRequest: func() proto.Message { return &pb.AOIToggleRequest{} },
|
||||
MsgAOIToggleResponse: func() proto.Message { return &pb.AOIToggleResponse{} },
|
||||
MsgMetricsRequest: func() proto.Message { return &pb.MetricsRequest{} },
|
||||
MsgServerMetrics: func() proto.Message { return &pb.ServerMetrics{} },
|
||||
}
|
||||
|
||||
// Encode serializes a packet into a wire-format byte slice: [2-byte type][protobuf payload].
|
||||
func Encode(msgType uint16, msg proto.Message) ([]byte, error) {
|
||||
payload, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal message 0x%04X: %w", msgType, err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 2+len(payload))
|
||||
binary.BigEndian.PutUint16(buf[:2], msgType)
|
||||
copy(buf[2:], payload)
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// Decode parses a wire-format byte slice into a Packet.
|
||||
func Decode(data []byte) (*Packet, error) {
|
||||
if len(data) < 2 {
|
||||
return nil, ErrMessageTooShort
|
||||
}
|
||||
|
||||
msgType := binary.BigEndian.Uint16(data[:2])
|
||||
|
||||
factory, ok := messageFactory[msgType]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: 0x%04X", ErrUnknownMessageType, msgType)
|
||||
}
|
||||
|
||||
msg := factory()
|
||||
if len(data) > 2 {
|
||||
if err := proto.Unmarshal(data[2:], msg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal message 0x%04X: %w", msgType, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Packet{Type: msgType, Payload: msg}, nil
|
||||
}
|
||||
89
internal/network/server.go
Normal file
89
internal/network/server.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"a301_game_server/config"
|
||||
"a301_game_server/pkg/logger"
|
||||
)
|
||||
|
||||
// Server listens for WebSocket connections and creates Connection objects.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
upgrader websocket.Upgrader
|
||||
handler PacketHandler
|
||||
nextID atomic.Uint64
|
||||
srv *http.Server
|
||||
}
|
||||
|
||||
// NewServer creates a new WebSocket server.
|
||||
func NewServer(cfg *config.Config, handler PacketHandler) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: handler,
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: cfg.Network.ReadBufferSize,
|
||||
WriteBufferSize: cfg.Network.WriteBufferSize,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins listening for connections. Blocks until the context is cancelled.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ws", s.handleWebSocket)
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
addr := s.cfg.Server.Address()
|
||||
s.srv = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("websocket server starting", "address", addr)
|
||||
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutting down websocket server")
|
||||
return s.srv.Shutdown(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
logger.Error("websocket upgrade failed", "error", err, "remote", r.RemoteAddr)
|
||||
return
|
||||
}
|
||||
|
||||
connID := s.nextID.Add(1)
|
||||
conn := NewConnection(
|
||||
connID,
|
||||
ws,
|
||||
s.handler,
|
||||
s.cfg.Network.SendChannelSize,
|
||||
s.cfg.Network.MaxMessageSize,
|
||||
s.cfg.Network.HeartbeatInterval,
|
||||
s.cfg.Network.HeartbeatTimeout,
|
||||
)
|
||||
|
||||
logger.Info("client connected", "connID", connID, "remote", r.RemoteAddr)
|
||||
conn.Start()
|
||||
}
|
||||
249
internal/player/player.go
Normal file
249
internal/player/player.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"a301_game_server/internal/combat"
|
||||
"a301_game_server/internal/db/repository"
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/internal/network"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
pb "a301_game_server/proto/gen/pb"
|
||||
)
|
||||
|
||||
// Player represents an online player in the game world.
|
||||
type Player struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
id uint64
|
||||
charID int64 // database character ID
|
||||
acctID int64 // database account ID
|
||||
name string
|
||||
position mathutil.Vec3
|
||||
rotation float32
|
||||
velocity mathutil.Vec3
|
||||
|
||||
stats Stats
|
||||
|
||||
conn *network.Connection
|
||||
zoneID uint32
|
||||
session *Session
|
||||
dirty bool // true if state changed since last DB save
|
||||
}
|
||||
|
||||
// NewPlayer creates a new player.
|
||||
func NewPlayer(id uint64, name string, conn *network.Connection) *Player {
|
||||
return &Player{
|
||||
id: id,
|
||||
name: name,
|
||||
conn: conn,
|
||||
stats: Stats{
|
||||
HP: 100, MaxHP: 100,
|
||||
MP: 50, MaxMP: 50,
|
||||
Str: 10, Dex: 10, Int: 10,
|
||||
Level: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewPlayerFromDB creates a player from persisted character data.
|
||||
func NewPlayerFromDB(data *repository.CharacterData, conn *network.Connection) *Player {
|
||||
return &Player{
|
||||
id: uint64(data.ID),
|
||||
charID: data.ID,
|
||||
acctID: data.AccountID,
|
||||
name: data.Name,
|
||||
position: mathutil.NewVec3(data.PosX, data.PosY, data.PosZ),
|
||||
rotation: data.Rotation,
|
||||
stats: Stats{
|
||||
HP: data.HP, MaxHP: data.MaxHP,
|
||||
MP: data.MP, MaxMP: data.MaxMP,
|
||||
Str: data.Str, Dex: data.Dex, Int: data.IntStat,
|
||||
Level: data.Level, Exp: data.Exp,
|
||||
},
|
||||
conn: conn,
|
||||
zoneID: uint32(data.ZoneID),
|
||||
}
|
||||
}
|
||||
|
||||
// ToCharacterData converts current state to a persistable format.
|
||||
func (p *Player) ToCharacterData() *repository.CharacterData {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return &repository.CharacterData{
|
||||
ID: p.charID,
|
||||
AccountID: p.acctID,
|
||||
Name: p.name,
|
||||
Level: p.stats.Level,
|
||||
Exp: p.stats.Exp,
|
||||
HP: p.stats.HP,
|
||||
MaxHP: p.stats.MaxHP,
|
||||
MP: p.stats.MP,
|
||||
MaxMP: p.stats.MaxMP,
|
||||
Str: p.stats.Str,
|
||||
Dex: p.stats.Dex,
|
||||
IntStat: p.stats.Int,
|
||||
ZoneID: int32(p.zoneID),
|
||||
PosX: p.position.X,
|
||||
PosY: p.position.Y,
|
||||
PosZ: p.position.Z,
|
||||
Rotation: p.rotation,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Player) EntityID() uint64 { return p.id }
|
||||
func (p *Player) EntityType() entity.Type { return entity.TypePlayer }
|
||||
|
||||
func (p *Player) Position() mathutil.Vec3 {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.position
|
||||
}
|
||||
|
||||
func (p *Player) SetPosition(pos mathutil.Vec3) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.position = pos
|
||||
p.dirty = true
|
||||
}
|
||||
|
||||
func (p *Player) Rotation() float32 {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.rotation
|
||||
}
|
||||
|
||||
func (p *Player) SetRotation(rot float32) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.rotation = rot
|
||||
p.dirty = true
|
||||
}
|
||||
|
||||
func (p *Player) Velocity() mathutil.Vec3 {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.velocity
|
||||
}
|
||||
|
||||
func (p *Player) SetVelocity(vel mathutil.Vec3) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.velocity = vel
|
||||
}
|
||||
|
||||
func (p *Player) Name() string { return p.name }
|
||||
func (p *Player) CharID() int64 { return p.charID }
|
||||
func (p *Player) AccountID() int64 { return p.acctID }
|
||||
func (p *Player) Connection() *network.Connection { return p.conn }
|
||||
func (p *Player) ZoneID() uint32 { return p.zoneID }
|
||||
func (p *Player) SetZoneID(id uint32) { p.zoneID = id }
|
||||
|
||||
func (p *Player) Stats() combat.CombatStats {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return combat.CombatStats{
|
||||
Str: p.stats.Str,
|
||||
Dex: p.stats.Dex,
|
||||
Int: p.stats.Int,
|
||||
Level: p.stats.Level,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Player) RawStats() Stats {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.stats
|
||||
}
|
||||
|
||||
func (p *Player) SetStats(s Stats) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stats = s
|
||||
p.dirty = true
|
||||
}
|
||||
|
||||
func (p *Player) HP() int32 {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.stats.HP
|
||||
}
|
||||
|
||||
func (p *Player) SetHP(hp int32) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if hp < 0 {
|
||||
hp = 0
|
||||
}
|
||||
if hp > p.stats.MaxHP {
|
||||
hp = p.stats.MaxHP
|
||||
}
|
||||
p.stats.HP = hp
|
||||
p.dirty = true
|
||||
}
|
||||
|
||||
func (p *Player) MaxHP() int32 {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.stats.MaxHP
|
||||
}
|
||||
|
||||
func (p *Player) MP() int32 {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.stats.MP
|
||||
}
|
||||
|
||||
func (p *Player) SetMP(mp int32) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if mp < 0 {
|
||||
mp = 0
|
||||
}
|
||||
if mp > p.stats.MaxMP {
|
||||
mp = p.stats.MaxMP
|
||||
}
|
||||
p.stats.MP = mp
|
||||
p.dirty = true
|
||||
}
|
||||
|
||||
func (p *Player) Level() int32 {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.stats.Level
|
||||
}
|
||||
|
||||
func (p *Player) IsAlive() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.stats.HP > 0
|
||||
}
|
||||
|
||||
// IsDirty returns true if state has changed since last save.
|
||||
func (p *Player) IsDirty() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.dirty
|
||||
}
|
||||
|
||||
// ClearDirty resets the dirty flag after a successful save.
|
||||
func (p *Player) ClearDirty() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.dirty = false
|
||||
}
|
||||
|
||||
func (p *Player) ToProto() *pb.EntityState {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return &pb.EntityState{
|
||||
EntityId: p.id,
|
||||
Name: p.name,
|
||||
Position: &pb.Vector3{X: p.position.X, Y: p.position.Y, Z: p.position.Z},
|
||||
Rotation: p.rotation,
|
||||
Hp: p.stats.HP,
|
||||
MaxHp: p.stats.MaxHP,
|
||||
Level: p.stats.Level,
|
||||
EntityType: pb.EntityType_ENTITY_TYPE_PLAYER,
|
||||
}
|
||||
}
|
||||
90
internal/player/session.go
Normal file
90
internal/player/session.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Session holds an authenticated player's session state.
|
||||
type Session struct {
|
||||
Token string
|
||||
PlayerID uint64
|
||||
PlayerName string
|
||||
CreatedAt time.Time
|
||||
LastActive time.Time
|
||||
}
|
||||
|
||||
// SessionManager manages active sessions.
|
||||
type SessionManager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*Session // token -> session
|
||||
}
|
||||
|
||||
// NewSessionManager creates a new session manager.
|
||||
func NewSessionManager() *SessionManager {
|
||||
return &SessionManager{
|
||||
sessions: make(map[string]*Session),
|
||||
}
|
||||
}
|
||||
|
||||
// Create generates a new session for the given player.
|
||||
func (sm *SessionManager) Create(playerID uint64, playerName string) *Session {
|
||||
token := generateToken()
|
||||
now := time.Now()
|
||||
|
||||
s := &Session{
|
||||
Token: token,
|
||||
PlayerID: playerID,
|
||||
PlayerName: playerName,
|
||||
CreatedAt: now,
|
||||
LastActive: now,
|
||||
}
|
||||
|
||||
sm.mu.Lock()
|
||||
sm.sessions[token] = s
|
||||
sm.mu.Unlock()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Get retrieves a session by token. Returns nil if not found.
|
||||
func (sm *SessionManager) Get(token string) *Session {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
s := sm.sessions[token]
|
||||
if s != nil {
|
||||
s.LastActive = time.Now()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Remove deletes a session.
|
||||
func (sm *SessionManager) Remove(token string) {
|
||||
sm.mu.Lock()
|
||||
delete(sm.sessions, token)
|
||||
sm.mu.Unlock()
|
||||
}
|
||||
|
||||
// CleanupExpired removes sessions inactive for longer than the given duration.
|
||||
func (sm *SessionManager) CleanupExpired(maxIdle time.Duration) int {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-maxIdle)
|
||||
removed := 0
|
||||
for token, s := range sm.sessions {
|
||||
if s.LastActive.Before(cutoff) {
|
||||
delete(sm.sessions, token)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
func generateToken() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
14
internal/player/stats.go
Normal file
14
internal/player/stats.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package player
|
||||
|
||||
// Stats holds combat-related attributes.
|
||||
type Stats struct {
|
||||
HP int32
|
||||
MaxHP int32
|
||||
MP int32
|
||||
MaxMP int32
|
||||
Str int32
|
||||
Dex int32
|
||||
Int int32
|
||||
Level int32
|
||||
Exp int64
|
||||
}
|
||||
70
internal/world/aoi.go
Normal file
70
internal/world/aoi.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
)
|
||||
|
||||
// AOIEvent represents an entity entering or leaving another entity's area of interest.
|
||||
type AOIEvent struct {
|
||||
Observer entity.Entity
|
||||
Target entity.Entity
|
||||
Type AOIEventType
|
||||
}
|
||||
|
||||
type AOIEventType int
|
||||
|
||||
const (
|
||||
AOIEnter AOIEventType = iota
|
||||
AOILeave
|
||||
)
|
||||
|
||||
// AOIManager determines which entities can see each other.
|
||||
type AOIManager interface {
|
||||
Add(ent entity.Entity)
|
||||
Remove(ent entity.Entity) []AOIEvent
|
||||
UpdatePosition(ent entity.Entity, oldPos, newPos mathutil.Vec3) []AOIEvent
|
||||
GetNearby(ent entity.Entity) []entity.Entity
|
||||
}
|
||||
|
||||
// BroadcastAllAOI is a trivial AOI that treats all entities as visible to each other.
|
||||
// Used when AOI is disabled for debugging/comparison.
|
||||
type BroadcastAllAOI struct {
|
||||
entities map[uint64]entity.Entity
|
||||
}
|
||||
|
||||
func NewBroadcastAllAOI() *BroadcastAllAOI {
|
||||
return &BroadcastAllAOI{
|
||||
entities: make(map[uint64]entity.Entity),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BroadcastAllAOI) Add(ent entity.Entity) {
|
||||
b.entities[ent.EntityID()] = ent
|
||||
}
|
||||
|
||||
func (b *BroadcastAllAOI) Remove(ent entity.Entity) []AOIEvent {
|
||||
delete(b.entities, ent.EntityID())
|
||||
var events []AOIEvent
|
||||
for _, other := range b.entities {
|
||||
if other.EntityID() == ent.EntityID() {
|
||||
continue
|
||||
}
|
||||
events = append(events, AOIEvent{Observer: other, Target: ent, Type: AOILeave})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func (b *BroadcastAllAOI) UpdatePosition(_ entity.Entity, _, _ mathutil.Vec3) []AOIEvent {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BroadcastAllAOI) GetNearby(ent entity.Entity) []entity.Entity {
|
||||
result := make([]entity.Entity, 0, len(b.entities)-1)
|
||||
for _, e := range b.entities {
|
||||
if e.EntityID() != ent.EntityID() {
|
||||
result = append(result, e)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
150
internal/world/aoi_test.go
Normal file
150
internal/world/aoi_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
pb "a301_game_server/proto/gen/pb"
|
||||
)
|
||||
|
||||
// mockEntity is a minimal entity for testing.
|
||||
type mockEntity struct {
|
||||
id uint64
|
||||
pos mathutil.Vec3
|
||||
}
|
||||
|
||||
func (m *mockEntity) EntityID() uint64 { return m.id }
|
||||
func (m *mockEntity) EntityType() entity.Type { return entity.TypePlayer }
|
||||
func (m *mockEntity) Position() mathutil.Vec3 { return m.pos }
|
||||
func (m *mockEntity) SetPosition(p mathutil.Vec3) { m.pos = p }
|
||||
func (m *mockEntity) Rotation() float32 { return 0 }
|
||||
func (m *mockEntity) SetRotation(float32) {}
|
||||
func (m *mockEntity) ToProto() *pb.EntityState { return &pb.EntityState{EntityId: m.id} }
|
||||
|
||||
func TestBroadcastAllAOI_GetNearby(t *testing.T) {
|
||||
aoi := NewBroadcastAllAOI()
|
||||
|
||||
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
|
||||
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(100, 0, 100)}
|
||||
e3 := &mockEntity{id: 3, pos: mathutil.NewVec3(999, 0, 999)}
|
||||
|
||||
aoi.Add(e1)
|
||||
aoi.Add(e2)
|
||||
aoi.Add(e3)
|
||||
|
||||
// With broadcast-all, everyone sees everyone.
|
||||
nearby := aoi.GetNearby(e1)
|
||||
if len(nearby) != 2 {
|
||||
t.Errorf("expected 2 nearby, got %d", len(nearby))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastAllAOI_Remove(t *testing.T) {
|
||||
aoi := NewBroadcastAllAOI()
|
||||
|
||||
e1 := &mockEntity{id: 1}
|
||||
e2 := &mockEntity{id: 2}
|
||||
aoi.Add(e1)
|
||||
aoi.Add(e2)
|
||||
|
||||
events := aoi.Remove(e1)
|
||||
if len(events) != 1 {
|
||||
t.Errorf("expected 1 leave event, got %d", len(events))
|
||||
}
|
||||
if events[0].Type != AOILeave {
|
||||
t.Errorf("expected AOILeave event")
|
||||
}
|
||||
|
||||
nearby := aoi.GetNearby(e2)
|
||||
if len(nearby) != 0 {
|
||||
t.Errorf("expected 0 nearby after removal, got %d", len(nearby))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridAOI_NearbyInSameCell(t *testing.T) {
|
||||
aoi := NewGridAOI(50, 2)
|
||||
|
||||
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(10, 0, 10)}
|
||||
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(20, 0, 20)}
|
||||
|
||||
aoi.Add(e1)
|
||||
aoi.Add(e2)
|
||||
|
||||
nearby := aoi.GetNearby(e1)
|
||||
if len(nearby) != 1 {
|
||||
t.Errorf("expected 1 nearby, got %d", len(nearby))
|
||||
}
|
||||
if nearby[0].EntityID() != 2 {
|
||||
t.Errorf("expected entity 2, got %d", nearby[0].EntityID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridAOI_FarAwayNotVisible(t *testing.T) {
|
||||
aoi := NewGridAOI(50, 1) // viewRange=1 means 3x3 grid = 150 units visibility
|
||||
|
||||
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
|
||||
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(500, 0, 500)} // far away
|
||||
|
||||
aoi.Add(e1)
|
||||
aoi.Add(e2)
|
||||
|
||||
nearby := aoi.GetNearby(e1)
|
||||
if len(nearby) != 0 {
|
||||
t.Errorf("expected 0 nearby for far entity, got %d", len(nearby))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridAOI_MoveGeneratesEvents(t *testing.T) {
|
||||
aoi := NewGridAOI(50, 1)
|
||||
|
||||
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
|
||||
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(200, 0, 200)}
|
||||
|
||||
aoi.Add(e1)
|
||||
aoi.Add(e2)
|
||||
|
||||
// Initially not visible to each other.
|
||||
nearby := aoi.GetNearby(e1)
|
||||
if len(nearby) != 0 {
|
||||
t.Fatalf("expected not visible initially, got %d", len(nearby))
|
||||
}
|
||||
|
||||
// Move e2 close to e1.
|
||||
oldPos := e2.pos
|
||||
e2.pos = mathutil.NewVec3(10, 0, 10)
|
||||
events := aoi.UpdatePosition(e2, oldPos, e2.pos)
|
||||
|
||||
// Should generate enter events (e1 sees e2, e2 sees e1).
|
||||
enterCount := 0
|
||||
for _, evt := range events {
|
||||
if evt.Type == AOIEnter {
|
||||
enterCount++
|
||||
}
|
||||
}
|
||||
if enterCount != 2 {
|
||||
t.Errorf("expected 2 enter events, got %d", enterCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridAOI_ToggleComparison(t *testing.T) {
|
||||
// Demonstrates the difference between BroadcastAll and Grid AOI.
|
||||
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
|
||||
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(500, 0, 500)}
|
||||
|
||||
// BroadcastAll: both visible
|
||||
broadcast := NewBroadcastAllAOI()
|
||||
broadcast.Add(e1)
|
||||
broadcast.Add(e2)
|
||||
if len(broadcast.GetNearby(e1)) != 1 {
|
||||
t.Error("broadcast-all should see all entities")
|
||||
}
|
||||
|
||||
// Grid: e2 not visible from e1 (too far)
|
||||
grid := NewGridAOI(50, 1)
|
||||
grid.Add(e1)
|
||||
grid.Add(e2)
|
||||
if len(grid.GetNearby(e1)) != 0 {
|
||||
t.Error("grid AOI should NOT see distant entities")
|
||||
}
|
||||
}
|
||||
179
internal/world/spatial_grid.go
Normal file
179
internal/world/spatial_grid.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
)
|
||||
|
||||
// cellKey uniquely identifies a grid cell.
|
||||
type cellKey struct {
|
||||
cx, cz int
|
||||
}
|
||||
|
||||
// GridAOI implements AOI using a spatial grid. Entities in nearby cells are considered visible.
|
||||
type GridAOI struct {
|
||||
cellSize float32
|
||||
viewRange int
|
||||
cells map[cellKey]map[uint64]entity.Entity
|
||||
entityCell map[uint64]cellKey
|
||||
}
|
||||
|
||||
func NewGridAOI(cellSize float32, viewRange int) *GridAOI {
|
||||
return &GridAOI{
|
||||
cellSize: cellSize,
|
||||
viewRange: viewRange,
|
||||
cells: make(map[cellKey]map[uint64]entity.Entity),
|
||||
entityCell: make(map[uint64]cellKey),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GridAOI) posToCell(pos mathutil.Vec3) cellKey {
|
||||
cx := int(pos.X / g.cellSize)
|
||||
cz := int(pos.Z / g.cellSize)
|
||||
if pos.X < 0 {
|
||||
cx--
|
||||
}
|
||||
if pos.Z < 0 {
|
||||
cz--
|
||||
}
|
||||
return cellKey{cx, cz}
|
||||
}
|
||||
|
||||
func (g *GridAOI) Add(ent entity.Entity) {
|
||||
cell := g.posToCell(ent.Position())
|
||||
g.addToCell(cell, ent)
|
||||
g.entityCell[ent.EntityID()] = cell
|
||||
}
|
||||
|
||||
func (g *GridAOI) Remove(ent entity.Entity) []AOIEvent {
|
||||
eid := ent.EntityID()
|
||||
cell, ok := g.entityCell[eid]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
nearby := g.getNearbyFromCell(cell, eid)
|
||||
events := make([]AOIEvent, 0, len(nearby))
|
||||
for _, observer := range nearby {
|
||||
events = append(events, AOIEvent{Observer: observer, Target: ent, Type: AOILeave})
|
||||
}
|
||||
|
||||
g.removeFromCell(cell, eid)
|
||||
delete(g.entityCell, eid)
|
||||
return events
|
||||
}
|
||||
|
||||
func (g *GridAOI) UpdatePosition(ent entity.Entity, oldPos, newPos mathutil.Vec3) []AOIEvent {
|
||||
eid := ent.EntityID()
|
||||
oldCell := g.posToCell(oldPos)
|
||||
newCell := g.posToCell(newPos)
|
||||
|
||||
if oldCell == newCell {
|
||||
return nil
|
||||
}
|
||||
|
||||
oldVisible := g.visibleCells(oldCell)
|
||||
newVisible := g.visibleCells(newCell)
|
||||
|
||||
leaving := cellDifference(oldVisible, newVisible)
|
||||
entering := cellDifference(newVisible, oldVisible)
|
||||
|
||||
var events []AOIEvent
|
||||
|
||||
for _, c := range leaving {
|
||||
if cellEntities, ok := g.cells[c]; ok {
|
||||
for _, other := range cellEntities {
|
||||
if other.EntityID() == eid {
|
||||
continue
|
||||
}
|
||||
events = append(events,
|
||||
AOIEvent{Observer: other, Target: ent, Type: AOILeave},
|
||||
AOIEvent{Observer: ent, Target: other, Type: AOILeave},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range entering {
|
||||
if cellEntities, ok := g.cells[c]; ok {
|
||||
for _, other := range cellEntities {
|
||||
if other.EntityID() == eid {
|
||||
continue
|
||||
}
|
||||
events = append(events,
|
||||
AOIEvent{Observer: other, Target: ent, Type: AOIEnter},
|
||||
AOIEvent{Observer: ent, Target: other, Type: AOIEnter},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g.removeFromCell(oldCell, eid)
|
||||
g.addToCell(newCell, ent)
|
||||
g.entityCell[eid] = newCell
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
func (g *GridAOI) GetNearby(ent entity.Entity) []entity.Entity {
|
||||
cell, ok := g.entityCell[ent.EntityID()]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return g.getNearbyFromCell(cell, ent.EntityID())
|
||||
}
|
||||
|
||||
func (g *GridAOI) getNearbyFromCell(cell cellKey, excludeID uint64) []entity.Entity {
|
||||
var result []entity.Entity
|
||||
for _, c := range g.visibleCells(cell) {
|
||||
if cellEntities, ok := g.cells[c]; ok {
|
||||
for _, e := range cellEntities {
|
||||
if e.EntityID() != excludeID {
|
||||
result = append(result, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (g *GridAOI) visibleCells(center cellKey) []cellKey {
|
||||
size := (2*g.viewRange + 1) * (2*g.viewRange + 1)
|
||||
cells := make([]cellKey, 0, size)
|
||||
for dx := -g.viewRange; dx <= g.viewRange; dx++ {
|
||||
for dz := -g.viewRange; dz <= g.viewRange; dz++ {
|
||||
cells = append(cells, cellKey{center.cx + dx, center.cz + dz})
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
func (g *GridAOI) addToCell(cell cellKey, ent entity.Entity) {
|
||||
if g.cells[cell] == nil {
|
||||
g.cells[cell] = make(map[uint64]entity.Entity)
|
||||
}
|
||||
g.cells[cell][ent.EntityID()] = ent
|
||||
}
|
||||
|
||||
func (g *GridAOI) removeFromCell(cell cellKey, eid uint64) {
|
||||
if m, ok := g.cells[cell]; ok {
|
||||
delete(m, eid)
|
||||
if len(m) == 0 {
|
||||
delete(g.cells, cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cellDifference(a, b []cellKey) []cellKey {
|
||||
set := make(map[cellKey]struct{}, len(b))
|
||||
for _, c := range b {
|
||||
set[c] = struct{}{}
|
||||
}
|
||||
var diff []cellKey
|
||||
for _, c := range a {
|
||||
if _, ok := set[c]; !ok {
|
||||
diff = append(diff, c)
|
||||
}
|
||||
}
|
||||
return diff
|
||||
}
|
||||
20
internal/world/zone_transfer.go
Normal file
20
internal/world/zone_transfer.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package world
|
||||
|
||||
import "a301_game_server/pkg/mathutil"
|
||||
|
||||
// ZonePortal defines a connection between two zones.
|
||||
type ZonePortal struct {
|
||||
// Trigger area in source zone.
|
||||
SourceZoneID uint32
|
||||
TriggerPos mathutil.Vec3
|
||||
TriggerRadius float32
|
||||
|
||||
// Destination in target zone.
|
||||
TargetZoneID uint32
|
||||
TargetPos mathutil.Vec3
|
||||
}
|
||||
|
||||
// IsInRange returns true if the given position is within the portal's trigger area.
|
||||
func (p *ZonePortal) IsInRange(pos mathutil.Vec3) bool {
|
||||
return pos.DistanceXZ(p.TriggerPos) <= p.TriggerRadius
|
||||
}
|
||||
Reference in New Issue
Block a user