概念
Area of Interest,感兴趣区域,决定"谁需要看到谁的哪些消息"。服务器不把所有消息广播给所有人,而是按 AOI 算出"视野关系",只把相关实体的状态同步给相关玩家。
常见类型

mmo游戏使用的aoi:以"人"为中心,高频、强实时
典型场景:WOW、原神、MMORPG、吃鸡------玩家是一个单位,走路是连续的,视角跟着人走。
核心问题:几千人同图,怎么让每个人只收自己周围的数据,且移动不能卡。
特点
- 以 Player 为单位建 AOI:每个客户端关心"我周围 N 米内"的东西。
- 移动高频:每秒几次坐标更新,AOI 进出事件触发很频繁。
- 视野对称:A 能看到 B ≈ B 能看到 A(双向)。
- 同步对象多而杂:玩家、怪、NPC、掉落、技能特效
常见做法
-
九宫格为主流:把地图切成固定大小格子(比如 10m×10m),玩家移动跨格时,算增量 ------ 出旧格、入新格,触发 on_enter/ on_leave。
-
灯塔模式(Tower):每个格子是个"灯塔",维护"订阅这个格子的玩家"和"这个格子里的实体",双向索引,发消息时按灯塔广播。
-
二次过滤:九宫格粗筛后,再按真实距离(或扇形/扇形+Z 轴)裁一次,避免对角格子其实够不到。
代码
python
"""
MMO AOI (Area of Interest) 兴趣区域算法实现
基于九宫格(3x3网格)的AOI系统
核心思想:
将整个游戏地图划分为固定大小的格子,每个实体的视野范围是
其所在格子以及周围8个格子组成的九宫格。当实体移动时,
只需要检查是否跨越了格子边界,从而高效地更新视野内的实体。
优点:
1. 时间复杂度低:实体移动时只需检查跨格子的情况
2. 实现简单:逻辑清晰,易于理解和维护
3. 性能稳定:不受实体数量影响,适合大规模MMO场景
"""
from typing import Dict, List, Set, Callable, Optional
import uuid
class Entity:
"""
游戏实体类
代表游戏中的玩家、NPC、怪物等需要同步的对象
"""
def __init__(self, entity_id: Optional[str] = None, x: float = 0, y: float = 0):
"""
初始化实体
Args:
entity_id: 实体唯一标识,不传则自动生成
x: 初始x坐标
y: 初始y坐标
"""
# 实体唯一ID
self.id = entity_id if entity_id else str(uuid.uuid4())
# 实体在世界坐标系中的位置
self.x = x
self.y = y
# 实体所在的格子坐标(整数格子索引)
self.grid_x = 0
self.grid_y = 0
# 视野集合:存储当前能看到的所有实体ID
# 使用集合是为了O(1)的查找和删除性能
self.visible_entities: Set[str] = set()
def __repr__(self) -> str:
return f"Entity(id={self.id[:8]}..., pos=({self.x:.1f}, {self.y:.1f}), grid=({self.grid_x}, {self.grid_y}))"
class Grid:
"""
格子类
代表地图中的一个格子,存储该格子内的所有实体
"""
def __init__(self, grid_x: int, grid_y: int):
"""
初始化格子
Args:
grid_x: 格子的x索引
grid_y: 格子的y索引
"""
# 格子坐标
self.grid_x = grid_x
self.grid_y = grid_y
# 该格子中的实体集合(使用字典便于快速查找和删除)
self.entities: Dict[str, Entity] = {}
def add_entity(self, entity: Entity) -> None:
"""向格子中添加实体"""
self.entities[entity.id] = entity
def remove_entity(self, entity_id: str) -> Optional[Entity]:
"""从格子中移除实体,返回被移除的实体"""
return self.entities.pop(entity_id, None)
def get_entity_count(self) -> int:
"""获取格子中的实体数量"""
return len(self.entities)
def __repr__(self) -> str:
return f"Grid({self.grid_x}, {self.grid_y})[{len(self.entities)} entities]"
class AOIManager:
"""
AOI管理器
负责管理整个AOI系统,包括格子创建、实体移动、视野更新等
"""
def __init__(self, grid_size: float = 100.0,
on_enter_view: Optional[Callable[[Entity, Entity], None]] = None,
on_leave_view: Optional[Callable[[Entity, Entity], None]] = None):
"""
初始化AOI管理器
Args:
grid_size: 格子大小(像素/单位),决定了AOI的精度和性能
格子越小,精度越高,但格子数量越多
格子越大,性能越好,但视野边界越不精确
on_enter_view: 实体进入视野的回调函数 (watcher, target)
on_leave_view: 实体离开视野的回调函数 (watcher, target)
"""
# 格子大小
self.grid_size = grid_size
# 所有格子的字典,key为"grid_x,grid_y"格式的字符串
self.grids: Dict[str, Grid] = {}
# 所有实体的字典,便于快速查找
self.entities: Dict[str, Entity] = {}
# 视野事件回调
self.on_enter_view = on_enter_view
self.on_leave_view = on_leave_view
def _get_grid_key(self, grid_x: int, grid_y: int) -> str:
"""生成格子的唯一键名"""
return f"{grid_x},{grid_y}"
def _get_or_create_grid(self, grid_x: int, grid_y: int) -> Grid:
"""
获取或创建格子
关键步骤:懒加载方式,只有当实体进入某个格子时才创建该格子
这样可以节省内存,特别是地图很大但实体分布稀疏的情况
"""
key = self._get_grid_key(grid_x, grid_y)
if key not in self.grids:
self.grids[key] = Grid(grid_x, grid_y)
return self.grids[key]
def _get_grid_by_pos(self, x: float, y: float) -> tuple:
"""
根据世界坐标计算所在的格子坐标
关键步骤:使用 math.floor 而不是 int(x // grid_size)
原因:int() 是向零取整,math.floor() 是向下取整
举例(格子大小=100):
x=50 → int(50//100)=0, floor(50/100)=0 ✅ 相同
x=100 → int(100//100)=1, floor(100/100)=1 ✅ 相同
x=0 → int(0//100)=0, floor(0/100)=0 ✅ 相同
x=-50 → int(-50//100)=-1, floor(-50/100)=floor(-0.5)=-1 ✅ 相同
x=-100 → int(-100//100)=-1, floor(-100/100)=floor(-1)=-1 ✅ 相同
x=-101 → int(-101//100)=-2, floor(-101/100)=floor(-1.01)=-2 ✅ 相同
x=-200 → int(-200//100)=-2, floor(-200/100)=floor(-2)=-2 ✅ 相同
格子边界定义:[n*size, (n+1)*size)
- 格子0: [-100, 0) 或 [0, 100)
- 格子-1: [-200, -100) 或 [-100, 0)
注意:格子坐标可以是负数,地图不一定从(0,0)开始!
"""
import math
grid_x = math.floor(x / self.grid_size)
grid_y = math.floor(y / self.grid_size)
return grid_x, grid_y
def _get_surrounding_grids(self, grid_x: int, grid_y: int) -> List[Grid]:
"""
获取以指定格子为中心的九宫格(3x3)内的所有格子
关键步骤:这是AOI的核心 - 每个实体的视野范围就是其所在格子的九宫格
遍历中心格子周围8个方向,共9个格子
"""
grids = []
# 遍历x方向:-1, 0, 1(左、中、右)
for dx in [-1, 0, 1]:
# 遍历y方向:-1, 0, 1(下、中、上)
for dy in [-1, 0, 1]:
gx = grid_x + dx
gy = grid_y + dy
key = self._get_grid_key(gx, gy)
# 只返回已存在的格子(有实体的格子),不存在的跳过
if key in self.grids:
grids.append(self.grids[key])
return grids
def add_entity(self, entity: Entity) -> None:
"""
向AOI系统中添加实体
关键步骤:
1. 计算实体所在格子
2. 将实体加入格子
3. 计算初始视野(九宫格内的所有实体)
4. 触发进入视野事件
"""
if entity.id in self.entities:
return # 实体已存在,避免重复添加
# 步骤1:计算实体所在的格子坐标
grid_x, grid_y = self._get_grid_by_pos(entity.x, entity.y)
entity.grid_x = grid_x
entity.grid_y = grid_y
# 步骤2:将实体加入对应格子
grid = self._get_or_create_grid(grid_x, grid_y)
grid.add_entity(entity)
# 步骤3:将实体加入全局字典
self.entities[entity.id] = entity
# 步骤4:计算初始视野 - 获取九宫格内所有实体
surrounding_grids = self._get_surrounding_grids(grid_x, grid_y)
for g in surrounding_grids:
for other_id, other_entity in g.entities.items():
if other_id == entity.id:
continue # 跳过自己
# 互相添加到对方的视野中
entity.visible_entities.add(other_id)
other_entity.visible_entities.add(entity.id)
# 触发进入视野事件
if self.on_enter_view:
self.on_enter_view(entity, other_entity)
self.on_enter_view(other_entity, entity)
def remove_entity(self, entity_id: str) -> Optional[Entity]:
"""
从AOI系统中移除实体
关键步骤:
1. 从所在格子移除
2. 通知所有能看到它的实体更新视野
3. 触发离开视野事件
"""
if entity_id not in self.entities:
return None
entity = self.entities[entity_id]
# 步骤1:获取当前九宫格内的所有实体(需要通知的对象)
surrounding_grids = self._get_surrounding_grids(entity.grid_x, entity.grid_y)
# 步骤2:从所在格子中移除
key = self._get_grid_key(entity.grid_x, entity.grid_y)
if key in self.grids:
self.grids[key].remove_entity(entity_id)
# 如果格子变空了,可以选择删除以节省内存(可选)
# if self.grids[key].get_entity_count() == 0:
# del self.grids[key]
# 步骤3:从全局字典移除
del self.entities[entity_id]
# 步骤4:通知视野内的其他实体,该实体离开了它们的视野
for g in surrounding_grids:
for other_id, other_entity in g.entities.items():
if other_id == entity_id:
continue
# 从对方的视野集合中移除
if entity_id in other_entity.visible_entities:
other_entity.visible_entities.discard(entity_id)
# 触发离开视野事件
if self.on_leave_view:
self.on_leave_view(other_entity, entity)
# 步骤5:清空被移除实体的视野集合
entity.visible_entities.clear()
return entity
def move_entity(self, entity_id: str, new_x: float, new_y: float) -> None:
"""
移动实体到新位置,更新AOI视野
关键步骤:
1. 计算新位置所在的格子
2. 如果格子没变,只更新坐标,不需要做AOI计算(性能优化点)
3. 如果格子变了,计算旧九宫格和新九宫格的差异
4. 处理离开的实体(旧视野有但新视野没有)
5. 处理进入的实体(新视野有但旧视野没有)
"""
if entity_id not in self.entities:
return
entity = self.entities[entity_id]
# 步骤1:计算新位置的格子坐标
new_grid_x, new_grid_y = self._get_grid_by_pos(new_x, new_y)
# 步骤2:更新实体的世界坐标(即使格子没变也要更新坐标)
entity.x = new_x
entity.y = new_y
# 步骤3:检查是否跨格子了
if new_grid_x == entity.grid_x and new_grid_y == entity.grid_y:
# 格子没变,无需进行AOI计算,直接返回
# 这是九宫格AOI的重要性能优化:大部分移动都不会跨格子
return
# ===================== 以下是跨格子的处理逻辑 =====================
# 步骤4:获取旧九宫格和新九宫格中的所有实体
old_surrounding_grids = self._get_surrounding_grids(entity.grid_x, entity.grid_y)
new_surrounding_grids = self._get_surrounding_grids(new_grid_x, new_grid_y)
# 构建旧视野和新视野的实体集合(便于集合运算)
old_visible: Set[str] = set()
for g in old_surrounding_grids:
old_visible.update(g.entities.keys())
old_visible.discard(entity.id) # 移除自己
new_visible: Set[str] = set()
for g in new_surrounding_grids:
new_visible.update(g.entities.keys())
new_visible.discard(entity.id) # 移除自己
# 步骤5:计算离开视野的实体(旧的有,新的没有)
left_view = old_visible - new_visible
for left_id in left_view:
if left_id in self.entities:
left_entity = self.entities[left_id]
# 互相从对方视野中移除
entity.visible_entities.discard(left_id)
left_entity.visible_entities.discard(entity.id)
# 触发离开视野事件
if self.on_leave_view:
self.on_leave_view(entity, left_entity)
self.on_leave_view(left_entity, entity)
# 步骤6:计算进入视野的实体(新的有,旧的没有)
entered_view = new_visible - old_visible
for entered_id in entered_view:
if entered_id in self.entities:
entered_entity = self.entities[entered_id]
# 互相添加到对方视野中
entity.visible_entities.add(entered_id)
entered_entity.visible_entities.add(entity.id)
# 触发进入视野事件
if self.on_enter_view:
self.on_enter_view(entity, entered_entity)
self.on_enter_view(entered_entity, entity)
# 步骤7:将实体从旧格子移除,加入新格子
old_key = self._get_grid_key(entity.grid_x, entity.grid_y)
if old_key in self.grids:
self.grids[old_key].remove_entity(entity_id)
new_grid = self._get_or_create_grid(new_grid_x, new_grid_y)
new_grid.add_entity(entity)
# 步骤8:更新实体的格子坐标
entity.grid_x = new_grid_x
entity.grid_y = new_grid_y
def get_visible_entities(self, entity_id: str) -> List[Entity]:
"""获取指定实体视野内的所有实体列表"""
if entity_id not in self.entities:
return []
entity = self.entities[entity_id]
return [self.entities[eid] for eid in entity.visible_entities
if eid in self.entities]
def get_entity_by_id(self, entity_id: str) -> Optional[Entity]:
"""根据ID获取实体"""
return self.entities.get(entity_id)
def get_all_entity_count(self) -> int:
"""获取AOI系统中实体总数"""
return len(self.entities)
def get_grid_count(self) -> int:
"""获取当前活跃的格子数量"""
return len(self.grids)
def debug_print_status(self) -> None:
"""打印调试信息,用于排查问题"""
print(f"=== AOI 状态 ===")
print(f"实体总数: {self.get_all_entity_count()}")
print(f"活跃格子数: {self.get_grid_count()}")
print(f"格子大小: {self.grid_size}")
print()
for key, grid in sorted(self.grids.items()):
print(f" 格子 {key}: {grid.get_entity_count()} 个实体")
for eid, entity in grid.entities.items():
visible_count = len(entity.visible_entities)
print(f" - {entity.id[:8]}... 位置({entity.x:.1f},{entity.y:.1f}) 视野内:{visible_count}个")
print("=================")
# ==================== 测试代码 ====================
def test_aoi():
"""
测试AOI算法
测试场景:
1. 添加多个实体
2. 验证初始视野是否正确
3. 移动实体跨格子,验证视野更新
4. 移除实体,验证视野清理
"""
# 进入视野回调
def on_enter(watcher: Entity, target: Entity):
print(f"[进入视野] {watcher.id[:6]}... 看到了 {target.id[:6]}...")
# 离开视野回调
def on_leave(watcher: Entity, target: Entity):
print(f"[离开视野] {watcher.id[:6]}... 失去了 {target.id[:6]}...")
print("=" * 50)
print("AOI算法测试开始")
print("=" * 50)
# 初始化AOI管理器,格子大小为100
aoi = AOIManager(grid_size=100.0,
on_enter_view=on_enter,
on_leave_view=on_leave)
# --- 测试1:添加实体 ---
print("\n--- 测试1:添加实体 ---")
player1 = Entity("player1", x=50, y=50) # 格子(0, 0)
player2 = Entity("player2", x=150, y=50) # 格子(1, 0)
player3 = Entity("player3", x=250, y=50) # 格子(2, 0) - 离player1远
npc1 = Entity("npc1", x=50, y=150) # 格子(0, 1)
aoi.add_entity(player1)
aoi.add_entity(player2)
aoi.add_entity(player3)
aoi.add_entity(npc1)
# 验证视野
print(f"\nplayer1的视野实体数: {len(player1.visible_entities)}")
print(f"player1能看到: {[e[:8] for e in player1.visible_entities]}")
# player1在(0,0),九宫格范围(-1~1, -1~1)
# 能看到player2(1,0)和npc1(0,1),看不到player3(2,0)
print(f"player3的视野实体数: {len(player3.visible_entities)}")
print(f"player3能看到: {[e[:8] for e in player3.visible_entities]}")
# player3在(2,0),只能看到player2(1,0)
# --- 测试2:移动实体跨格子 ---
print("\n--- 测试2:移动player1向右 ---")
# player1从(50,50)移动到(250,50),经过格子(0,0)->(1,0)->(2,0)
# 分步移动,观察变化
aoi.move_entity("player1", 150, 50) # 移动到格子(1,0)
print(f"player1移动到(150,50)后,视野实体数: {len(player1.visible_entities)}")
print(f"player1能看到: {[e[:8] for e in player1.visible_entities]}")
# 此时在(1,0),九宫格是(0~2, -1~1)
# 应该能看到player2(1,0)、player3(2,0)、npc1(0,1)
# --- 测试3:继续移动 ---
print("\n--- 测试3:继续移动player1 ---")
aoi.move_entity("player1", 350, 50) # 移动到格子(3,0)
print(f"player1移动到(350,50)后,视野实体数: {len(player1.visible_entities)}")
print(f"player1能看到: {[e[:8] for e in player1.visible_entities]}")
# 此时在(3,0),九宫格是(2~4, -1~1)
# 只能看到player3(2,0)
# --- 测试4:移除实体 ---
print("\n--- 测试4:移除player3 ---")
aoi.remove_entity("player3")
print(f"player3移除后,player1的视野实体数: {len(player1.visible_entities)}")
print(f"player1能看到: {[e[:8] for e in player1.visible_entities]}")
# player1应该看不到player3了
# --- 测试5:同格子内移动(不跨格) ---
print("\n--- 测试5:同格子内移动 ---")
visible_before = len(player1.visible_entities)
aoi.move_entity("player1", 320, 70) # 仍然在格子(3,0)内
visible_after = len(player1.visible_entities)
print(f"同格子移动前视野数: {visible_before}, 移动后: {visible_after}")
print(f"视野变化: {'有' if visible_before != visible_after else '无(正常,同格子移动不触发AOI)'}")
# --- 打印最终状态 ---
print("\n--- 最终状态 ---")
aoi.debug_print_status()
print("\n" + "=" * 50)
print("AOI算法测试完成")
print("=" * 50)
if __name__ == "__main__":
test_aoi()
slg使用的aoi:以"地块/要塞"为中心,低频、大尺度
典型场景:COK、万国觉醒、三国志·战略版------大世界地图,联盟、要塞、行军队列,坐标单位是"格"不是"米"。
核心问题:超大尺度大地图上,几万个建筑、几十万支部队,怎么让每个人只看到'和他有关的那一小块',且服务器不炸。
特点
-
地图尺度极大:MMO 一张图可能 1km²,SLG 大地图是 几千×几千格,甚至"整服一张图"。
-
移动是"行军"不是"走路":A 打 B,是"A 出兵 → 沿路走 10 分钟 → 到达",中间不需要逐帧同步位置给全世界。
-
关注对象不是人,是"建筑 + 部队 + 资源点":玩家本人大部分时间在 UI 界面,不在"场景里跑"。
-
视野是"领地+侦察"驱动的,不是坐标距离驱动的:
-
你能看到自己城堡周围 N 格
-
能看到盟友领土连过来的地方
-
能看到你派出去的队伍
-
别人打你,靠"侦察"机制,不是靠"走过去自动看见"
-
常见做法
-
格子订阅制:大地图分块(比如 32×32 一格 chunk),客户端打开某个 chunk 才订阅这块的"建筑/部队/资源"变更推送给它。不是九宫格追着人跑,而是 UI 切哪块就订阅哪块。
-
联盟/领土做视野扩展:你的 AOI = 个人城堡半径 + 联盟领土连通块,算法上是 flood-fill 或并查集维护"可见区域",不是圆形半径。
-
行军队列不需要实时 AOI:出发和到达发事件就够了,中途位置只有"当事人+目标防守方"关心,其他人看不到(除非路过别人主城且对方有侦察哨,那是另一个机制)。
代码
python
"""
SLG游戏 AOI (Area of Interest) 兴趣区域算法实现
采用「四叉树空间索引 + 战争迷雾 + 多视野源」架构
SLG游戏AOI特点:
1. 大地图(几千x几千格子),实体分布稀疏
2. 大量静态实体(建筑、资源点)和少量动态实体(部队)
3. 多视野源:主城、分城、部队、哨塔、侦察
4. 战争迷雾三级状态:未探索 / 已探索(黑雾)/ 当前可见
5. 视野形状通常为圆形,可扩展视野加成
"""
from typing import Dict, List, Set, Optional, Tuple
import math
import uuid
# ==================== 常量定义 ====================
# 战争迷雾状态
FOG_UNEXPLORED = 0 # 未探索(完全黑雾)
FOG_EXPLORED = 1 # 已探索但不可见(灰雾,能看到地形但看不到最新状态)
FOG_VISIBLE = 2 # 当前可见(完全可见)
# 实体类型
ENTITY_CITY = "city" # 城市(主城/分城)
ENTITY_BUILDING = "building" # 建筑(箭塔、哨塔等)
ENTITY_ARMY = "army" # 部队(移动中或驻扎)
ENTITY_RESOURCE = "resource" # 资源点
ENTITY_MONSTER = "monster" # 野怪/蛮族
class MapEntity:
"""
地图实体类
代表SLG大地图上的所有对象:城市、建筑、部队、资源点等
"""
def __init__(self, entity_id: Optional[str] = None,
entity_type: str = ENTITY_BUILDING,
x: float = 0, y: float = 0,
vision_radius: float = 0,
data: Optional[dict] = None):
"""
初始化地图实体
Args:
entity_id: 实体唯一ID
entity_type: 实体类型
x: 世界坐标x
y: 世界坐标y
vision_radius: 该实体提供的视野半径(0表示不提供视野)
data: 附加数据(等级、血量、所属玩家等)
"""
self.id = entity_id if entity_id else str(uuid.uuid4())
self.type = entity_type
self.x = x
self.y = y
# 该实体提供的视野半径(城市、哨塔、部队会提供视野)
# 资源点、野怪等不提供视野,值为0
self.vision_radius = vision_radius
# 所属玩家ID(用于判断阵营/归属)
self.owner_id: Optional[str] = None
# 附加属性数据
self.data = data if data else {}
def __repr__(self) -> str:
return (f"Entity({self.id[:8]}.., type={self.type}, "
f"pos=({self.x:.0f},{self.y:.0f}), vision={self.vision_radius})")
class QuadTreeNode:
"""
四叉树节点
每个节点代表一个矩形区域,当实体数量超过阈值时分裂为4个子节点
"""
# 每个节点最多容纳的实体数,超过则分裂
MAX_ENTITIES_PER_NODE = 8
# 四叉树最大深度,防止无限分裂
MAX_DEPTH = 10
def __init__(self, x: float, y: float, width: float, height: float, depth: int = 0):
"""
初始化四叉树节点
Args:
x, y: 节点区域的左上角坐标
width, height: 节点区域的宽高
depth: 当前深度(根节点为0)
"""
self.x = x
self.y = y
self.width = width
self.height = height
self.depth = depth
# 该节点中的实体(用字典便于快速查找)
self.entities: Dict[str, MapEntity] = {}
# 四个子节点:左上、右上、左下、右下
# None 表示尚未分裂
self.northwest: Optional[QuadTreeNode] = None
self.northeast: Optional[QuadTreeNode] = None
self.southwest: Optional[QuadTreeNode] = None
self.southeast: Optional[QuadTreeNode] = None
# 是否已分裂(有子节点)
self._is_divided = False
def _contains(self, entity: MapEntity) -> bool:
"""判断实体是否在本节点范围内"""
return (self.x <= entity.x < self.x + self.width and
self.y <= entity.y < self.y + self.height)
def _intersects_circle(self, cx: float, cy: float, radius: float) -> bool:
"""
判断本节点矩形是否与圆形相交
关键步骤:用于圆形范围查询,快速排除不相交的节点
"""
# 找到矩形上距离圆心最近的点
closest_x = max(self.x, min(cx, self.x + self.width))
closest_y = max(self.y, min(cy, self.y + self.height))
# 计算最近点到圆心的距离
dx = cx - closest_x
dy = cy - closest_y
return (dx * dx + dy * dy) <= (radius * radius)
def _intersects_rect(self, rx: float, ry: float, rw: float, rh: float) -> bool:
"""判断本节点是否与矩形区域相交"""
return not (rx + rw < self.x or rx > self.x + self.width or
ry + rh < self.y or ry > self.y + self.height)
def _subdivide(self) -> None:
"""
分裂节点为四个子节点
关键步骤:当节点内实体数超过阈值时,分裂成4个更小的节点
将当前节点的所有实体分配到对应的子节点中
"""
if self.depth >= self.MAX_DEPTH:
return # 达到最大深度,不再分裂
half_w = self.width / 2
half_h = self.height / 2
next_depth = self.depth + 1
# 创建四个子节点
self.northwest = QuadTreeNode(self.x, self.y, half_w, half_h, next_depth)
self.northeast = QuadTreeNode(self.x + half_w, self.y, half_w, half_h, next_depth)
self.southwest = QuadTreeNode(self.x, self.y + half_h, half_w, half_h, next_depth)
self.southeast = QuadTreeNode(self.x + half_w, self.y + half_h, half_w, half_h, next_depth)
self._is_divided = True
# 将当前节点的实体分配到子节点中
entities_to_move = list(self.entities.values())
self.entities.clear()
for entity in entities_to_move:
self._insert_into_child(entity)
def _insert_into_child(self, entity: MapEntity) -> bool:
"""将实体插入到对应的子节点中"""
if self.northwest and self.northwest._contains(entity):
return self.northwest.insert(entity)
if self.northeast and self.northeast._contains(entity):
return self.northeast.insert(entity)
if self.southwest and self.southwest._contains(entity):
return self.southwest.insert(entity)
if self.southeast and self.southeast._contains(entity):
return self.southeast.insert(entity)
return False
def insert(self, entity: MapEntity) -> bool:
"""
向四叉树中插入实体
关键步骤:
1. 检查实体是否在本节点范围内
2. 如果未分裂且没满,直接加入
3. 如果满了,先分裂再插入子节点
4. 如果已分裂,插入到对应的子节点
"""
# 检查是否在范围内
if not self._contains(entity):
return False
# 如果已分裂,插入到子节点
if self._is_divided:
return self._insert_into_child(entity)
# 未分裂,先加入当前节点
self.entities[entity.id] = entity
# 检查是否需要分裂
if len(self.entities) > self.MAX_ENTITIES_PER_NODE and self.depth < self.MAX_DEPTH:
self._subdivide()
return True
def remove(self, entity_id: str, x: float, y: float) -> bool:
"""
从四叉树中移除实体
需要提供坐标以快速定位到正确的节点
"""
# 先检查坐标是否在本节点范围内
if not (self.x <= x < self.x + self.width and
self.y <= y < self.y + self.height):
return False
# 如果在当前节点的实体列表中,直接移除
if entity_id in self.entities:
del self.entities[entity_id]
return True
# 否则递归查找子节点
if self._is_divided:
if self.northwest and self.northwest.remove(entity_id, x, y):
return True
if self.northeast and self.northeast.remove(entity_id, x, y):
return True
if self.southwest and self.southwest.remove(entity_id, x, y):
return True
if self.southeast and self.southeast.remove(entity_id, x, y):
return True
return False
def query_circle(self, cx: float, cy: float, radius: float,
result: Optional[List[MapEntity]] = None) -> List[MapEntity]:
"""
圆形范围查询
关键步骤:这是SLG AOI最核心的查询操作
1. 检查本节点是否与圆相交,不相交直接返回
2. 收集本节点内的实体(距离检测)
3. 递归查询四个子节点
"""
if result is None:
result = []
# 本节点不与圆相交,直接返回
if not self._intersects_circle(cx, cy, radius):
return result
# 收集本节点内符合条件的实体
radius_sq = radius * radius
for entity in self.entities.values():
dx = entity.x - cx
dy = entity.y - cy
if dx * dx + dy * dy <= radius_sq:
result.append(entity)
# 递归查询子节点
if self._is_divided:
if self.northwest:
self.northwest.query_circle(cx, cy, radius, result)
if self.northeast:
self.northeast.query_circle(cx, cy, radius, result)
if self.southwest:
self.southwest.query_circle(cx, cy, radius, result)
if self.southeast:
self.southeast.query_circle(cx, cy, radius, result)
return result
def query_rect(self, rx: float, ry: float, rw: float, rh: float,
result: Optional[List[MapEntity]] = None) -> List[MapEntity]:
"""矩形范围查询"""
if result is None:
result = []
if not self._intersects_rect(rx, ry, rw, rh):
return result
# 收集本节点内符合条件的实体
for entity in self.entities.values():
if (rx <= entity.x < rx + rw and
ry <= entity.y < ry + rh):
result.append(entity)
# 递归查询子节点
if self._is_divided:
if self.northwest:
self.northwest.query_rect(rx, ry, rw, rh, result)
if self.northeast:
self.northeast.query_rect(rx, ry, rw, rh, result)
if self.southwest:
self.southwest.query_rect(rx, ry, rw, rh, result)
if self.southeast:
self.southeast.query_rect(rx, ry, rw, rh, result)
return result
def get_all_entities(self, result: Optional[List[MapEntity]] = None) -> List[MapEntity]:
"""获取所有实体(用于调试)"""
if result is None:
result = []
result.extend(self.entities.values())
if self._is_divided:
if self.northwest:
self.northwest.get_all_entities(result)
if self.northeast:
self.northeast.get_all_entities(result)
if self.southwest:
self.southwest.get_all_entities(result)
if self.southeast:
self.southeast.get_all_entities(result)
return result
def debug_print(self, prefix: str = "") -> None:
"""打印四叉树结构(调试用)"""
status = f"[{len(self.entities)} entities]"
if self._is_divided:
status += " (divided)"
print(f"{prefix}Node({self.x:.0f},{self.y:.0f},{self.width:.0f}x{self.height:.0f}){status}")
if self._is_divided:
child_prefix = prefix + " "
if self.northwest:
self.northwest.debug_print(child_prefix + "NW: ")
if self.northeast:
self.northeast.debug_print(child_prefix + "NE: ")
if self.southwest:
self.southwest.debug_print(child_prefix + "SW: ")
if self.southeast:
self.southeast.debug_print(child_prefix + "SE: ")
class FogOfWar:
"""
战争迷雾管理器
管理每个玩家的战争迷雾状态
迷雾三级状态:
FOG_UNEXPLORED (0) - 未探索:完全黑色,什么都看不到
FOG_EXPLORED (1) - 已探索:灰色雾,能看到地形和建筑轮廓,但看不到最新动态
FOG_VISIBLE (2) - 当前可见:完全清晰,能看到实时状态
实现思路:
1. 用集合存储已探索的地块(explored_tiles)
2. 每帧重新计算当前可见的地块(visible_tiles)
3. 查询地块状态时:在visible中=可见,否则在explored中=已探索,否则=未探索
"""
def __init__(self, tile_size: float = 1.0):
"""
初始化战争迷雾
Args:
tile_size: 迷雾格子大小,值越大精度越低但性能越好
"""
self.tile_size = tile_size
# 玩家ID -> 已探索的格子集合 (set of (tile_x, tile_y))
self._explored: Dict[str, Set[Tuple[int, int]]] = {}
# 玩家ID -> 当前可见的格子集合 (每帧重新计算)
self._visible: Dict[str, Set[Tuple[int, int]]] = {}
def _pos_to_tile(self, x: float, y: float) -> Tuple[int, int]:
"""世界坐标转迷雾格子坐标"""
return int(x // self.tile_size), int(y // self.tile_size)
def _get_circle_tiles(self, cx: float, cy: float, radius: float) -> Set[Tuple[int, int]]:
"""
获取圆形范围内的所有迷雾格子
关键步骤:用 Bresenham 圆算法思想,遍历圆的外接正方形内的格子,
然后判断是否在圆内,返回所有在圆内的格子坐标
"""
tiles = set()
ts = self.tile_size
# 计算外接正方形的范围
min_tx = int((cx - radius) // ts)
max_tx = int((cx + radius) // ts)
min_ty = int((cy - radius) // ts)
max_ty = int((cy + radius) // ts)
radius_sq = radius * radius
# 遍历正方形内所有格子,检查中心点是否在圆内
for tx in range(min_tx, max_tx + 1):
for ty in range(min_ty, max_ty + 1):
# 格子中心点坐标
tile_cx = (tx + 0.5) * ts
tile_cy = (ty + 0.5) * ts
dx = tile_cx - cx
dy = tile_cy - cy
if dx * dx + dy * dy <= radius_sq:
tiles.add((tx, ty))
return tiles
def add_vision_source(self, player_id: str, x: float, y: float, radius: float) -> None:
"""
添加一个视野源(计算当前可见区域)
关键步骤:将视野源覆盖的格子加入visible集合,
同时也加入explored集合(永久探索)
"""
tiles = self._get_circle_tiles(x, y, radius)
# 初始化玩家数据
if player_id not in self._visible:
self._visible[player_id] = set()
if player_id not in self._explored:
self._explored[player_id] = set()
# 加入当前可见
self._visible[player_id].update(tiles)
# 加入已探索(永久记录)
self._explored[player_id].update(tiles)
def clear_visible(self, player_id: str) -> None:
"""
清空当前可见集合(每帧重新计算前调用)
注意:已探索的不会被清除
"""
if player_id in self._visible:
self._visible[player_id].clear()
def get_tile_fog_state(self, player_id: str, x: float, y: float) -> int:
"""
获取指定坐标对某玩家的迷雾状态
Returns:
FOG_UNEXPLORED / FOG_EXPLORED / FOG_VISIBLE
"""
tile = self._pos_to_tile(x, y)
# 先检查是否当前可见
if player_id in self._visible and tile in self._visible[player_id]:
return FOG_VISIBLE
# 再检查是否已探索
if player_id in self._explored and tile in self._explored[player_id]:
return FOG_EXPLORED
# 否则是未探索
return FOG_UNEXPLORED
def is_visible(self, player_id: str, x: float, y: float) -> bool:
"""判断某位置对玩家是否当前可见"""
return self.get_tile_fog_state(player_id, x, y) == FOG_VISIBLE
def is_explored(self, player_id: str, x: float, y: float) -> bool:
"""判断某位置对玩家是否已探索过"""
return self.get_tile_fog_state(player_id, x, y) >= FOG_EXPLORED
def get_explored_count(self, player_id: str) -> int:
"""获取玩家已探索的格子数"""
if player_id not in self._explored:
return 0
return len(self._explored[player_id])
def get_visible_count(self, player_id: str) -> int:
"""获取玩家当前可见的格子数"""
if player_id not in self._visible:
return 0
return len(self._visible[player_id])
class SLGAOIManager:
"""
SLG游戏 AOI 管理器
整合四叉树空间索引 + 战争迷雾 + 视野源管理
主要功能:
1. 实体的增删改查(基于四叉树)
2. 玩家视野计算(基于多视野源)
3. 战争迷雾管理
4. 视野内实体查询
"""
def __init__(self, map_width: float = 2000.0, map_height: float = 2000.0,
fog_tile_size: float = 10.0):
"""
初始化SLG AOI管理器
Args:
map_width: 地图宽度
map_height: 地图高度
fog_tile_size: 战争迷雾格子大小
"""
self.map_width = map_width
self.map_height = map_height
# 四叉树根节点(覆盖整张地图)
self.quadtree = QuadTreeNode(0, 0, map_width, map_height)
# 战争迷雾管理器
self.fog_of_war = FogOfWar(tile_size=fog_tile_size)
# 所有实体的字典(便于快速查找)
self._entities: Dict[str, MapEntity] = {}
# 玩家的视野源列表:player_id -> [entity_id, ...]
# 视野源是指该玩家拥有的、能提供视野的实体(城市、部队、哨塔等)
self._player_vision_sources: Dict[str, List[str]] = {}
def add_entity(self, entity: MapEntity) -> bool:
"""
添加实体到AOI系统
关键步骤:
1. 加入四叉树
2. 加入全局字典
3. 如果该实体有视野且属于某个玩家,加入该玩家的视野源列表
"""
if entity.id in self._entities:
return False
# 加入四叉树
success = self.quadtree.insert(entity)
if not success:
return False
# 加入全局字典
self._entities[entity.id] = entity
# 如果有所有者且有视野半径,加入视野源列表
if entity.owner_id and entity.vision_radius > 0:
if entity.owner_id not in self._player_vision_sources:
self._player_vision_sources[entity.owner_id] = []
self._player_vision_sources[entity.owner_id].append(entity.id)
return True
def remove_entity(self, entity_id: str) -> bool:
"""
从AOI系统中移除实体
"""
if entity_id not in self._entities:
return False
entity = self._entities[entity_id]
# 从四叉树移除
self.quadtree.remove(entity_id, entity.x, entity.y)
# 从视野源列表移除
if entity.owner_id and entity.owner_id in self._player_vision_sources:
sources = self._player_vision_sources[entity.owner_id]
if entity_id in sources:
sources.remove(entity_id)
# 从全局字典移除
del self._entities[entity_id]
return True
def move_entity(self, entity_id: str, new_x: float, new_y: float) -> bool:
"""
移动实体(部队行军等)
关键步骤:先从旧位置移除,再插入到新位置
因为四叉树不支持直接移动,所以用删了再加的方式
"""
if entity_id not in self._entities:
return False
entity = self._entities[entity_id]
# 坐标没变就不用动
if entity.x == new_x and entity.y == new_y:
return True
# 从旧位置移除
self.quadtree.remove(entity_id, entity.x, entity.y)
# 更新坐标
entity.x = new_x
entity.y = new_y
# 插入到新位置
success = self.quadtree.insert(entity)
if not success:
# 插入失败,恢复旧位置(理论上不会发生)
entity.x = entity.x # 保持当前值
return False
return True
def get_entity(self, entity_id: str) -> Optional[MapEntity]:
"""根据ID获取实体"""
return self._entities.get(entity_id)
def query_entities_in_circle(self, cx: float, cy: float, radius: float) -> List[MapEntity]:
"""
查询圆形范围内的所有实体
这是最常用的AOI查询
"""
return self.quadtree.query_circle(cx, cy, radius)
def query_entities_in_rect(self, x: float, y: float, w: float, h: float) -> List[MapEntity]:
"""查询矩形范围内的所有实体"""
return self.quadtree.query_rect(x, y, w, h)
def update_player_vision(self, player_id: str) -> Set[str]:
"""
更新玩家的视野,返回当前视野内所有可见的实体ID集合
关键步骤:
1. 清空玩家的当前可见迷雾
2. 遍历玩家所有视野源(城市、部队、哨塔等)
3. 将每个视野源的视野范围加入迷雾系统
4. 合并所有视野源的查询范围,获取视野内的实体
5. 过滤掉战争迷雾不可见的实体
注意:这个函数应该每帧(或每个tick)调用一次
"""
# 步骤1:清空当前可见迷雾
self.fog_of_war.clear_visible(player_id)
# 步骤2:获取玩家的所有视野源实体
if player_id not in self._player_vision_sources:
return set()
source_ids = self._player_vision_sources[player_id]
if not source_ids:
return set()
# 步骤3:遍历所有视野源,加入迷雾系统
# 同时收集视野范围的外包矩形,用于四叉树查询
min_x = float('inf')
min_y = float('inf')
max_x = float('-inf')
max_y = float('-inf')
for source_id in source_ids:
source = self._entities.get(source_id)
if not source or source.vision_radius <= 0:
continue
# 加入战争迷雾
self.fog_of_war.add_vision_source(player_id, source.x, source.y, source.vision_radius)
# 更新外包矩形
min_x = min(min_x, source.x - source.vision_radius)
min_y = min(min_y, source.y - source.vision_radius)
max_x = max(max_x, source.x + source.vision_radius)
max_y = max(max_y, source.y + source.vision_radius)
# 步骤4:用外包矩形做粗略查询(四叉树矩形查询很快)
rect_w = max_x - min_x
rect_h = max_y - min_y
if rect_w <= 0 or rect_h <= 0:
return set()
# 限制在地图范围内
min_x = max(0, min_x)
min_y = max(0, min_y)
rect_w = min(self.map_width - min_x, rect_w)
rect_h = min(self.map_height - min_y, rect_h)
candidate_entities = self.quadtree.query_rect(min_x, min_y, rect_w, rect_h)
# 步骤5:精确过滤 - 检查每个候选实体是否在任一视野源的视野内
visible_entity_ids: Set[str] = set()
for entity in candidate_entities:
# 跳过自己的视野源(可选,看需求)
# if entity.owner_id == player_id:
# visible_entity_ids.add(entity.id)
# continue
# 检查是否在迷雾可见区域
if self.fog_of_war.is_visible(player_id, entity.x, entity.y):
visible_entity_ids.add(entity.id)
return visible_entity_ids
def get_entity_fog_state(self, player_id: str, entity_id: str) -> int:
"""
获取某实体对玩家的迷雾状态
"""
entity = self._entities.get(entity_id)
if not entity:
return FOG_UNEXPLORED
return self.fog_of_war.get_tile_fog_state(player_id, entity.x, entity.y)
def get_player_visible_entities(self, player_id: str) -> List[MapEntity]:
"""
获取玩家当前可见的所有实体列表
注意:需要先调用 update_player_vision() 更新视野
"""
visible_ids = self.update_player_vision(player_id)
return [self._entities[eid] for eid in visible_ids if eid in self._entities]
def get_all_entity_count(self) -> int:
"""获取实体总数"""
return len(self._entities)
def debug_print_quadtree(self) -> None:
"""打印四叉树结构"""
print("=== 四叉树结构 ===")
self.quadtree.debug_print()
print(f"实体总数: {len(self._entities)}")
print("=================")
def debug_print_player_vision(self, player_id: str) -> None:
"""打印玩家视野信息"""
print(f"=== 玩家 {player_id} 视野信息 ===")
source_count = len(self._player_vision_sources.get(player_id, []))
print(f"视野源数量: {source_count}")
print(f"已探索格子数: {self.fog_of_war.get_explored_count(player_id)}")
print(f"当前可见格子数: {self.fog_of_war.get_visible_count(player_id)}")
visible = self.get_player_visible_entities(player_id)
print(f"可见实体数: {len(visible)}")
for e in visible:
print(f" - {e}")
print("================================")
# ==================== 测试代码 ====================
def test_slg_aoi():
"""测试SLG AOI系统"""
print("=" * 60)
print("SLG AOI 算法测试")
print("=" * 60)
# 初始化AOI管理器(2000x2000的地图,迷雾格子大小10)
aoi = SLGAOIManager(map_width=2000, map_height=2000, fog_tile_size=10)
# --- 测试1:添加各种实体 ---
print("\n--- 测试1:添加实体 ---")
# 玩家1的主城(提供视野)
city1 = MapEntity("city_p1", ENTITY_CITY, x=500, y=500, vision_radius=150)
city1.owner_id = "player1"
aoi.add_entity(city1)
# 玩家1的分城
city2 = MapEntity("city_p1_2", ENTITY_CITY, x=800, y=500, vision_radius=120)
city2.owner_id = "player1"
aoi.add_entity(city2)
# 玩家1的部队(移动中,提供视野)
army1 = MapEntity("army_p1_1", ENTITY_ARMY, x=600, y=600, vision_radius=80)
army1.owner_id = "player1"
aoi.add_entity(army1)
# 玩家2的主城
city_p2 = MapEntity("city_p2", ENTITY_CITY, x=1200, y=500, vision_radius=150)
city_p2.owner_id = "player2"
aoi.add_entity(city_p2)
# 一些资源点(不提供视野)
for i in range(20):
res = MapEntity(f"res_{i}", ENTITY_RESOURCE,
x=100 + i * 90, y=300 + (i % 5) * 80,
vision_radius=0)
aoi.add_entity(res)
# 一些野怪(不提供视野)
for i in range(10):
monster = MapEntity(f"monster_{i}", ENTITY_MONSTER,
x=300 + i * 150, y=800 + (i % 3) * 100,
vision_radius=0)
aoi.add_entity(monster)
print(f"实体总数: {aoi.get_all_entity_count()}")
# --- 测试2:四叉树圆形查询 ---
print("\n--- 测试2:圆形范围查询 ---")
result = aoi.query_entities_in_circle(500, 500, 200)
print(f"以(500,500)为中心,半径200范围内的实体数: {len(result)}")
for e in result:
print(f" {e}")
# --- 测试3:战争迷雾 - 玩家初始视野 ---
print("\n--- 测试3:玩家1的初始视野 ---")
visible = aoi.get_player_visible_entities("player1")
print(f"玩家1可见实体数: {len(visible)}")
for e in visible:
fog = aoi.get_entity_fog_state("player1", e.id)
fog_str = "可见" if fog == FOG_VISIBLE else "已探索" if fog == FOG_EXPLORED else "未探索"
print(f" {e.id[:12]}... ({fog_str})")
aoi.debug_print_player_vision("player1")
# --- 测试4:部队移动,探索新区域 ---
print("\n--- 测试4:部队移动,探索新区域 ---")
print("部队从(600,600)移动到(1000,600)...")
# 逐步移动,每步更新视野
for step in range(5):
new_x = 600 + step * 100
aoi.move_entity("army_p1_1", new_x, 600)
visible = aoi.get_player_visible_entities("player1")
explored = aoi.fog_of_war.get_explored_count("player1")
visible_tiles = aoi.fog_of_war.get_visible_count("player1")
print(f" 步骤{step+1}: 部队位置({new_x},600), 可见实体:{len(visible)}个, "
f"已探索格子:{explored}, 当前可见格子:{visible_tiles}")
# --- 测试5:检查玩家2的城市是否被玩家1看到 ---
print("\n--- 测试5:玩家1对玩家2主城的可见性 ---")
fog_state = aoi.get_entity_fog_state("player1", "city_p2")
state_str = {FOG_UNEXPLORED: "未探索", FOG_EXPLORED: "已探索(黑雾)", FOG_VISIBLE: "当前可见"}
print(f"玩家2主城(1200,500) 对玩家1: {state_str[fog_state]}")
# 部队再靠近一点
print("\n部队继续移动到(1100,550)...")
aoi.move_entity("army_p1_1", 1100, 550)
aoi.update_player_vision("player1")
fog_state = aoi.get_entity_fog_state("player1", "city_p2")
print(f"玩家2主城 对玩家1: {state_str[fog_state]}")
# --- 测试6:部队撤回,检查已探索状态 ---
print("\n--- 测试6:部队撤回,验证已探索记忆 ---")
aoi.move_entity("army_p1_1", 500, 500) # 撤退回主城
aoi.update_player_vision("player1")
fog_state = aoi.get_entity_fog_state("player1", "city_p2")
print(f"部队撤回后,玩家2主城对玩家1: {state_str[fog_state]}")
print(f"(即使不在视野内,已探索的区域仍然保持'已探索'状态)")
# --- 测试7:矩形查询 ---
print("\n--- 测试7:矩形范围查询 ---")
rect_result = aoi.query_entities_in_rect(400, 400, 300, 300)
print(f"矩形(400,400,300x300)内实体数: {len(rect_result)}")
# --- 打印四叉树结构 ---
print("\n--- 四叉树结构 ---")
aoi.debug_print_quadtree()
print("\n" + "=" * 60)
print("SLG AOI 测试完成")
print("=" * 60)
if __name__ == "__main__":
test_slg_aoi()
两种类型游戏的aoi差异
计算目标不同
MMO AOI:算的是「A玩家视野里有哪些实体」
玩家A的视野 → {玩家B, 怪物C, NPC D, ...}
→ 输出是实体列表
SLG AOI:算的是「玩家能看到地图上哪些格子」
玩家的视野 → {格子(5,3), 格子(5,4), 格子(6,3), ...}
↓
再去这些格子里找实体
→ 输出是地图区域,再从区域里找实体
视野源数量不同
MMO AOI:每个实体只有一个视野源(自己)
玩家A → 1个视野(跟着玩家A走)
SLG AOI:每个玩家有N个视野源(叠加)
玩家 → 主城视野
+ 分城视野
+ 部队视野
+ 哨塔视野
+ 侦察视野
...
= 最终可见区域(取并集)
空间索引方式不同
MMO AOI:用网格(九宫格)
适合:实体密集、移动频繁
优点:移动检测极快(O(1)判断是否跨格)
缺点:视野是矩形近似,不够精确
┌───┬───┬───┐
│ │ │ │ 视野 = 九宫格 = 9个格子
├───┼───┼───┤
│ │ ● │ │
├───┼───┼───┤
│ │ │ │
└───┴───┴───┘
```
SLG AOI:用四叉树
适合:大地图、实体稀疏、大量静态实体
优点:查询效率高、支持任意形状(圆形)、节省内存
缺点:移动需要删了再插,比网格慢一点
```
┌───────────┐
│ 根节点 │
└─────┬─────┘
┌──────────┴──────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ NW │ │ NE │
│ (有实体) │ │ (有实体) │
└─────┬─────┘ └───────────┘
┌──┴──┐
▼ ▼
┌───┐ ┌───┐
│...│ │...│ ← 查询圆形范围时,
└───┘ └───┘ 只遍历相关的节点
```