first commit

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

184
internal/ai/behavior.go Normal file
View 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
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,86 @@
package combat
import "time"
// BuffDef defines a buff/debuff type.
type BuffDef struct {
ID uint32
Name string
IsDebuff bool
DamagePerTick int32 // for DoTs (debuff); heal per tick for HoTs (buff)
StatModifier StatMod
}
// StatMod is a temporary stat modification from a buff.
type StatMod struct {
StrBonus int32
DexBonus int32
IntBonus int32
}
// ActiveBuff is an active buff/debuff on an entity.
type ActiveBuff struct {
Def *BuffDef
CasterID uint64
Remaining time.Duration
TickInterval time.Duration
NextTick time.Duration // time until next tick
}
// Tick advances the buff by dt. Returns damage/heal to apply this tick (0 if no tick).
func (b *ActiveBuff) Tick(dt time.Duration) int32 {
b.Remaining -= dt
var tickValue int32
if b.TickInterval > 0 {
b.NextTick -= dt
if b.NextTick <= 0 {
tickValue = b.Def.DamagePerTick
b.NextTick += b.TickInterval
}
}
return tickValue
}
// IsExpired returns true if the buff has no remaining duration.
func (b *ActiveBuff) IsExpired() bool {
return b.Remaining <= 0
}
// BuffRegistry holds all buff/debuff definitions.
type BuffRegistry struct {
buffs map[uint32]*BuffDef
}
// NewBuffRegistry creates a registry with default buffs.
func NewBuffRegistry() *BuffRegistry {
r := &BuffRegistry{buffs: make(map[uint32]*BuffDef)}
r.registerDefaults()
return r
}
// Get returns a buff definition.
func (r *BuffRegistry) Get(id uint32) *BuffDef {
return r.buffs[id]
}
func (r *BuffRegistry) registerDefaults() {
// Poison DoT (referenced by skill ID 5, effect Value=1)
r.buffs[1] = &BuffDef{
ID: 1,
Name: "Poison",
IsDebuff: true,
DamagePerTick: 8,
}
// Power Up buff (referenced by skill ID 6, effect Value=2)
r.buffs[2] = &BuffDef{
ID: 2,
Name: "Power Up",
IsDebuff: false,
StatModifier: StatMod{
StrBonus: 20,
},
}
}

View File

@@ -0,0 +1,368 @@
package combat
import (
"time"
"a301_game_server/internal/entity"
"a301_game_server/internal/network"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
)
// Combatant is an entity that can participate in combat.
type Combatant interface {
entity.Entity
HP() int32
SetHP(int32)
MaxHP() int32
MP() int32
SetMP(int32)
IsAlive() bool
Stats() CombatStats
}
// CombatStats provides stat access for damage calculation.
type CombatStats struct {
Str int32
Dex int32
Int int32
Level int32
}
// CombatantWithConn is a combatant that can receive messages (player).
type CombatantWithConn interface {
Combatant
Connection() *network.Connection
}
// Manager handles all combat logic for a zone.
type Manager struct {
skills *SkillRegistry
buffs *BuffRegistry
// Per-entity state
cooldowns map[uint64]map[uint32]time.Time // entityID -> skillID -> ready time
activeBuffs map[uint64][]*ActiveBuff // entityID -> active buffs
// Broadcast function (set by zone to send to AOI)
broadcastToNearby func(ent entity.Entity, msgType uint16, msg interface{})
sendToEntity func(entityID uint64, msgType uint16, msg interface{})
}
// NewManager creates a combat manager.
func NewManager() *Manager {
return &Manager{
skills: NewSkillRegistry(),
buffs: NewBuffRegistry(),
cooldowns: make(map[uint64]map[uint32]time.Time),
activeBuffs: make(map[uint64][]*ActiveBuff),
}
}
// Skills returns the skill registry.
func (m *Manager) Skills() *SkillRegistry { return m.skills }
// SetBroadcast configures the broadcast callback.
func (m *Manager) SetBroadcast(
broadcast func(ent entity.Entity, msgType uint16, msg interface{}),
send func(entityID uint64, msgType uint16, msg interface{}),
) {
m.broadcastToNearby = broadcast
m.sendToEntity = send
}
// UseSkill attempts to execute a skill.
func (m *Manager) UseSkill(
caster Combatant,
skillID uint32,
targetID uint64,
targetPos mathutil.Vec3,
getEntity func(uint64) entity.Entity,
getEntitiesInRadius func(center mathutil.Vec3, radius float32) []entity.Entity,
) (bool, string) {
if !caster.IsAlive() {
return false, "you are dead"
}
skill := m.skills.Get(skillID)
if skill == nil {
return false, "unknown skill"
}
// Cooldown check.
if cd, ok := m.cooldowns[caster.EntityID()]; ok {
if readyAt, ok := cd[skillID]; ok && time.Now().Before(readyAt) {
return false, "skill on cooldown"
}
}
// Mana check.
if caster.MP() < skill.ManaCost {
return false, "not enough mana"
}
// Resolve targets.
var targets []Combatant
switch skill.TargetType {
case TargetSelf:
targets = []Combatant{caster}
case TargetSingleEnemy:
ent := getEntity(targetID)
if ent == nil {
return false, "target not found"
}
target, ok := ent.(Combatant)
if !ok || !target.IsAlive() {
return false, "invalid target"
}
if caster.Position().DistanceXZ(target.Position()) > skill.Range {
return false, "target out of range"
}
targets = []Combatant{target}
case TargetSingleAlly:
ent := getEntity(targetID)
if ent == nil {
return false, "target not found"
}
target, ok := ent.(Combatant)
if !ok || !target.IsAlive() {
return false, "invalid target"
}
if caster.Position().DistanceXZ(target.Position()) > skill.Range {
return false, "target out of range"
}
targets = []Combatant{target}
case TargetAoEGround:
if caster.Position().DistanceXZ(targetPos) > skill.Range {
return false, "target position out of range"
}
entities := getEntitiesInRadius(targetPos, skill.AoERadius)
for _, e := range entities {
if c, ok := e.(Combatant); ok && c.IsAlive() && c.EntityID() != caster.EntityID() {
targets = append(targets, c)
}
}
case TargetAoETarget:
ent := getEntity(targetID)
if ent == nil {
return false, "target not found"
}
if caster.Position().DistanceXZ(ent.Position()) > skill.Range {
return false, "target out of range"
}
entities := getEntitiesInRadius(ent.Position(), skill.AoERadius)
for _, e := range entities {
if c, ok := e.(Combatant); ok && c.IsAlive() && c.EntityID() != caster.EntityID() {
targets = append(targets, c)
}
}
}
// Consume mana.
caster.SetMP(caster.MP() - skill.ManaCost)
// Set cooldown.
if m.cooldowns[caster.EntityID()] == nil {
m.cooldowns[caster.EntityID()] = make(map[uint32]time.Time)
}
m.cooldowns[caster.EntityID()][skillID] = time.Now().Add(skill.Cooldown)
// Calculate effective stats (base + buff modifiers).
casterStats := m.effectiveStats(caster)
// Apply effects to each target.
for _, target := range targets {
m.applyEffects(caster, target, skill, casterStats)
}
return true, ""
}
func (m *Manager) applyEffects(caster, target Combatant, skill *SkillDef, casterStats CombatStats) {
for _, effect := range skill.Effects {
switch effect.Type {
case EffectDamage:
targetStats := m.effectiveStats(target)
result := CalcDamage(effect.Value, casterStats.Str, targetStats.Dex)
target.SetHP(target.HP() - result.FinalDamage)
died := !target.IsAlive()
evt := &pb.CombatEvent{
CasterId: caster.EntityID(),
TargetId: target.EntityID(),
SkillId: skill.ID,
Damage: result.FinalDamage,
IsCritical: result.IsCritical,
TargetDied: died,
TargetHp: target.HP(),
TargetMaxHp: target.MaxHP(),
EventType: pb.CombatEventType_COMBAT_EVENT_DAMAGE,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgCombatEvent, evt)
}
if died {
deathEvt := &pb.CombatEvent{
CasterId: caster.EntityID(),
TargetId: target.EntityID(),
EventType: pb.CombatEventType_COMBAT_EVENT_DEATH,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgCombatEvent, deathEvt)
}
}
case EffectHeal:
heal := CalcHeal(effect.Value, casterStats.Int)
target.SetHP(target.HP() + heal)
evt := &pb.CombatEvent{
CasterId: caster.EntityID(),
TargetId: target.EntityID(),
SkillId: skill.ID,
Heal: heal,
TargetHp: target.HP(),
TargetMaxHp: target.MaxHP(),
EventType: pb.CombatEventType_COMBAT_EVENT_HEAL,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgCombatEvent, evt)
}
case EffectBuff, EffectDebuff:
buffDef := m.buffs.Get(uint32(effect.Value))
if buffDef == nil {
continue
}
ab := &ActiveBuff{
Def: buffDef,
CasterID: caster.EntityID(),
Remaining: effect.Duration,
TickInterval: effect.TickInterval,
NextTick: effect.TickInterval,
}
m.activeBuffs[target.EntityID()] = append(m.activeBuffs[target.EntityID()], ab)
evt := &pb.BuffApplied{
TargetId: target.EntityID(),
BuffId: buffDef.ID,
BuffName: buffDef.Name,
Duration: float32(effect.Duration.Seconds()),
IsDebuff: buffDef.IsDebuff,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgBuffApplied, evt)
}
}
}
}
// UpdateBuffs processes active buffs each tick. Call once per zone tick.
func (m *Manager) UpdateBuffs(dt time.Duration, getEntity func(uint64) Combatant) {
for entityID, buffs := range m.activeBuffs {
target := getEntity(entityID)
if target == nil {
delete(m.activeBuffs, entityID)
continue
}
var remaining []*ActiveBuff
for _, b := range buffs {
tickValue := b.Tick(dt)
if tickValue != 0 && target.IsAlive() {
if b.Def.IsDebuff {
// DoT damage.
target.SetHP(target.HP() - tickValue)
evt := &pb.CombatEvent{
CasterId: b.CasterID,
TargetId: entityID,
Damage: tickValue,
TargetHp: target.HP(),
TargetMaxHp: target.MaxHP(),
EventType: pb.CombatEventType_COMBAT_EVENT_DAMAGE,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgCombatEvent, evt)
}
} else {
// HoT heal.
target.SetHP(target.HP() + tickValue)
evt := &pb.CombatEvent{
CasterId: b.CasterID,
TargetId: entityID,
Heal: tickValue,
TargetHp: target.HP(),
TargetMaxHp: target.MaxHP(),
EventType: pb.CombatEventType_COMBAT_EVENT_HEAL,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgCombatEvent, evt)
}
}
}
if !b.IsExpired() {
remaining = append(remaining, b)
} else {
// Notify buff removed.
evt := &pb.BuffRemoved{
TargetId: entityID,
BuffId: b.Def.ID,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgBuffRemoved, evt)
}
}
}
if len(remaining) == 0 {
delete(m.activeBuffs, entityID)
} else {
m.activeBuffs[entityID] = remaining
}
}
}
// Respawn resets a dead entity to full HP at a spawn position.
func (m *Manager) Respawn(ent Combatant, spawnPos mathutil.Vec3) {
ent.SetHP(ent.MaxHP())
ent.SetPosition(spawnPos)
m.clearCooldowns(ent.EntityID())
m.clearBuffs(ent.EntityID())
}
// RemoveEntity cleans up combat state for a removed entity.
func (m *Manager) RemoveEntity(entityID uint64) {
m.clearCooldowns(entityID)
m.clearBuffs(entityID)
}
// effectiveStats returns stats with buff modifiers applied.
func (m *Manager) effectiveStats(c Combatant) CombatStats {
base := c.Stats()
buffs := m.activeBuffs[c.EntityID()]
for _, b := range buffs {
base.Str += b.Def.StatModifier.StrBonus
base.Dex += b.Def.StatModifier.DexBonus
base.Int += b.Def.StatModifier.IntBonus
}
return base
}
func (m *Manager) clearCooldowns(entityID uint64) {
delete(m.cooldowns, entityID)
}
func (m *Manager) clearBuffs(entityID uint64) {
delete(m.activeBuffs, entityID)
}

View File

@@ -0,0 +1,239 @@
package combat
import (
"testing"
"time"
"a301_game_server/internal/entity"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
)
// mockCombatant implements Combatant for testing.
type mockCombatant struct {
id uint64
pos mathutil.Vec3
hp, maxHP int32
mp, maxMP int32
str, dex, intStat int32
}
func (m *mockCombatant) EntityID() uint64 { return m.id }
func (m *mockCombatant) EntityType() entity.Type { return entity.TypePlayer }
func (m *mockCombatant) Position() mathutil.Vec3 { return m.pos }
func (m *mockCombatant) SetPosition(p mathutil.Vec3) { m.pos = p }
func (m *mockCombatant) Rotation() float32 { return 0 }
func (m *mockCombatant) SetRotation(float32) {}
func (m *mockCombatant) ToProto() *pb.EntityState { return &pb.EntityState{EntityId: m.id} }
func (m *mockCombatant) HP() int32 { return m.hp }
func (m *mockCombatant) SetHP(hp int32) {
if hp < 0 { hp = 0 }
if hp > m.maxHP { hp = m.maxHP }
m.hp = hp
}
func (m *mockCombatant) MaxHP() int32 { return m.maxHP }
func (m *mockCombatant) MP() int32 { return m.mp }
func (m *mockCombatant) SetMP(mp int32) {
if mp < 0 { mp = 0 }
m.mp = mp
}
func (m *mockCombatant) IsAlive() bool { return m.hp > 0 }
func (m *mockCombatant) Stats() CombatStats {
return CombatStats{Str: m.str, Dex: m.dex, Int: m.intStat, Level: 1}
}
func newMock(id uint64, x, z float32) *mockCombatant {
return &mockCombatant{
id: id, pos: mathutil.NewVec3(x, 0, z),
hp: 100, maxHP: 100, mp: 100, maxMP: 100,
str: 10, dex: 10, intStat: 10,
}
}
func TestBasicAttack(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 2, 0) // within range (3.0)
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
ok, errMsg := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if !ok {
t.Fatalf("expected success, got error: %s", errMsg)
}
if target.HP() >= 100 {
t.Error("target should have taken damage")
}
}
func TestOutOfRange(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 100, 0) // far away
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
ok, _ := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if ok {
t.Error("expected failure due to range")
}
}
func TestCooldown(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 2, 0)
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
getEnt := func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
}
// First use should succeed.
ok, _ := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{}, getEnt, nil)
if !ok {
t.Fatal("first use should succeed")
}
// Immediate second use should fail (cooldown).
ok, errMsg := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{}, getEnt, nil)
if ok {
t.Error("expected cooldown failure")
}
if errMsg != "skill on cooldown" {
t.Errorf("expected 'skill on cooldown', got '%s'", errMsg)
}
}
func TestManaConsumption(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
caster := newMock(1, 0, 0)
target := newMock(2, 5, 0)
caster.mp = 10 // not enough for Fireball (20 mana)
entities := map[uint64]*mockCombatant{1: caster, 2: target}
ok, errMsg := mgr.UseSkill(caster, 2, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if ok {
t.Error("expected mana failure")
}
if errMsg != "not enough mana" {
t.Errorf("expected 'not enough mana', got '%s'", errMsg)
}
}
func TestHealSelf(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
caster := newMock(1, 0, 0)
caster.hp = 30
ok, _ := mgr.UseSkill(caster, 3, 0, mathutil.Vec3{},
func(id uint64) entity.Entity { return nil },
nil,
)
if !ok {
t.Fatal("heal should succeed")
}
if caster.HP() <= 30 {
t.Error("HP should have increased")
}
}
func TestPoisonDoT(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 5, 0)
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
// Apply poison (skill 5).
ok, _ := mgr.UseSkill(attacker, 5, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if !ok {
t.Fatal("poison should succeed")
}
initialHP := target.HP()
// Simulate ticks until first DoT tick (2s at 50ms/tick = 40 ticks).
for i := 0; i < 40; i++ {
mgr.UpdateBuffs(50*time.Millisecond, func(id uint64) Combatant {
if e, ok := entities[id]; ok { return e }
return nil
})
}
if target.HP() >= initialHP {
t.Error("poison DoT should have dealt damage")
}
}
func TestDamageFormula(t *testing.T) {
// Base 15, attacker STR 10 (1.1x), defender DEX 10 (0.95x)
// Expected ~15.675 (without crit).
result := CalcDamage(15, 10, 10)
if result.FinalDamage < 1 {
t.Error("damage should be at least 1")
}
}
func TestRespawn(t *testing.T) {
mgr := NewManager()
ent := newMock(1, 50, 50)
ent.hp = 0
spawn := mathutil.NewVec3(0, 0, 0)
mgr.Respawn(ent, spawn)
if !ent.IsAlive() {
t.Error("should be alive after respawn")
}
if ent.HP() != ent.MaxHP() {
t.Error("should have full HP after respawn")
}
if ent.Position() != spawn {
t.Error("should be at spawn position")
}
}

51
internal/combat/damage.go Normal file
View File

@@ -0,0 +1,51 @@
package combat
import (
"math/rand"
)
const (
critChance = 0.15 // 15% crit chance
critMultiplier = 1.5
)
// DamageResult holds the outcome of a damage calculation.
type DamageResult struct {
FinalDamage int32
IsCritical bool
}
// CalcDamage computes final damage from base damage and attacker/defender stats.
// Formula: base * (1 + attackerStr/100) * (1 - defenderDex/200)
// Then roll for crit.
func CalcDamage(baseDamage int32, attackerStr, defenderDex int32) DamageResult {
attack := float64(baseDamage) * (1.0 + float64(attackerStr)/100.0)
defense := 1.0 - float64(defenderDex)/200.0
if defense < 0.1 {
defense = 0.1 // minimum 10% damage
}
dmg := attack * defense
isCrit := rand.Float64() < critChance
if isCrit {
dmg *= critMultiplier
}
final := int32(dmg)
if final < 1 {
final = 1
}
return DamageResult{FinalDamage: final, IsCritical: isCrit}
}
// CalcHeal computes final healing.
// Formula: base * (1 + casterInt/100)
func CalcHeal(baseHeal int32, casterInt int32) int32 {
heal := float64(baseHeal) * (1.0 + float64(casterInt)/100.0)
result := int32(heal)
if result < 1 {
result = 1
}
return result
}

148
internal/combat/skill.go Normal file
View File

@@ -0,0 +1,148 @@
package combat
import "time"
// TargetType determines how a skill selects its target.
type TargetType int
const (
TargetSelf TargetType = iota
TargetSingleEnemy // requires a valid enemy entity ID
TargetSingleAlly // requires a valid ally entity ID
TargetAoEGround // requires a ground position
TargetAoETarget // AoE centered on target entity
)
// EffectType determines what a skill effect does.
type EffectType int
const (
EffectDamage EffectType = iota
EffectHeal
EffectBuff
EffectDebuff
)
// Effect is a single outcome of a skill.
type Effect struct {
Type EffectType
Value int32 // damage amount, heal amount, or buff/debuff ID
Duration time.Duration // 0 for instant effects
TickInterval time.Duration // for DoT/HoT; 0 means apply once
}
// SkillDef defines a skill's properties (loaded from data).
type SkillDef struct {
ID uint32
Name string
Cooldown time.Duration
ManaCost int32
Range float32 // max distance to target (0 = self only)
TargetType TargetType
AoERadius float32 // for AoE skills
CastTime time.Duration
Effects []Effect
}
// SkillRegistry holds all skill definitions.
type SkillRegistry struct {
skills map[uint32]*SkillDef
}
// NewSkillRegistry creates a registry with default skills.
func NewSkillRegistry() *SkillRegistry {
r := &SkillRegistry{skills: make(map[uint32]*SkillDef)}
r.registerDefaults()
return r
}
// Get returns a skill definition by ID.
func (r *SkillRegistry) Get(id uint32) *SkillDef {
return r.skills[id]
}
// Register adds a skill definition.
func (r *SkillRegistry) Register(s *SkillDef) {
r.skills[s.ID] = s
}
func (r *SkillRegistry) registerDefaults() {
// Basic attack
r.Register(&SkillDef{
ID: 1,
Name: "Basic Attack",
Cooldown: 1 * time.Second,
ManaCost: 0,
Range: 3.0,
TargetType: TargetSingleEnemy,
Effects: []Effect{
{Type: EffectDamage, Value: 15},
},
})
// Fireball - ranged damage
r.Register(&SkillDef{
ID: 2,
Name: "Fireball",
Cooldown: 3 * time.Second,
ManaCost: 20,
Range: 15.0,
TargetType: TargetSingleEnemy,
Effects: []Effect{
{Type: EffectDamage, Value: 40},
},
})
// Heal
r.Register(&SkillDef{
ID: 3,
Name: "Heal",
Cooldown: 5 * time.Second,
ManaCost: 30,
Range: 0,
TargetType: TargetSelf,
Effects: []Effect{
{Type: EffectHeal, Value: 50},
},
})
// AoE - Flame Strike
r.Register(&SkillDef{
ID: 4,
Name: "Flame Strike",
Cooldown: 8 * time.Second,
ManaCost: 40,
Range: 12.0,
TargetType: TargetAoEGround,
AoERadius: 5.0,
Effects: []Effect{
{Type: EffectDamage, Value: 30},
},
})
// Poison (DoT debuff)
r.Register(&SkillDef{
ID: 5,
Name: "Poison",
Cooldown: 6 * time.Second,
ManaCost: 15,
Range: 8.0,
TargetType: TargetSingleEnemy,
Effects: []Effect{
{Type: EffectDebuff, Value: 1, Duration: 10 * time.Second, TickInterval: 2 * time.Second},
},
})
// Power Buff (self buff - increases damage)
r.Register(&SkillDef{
ID: 6,
Name: "Power Up",
Cooldown: 15 * time.Second,
ManaCost: 25,
Range: 0,
TargetType: TargetSelf,
Effects: []Effect{
{Type: EffectBuff, Value: 2, Duration: 10 * time.Second},
},
})
}

47
internal/db/migrations.go Normal file
View 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
View 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
}

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

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

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

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

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

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

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