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

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
}