"""
游戏逻辑 —— 占领模式 + 战斗系统（水镜 / 镜像 分离）
所有坐标/距离单位为像素（世界 4000×3000）
"""
import asyncio
import json
import logging
import math
import random
import time
import uuid
from typing import Optional

from app.game.heroes import AttackType, DamageType, get_hero
from app.game.room_manager import (
    WORLD_W, WORLD_H,
    CapturePoint, DamageNumber, Mirror, Player, Projectile, Room,
    RoomStatus, Team, VisualEffect,
)

logger = logging.getLogger(__name__)

# ─── 地图配置（像素坐标）───────────────────────────────────
SPAWN_RED  = (400, 2550)     # 0.1*4000, 0.85*3000
SPAWN_BLUE = (3600, 450)     # 0.9*4000, 0.15*3000

CAPTURE_POINTS = [
    {"id": 0, "x": 800,  "y": 600},    # 0.2*4000, 0.2*3000
    {"id": 1, "x": 2000, "y": 1500},   # 0.5*4000, 0.5*3000
    {"id": 2, "x": 3200, "y": 2400},   # 0.8*4000, 0.8*3000
]

CAPTURE_SPEED = 2.0
CAPTURE_THRESHOLD = 100.0
TICK_RATE = 1 / 30
TICK_DT = TICK_RATE
_TICK_SCALE = TICK_DT * 20   # =2/3  让所有 20Hz 调优的速度保持物理速率不变

RESPAWN_TIME = 5.0

# 地图边界（像素）
BORDER_MIN_X = 80
BORDER_MAX_X = 3920
BORDER_MIN_Y = 60
BORDER_MAX_Y = 2940

# 技能常量（像素）
MIRROR_DURATION    = 14.0
PROJECTILE_SPEED   = 48.0 * _TICK_SCALE  # 像素/tick（已缩放）
PROJECTILE_RANGE   = 1400.0    # 最大飞行距离（像素）
PROJECTILE_HIT_R   = 80.0     # 投射物命中半径（像素）
SPIN_RADIUS        = 200.0     # E技能旋转半径（像素）
MIRROR_MAX_RANGE   = 1200.0    # 水镜最大放置距离（像素）
MIRROR_MIN_DIST    = 20.0      # 水镜最小距离


# ═══════════════════════════════════════════════════════════
# 镜像位置计算（核心工具函数）
# ═══════════════════════════════════════════════════════════

def _clone_position(player: Player, mirror: Mirror) -> tuple[float, float]:
    """
    计算镜像位置 —— 玩家关于水镜所在直线的对称点。
    水镜直线：过 (mirror.x, mirror.y)，方向为 (mirror.dir_x, mirror.dir_y)。
    """
    vx = player.x - mirror.x
    vy = player.y - mirror.y
    dot = vx * mirror.dir_x + vy * mirror.dir_y
    cx = mirror.x + 2 * dot * mirror.dir_x - vx
    cy = mirror.y + 2 * dot * mirror.dir_y - vy
    return (max(BORDER_MIN_X, min(BORDER_MAX_X, cx)),
            max(BORDER_MIN_Y, min(BORDER_MAX_Y, cy)))


def _mirror_facing(facing: float, mirror: Mirror) -> float:
    """计算镜像朝向 —— 玩家朝向关于镜面方向的对称。"""
    phi = math.atan2(mirror.dir_y, mirror.dir_x)
    return 2 * phi - facing


# ═══════════════════════════════════════════════════════════
# 初始化
# ═══════════════════════════════════════════════════════════

def init_game_state(room: Room):
    room.capture_points = [
        CapturePoint(id=cp["id"], x=cp["x"], y=cp["y"]) for cp in CAPTURE_POINTS
    ]
    room.mirrors = {}
    room.projectiles = []
    room.damage_numbers = []
    room.effects = []
    room.delayed_balls = []

    for player in room.players.values():
        if player.team == Team.RED:
            player.x, player.y = SPAWN_RED
        else:
            player.x, player.y = SPAWN_BLUE
        player.init_from_hero()
        player.facing = 0.0

    room.status = RoomStatus.PLAYING


# ═══════════════════════════════════════════════════════════
# 玩家移动 & 朝向
# ═══════════════════════════════════════════════════════════

def update_player_position(room: Room, user_id: str, dx: float, dy: float):
    player = room.players.get(user_id)
    if not player or not player.alive:
        return
    if time.time() < player.move_lock_until:
        return
    # 定身检查
    if time.time() < player.root_until:
        return
    # 斜向归一化
    mag = math.sqrt(dx * dx + dy * dy)
    if mag > 1e-6:
        dx /= mag
        dy /= mag
    player.x = max(BORDER_MIN_X, min(BORDER_MAX_X, player.x + dx * player.speed * _TICK_SCALE))
    player.y = max(BORDER_MIN_Y, min(BORDER_MAX_Y, player.y + dy * player.speed * _TICK_SCALE))


def update_player_facing(room: Room, user_id: str, angle: float):
    player = room.players.get(user_id)
    if player and player.alive:
        player.facing = angle


# ═══════════════════════════════════════════════════════════
# 伤害系统
# ═══════════════════════════════════════════════════════════

def calculate_damage(atk_value: int, defense: int, multiplier: float = 1.0) -> int:
    raw = atk_value * multiplier
    reduced = raw * (100 / (100 + defense))
    return max(1, int(reduced))


def apply_damage(room: Room, attacker: Player, target: Player, dmg: int):
    if not target.alive:
        return
    target.hp = max(0, target.hp - dmg)
    room.damage_numbers.append(DamageNumber(
        target_id=target.user_id, amount=dmg,
        x=target.x, y=target.y,
    ))
    if target.hp <= 0:
        target.alive = False
        target.hp = 0
        target._death_time = time.time()


def _in_fan(ox: float, oy: float, facing: float,
            tx: float, ty: float, radius: float, half_angle: float) -> bool:
    dx = tx - ox
    dy = ty - oy
    dist = math.sqrt(dx * dx + dy * dy)
    if dist > radius or dist < 1.0:
        return False
    angle_to_target = math.atan2(dy, dx)
    diff = (angle_to_target - facing + math.pi) % (2 * math.pi) - math.pi
    return abs(diff) <= half_angle


# ═══════════════════════════════════════════════════════════
# 扇形攻击
# ═══════════════════════════════════════════════════════════

def _do_fan_attack(room: Room, attacker: Player,
                   ox: float, oy: float, facing: float,
                   is_clone: bool = False):
    room.effects.append(VisualEffect(
        type="fan_attack",
        x=ox, y=oy,
        data={
            "facing": round(facing, 3),
            "range": round(attacker.attack_range, 1),
            "angle": round(attacker.attack_angle, 3),
            "team": attacker.team.value,
            "clone": is_clone,
        },
    ))
    for target in room.players.values():
        if target.user_id == attacker.user_id or target.team == attacker.team or not target.alive:
            continue
        if _in_fan(ox, oy, facing, target.x, target.y,
                   attacker.attack_range, attacker.attack_angle):
            dmg = calculate_damage(
                attacker.physical_attack, target.physical_defense, 1.0
            )
            apply_damage(room, attacker, target, dmg)


# ═══════════════════════════════════════════════════════════
# 普通攻击
# ═══════════════════════════════════════════════════════════

# ─── 方向普攻：向鼠标方向发射能量球 ──────────────────────
ENERGY_BALL_SPEED  = 36.0 * _TICK_SCALE   # 像素/tick（已缩放）
ENERGY_BALL_RANGE  = 500.0   # 默认最大飞行距离（像素）
ENERGY_BALL_F_RANGE = 600.0  # F 强化射程
ENERGY_BALL_HIT_R  = 40.0    # 命中半径（像素）
ENERGY_BALL_SUB_HIT_R = 30.0 # R 小球命中半径
HOMING_SEARCH_R    = 300.0   # 追踪搜索范围（像素）
HOMING_TURN_RATE   = 0.06 * _TICK_SCALE  # 追踪偏转率（弧度/tick，已缩放）


def _make_energy_ball(room: Room, attacker: Player,
                      ox: float, oy: float,
                      ndx: float, ndy: float,
                      max_dist: float,
                      homing: bool = False,
                      bonus_magic: float = 0.0,
                      root_dur: float = 0.0,
                      dmg_mult: float = 1.0,
                      sub_ball: bool = False) -> Projectile:
    """创建一个能量球弹丸并加入房间。"""
    proj = Projectile(
        id=str(uuid.uuid4())[:8],
        owner_id=attacker.user_id,
        team=attacker.team,
        x=ox, y=oy,
        dx=ndx, dy=ndy,
        speed=ENERGY_BALL_SPEED,
        damage=attacker.physical_attack,
        max_dist=max_dist,
        proj_type="energy_ball" if not sub_ball else "energy_ball_sub",
        hit_radius=ENERGY_BALL_SUB_HIT_R if sub_ball else ENERGY_BALL_HIT_R,
        homing=homing,
        bonus_magic_dmg=bonus_magic,
        root_duration=root_dur,
        damage_mult=dmg_mult,
    )
    room.projectiles.append(proj)
    return proj


def _do_projectile_attack(room: Room, attacker: Player,
                          ox: float, oy: float,
                          mx: float, my: float):
    """从 (ox,oy) 向鼠标方向 (mx,my) 发射能量球，根据buff增强。"""
    dx = mx - ox
    dy = my - oy
    dist = math.sqrt(dx * dx + dy * dy)
    if dist < 1.0:
        dx, dy, dist = 1.0, 0.0, 1.0
    ndx = dx / dist
    ndy = dy / dist

    now = time.time()
    has_f = now < attacker.sw_f_until
    has_e = now < attacker.sw_e_until
    has_r = now < attacker.sw_r_until

    # 确定射程
    ball_range = ENERGY_BALL_F_RANGE if has_f else ENERGY_BALL_RANGE
    # F 追踪 + 附伤
    homing = has_f
    bonus_magic = 0.0
    if has_f:
        bonus_magic = attacker.magical_attack * 1.4  # 140% 法攻（绝对值，命中时再经过抗性计算）
    # E 定身
    root_dur = 1.5 if has_e else 0.0

    # 发射主弹
    _make_energy_ball(room, attacker, ox, oy, ndx, ndy,
                      ball_range, homing, bonus_magic, root_dur)

    # R 弹幕：普攻后 0.2/0.4/0.6/0.8s 各发射 1 个小能量球，60° 散射
    if has_r:
        attacker.sw_r_until = 0.0  # 消耗
        sub_root = root_dur * 0.25 if has_e else 0.0
        sub_bonus = bonus_magic * 0.25 if has_f else 0.0
        main_angle = math.atan2(ndy, ndx)
        for i in range(4):
            fire_time = now + 0.2 * (i + 1)  # 0.2, 0.4, 0.6, 0.8
            room.delayed_balls.append({
                "fire_at": fire_time,
                "owner_id": attacker.user_id,
                "ox": ox, "oy": oy,
                "main_angle": main_angle,
                "ball_range": ball_range,
                "homing": homing,
                "sub_bonus": sub_bonus,
                "sub_root": sub_root,
            })

    # 消耗 E buff（下一次普攻用完）
    if has_e:
        attacker.sw_e_until = 0.0

    room.effects.append(VisualEffect(
        type="energy_ball_fire",
        x=ox, y=oy,
        data={"team": attacker.team.value,
              "enhanced": has_f or has_e or has_r},
    ))


def process_attack(room: Room, user_id: str,
                   target_x: float = 0, target_y: float = 0) -> bool:
    player = room.players.get(user_id)
    if not player or not player.alive:
        return False
    if player.attack_cd_remaining > 0:
        return False

    hero = get_hero(player.hero_id)
    attack_type = hero.stats.attack_type if hero else AttackType.FAN

    player.attack_cd_remaining = player.attack_cd
    player.move_lock_until = time.time() + 0.3

    if attack_type == AttackType.FAN:
        _do_fan_attack(room, player, player.x, player.y, player.facing, is_clone=False)
        if player.mirror_id and player.mirror_id in room.mirrors:
            mirror = room.mirrors[player.mirror_id]
            cx, cy = _clone_position(player, mirror)
            cf = _mirror_facing(player.facing, mirror)
            _do_fan_attack(room, player, cx, cy, cf, is_clone=True)

    elif attack_type == AttackType.TARGETED:
        _do_projectile_attack(room, player, player.x, player.y, target_x, target_y)

    return True


# ═══════════════════════════════════════════════════════════
# 技能系统
# ═══════════════════════════════════════════════════════════

def process_skill(room: Room, user_id: str, key: str,
                  target_x: float = 0, target_y: float = 0,
                  angle: float = 0) -> bool:
    player = room.players.get(user_id)
    if not player or not player.alive:
        return False

    hero = get_hero(player.hero_id)
    if not hero:
        return False

    skill_def = None
    for s in hero.skills:
        if s.key == key:
            skill_def = s
            break
    if not skill_def:
        return False

    if skill_def.id == "red_lady_f":
        if player.mirror_id and player.mirror_id in room.mirrors:
            _destroy_mirror(room, player)
            return True
        cd = player.skill_cds.get(key, 0)
        if cd > 0:
            return False
        success = _skill_red_lady_f(room, player, target_x, target_y)
        return success

    if skill_def.id == "red_lady_e":
        success = _skill_red_lady_e(room, player)
        return success

    if skill_def.id == "red_lady_r":
        cd = player.skill_cds.get(key, 0)
        if cd > 0:
            return False
        success = _skill_red_lady_r(room, player, angle)
        if success:
            player.skill_cds[key] = skill_def.cooldown
        return success

    # ── 缀魂者 FER：均为自身强化 buff ──
    if skill_def.id in ("soul_weaver_f", "soul_weaver_e", "soul_weaver_r"):
        cd = player.skill_cds.get(key, 0)
        if cd > 0:
            return False
        now = time.time()
        buff_dur = 5.0
        if skill_def.id == "soul_weaver_f":
            player.sw_f_until = now + buff_dur
        elif skill_def.id == "soul_weaver_e":
            player.sw_e_until = now + buff_dur
        elif skill_def.id == "soul_weaver_r":
            player.sw_r_until = now + buff_dur
        player.skill_cds[key] = skill_def.cooldown
        room.effects.append(VisualEffect(
            type="sw_buff",
            x=player.x, y=player.y,
            data={"skill": key, "team": player.team.value},
        ))
        return True

    return False


# ──── 红夫人 F: 水镜 ────────────────────────────────────
def _skill_red_lady_f(room: Room, player: Player,
                      tx: float, ty: float) -> bool:
    if player.mirror_id and player.mirror_id in room.mirrors:
        del room.mirrors[player.mirror_id]
        player.mirror_id = None

    dx = tx - player.x
    dy = ty - player.y
    dist = math.sqrt(dx * dx + dy * dy)
    if dist < MIRROR_MIN_DIST:
        dx, dy = 1.0, 0.0
        dist = 1.0

    if dist > MIRROR_MAX_RANGE:
        tx = player.x + dx / dist * MIRROR_MAX_RANGE
        ty = player.y + dy / dist * MIRROR_MAX_RANGE

    tx = max(BORDER_MIN_X, min(BORDER_MAX_X, tx))
    ty = max(BORDER_MIN_Y, min(BORDER_MAX_Y, ty))

    ndx = dx / dist
    ndy = dy / dist
    mirror_dir_x = -ndy
    mirror_dir_y = ndx

    mirror_id = str(uuid.uuid4())[:8]
    room.mirrors[mirror_id] = Mirror(
        id=mirror_id,
        owner_id=player.user_id,
        team=player.team,
        x=tx, y=ty,
        dir_x=mirror_dir_x,
        dir_y=mirror_dir_y,
        created_at=time.time(),
        duration=MIRROR_DURATION,
    )
    player.mirror_id = mirror_id

    room.effects.append(VisualEffect(
        type="mirror_place",
        x=tx, y=ty,
        data={
            "mirror_id": mirror_id,
            "team": player.team.value,
            "dir_x": round(mirror_dir_x, 4),
            "dir_y": round(mirror_dir_y, 4),
        },
    ))
    return True


# ──── 红夫人 E: 镜花旋舞 ─────────────────────────────────
def _do_spin_damage(room: Room, attacker: Player, ox: float, oy: float,
                    is_clone: bool = False):
    room.effects.append(VisualEffect(
        type="spin",
        x=ox, y=oy,
        data={"radius": SPIN_RADIUS, "team": attacker.team.value, "clone": is_clone},
    ))
    for target in room.players.values():
        if target.user_id == attacker.user_id or target.team == attacker.team or not target.alive:
            continue
        tdx = target.x - ox
        tdy = target.y - oy
        if math.sqrt(tdx * tdx + tdy * tdy) <= SPIN_RADIUS:
            dmg = calculate_damage(
                attacker.physical_attack, target.physical_defense, 1.2
            )
            apply_damage(room, attacker, target, dmg)


def _skill_red_lady_e(room: Room, player: Player) -> bool:
    if not player.mirror_id or player.mirror_id not in room.mirrors:
        return False
    mirror = room.mirrors[player.mirror_id]
    if mirror.used_e:
        return False

    mirror.used_e = True
    player.move_lock_until = time.time() + 0.5

    _do_spin_damage(room, player, player.x, player.y, is_clone=False)
    clone_x, clone_y = _clone_position(player, mirror)
    _do_spin_damage(room, player, clone_x, clone_y, is_clone=True)

    room.pending_swaps.append({
        'user_id': player.user_id,
        'mirror_id': player.mirror_id,
        'swap_at': time.time() + 0.5,
    })
    return True


# ──── 红夫人 R: 碎裂镜片（3 发扇形）─────────────────────────
SHARD_SPREAD    = math.pi / 6   # 总散度 30° → 半角 15°
SHARD_LEAD_DIST = 60.0          # 中间碎片领先距离（像素）
SHARD_OFFSETS   = [0, -1, 1]    # 中间、左、右
SHARD_DMG_MULT  = [1.0, 0.7, 0.7]  # 中间满额，两侧 70%


def _spawn_shards(room: Room, owner: Player,
                  ox: float, oy: float, center_angle: float):
    """以 (ox,oy) 为起点、center_angle 为中心方向，生成 3 片碎镜。"""
    half = SHARD_SPREAD / 2
    for idx, (si, mult) in enumerate(zip(SHARD_OFFSETS, SHARD_DMG_MULT)):
        a = center_angle + si * half
        dx = math.cos(a)
        dy = math.sin(a)
        # 中间碎片（si==0）向前偏移一段距离
        lead = SHARD_LEAD_DIST if si == 0 else 0.0
        room.projectiles.append(Projectile(
            id=str(uuid.uuid4())[:8],
            owner_id=owner.user_id,
            team=owner.team,
            x=ox + dx * lead,
            y=oy + dy * lead,
            dx=dx, dy=dy,
            speed=PROJECTILE_SPEED,
            damage=int(owner.physical_attack * mult),
            max_dist=PROJECTILE_RANGE,
            shard_index=si,
        ))


def _skill_red_lady_r(room: Room, player: Player, angle: float) -> bool:
    player.move_lock_until = time.time() + 0.3

    _spawn_shards(room, player, player.x, player.y, angle)

    if player.mirror_id and player.mirror_id in room.mirrors:
        mirror = room.mirrors[player.mirror_id]
        cx, cy = _clone_position(player, mirror)
        c_angle = _mirror_facing(angle, mirror)
        _spawn_shards(room, player, cx, cy, c_angle)

    return True


# ═══════════════════════════════════════════════════════════
# 每 Tick 更新
# ═══════════════════════════════════════════════════════════

def _tick_cooldowns(room: Room):
    for p in room.players.values():
        if p.attack_cd_remaining > 0:
            p.attack_cd_remaining = max(0, p.attack_cd_remaining - TICK_DT)
        for key in p.skill_cds:
            if p.skill_cds[key] > 0:
                p.skill_cds[key] = max(0, p.skill_cds[key] - TICK_DT)


def _tick_delayed_balls(room: Room):
    """处理 R 弹幕延迟小球：到期的立即生成。"""
    now = time.time()
    remaining = []
    for db in room.delayed_balls:
        if now < db["fire_at"]:
            remaining.append(db)
            continue
        # 到期 → 发射
        attacker = room.players.get(db["owner_id"])
        if not attacker or not attacker.alive:
            continue  # 玩家已死，跳过
        # 60° 散射 → ±30°
        rand_offset = random.uniform(-math.pi / 6, math.pi / 6)
        a = db["main_angle"] + rand_offset
        sdx = math.cos(a)
        sdy = math.sin(a)
        _make_energy_ball(
            room, attacker,
            db["ox"], db["oy"],
            sdx, sdy,
            db["ball_range"],
            db["homing"],
            db["sub_bonus"],
            db["sub_root"],
            dmg_mult=0.25, sub_ball=True,
        )
        room.effects.append(VisualEffect(
            type="energy_ball_fire",
            x=db["ox"], y=db["oy"],
            data={"team": attacker.team.value, "enhanced": True},
        ))
    room.delayed_balls = remaining


def _tick_projectiles(room: Room):
    alive_projs = []
    for proj in room.projectiles:
        if proj.hit:
            continue

        # 追踪逻辑：轻微偏转朝向最近敌人
        if proj.homing:
            best_dist = HOMING_SEARCH_R
            best_target = None
            for t in room.players.values():
                if t.user_id == proj.owner_id or t.team == proj.team or not t.alive:
                    continue
                tdx = t.x - proj.x
                tdy = t.y - proj.y
                d = math.sqrt(tdx * tdx + tdy * tdy)
                if d < best_dist:
                    best_dist = d
                    best_target = t
            if best_target:
                desired = math.atan2(best_target.y - proj.y, best_target.x - proj.x)
                current = math.atan2(proj.dy, proj.dx)
                diff = (desired - current + math.pi) % (2 * math.pi) - math.pi
                turn = max(-HOMING_TURN_RATE, min(HOMING_TURN_RATE, diff))
                new_angle = current + turn
                proj.dx = math.cos(new_angle)
                proj.dy = math.sin(new_angle)

        proj.x += proj.dx * proj.speed
        proj.y += proj.dy * proj.speed
        proj.traveled += proj.speed

        if proj.x < 0 or proj.x > WORLD_W or proj.y < 0 or proj.y > WORLD_H:
            continue
        if proj.traveled >= proj.max_dist:
            continue

        hit_target = False
        for target in room.players.values():
            if target.user_id == proj.owner_id or target.team == proj.team or not target.alive:
                continue
            tdx = target.x - proj.x
            tdy = target.y - proj.y
            if math.sqrt(tdx * tdx + tdy * tdy) < proj.hit_radius:
                # 物理伤害
                phys_dmg = calculate_damage(
                    int(proj.damage * proj.damage_mult),
                    target.physical_defense, 1.0)
                target.hp = max(0, target.hp - phys_dmg)
                room.damage_numbers.append(DamageNumber(
                    target_id=target.user_id, amount=phys_dmg,
                    x=target.x, y=target.y,
                ))
                # 附加法术伤害（F buff）
                if proj.bonus_magic_dmg > 0:
                    mag_dmg = calculate_damage(
                        int(proj.bonus_magic_dmg * proj.damage_mult),
                        target.magical_defense, 1.0)
                    target.hp = max(0, target.hp - mag_dmg)
                    room.damage_numbers.append(DamageNumber(
                        target_id=target.user_id, amount=mag_dmg,
                        x=target.x, y=target.y - 20,
                    ))
                # 定身（E buff）
                if proj.root_duration > 0:
                    target.root_until = max(
                        target.root_until, time.time() + proj.root_duration)
                # 击杀判定
                if target.hp <= 0:
                    target.alive = False
                    target.hp = 0
                    target._death_time = time.time()
                proj.hit = True
                hit_target = True
                break

        if not hit_target:
            alive_projs.append(proj)
    room.projectiles = alive_projs


def _destroy_mirror(room: Room, player: Player):
    mid = player.mirror_id
    if mid and mid in room.mirrors:
        del room.mirrors[mid]
    player.mirror_id = None
    hero = get_hero(player.hero_id)
    if hero:
        for sk in hero.skills:
            if sk.id == "red_lady_f":
                player.skill_cds['f'] = sk.cooldown
                break


def _tick_mirrors(room: Room):
    expired = [mid for mid, m in room.mirrors.items() if m.expired or not m.active]
    for mid in expired:
        for p in room.players.values():
            if p.mirror_id == mid:
                p.mirror_id = None
                hero = get_hero(p.hero_id)
                if hero:
                    for sk in hero.skills:
                        if sk.id == "red_lady_f":
                            p.skill_cds['f'] = sk.cooldown
                            break
        del room.mirrors[mid]


def _tick_pending_swaps(room: Room):
    now = time.time()
    remaining = []
    for swap in room.pending_swaps:
        if now >= swap['swap_at']:
            player = room.players.get(swap['user_id'])
            if not player or not player.alive:
                continue
            # 定身中：取消传送
            if now < player.root_until:
                continue
            mid = swap['mirror_id']
            if mid not in room.mirrors:
                continue
            mirror = room.mirrors[mid]
            clone_x, clone_y = _clone_position(player, mirror)
            old_x, old_y = player.x, player.y
            player.x, player.y = clone_x, clone_y
            room.effects.append(VisualEffect(
                type="mirror_swap",
                x=player.x, y=player.y,
                data={"from_x": round(old_x, 1), "from_y": round(old_y, 1),
                      "to_x": round(clone_x, 1), "to_y": round(clone_y, 1),
                      "team": player.team.value},
            ))
        else:
            remaining.append(swap)
    room.pending_swaps = remaining


def _tick_respawn(room: Room):
    now = time.time()
    for p in room.players.values():
        if not p.alive and hasattr(p, '_death_time'):
            if now - p._death_time >= RESPAWN_TIME:
                p.alive = True
                p.hp = p.max_hp
                p.attack_cd_remaining = 0
                for key in p.skill_cds:
                    p.skill_cds[key] = 0
                if p.team == Team.RED:
                    p.x, p.y = SPAWN_RED
                else:
                    p.x, p.y = SPAWN_BLUE
                del p._death_time


# ═══════════════════════════════════════════════════════════
# 占领点
# ═══════════════════════════════════════════════════════════

def update_capture_points(room: Room) -> Optional[Team]:
    for cp in room.capture_points:
        red_in = 0
        blue_in = 0
        for p in room.players.values():
            if not p.alive:
                continue
            dist = ((p.x - cp.x) ** 2 + (p.y - cp.y) ** 2) ** 0.5
            if dist <= cp.radius:
                if p.team == Team.RED:
                    red_in += 1
                else:
                    blue_in += 1

        if red_in > blue_in:
            cp.progress = max(-CAPTURE_THRESHOLD, cp.progress - CAPTURE_SPEED * _TICK_SCALE * (red_in - blue_in))
        elif blue_in > red_in:
            cp.progress = min(CAPTURE_THRESHOLD, cp.progress + CAPTURE_SPEED * _TICK_SCALE * (blue_in - red_in))

        if cp.progress <= -CAPTURE_THRESHOLD:
            cp.owner = Team.RED
        elif cp.progress >= CAPTURE_THRESHOLD:
            cp.owner = Team.BLUE
        elif abs(cp.progress) < CAPTURE_THRESHOLD * 0.5:
            if cp.owner is not None and (
                (cp.owner == Team.RED and cp.progress > 0)
                or (cp.owner == Team.BLUE and cp.progress < 0)
            ):
                cp.owner = None

    red_owned = sum(1 for cp in room.capture_points if cp.owner == Team.RED)
    blue_owned = sum(1 for cp in room.capture_points if cp.owner == Team.BLUE)
    if red_owned == 3:
        return Team.RED
    if blue_owned == 3:
        return Team.BLUE
    return None


# ═══════════════════════════════════════════════════════════
# 状态序列化（所有坐标为像素值）
# ═══════════════════════════════════════════════════════════

def get_game_state(room: Room) -> dict:
    clones = []
    for p in room.players.values():
        if p.mirror_id and p.mirror_id in room.mirrors and p.alive:
            mirror = room.mirrors[p.mirror_id]
            cx, cy = _clone_position(p, mirror)
            cf = _mirror_facing(p.facing, mirror)
            clones.append({
                "owner_id": p.user_id,
                "team": p.team.value,
                "x": round(cx, 1),
                "y": round(cy, 1),
                "facing": round(cf, 3),
                "hero_id": p.hero_id,
            })

    state = {
        "type": "game_state",
        "players": [
            {
                "user_id": p.user_id,
                "nickname": p.nickname,
                "team": p.team.value,
                "x": round(p.x, 1),
                "y": round(p.y, 1),
                "alive": p.alive,
                "hp": p.hp,
                "max_hp": p.max_hp,
                "hero_id": p.hero_id,
                "facing": round(p.facing, 3),
                "attack_cd": round(p.attack_cd_remaining, 2),
                "skill_cds": {k: round(v, 2) for k, v in p.skill_cds.items()},
                "move_locked": time.time() < p.move_lock_until,
                "has_mirror": bool(p.mirror_id and p.mirror_id in room.mirrors),
                "mirror_remaining": (
                    round(max(0, room.mirrors[p.mirror_id].duration
                          - (time.time() - room.mirrors[p.mirror_id].created_at)), 1)
                    if p.mirror_id and p.mirror_id in room.mirrors
                    else 0
                ),
                "mirror_used_e": (
                    room.mirrors[p.mirror_id].used_e
                    if p.mirror_id and p.mirror_id in room.mirrors
                    else True
                ),
                "buffs": {
                    "sw_f": round(max(0, p.sw_f_until - time.time()), 2),
                    "sw_e": round(max(0, p.sw_e_until - time.time()), 2),
                    "sw_r": round(max(0, p.sw_r_until - time.time()), 2),
                },
                "rooted": time.time() < p.root_until,
            }
            for p in room.players.values()
        ],
        "capture_points": [
            {
                "id": cp.id,
                "x": round(cp.x, 1),
                "y": round(cp.y, 1),
                "owner": cp.owner.value if cp.owner else None,
                "progress": round(cp.progress, 1),
            }
            for cp in room.capture_points
        ],
        "mirrors": [
            {
                "id": m.id, "owner_id": m.owner_id, "team": m.team.value,
                "x": round(m.x, 1), "y": round(m.y, 1),
                "dir_x": round(m.dir_x, 4), "dir_y": round(m.dir_y, 4),
            }
            for m in room.mirrors.values()
        ],
        "clones": clones,
        "projectiles": [
            {
                "id": p.id, "owner_id": p.owner_id, "team": p.team.value,
                "x": round(p.x, 1), "y": round(p.y, 1),
                "dx": round(p.dx, 4), "dy": round(p.dy, 4),
                "speed": round(p.speed, 1),
                "shard": p.shard_index,
                "ptype": p.proj_type,
            }
            for p in room.projectiles
        ],
    }

    if room.damage_numbers:
        state["damage_numbers"] = [
            {"target_id": d.target_id, "amount": d.amount,
             "x": round(d.x, 1), "y": round(d.y, 1)}
            for d in room.damage_numbers
        ]
        room.damage_numbers = []

    if room.effects:
        state["effects"] = [
            {"type": e.type, "x": round(e.x, 1), "y": round(e.y, 1), "data": e.data}
            for e in room.effects
        ]
        room.effects = []

    return state


# ═══════════════════════════════════════════════════════════
# 广播 & 主循环
# ═══════════════════════════════════════════════════════════

async def broadcast(room: Room, message: dict):
    data = json.dumps(message, ensure_ascii=False)
    disconnected = []
    for uid, player in room.players.items():
        if player.ws:
            try:
                await player.ws.send_text(data)
            except Exception:
                disconnected.append(uid)
    for uid in disconnected:
        room.players[uid].ws = None


async def game_loop(room: Room):
    logger.info(f"Game loop started for room {room.room_id}")
    try:
        while room.status == RoomStatus.PLAYING:
            _tick_cooldowns(room)
            _tick_delayed_balls(room)
            _tick_projectiles(room)
            _tick_mirrors(room)
            _tick_pending_swaps(room)
            _tick_respawn(room)

            winner = update_capture_points(room)
            state = get_game_state(room)
            await broadcast(room, state)

            if winner:
                room.status = RoomStatus.FINISHED
                await broadcast(room, {
                    "type": "game_over",
                    "winner": winner.value,
                })
                break

            await asyncio.sleep(TICK_RATE)
    except asyncio.CancelledError:
        logger.info(f"Game loop cancelled for room {room.room_id}")
    except Exception as e:
        logger.error(f"Game loop error: {e}", exc_info=True)
    finally:
        room.status = RoomStatus.FINISHED
