first commit
This commit is contained in:
70
internal/world/aoi.go
Normal file
70
internal/world/aoi.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
)
|
||||
|
||||
// AOIEvent represents an entity entering or leaving another entity's area of interest.
|
||||
type AOIEvent struct {
|
||||
Observer entity.Entity
|
||||
Target entity.Entity
|
||||
Type AOIEventType
|
||||
}
|
||||
|
||||
type AOIEventType int
|
||||
|
||||
const (
|
||||
AOIEnter AOIEventType = iota
|
||||
AOILeave
|
||||
)
|
||||
|
||||
// AOIManager determines which entities can see each other.
|
||||
type AOIManager interface {
|
||||
Add(ent entity.Entity)
|
||||
Remove(ent entity.Entity) []AOIEvent
|
||||
UpdatePosition(ent entity.Entity, oldPos, newPos mathutil.Vec3) []AOIEvent
|
||||
GetNearby(ent entity.Entity) []entity.Entity
|
||||
}
|
||||
|
||||
// BroadcastAllAOI is a trivial AOI that treats all entities as visible to each other.
|
||||
// Used when AOI is disabled for debugging/comparison.
|
||||
type BroadcastAllAOI struct {
|
||||
entities map[uint64]entity.Entity
|
||||
}
|
||||
|
||||
func NewBroadcastAllAOI() *BroadcastAllAOI {
|
||||
return &BroadcastAllAOI{
|
||||
entities: make(map[uint64]entity.Entity),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BroadcastAllAOI) Add(ent entity.Entity) {
|
||||
b.entities[ent.EntityID()] = ent
|
||||
}
|
||||
|
||||
func (b *BroadcastAllAOI) Remove(ent entity.Entity) []AOIEvent {
|
||||
delete(b.entities, ent.EntityID())
|
||||
var events []AOIEvent
|
||||
for _, other := range b.entities {
|
||||
if other.EntityID() == ent.EntityID() {
|
||||
continue
|
||||
}
|
||||
events = append(events, AOIEvent{Observer: other, Target: ent, Type: AOILeave})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func (b *BroadcastAllAOI) UpdatePosition(_ entity.Entity, _, _ mathutil.Vec3) []AOIEvent {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BroadcastAllAOI) GetNearby(ent entity.Entity) []entity.Entity {
|
||||
result := make([]entity.Entity, 0, len(b.entities)-1)
|
||||
for _, e := range b.entities {
|
||||
if e.EntityID() != ent.EntityID() {
|
||||
result = append(result, e)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
150
internal/world/aoi_test.go
Normal file
150
internal/world/aoi_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
pb "a301_game_server/proto/gen/pb"
|
||||
)
|
||||
|
||||
// mockEntity is a minimal entity for testing.
|
||||
type mockEntity struct {
|
||||
id uint64
|
||||
pos mathutil.Vec3
|
||||
}
|
||||
|
||||
func (m *mockEntity) EntityID() uint64 { return m.id }
|
||||
func (m *mockEntity) EntityType() entity.Type { return entity.TypePlayer }
|
||||
func (m *mockEntity) Position() mathutil.Vec3 { return m.pos }
|
||||
func (m *mockEntity) SetPosition(p mathutil.Vec3) { m.pos = p }
|
||||
func (m *mockEntity) Rotation() float32 { return 0 }
|
||||
func (m *mockEntity) SetRotation(float32) {}
|
||||
func (m *mockEntity) ToProto() *pb.EntityState { return &pb.EntityState{EntityId: m.id} }
|
||||
|
||||
func TestBroadcastAllAOI_GetNearby(t *testing.T) {
|
||||
aoi := NewBroadcastAllAOI()
|
||||
|
||||
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
|
||||
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(100, 0, 100)}
|
||||
e3 := &mockEntity{id: 3, pos: mathutil.NewVec3(999, 0, 999)}
|
||||
|
||||
aoi.Add(e1)
|
||||
aoi.Add(e2)
|
||||
aoi.Add(e3)
|
||||
|
||||
// With broadcast-all, everyone sees everyone.
|
||||
nearby := aoi.GetNearby(e1)
|
||||
if len(nearby) != 2 {
|
||||
t.Errorf("expected 2 nearby, got %d", len(nearby))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastAllAOI_Remove(t *testing.T) {
|
||||
aoi := NewBroadcastAllAOI()
|
||||
|
||||
e1 := &mockEntity{id: 1}
|
||||
e2 := &mockEntity{id: 2}
|
||||
aoi.Add(e1)
|
||||
aoi.Add(e2)
|
||||
|
||||
events := aoi.Remove(e1)
|
||||
if len(events) != 1 {
|
||||
t.Errorf("expected 1 leave event, got %d", len(events))
|
||||
}
|
||||
if events[0].Type != AOILeave {
|
||||
t.Errorf("expected AOILeave event")
|
||||
}
|
||||
|
||||
nearby := aoi.GetNearby(e2)
|
||||
if len(nearby) != 0 {
|
||||
t.Errorf("expected 0 nearby after removal, got %d", len(nearby))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridAOI_NearbyInSameCell(t *testing.T) {
|
||||
aoi := NewGridAOI(50, 2)
|
||||
|
||||
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(10, 0, 10)}
|
||||
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(20, 0, 20)}
|
||||
|
||||
aoi.Add(e1)
|
||||
aoi.Add(e2)
|
||||
|
||||
nearby := aoi.GetNearby(e1)
|
||||
if len(nearby) != 1 {
|
||||
t.Errorf("expected 1 nearby, got %d", len(nearby))
|
||||
}
|
||||
if nearby[0].EntityID() != 2 {
|
||||
t.Errorf("expected entity 2, got %d", nearby[0].EntityID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridAOI_FarAwayNotVisible(t *testing.T) {
|
||||
aoi := NewGridAOI(50, 1) // viewRange=1 means 3x3 grid = 150 units visibility
|
||||
|
||||
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
|
||||
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(500, 0, 500)} // far away
|
||||
|
||||
aoi.Add(e1)
|
||||
aoi.Add(e2)
|
||||
|
||||
nearby := aoi.GetNearby(e1)
|
||||
if len(nearby) != 0 {
|
||||
t.Errorf("expected 0 nearby for far entity, got %d", len(nearby))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridAOI_MoveGeneratesEvents(t *testing.T) {
|
||||
aoi := NewGridAOI(50, 1)
|
||||
|
||||
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
|
||||
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(200, 0, 200)}
|
||||
|
||||
aoi.Add(e1)
|
||||
aoi.Add(e2)
|
||||
|
||||
// Initially not visible to each other.
|
||||
nearby := aoi.GetNearby(e1)
|
||||
if len(nearby) != 0 {
|
||||
t.Fatalf("expected not visible initially, got %d", len(nearby))
|
||||
}
|
||||
|
||||
// Move e2 close to e1.
|
||||
oldPos := e2.pos
|
||||
e2.pos = mathutil.NewVec3(10, 0, 10)
|
||||
events := aoi.UpdatePosition(e2, oldPos, e2.pos)
|
||||
|
||||
// Should generate enter events (e1 sees e2, e2 sees e1).
|
||||
enterCount := 0
|
||||
for _, evt := range events {
|
||||
if evt.Type == AOIEnter {
|
||||
enterCount++
|
||||
}
|
||||
}
|
||||
if enterCount != 2 {
|
||||
t.Errorf("expected 2 enter events, got %d", enterCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridAOI_ToggleComparison(t *testing.T) {
|
||||
// Demonstrates the difference between BroadcastAll and Grid AOI.
|
||||
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
|
||||
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(500, 0, 500)}
|
||||
|
||||
// BroadcastAll: both visible
|
||||
broadcast := NewBroadcastAllAOI()
|
||||
broadcast.Add(e1)
|
||||
broadcast.Add(e2)
|
||||
if len(broadcast.GetNearby(e1)) != 1 {
|
||||
t.Error("broadcast-all should see all entities")
|
||||
}
|
||||
|
||||
// Grid: e2 not visible from e1 (too far)
|
||||
grid := NewGridAOI(50, 1)
|
||||
grid.Add(e1)
|
||||
grid.Add(e2)
|
||||
if len(grid.GetNearby(e1)) != 0 {
|
||||
t.Error("grid AOI should NOT see distant entities")
|
||||
}
|
||||
}
|
||||
179
internal/world/spatial_grid.go
Normal file
179
internal/world/spatial_grid.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"a301_game_server/internal/entity"
|
||||
"a301_game_server/pkg/mathutil"
|
||||
)
|
||||
|
||||
// cellKey uniquely identifies a grid cell.
|
||||
type cellKey struct {
|
||||
cx, cz int
|
||||
}
|
||||
|
||||
// GridAOI implements AOI using a spatial grid. Entities in nearby cells are considered visible.
|
||||
type GridAOI struct {
|
||||
cellSize float32
|
||||
viewRange int
|
||||
cells map[cellKey]map[uint64]entity.Entity
|
||||
entityCell map[uint64]cellKey
|
||||
}
|
||||
|
||||
func NewGridAOI(cellSize float32, viewRange int) *GridAOI {
|
||||
return &GridAOI{
|
||||
cellSize: cellSize,
|
||||
viewRange: viewRange,
|
||||
cells: make(map[cellKey]map[uint64]entity.Entity),
|
||||
entityCell: make(map[uint64]cellKey),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GridAOI) posToCell(pos mathutil.Vec3) cellKey {
|
||||
cx := int(pos.X / g.cellSize)
|
||||
cz := int(pos.Z / g.cellSize)
|
||||
if pos.X < 0 {
|
||||
cx--
|
||||
}
|
||||
if pos.Z < 0 {
|
||||
cz--
|
||||
}
|
||||
return cellKey{cx, cz}
|
||||
}
|
||||
|
||||
func (g *GridAOI) Add(ent entity.Entity) {
|
||||
cell := g.posToCell(ent.Position())
|
||||
g.addToCell(cell, ent)
|
||||
g.entityCell[ent.EntityID()] = cell
|
||||
}
|
||||
|
||||
func (g *GridAOI) Remove(ent entity.Entity) []AOIEvent {
|
||||
eid := ent.EntityID()
|
||||
cell, ok := g.entityCell[eid]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
nearby := g.getNearbyFromCell(cell, eid)
|
||||
events := make([]AOIEvent, 0, len(nearby))
|
||||
for _, observer := range nearby {
|
||||
events = append(events, AOIEvent{Observer: observer, Target: ent, Type: AOILeave})
|
||||
}
|
||||
|
||||
g.removeFromCell(cell, eid)
|
||||
delete(g.entityCell, eid)
|
||||
return events
|
||||
}
|
||||
|
||||
func (g *GridAOI) UpdatePosition(ent entity.Entity, oldPos, newPos mathutil.Vec3) []AOIEvent {
|
||||
eid := ent.EntityID()
|
||||
oldCell := g.posToCell(oldPos)
|
||||
newCell := g.posToCell(newPos)
|
||||
|
||||
if oldCell == newCell {
|
||||
return nil
|
||||
}
|
||||
|
||||
oldVisible := g.visibleCells(oldCell)
|
||||
newVisible := g.visibleCells(newCell)
|
||||
|
||||
leaving := cellDifference(oldVisible, newVisible)
|
||||
entering := cellDifference(newVisible, oldVisible)
|
||||
|
||||
var events []AOIEvent
|
||||
|
||||
for _, c := range leaving {
|
||||
if cellEntities, ok := g.cells[c]; ok {
|
||||
for _, other := range cellEntities {
|
||||
if other.EntityID() == eid {
|
||||
continue
|
||||
}
|
||||
events = append(events,
|
||||
AOIEvent{Observer: other, Target: ent, Type: AOILeave},
|
||||
AOIEvent{Observer: ent, Target: other, Type: AOILeave},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range entering {
|
||||
if cellEntities, ok := g.cells[c]; ok {
|
||||
for _, other := range cellEntities {
|
||||
if other.EntityID() == eid {
|
||||
continue
|
||||
}
|
||||
events = append(events,
|
||||
AOIEvent{Observer: other, Target: ent, Type: AOIEnter},
|
||||
AOIEvent{Observer: ent, Target: other, Type: AOIEnter},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g.removeFromCell(oldCell, eid)
|
||||
g.addToCell(newCell, ent)
|
||||
g.entityCell[eid] = newCell
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
func (g *GridAOI) GetNearby(ent entity.Entity) []entity.Entity {
|
||||
cell, ok := g.entityCell[ent.EntityID()]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return g.getNearbyFromCell(cell, ent.EntityID())
|
||||
}
|
||||
|
||||
func (g *GridAOI) getNearbyFromCell(cell cellKey, excludeID uint64) []entity.Entity {
|
||||
var result []entity.Entity
|
||||
for _, c := range g.visibleCells(cell) {
|
||||
if cellEntities, ok := g.cells[c]; ok {
|
||||
for _, e := range cellEntities {
|
||||
if e.EntityID() != excludeID {
|
||||
result = append(result, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (g *GridAOI) visibleCells(center cellKey) []cellKey {
|
||||
size := (2*g.viewRange + 1) * (2*g.viewRange + 1)
|
||||
cells := make([]cellKey, 0, size)
|
||||
for dx := -g.viewRange; dx <= g.viewRange; dx++ {
|
||||
for dz := -g.viewRange; dz <= g.viewRange; dz++ {
|
||||
cells = append(cells, cellKey{center.cx + dx, center.cz + dz})
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
func (g *GridAOI) addToCell(cell cellKey, ent entity.Entity) {
|
||||
if g.cells[cell] == nil {
|
||||
g.cells[cell] = make(map[uint64]entity.Entity)
|
||||
}
|
||||
g.cells[cell][ent.EntityID()] = ent
|
||||
}
|
||||
|
||||
func (g *GridAOI) removeFromCell(cell cellKey, eid uint64) {
|
||||
if m, ok := g.cells[cell]; ok {
|
||||
delete(m, eid)
|
||||
if len(m) == 0 {
|
||||
delete(g.cells, cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cellDifference(a, b []cellKey) []cellKey {
|
||||
set := make(map[cellKey]struct{}, len(b))
|
||||
for _, c := range b {
|
||||
set[c] = struct{}{}
|
||||
}
|
||||
var diff []cellKey
|
||||
for _, c := range a {
|
||||
if _, ok := set[c]; !ok {
|
||||
diff = append(diff, c)
|
||||
}
|
||||
}
|
||||
return diff
|
||||
}
|
||||
20
internal/world/zone_transfer.go
Normal file
20
internal/world/zone_transfer.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package world
|
||||
|
||||
import "a301_game_server/pkg/mathutil"
|
||||
|
||||
// ZonePortal defines a connection between two zones.
|
||||
type ZonePortal struct {
|
||||
// Trigger area in source zone.
|
||||
SourceZoneID uint32
|
||||
TriggerPos mathutil.Vec3
|
||||
TriggerRadius float32
|
||||
|
||||
// Destination in target zone.
|
||||
TargetZoneID uint32
|
||||
TargetPos mathutil.Vec3
|
||||
}
|
||||
|
||||
// IsInRange returns true if the given position is within the portal's trigger area.
|
||||
func (p *ZonePortal) IsInRange(pos mathutil.Vec3) bool {
|
||||
return pos.DistanceXZ(p.TriggerPos) <= p.TriggerRadius
|
||||
}
|
||||
Reference in New Issue
Block a user