from __future__ import annotations

import math
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Protocol

from grim.color import RGBA
from grim.geom import Vec2
from grim.math import clamp

from .effects_atlas import EffectId
from .math_parity import f32

__all__ = [
    "FX_QUEUE_CAPACITY",
    "FX_QUEUE_MAX_COUNT",
    "FX_QUEUE_ROTATED_CAPACITY",
    "FX_QUEUE_ROTATED_MAX_COUNT",
    "EFFECT_POOL_SIZE",
    "PARTICLE_POOL_SIZE",
    "SPRITE_EFFECT_POOL_SIZE",
    "FxQueue",
    "FxQueueEntry",
    "FxQueueRotated",
    "FxQueueRotatedEntry",
    "EffectEntry",
    "EffectPool",
    "Particle",
    "ParticleStyleId",
    "ParticlePool",
    "SpriteEffect",
    "SpriteEffectPool",
]

EFFECT_POOL_SIZE = 0x200
PARTICLE_POOL_SIZE = 0x80
SPRITE_EFFECT_POOL_SIZE = 0x180

FX_QUEUE_CAPACITY = 0x80
FX_QUEUE_MAX_COUNT = 0x7F

FX_QUEUE_ROTATED_CAPACITY = 0x40
FX_QUEUE_ROTATED_MAX_COUNT = 0x3F


def _default_rand() -> int:
    return 0


class ParticleStyleId(IntEnum):
    FLAMETHROWER = 0
    BLOW_TORCH = 1
    HR_FLAMER = 2
    BUBBLEGUN = 8


class _CreatureForParticles(Protocol):
    active: bool
    pos: Vec2
    hp: float
    size: float
    hitbox_size: float
    tint: RGBA


CreatureDamageApplier = Callable[[int, float, int, Vec2, int], None]
CreatureKillHandler = Callable[[int, int], None]


@dataclass(slots=True)
class Particle:
    active: bool = False
    render_flag: bool = False
    pos: Vec2 = field(default_factory=Vec2)
    vel: Vec2 = field(default_factory=Vec2)
    scale_x: float = 1.0
    scale_y: float = 1.0
    scale_z: float = 1.0
    age: float = 0.0
    intensity: float = 0.0
    angle: float = 0.0
    spin: float = 0.0
    style_id: int = ParticleStyleId.FLAMETHROWER
    target_id: int = -1
    owner_id: int = -100


class ParticlePool:
    def __init__(self, *, size: int = PARTICLE_POOL_SIZE, rand: Callable[[], int] | None = None) -> None:
        self._entries = [Particle() for _ in range(int(size))]
        self._rand = rand or _default_rand

    @property
    def entries(self) -> list[Particle]:
        return self._entries

    def reset(self) -> None:
        for entry in self._entries:
            entry.active = False

    def _alloc_slot(self) -> int:
        for i, entry in enumerate(self._entries):
            if not entry.active:
                return i
        if not self._entries:
            raise ValueError("Particle pool has zero entries")
        # Native: `crt_rand() & 0x7f` (pool size is 0x80).
        return int(self._rand()) % len(self._entries)

    def spawn_particle(
        self,
        *,
        pos: Vec2,
        angle: float,
        intensity: float = 1.0,
        owner_id: int = -100,
    ) -> int:
        """Port of `fx_spawn_particle` (0x00420130)."""

        idx = self._alloc_slot()
        entry = self._entries[idx]
        entry.active = True
        entry.render_flag = True
        entry.pos = pos
        entry.vel = Vec2.from_angle(angle) * 90.0
        entry.scale_x = 1.0
        entry.scale_y = 1.0
        entry.scale_z = 1.0
        entry.age = 0.0
        entry.intensity = float(intensity)
        entry.angle = float(angle)
        entry.spin = float(int(self._rand()) % 0x274) * 0.01
        entry.style_id = ParticleStyleId.FLAMETHROWER
        entry.target_id = -1
        entry.owner_id = int(owner_id)
        return idx

    def spawn_particle_slow(
        self,
        *,
        pos: Vec2,
        angle: float,
        owner_id: int = -100,
    ) -> int:
        """Port of `fx_spawn_particle_slow` (0x00420240)."""

        idx = self._alloc_slot()
        entry = self._entries[idx]
        entry.active = True
        entry.render_flag = True
        entry.pos = pos
        entry.vel = Vec2.from_angle(angle) * 30.0
        entry.scale_x = 1.0
        entry.scale_y = 1.0
        entry.scale_z = 1.0
        entry.age = 0.0
        entry.intensity = 1.0
        entry.angle = float(angle)
        entry.spin = float(int(self._rand()) % 0x274) * 0.01
        entry.style_id = ParticleStyleId.BUBBLEGUN
        entry.target_id = -1
        entry.owner_id = int(owner_id)
        return idx

    def iter_active(self) -> list[Particle]:
        return [entry for entry in self._entries if entry.active]

    def update(
        self,
        dt: float,
        *,
        creatures: Sequence[_CreatureForParticles] | None = None,
        apply_creature_damage: CreatureDamageApplier | None = None,
        kill_creature: CreatureKillHandler | None = None,
        fx_queue: FxQueue | None = None,
        sprite_effects: SpriteEffectPool | None = None,
    ) -> list[int]:
        """Advance particles and deactivate expired entries.

        This is a minimal port of the particle loop inside `projectile_update`
        (0x00420b90). It captures the per-style decay/movement rules that drive
        visual lifetimes and the weapon-driven collision damage.

        Returns indices of particles that were deactivated this tick.
        """

        if dt <= 0.0:
            return []
        dt = f32(float(dt))

        def _creature_find_in_radius(*, pos: Vec2, radius: float) -> int:
            if creatures is None:
                return -1
            max_index = min(len(creatures), 0x180)
            radius = f32(float(radius))

            for creature_idx in range(max_index):
                creature = creatures[creature_idx]
                if not creature.active:
                    continue
                # Native particle `creature_find_in_radius` is hitbox-gated, not
                # HP-gated: freshly killed creatures (hp<=0, hitbox>5) can still
                # receive same-tick style-0 damage callbacks.
                if creature.hitbox_size < 5.0:
                    continue

                size = f32(float(creature.size))
                dx = f32(float(creature.pos.x) - float(pos.x))
                dy = f32(float(creature.pos.y) - float(pos.y))
                dist_sq = f32(f32(float(dx) * float(dx)) + f32(float(dy) * float(dy)))
                dist = f32(f32(math.sqrt(float(dist_sq))) - float(radius))
                threshold = f32(f32(float(size) * 0.14285715) + 3.0)
                if float(threshold) < float(dist):
                    continue
                return int(creature_idx)

            return -1

        expired: list[int] = []
        rand = self._rand

        for idx, entry in enumerate(self._entries):
            if not entry.active:
                continue

            style = int(entry.style_id) & 0xFF

            if style == int(ParticleStyleId.BUBBLEGUN):
                entry.intensity = f32(float(entry.intensity) - float(dt) * 0.11)
                entry.spin = f32(float(entry.spin) + float(dt) * 5.0)
                move_scale = float(entry.intensity)
                if move_scale <= 0.15:
                    move_scale *= 0.55
                move = entry.vel * (float(dt) * float(move_scale))
                entry.pos = Vec2(
                    f32(float(entry.pos.x) + float(move.x)),
                    f32(float(entry.pos.y) + float(move.y)),
                )
            else:
                entry.intensity = f32(float(entry.intensity) - float(dt) * 0.9)
                entry.spin = f32(float(entry.spin) + float(dt))
                move_scale = max(float(entry.intensity), 0.15) * 2.5
                move = entry.vel * (float(dt) * float(move_scale))
                entry.pos = Vec2(
                    f32(float(entry.pos.x) + float(move.x)),
                    f32(float(entry.pos.y) + float(move.y)),
                )

            alive = entry.intensity > (0.0 if style == int(ParticleStyleId.FLAMETHROWER) else 0.8)
            if not alive:
                entry.active = False
                expired.append(idx)
                if style == int(ParticleStyleId.BUBBLEGUN) and entry.target_id != -1:
                    target_id = int(entry.target_id)
                    entry.target_id = -1
                    if kill_creature is not None:
                        kill_creature(target_id, int(entry.owner_id))
                    elif creatures is not None and 0 <= target_id < len(creatures):
                        creatures[target_id].hp = -1.0
                        creatures[target_id].active = False
                continue

            if entry.render_flag:
                # Random walk drift (native adjusts angle based on `crt_rand`).
                jitter = f32(float(int(rand()) % 100 - 50) * 0.06 * max(float(entry.intensity), 0.0) * float(dt))
                if style == int(ParticleStyleId.FLAMETHROWER):
                    jitter = f32(float(jitter) * 1.96)
                    speed = 82.0
                elif style == int(ParticleStyleId.BUBBLEGUN):
                    jitter = f32(float(jitter) * 1.1)
                    speed = 62.0
                else:
                    jitter = f32(float(jitter) * 1.1)
                    speed = 82.0
                entry.angle = f32(float(entry.angle) - float(jitter))
                vel = Vec2.from_angle(float(entry.angle)) * speed
                entry.vel = Vec2(
                    f32(float(vel.x)),
                    f32(float(vel.y)),
                )

            alpha = clamp(entry.intensity, 0.0, 1.0)
            shade = 1.0 - max(entry.intensity, 0.0) * 0.95
            entry.age = alpha
            entry.scale_x = shade
            entry.scale_y = shade
            # Native only updates scale_x/scale_y; scale_z stays at its spawn value (1.0).

            if (
                style == int(ParticleStyleId.BUBBLEGUN)
                and (not entry.render_flag)
                and entry.target_id != -1
                and creatures is not None
            ):
                target_id = int(entry.target_id)
                if 0 <= target_id < len(creatures) and creatures[target_id].active:
                    entry.pos = creatures[target_id].pos

            if entry.render_flag and creatures is not None:
                hit_idx = _creature_find_in_radius(pos=entry.pos, radius=max(float(entry.intensity), 0.0) * 8.0)
                if hit_idx != -1:
                    entry.render_flag = False
                    creature = creatures[hit_idx]
                    if style == int(ParticleStyleId.BUBBLEGUN):
                        entry.target_id = int(hit_idx)
                        entry.pos = creature.pos
                        entry.vel = Vec2()
                    else:
                        entry.angle = float(entry.angle) % math.tau
                        hit_angle = Vec2(
                            (entry.pos.x - entry.vel.x * dt) - creature.pos.x,
                            (entry.pos.y - entry.vel.y * dt) - creature.pos.y,
                        ).to_angle()
                        hit_angle = float(hit_angle) % math.tau
                        deflect_step = math.tau * 0.2
                        if float(entry.angle) <= float(hit_angle):
                            entry.angle = f32(float(entry.angle) + deflect_step)
                        else:
                            entry.angle = f32(float(entry.angle) - deflect_step)

                        bounce_velocity = Vec2.from_angle(float(entry.angle)) * 82.0
                        speed_scale = f32(float(int(rand()) % 10) * 0.1)
                        entry.vel = Vec2(
                            f32(float(bounce_velocity.x) * float(speed_scale)),
                            f32(float(bounce_velocity.y) * float(speed_scale)),
                        )

                        damage = max(0.0, float(entry.intensity) * 10.0)
                        if damage > 0.0:
                            if apply_creature_damage is not None:
                                apply_creature_damage(int(hit_idx), float(damage), 4, Vec2(), int(entry.owner_id))
                            else:
                                creature.hp -= float(damage)

                        tint = creature.tint
                        tint_sum = float(tint.r) + float(tint.g) + float(tint.b)
                        if tint_sum > 1.6:
                            factor = 1.0 - float(entry.intensity) * 0.01
                            creature.tint = tint.scaled(factor).clamped()

                        if sprite_effects is not None and (idx % 3 == 0):
                            sprite_vel = Vec2(
                                float(int(rand()) % 0x3C - 0x1E),
                                float(int(rand()) % 0x3C - 0x1E),
                            )
                            sprite_effects.spawn(
                                pos=creature.pos,
                                vel=sprite_vel,
                                scale=13.0,
                                color=RGBA(1.0, 1.0, 1.0, 0.7),
                            )

                        if fx_queue is not None:
                            fx_queue.add_random(
                                pos=creature.pos,
                                rand=rand,
                            )

                        creature.pos = Vec2(
                            f32(float(creature.pos.x) + float(entry.vel.x) * float(dt)),
                            f32(float(creature.pos.y) + float(entry.vel.y) * float(dt)),
                        )

        return expired


@dataclass(slots=True)
class SpriteEffect:
    active: bool = False
    color: RGBA = field(default_factory=lambda: RGBA(a=0.0))
    rotation: float = 0.0
    pos: Vec2 = field(default_factory=Vec2)
    vel: Vec2 = field(default_factory=Vec2)
    scale: float = 1.0


class SpriteEffectPool:
    def __init__(self, *, size: int = SPRITE_EFFECT_POOL_SIZE, rand: Callable[[], int] | None = None) -> None:
        self._entries = [SpriteEffect() for _ in range(int(size))]
        self._rand = rand or _default_rand

    @property
    def entries(self) -> list[SpriteEffect]:
        return self._entries

    def reset(self) -> None:
        for entry in self._entries:
            entry.active = False

    def spawn(self, *, pos: Vec2, vel: Vec2, scale: float = 1.0, color: RGBA | None = None) -> int:
        """Port of `fx_spawn_sprite` (0x0041fbb0)."""

        idx = None
        for i, entry in enumerate(self._entries):
            if not entry.active:
                idx = i
                break
        if idx is None:
            if not self._entries:
                raise ValueError("Sprite effect pool has zero entries")
            idx = int(self._rand()) % len(self._entries)

        entry = self._entries[idx]
        entry.active = True
        entry.color = RGBA() if color is None else color
        entry.rotation = float(int(self._rand()) % 0x274) * 0.01
        entry.pos = pos
        entry.vel = vel
        entry.scale = float(scale)
        return idx

    def iter_active(self) -> list[SpriteEffect]:
        return [entry for entry in self._entries if entry.active]

    def update(self, dt: float) -> list[int]:
        if dt <= 0.0:
            return []

        expired: list[int] = []
        for idx, entry in enumerate(self._entries):
            if not entry.active:
                continue
            entry.pos = entry.pos + entry.vel * dt
            entry.rotation += dt * 3.0
            entry.color = entry.color.with_alpha(entry.color.a - dt)
            entry.scale += dt * 60.0
            if entry.color.a <= 0.0:
                entry.active = False
                expired.append(idx)
        return expired


@dataclass(slots=True)
class FxQueueEntry:
    effect_id: int = 0
    rotation: float = 0.0
    pos: Vec2 = field(default_factory=Vec2)
    height: float = 0.0
    width: float = 0.0
    color: RGBA = field(default_factory=RGBA)


class FxQueue:
    """Per-frame terrain decal queue (`fx_queue` / `fx_queue_add`)."""

    def __init__(self, *, capacity: int = FX_QUEUE_CAPACITY, max_count: int = FX_QUEUE_MAX_COUNT) -> None:
        capacity = max(0, int(capacity))
        max_count = max(0, min(int(max_count), capacity))
        self._entries = [FxQueueEntry() for _ in range(capacity)]
        self._count = 0
        self._max_count = max_count

    @property
    def entries(self) -> list[FxQueueEntry]:
        return self._entries

    @property
    def count(self) -> int:
        return self._count

    def clear(self) -> None:
        self._count = 0

    def iter_active(self) -> list[FxQueueEntry]:
        return self._entries[: self._count]

    def add(
        self,
        *,
        effect_id: int,
        pos: Vec2,
        width: float,
        height: float,
        rotation: float,
        rgba: RGBA,
    ) -> bool:
        """Port of `fx_queue_add` (0x0041e840)."""

        if self._count >= self._max_count:
            return False

        entry = self._entries[self._count]
        entry.effect_id = int(effect_id)
        entry.rotation = float(rotation)
        entry.pos = pos
        entry.height = float(height)
        entry.width = float(width)
        entry.color = rgba
        self._count += 1
        return True

    def add_random(self, *, pos: Vec2, rand: Callable[[], int]) -> bool:
        """Port of `fx_queue_add_random` (effect ids 3..7 with grayscale tint)."""
        # Native `fx_queue_add_random` always consumes RNG even when the queue
        # is full, then lets `fx_queue_add` fail silently.
        gray = float(int(rand()) & 0xF) * 0.01 + 0.84
        w = float(int(rand()) % 0x18 - 0x0C) + 30.0
        rotation = float(int(rand()) % 0x274) * 0.01
        effect_id = int(rand()) % 5 + 3
        return self.add(
            effect_id=effect_id,
            pos=pos,
            width=w,
            height=w,
            rotation=rotation,
            rgba=RGBA(gray, gray, gray, 1.0),
        )


@dataclass(slots=True)
class FxQueueRotatedEntry:
    top_left: Vec2 = field(default_factory=Vec2)
    color: RGBA = field(default_factory=RGBA)
    rotation: float = 0.0
    scale: float = 1.0
    creature_type_id: int = 0


class FxQueueRotated:
    """Rotated corpse queue (`fx_queue_rotated` / `fx_queue_add_rotated`)."""

    def __init__(
        self, *, capacity: int = FX_QUEUE_ROTATED_CAPACITY, max_count: int = FX_QUEUE_ROTATED_MAX_COUNT,
    ) -> None:
        capacity = max(0, int(capacity))
        max_count = max(0, min(int(max_count), capacity))
        self._entries = [FxQueueRotatedEntry() for _ in range(capacity)]
        self._count = 0
        self._max_count = max_count

    @property
    def entries(self) -> list[FxQueueRotatedEntry]:
        return self._entries

    @property
    def count(self) -> int:
        return self._count

    def clear(self) -> None:
        self._count = 0

    def iter_active(self) -> list[FxQueueRotatedEntry]:
        return self._entries[: self._count]

    def add(
        self,
        *,
        top_left: Vec2,
        rgba: RGBA,
        rotation: float,
        scale: float,
        creature_type_id: int,
        terrain_bodies_transparency: float = 0.0,
        terrain_texture_failed: bool = False,
    ) -> bool:
        """Port of `fx_queue_add_rotated` (0x00427840)."""

        if terrain_texture_failed:
            return False
        if self._count >= self._max_count:
            return False

        color = rgba
        a = color.a
        if terrain_bodies_transparency != 0.0:
            a = a / float(terrain_bodies_transparency)
        else:
            a = a * 0.8

        entry = self._entries[self._count]
        entry.top_left = top_left
        entry.color = color.with_alpha(a)
        entry.rotation = float(rotation)
        entry.scale = float(scale)
        entry.creature_type_id = int(creature_type_id)

        self._count += 1
        return True


@dataclass(slots=True)
class EffectEntry:
    pos: Vec2 = field(default_factory=Vec2)
    effect_id: int = 0
    vel: Vec2 = field(default_factory=Vec2)
    rotation: float = 0.0
    scale: float = 1.0
    half_width: float = 0.0
    half_height: float = 0.0
    age: float = 0.0
    lifetime: float = 0.0
    flags: int = 0
    color: RGBA = field(default_factory=RGBA)
    rotation_step: float = 0.0
    scale_step: float = 0.0


class EffectPool:
    """Effect pool (`effect_spawn`, `effects_update`).

    This pool renders transient particle quads and can optionally enqueue decals
    into `FxQueue` on expiry (flags bit `0x80`).
    """

    def __init__(self, *, size: int = EFFECT_POOL_SIZE) -> None:
        size = max(0, int(size))
        self._entries = [EffectEntry() for _ in range(size)]
        self._free = list(range(size - 1, -1, -1))
        self._detail_toggle = 0
        self._overwrite_cursor = 0

    @property
    def entries(self) -> list[EffectEntry]:
        return self._entries

    def reset(self) -> None:
        for entry in self._entries:
            entry.flags = 0
        self._free = list(range(len(self._entries) - 1, -1, -1))
        self._detail_toggle = 0
        self._overwrite_cursor = 0

    def iter_active(self) -> list[EffectEntry]:
        return [entry for entry in self._entries if entry.flags]

    def _alloc_slot(self, *, detail_preset: int) -> int | None:
        # Native: if detail_preset < 3, skip every other spawn attempt.
        if int(detail_preset) < 3:
            skip = self._detail_toggle & 1
            self._detail_toggle += 1
            if skip:
                return None

        if self._free:
            return self._free.pop()

        if not self._entries:
            return None

        idx = self._overwrite_cursor % len(self._entries)
        self._overwrite_cursor = idx + 1
        return idx

    def spawn(
        self,
        *,
        effect_id: int,
        pos: Vec2,
        vel: Vec2,
        rotation: float,
        scale: float,
        half_width: float,
        half_height: float,
        age: float,
        lifetime: float,
        flags: int,
        color: RGBA,
        rotation_step: float,
        scale_step: float,
        detail_preset: int,
    ) -> int | None:
        idx = self._alloc_slot(detail_preset=int(detail_preset))
        if idx is None:
            return None

        entry = self._entries[idx]
        entry.pos = pos
        entry.effect_id = int(effect_id)
        entry.vel = vel
        entry.rotation = float(rotation)
        entry.scale = float(scale)
        entry.half_width = float(half_width)
        entry.half_height = float(half_height)
        entry.age = float(age)
        entry.lifetime = float(lifetime)
        entry.flags = int(flags)
        entry.color = color
        entry.rotation_step = float(rotation_step)
        entry.scale_step = float(scale_step)
        return idx

    def free(self, idx: int) -> None:
        if not (0 <= idx < len(self._entries)):
            return
        entry = self._entries[idx]
        entry.flags = 0
        self._free.append(idx)

    def update(self, dt: float, *, fx_queue: FxQueue | None = None) -> None:
        """Advance active effects and enqueue terrain decals on expiry."""

        if dt <= 0.0:
            return

        for idx, entry in enumerate(self._entries):
            flags = int(entry.flags)
            if not flags:
                continue

            age = float(entry.age) + float(dt)
            entry.age = age
            lifetime = float(entry.lifetime)

            if age < lifetime:
                if age >= 0.0:
                    entry.pos = entry.pos + entry.vel * float(dt)
                    if flags & 0x4:
                        entry.rotation += float(entry.rotation_step) * float(dt)
                    if flags & 0x8:
                        entry.scale += float(entry.scale_step) * float(dt)
                    if flags & 0x10:
                        next_alpha = 1.0 - age / lifetime if lifetime > 1e-9 else 0.0
                        entry.color = entry.color.with_alpha(next_alpha)
                continue

            if fx_queue is not None and (flags & 0x80):
                # On expiry, the native code overrides alpha before queuing.
                alpha = 0.35 if (flags & 0x100) else 0.8
                fx_queue.add(
                    effect_id=int(entry.effect_id),
                    pos=entry.pos,
                    width=float(entry.half_width) * 2.0,
                    height=float(entry.half_height) * 2.0,
                    rotation=float(entry.rotation),
                    rgba=entry.color.with_alpha(alpha),
                )

            self.free(idx)

    def spawn_shell_casing(
        self,
        *,
        pos: Vec2,
        aim_heading: float,
        weapon_flags: int,
        rand: Callable[[], int],
        detail_preset: int,
    ) -> None:
        """Port of the casing spawn in `player_update` (effect_id 0x12)."""

        if (int(weapon_flags) & 0x1) == 0:
            return

        angle = float(aim_heading) + float(int(rand()) & 0x3F) * 0.01
        speed = float(int(rand()) & 0x3F) * 0.022727273 + 1.0
        velocity = Vec2.from_angle(angle) * (speed * 100.0)

        rotation = float((int(rand()) & 0x3F) - 0x20) * 0.1
        rotation_step = (float(int(rand()) % 0x14) * 0.1 - 1.0) * 14.0

        self.spawn(
            effect_id=int(EffectId.CASING),
            pos=pos,
            vel=velocity,
            rotation=float(rotation),
            scale=1.0,
            half_width=2.0,
            half_height=2.0,
            age=0.0,
            lifetime=0.15,
            flags=0x1C5,
            color=RGBA(1.0, 1.0, 1.0, 0.6),
            rotation_step=float(rotation_step),
            scale_step=0.0,
            detail_preset=int(detail_preset),
        )

    def spawn_blood_splatter(
        self,
        *,
        pos: Vec2,
        angle: float,
        age: float,
        rand: Callable[[], int],
        detail_preset: int,
        fx_toggle: int,
    ) -> None:
        """Port of `effect_spawn_blood_splatter` (0x0042eb10)."""

        if int(fx_toggle) != 0:
            return

        lifetime = 0.25 - float(age)
        base = float(angle) + math.pi
        direction = Vec2.from_angle(base)

        for _ in range(2):
            r0 = int(rand())
            rotation = float((r0 & 0x3F) - 0x20) * 0.1 + base
            r1 = int(rand())
            half = float((r1 & 7) + 1)
            r2 = int(rand())
            speed_x = float((r2 & 0x3F) + 100)
            r3 = int(rand())
            speed_y = float((r3 & 0x3F) + 100)
            velocity = Vec2(direction.x * speed_x, direction.y * speed_y)
            r4 = int(rand())
            scale_step = float(r4 & 0x7F) * 0.03 + 0.1

            self.spawn(
                effect_id=int(EffectId.BLOOD_SPLATTER),
                pos=pos,
                vel=velocity,
                rotation=rotation,
                scale=1.0,
                half_width=half,
                half_height=half,
                age=float(age),
                lifetime=lifetime,
                flags=0xC9,
                color=RGBA(1.0, 1.0, 1.0, 0.5),
                rotation_step=0.0,
                scale_step=scale_step,
                detail_preset=int(detail_preset),
            )

    def spawn_burst(
        self,
        *,
        pos: Vec2,
        count: int,
        rand: Callable[[], int],
        detail_preset: int,
        lifetime: float = 0.5,
        scale_step: float | None = None,
        color: RGBA = RGBA(0.4, 0.5, 1.0, 0.5),
    ) -> None:
        """Port of `effect_spawn_burst` (0x0042ef60)."""

        count = max(0, int(count))
        for _ in range(count):
            r0 = int(rand())
            rotation = float(r0 & 0x7F) * 0.049087387
            r1 = int(rand())
            vx = float((r1 & 0x7F) - 0x40)
            r2 = int(rand())
            vy = float((r2 & 0x7F) - 0x40)
            velocity = Vec2(vx, vy)
            if scale_step is None:
                r3 = int(rand())
                step = float(r3 % 100) * 0.01 + 0.1
            else:
                step = float(scale_step)

            self.spawn(
                effect_id=int(EffectId.BURST),
                pos=pos,
                vel=velocity,
                rotation=rotation,
                scale=1.0,
                half_width=32.0,
                half_height=32.0,
                age=0.0,
                lifetime=float(lifetime),
                flags=0x1D,
                color=color,
                rotation_step=0.0,
                scale_step=step,
                detail_preset=int(detail_preset),
            )

    def spawn_ring(
        self,
        *,
        pos: Vec2,
        detail_preset: int,
        color: RGBA,
        lifetime: float = 0.25,
        scale_step: float = 50.0,
    ) -> None:
        """Ring/halo burst used by bonus pickup effects (`bonus_apply`)."""

        self.spawn(
            effect_id=int(EffectId.RING),
            pos=pos,
            vel=Vec2(),
            rotation=0.0,
            scale=1.0,
            half_width=32.0,
            half_height=32.0,
            age=0.0,
            lifetime=float(lifetime),
            flags=0x19,
            color=color,
            rotation_step=0.0,
            scale_step=float(scale_step),
            detail_preset=int(detail_preset),
        )

    def spawn_freeze_shard(
        self,
        *,
        pos: Vec2,
        angle: float,
        rand: Callable[[], int],
        detail_preset: int,
    ) -> None:
        """Port of `effect_spawn_freeze_shard` (0x0042ec80)."""

        lifetime = float(int(rand()) & 0xF) * 0.01 + 0.2
        base = float(angle) + math.pi

        rotation = float(int(rand()) % 100) * 0.01 + base
        half = float(int(rand()) % 5 + 7)

        velocity = Vec2.from_angle(base) * 114.0

        rotation_step = (float(int(rand()) % 0x14) * 0.1 - 1.0) * 4.0
        scale_step = -float(int(rand()) & 0xF) * 0.1

        effect_id = int(rand()) % 3 + 8
        self.spawn(
            effect_id=int(effect_id),
            pos=pos,
            vel=velocity,
            rotation=float(rotation),
            scale=1.0,
            half_width=float(half),
            half_height=float(half),
            age=0.0,
            lifetime=float(lifetime),
            flags=0x1CD,
            color=RGBA(1.0, 1.0, 1.0, 0.5),
            rotation_step=float(rotation_step),
            scale_step=float(scale_step),
            detail_preset=int(detail_preset),
        )

    def spawn_freeze_shatter(
        self,
        *,
        pos: Vec2,
        angle: float,
        rand: Callable[[], int],
        detail_preset: int,
    ) -> None:
        """Port of `effect_spawn_freeze_shatter` (0x0042ee00)."""

        lifetime = 1.1
        for idx in range(4):
            rotation = float(idx) * (math.pi / 2.0) + float(angle)
            velocity = Vec2.from_angle(rotation) * 42.0
            half = float(int(rand()) % 10 + 0x12)
            rotation_step = (float(int(rand()) % 0x14) * 0.1 - 1.0) * 1.9

            self.spawn(
                effect_id=int(EffectId.FREEZE_SHATTER),
                pos=pos,
                vel=velocity,
                rotation=float(rotation),
                scale=1.0,
                half_width=float(half),
                half_height=float(half),
                age=0.0,
                lifetime=float(lifetime),
                flags=0x5D,
                color=RGBA(1.0, 1.0, 1.0, 0.5),
                rotation_step=float(rotation_step),
                scale_step=0.0,
                detail_preset=int(detail_preset),
            )

        for _ in range(4):
            shard_angle = float(int(rand()) % 0x264) * 0.01
            self.spawn_freeze_shard(
                pos=pos,
                angle=float(shard_angle),
                rand=rand,
                detail_preset=int(detail_preset),
            )

    def spawn_explosion_burst(
        self,
        *,
        pos: Vec2,
        scale: float,
        rand: Callable[[], int],
        detail_preset: int,
    ) -> None:
        """Port of `effect_spawn_explosion_burst` (0x0042f6c0)."""

        detail_preset = int(detail_preset)
        scale = float(scale)

        # Shockwave ring.
        self.spawn(
            effect_id=int(EffectId.RING),
            pos=pos,
            vel=Vec2(),
            rotation=0.0,
            scale=1.0,
            half_width=32.0,
            half_height=32.0,
            age=-0.1,
            lifetime=0.35,
            flags=0x19,
            color=RGBA(0.6, 0.6, 0.6, 1.0),
            rotation_step=0.0,
            scale_step=scale * 25.0,
            detail_preset=detail_preset,
        )

        # Dark explosion puffs (high detail only).
        if detail_preset > 3:
            for idx in range(2):
                age = float(idx) * 0.2 - 0.5
                lifetime = float(idx) * 0.2 + 0.6
                rotation = float(int(rand()) % 0x266) * 0.02
                self.spawn(
                    effect_id=int(EffectId.EXPLOSION_PUFF),
                    pos=pos,
                    vel=Vec2(),
                    rotation=float(rotation),
                    scale=1.0,
                    half_width=32.0,
                    half_height=32.0,
                    age=float(age),
                    lifetime=float(lifetime),
                    flags=0x5D,
                    color=RGBA(0.1, 0.1, 0.1, 1.0),
                    rotation_step=1.4,
                    scale_step=scale * 5.0,
                    detail_preset=detail_preset,
                )

        # Bright flash.
        self.spawn(
            effect_id=int(EffectId.BURST),
            pos=pos,
            vel=Vec2(),
            rotation=0.0,
            scale=1.0,
            half_width=32.0,
            half_height=32.0,
            age=0.0,
            lifetime=0.3,
            flags=0x19,
            color=RGBA(1.0, 1.0, 1.0, 1.0),
            rotation_step=0.0,
            scale_step=scale * 45.0,
            detail_preset=detail_preset,
        )

        if detail_preset < 2:
            count = 1
        else:
            count = 3 + (1 if detail_preset > 3 else 0)

        # Extra shockwave particles.
        for _ in range(count):
            rotation = float(int(rand()) % 0x13A) * 0.02
            velocity = Vec2(
                float((int(rand()) & 0x3F) * 2 - 0x40),
                float((int(rand()) & 0x3F) * 2 - 0x40),
            )
            scale_step = float((int(rand()) - 3) & 7) * scale
            rotation_step = float((int(rand()) + 3) & 7)
            self.spawn(
                effect_id=int(EffectId.EXPLOSION_BURST),
                pos=pos,
                vel=velocity,
                rotation=float(rotation),
                scale=1.0,
                half_width=32.0,
                half_height=32.0,
                age=0.0,
                lifetime=0.7,
                flags=0x1D,
                color=RGBA(1.0, 1.0, 1.0, 1.0),
                rotation_step=float(rotation_step),
                scale_step=float(scale_step),
                detail_preset=detail_preset,
            )
