AOI算法学习

概念

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

常见类型

mmo游戏使用的aoi:以"人"为中心,高频、强实时

典型场景:WOW、原神、MMORPG、吃鸡------玩家是一个单位,走路是连续的,视角跟着人走。

核心问题:几千人同图,怎么让每个人只收自己周围的数据,且移动不能卡。

特点

  1. 以 Player 为单位建 AOI:每个客户端关心"我周围 N 米内"的东西。
  2. 移动高频:每秒几次坐标更新,AOI 进出事件触发很频繁。
  3. 视野对称:A 能看到 B ≈ B 能看到 A(双向)。
  4. 同步对象多而杂:玩家、怪、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、万国觉醒、三国志·战略版------大世界地图,联盟、要塞、行军队列,坐标单位是"格"不是"米"。

核心问题:超大尺度大地图上,几万个建筑、几十万支部队,怎么让每个人只看到'和他有关的那一小块',且服务器不炸。

特点

  1. 地图尺度极大:MMO 一张图可能 1km²,SLG 大地图是 几千×几千格,甚至"整服一张图"。

  2. 移动是"行军"不是"走路":A 打 B,是"A 出兵 → 沿路走 10 分钟 → 到达",中间不需要逐帧同步位置给全世界。

  3. 关注对象不是人,是"建筑 + 部队 + 资源点":玩家本人大部分时间在 UI 界面,不在"场景里跑"。

  4. 视野是"领地+侦察"驱动的,不是坐标距离驱动的:

    • 你能看到自己城堡周围 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       │
          │  (有实体)  │         │  (有实体)  │
          └─────┬─────┘         └───────────┘
             ┌──┴──┐
             ▼     ▼
          ┌───┐ ┌───┐
          │...│ │...│   ← 查询圆形范围时,
          └───┘ └───┘     只遍历相关的节点
```