Source code for mini_arcade_core.engine.entities

"""
Engine entities for mini-arcade-core.
"""

# pylint: disable=too-many-instance-attributes

# ruff: noqa: PLR0902

from __future__ import annotations

from dataclasses import dataclass, field

from mini_arcade_core.engine.animation import Animation
from mini_arcade_core.engine.components import Anim2D, Life, Sprite2D
from mini_arcade_core.engine.render.style import Fill, RenderStyle, Stroke
from mini_arcade_core.spaces.collision.specs import (
    CircleColliderSpec,
    ColliderSpec,
    LineColliderSpec,
    PolyColliderSpec,
    RectColliderSpec,
)
from mini_arcade_core.spaces.geometry.shapes import (
    Circle,
    Line,
    Poly,
    Rect,
    Shape2D,
    Triangle,
)
from mini_arcade_core.spaces.geometry.size import Size2D
from mini_arcade_core.spaces.geometry.transform import Transform2D
from mini_arcade_core.spaces.math.vec2 import Vec2
from mini_arcade_core.spaces.physics.kinematics2d import Kinematic2D


class EntityIdAllocator:
    """
    Simple entity ID allocator.

    :param start: The starting ID for allocation.
    :type start: int
    """

    def __init__(self, start: int = 1000):
        self._next = start

    def new(self) -> int:
        """
        Allocate a new unique entity ID.

        :return: The allocated entity ID.
        :rtype: int
        """
        eid = self._next
        self._next += 1
        return eid


# Justification: This class is a bit long but it's mostly data parsing and construction
# of the entity from a dict, hard to break down more without overcomplicating it.
# pylint: disable=too-many-instance-attributes
@dataclass
class BaseEntity:  # noqa: PLR0902  # pylint: disable=too-many-instance-attributes
    """
    Base entity class.

    :ivar id: The unique ID of the entity.
    :ivar name: The optional name of the entity.
    :ivar transform: The transform of the entity.
    :ivar shape: The shape of the entity.
    :ivar style: The render style of the entity.
    :ivar kinematic: The kinematic body of the entity.
    :ivar collider: The collider specification of the entity.
    :ivar sprite: The sprite component of the entity.
    :ivar anim: The animation component of the entity.
    :ivar life: The life component of the entity.
    """

    id: int
    name: str
    codename: str
    transform: Transform2D
    shape: Shape2D
    z_index: int = 0
    rotation_deg: float = 0.0
    style: RenderStyle | None = None

    kinematic: Kinematic2D | None = None
    collider: ColliderSpec | None = None
    sprite: Sprite2D | None = None
    anim: Anim2D | None = None
    life: Life | None = None
    tags: tuple[str, ...] = field(default_factory=tuple)

    @staticmethod
    def _get_shape_by_kind(kind: str, shape_data: dict) -> Shape2D:
        """
        Get a shape object by its kind and shape data.

        :param kind: The kind of shape (rect, circle, triangle, line, poly).
        :type kind: str
        :param shape_data: The dictionary containing the shape data.
        :type shape_data: dict
        :return: The created shape object.
        :rtype: Shape2D
        """
        shape = Rect(corner_radius=float(shape_data.get("corner_radius", 0.0)))
        if kind == "circle":
            shape = Circle(radius=float(shape_data.get("radius", 0.0)))
        elif kind == "triangle":
            shape = Triangle()
        elif kind == "line":
            # optional support later (needs a/b)
            a = shape_data.get("a", {}) or {}
            b = shape_data.get("b", {}) or {}
            dash = shape_data.get("dash", {}) or {}
            dash_length = dash.get("length", None)
            dash_gap = dash.get("gap", None)
            shape = Line(
                a=Vec2(float(a.get("x", 0.0)), float(a.get("y", 0.0))),
                b=Vec2(float(b.get("x", 0.0)), float(b.get("y", 0.0))),
                dash_length=(
                    float(dash_length) if dash_length is not None else None
                ),
                dash_gap=float(dash_gap) if dash_gap is not None else None,
            )
        elif kind == "poly":
            raw_points = shape_data.get("points") or []
            pts: list[Vec2] = []

            for p in raw_points:
                if isinstance(p, dict):
                    pts.append(
                        Vec2(float(p.get("x", 0.0)), float(p.get("y", 0.0)))
                    )
                else:
                    # allow tuples/lists too
                    x, y = p
                    pts.append(Vec2(float(x), float(y)))

            shape = Poly(points=pts or None)

        return shape

    @classmethod
    def _get_center(cls, transform_data: dict) -> Vec2:
        """
        Get the center position from the entity transform data.

        :param transform_data: The dictionary containing the entity transform data.
        :type transform_data: dict
        :return: The center position as a Vec2 object.
        :rtype: Vec2
        """
        center = transform_data.get("center", {}) or {}
        return Vec2(float(center.get("x", 0.0)), float(center.get("y", 0.0)))

    @classmethod
    def _get_size(cls, transform_data: dict) -> Size2D:
        """
        Get the size from the entity transform data.

        :param transform_data: The dictionary containing the entity transform data.
        :type transform_data: dict
        :return: The size as a Size2D object.
        :rtype: Size2D
        """
        size = transform_data.get("size", {}) or {}
        return Size2D(
            float(size.get("width", 0.0)), float(size.get("height", 0.0))
        )

    @classmethod
    def _get_kinematic(cls, data: dict) -> Kinematic2D | None:
        """
        Get the kinematic body from the entity data.

        :param data: The dictionary containing the entity data.
        :type data: dict
        :return: The kinematic body as a Kinematic2D object.
        :rtype: Kinematic2D
        """
        if data.get("kinematic"):
            k = data["kinematic"]
            vel = k.get("velocity", {}) or {}
            acc = k.get("acceleration", {}) or {}
            return Kinematic2D(
                velocity=Vec2(
                    float(k.get("velocity_x", vel.get("vx", 0.0))),
                    float(k.get("velocity_y", vel.get("vy", 0.0))),
                ),
                accel=Vec2(
                    float(k.get("acceleration_x", acc.get("ax", 0.0))),
                    float(k.get("acceleration_y", acc.get("ay", 0.0))),
                ),
                max_speed=float(k.get("max_speed", 0.0)),
            )
        return None

    @classmethod
    def _get_style(cls, data: dict) -> RenderStyle | None:
        """
        Get the render style from the entity data.

        :param data: The dictionary containing the entity data.
        :type data: dict
        :return: The render style as a RenderStyle object.
        :rtype: RenderStyle
        """
        if data.get("style"):
            st = data["style"]
            fill = None
            fill_raw = st.get("fill", None)
            if isinstance(fill_raw, Fill):
                fill = fill_raw
            elif isinstance(fill_raw, dict):
                fill = Fill(
                    color=tuple(fill_raw.get("color", (255, 255, 255, 255)))
                )
            elif isinstance(fill_raw, (list, tuple)):
                fill = Fill(color=tuple(fill_raw))

            stroke = None
            stroke_raw = st.get("stroke", None)
            if isinstance(stroke_raw, Stroke):
                stroke = stroke_raw
            elif isinstance(stroke_raw, dict):
                stroke = Stroke(
                    color=tuple(stroke_raw.get("color", (255, 255, 255, 255))),
                    thickness=float(stroke_raw.get("thickness", 1.0)),
                )
            elif isinstance(stroke_raw, (list, tuple)):
                stroke = Stroke(color=tuple(stroke_raw))

            return RenderStyle(
                fill=fill,
                stroke=stroke,
            )
        return None

    @classmethod
    def _get_collider(cls, data: dict) -> ColliderSpec | None:
        """
        Get the collider spec from the entity data.

        :param data: The dictionary containing the entity data.
        :type data: dict
        :return: The collider spec.
        :rtype: ColliderSpec | None
        """
        c = data.get("collider", {}) or {}
        if not c:
            return None

        kind = c.get("kind")
        if kind == "rect":
            s = c.get("size", {}) or {}
            size = None
            if s:
                size = Size2D(
                    float(s.get("width", 0.0)),
                    float(s.get("height", 0.0)),
                )
            return RectColliderSpec(size=size)

        if kind == "circle":
            radius = c.get("radius", None)
            return CircleColliderSpec(
                radius=float(radius) if radius is not None else None
            )

        if kind == "line":
            a = c.get("a", {}) or {}
            b = c.get("b", {}) or {}
            return LineColliderSpec(
                a=Vec2(float(a.get("x", 0.0)), float(a.get("y", 0.0))),
                b=Vec2(float(b.get("x", 0.0)), float(b.get("y", 0.0))),
            )

        if kind == "poly":
            raw_points = c.get("points", []) or []
            pts: list[Vec2] = []
            for p in raw_points:
                if isinstance(p, dict):
                    pts.append(
                        Vec2(float(p.get("x", 0.0)), float(p.get("y", 0.0)))
                    )
                else:
                    x, y = p
                    pts.append(Vec2(float(x), float(y)))
            return PolyColliderSpec(points=tuple(pts))

        return None

    @staticmethod
    def _get_tags(data: dict) -> tuple[str, ...]:
        raw_tags = data.get("tags", ()) or ()
        if not isinstance(raw_tags, (list, tuple, set)):
            return ()

        seen: set[str] = set()
        tags: list[str] = []
        for raw_tag in raw_tags:
            if not isinstance(raw_tag, str):
                continue
            tag = raw_tag.strip().lower()
            if not tag or tag in seen:
                continue
            seen.add(tag)
            tags.append(tag)
        return tuple(tags)

    # TODO: Think about refactoring this method later, it's a bit long but it does a lot
    # of parsing and construction of the entity from a dict.
    # Justification: A bit long but it's mostly data parsing and construction
    # hard to break down more without overcomplicating it.
    # pylint: disable=too-many-instance-attributes,too-many-locals
[docs] @classmethod def from_dict(cls, data: dict) -> BaseEntity: """ Create an entity from a dictionary. :param data: The dictionary containing the entity data. :type data: dict :return: The created entity. :rtype: BaseEntity """ t = data.get("transform", {}) or {} center = cls._get_center(t) size = cls._get_size(t) shape_data = data.get("shape", {}) or {} kind = shape_data.get("kind", "rect") shape = cls._get_shape_by_kind(kind, shape_data) kinematic = cls._get_kinematic(data) collider = cls._get_collider(data) style = cls._get_style(data) name: str = data.get("name", "") codename = name.lower().replace(" ", "_") sprite = None if data.get("sprite"): sprite_data = data["sprite"] sprite = Sprite2D(texture=int(sprite_data.get("texture", 0))) life = None if data.get("life"): life_data = data["life"] ttl = life_data.get("ttl", None) alive = bool(life_data.get("alive", True)) life = Life( ttl=float(ttl) if ttl is not None else None, alive=alive ) anim = None if data.get("anim"): anim_data = data["anim"] frames = anim_data.get("frames", []) or [] fps = float(anim_data.get("fps", 0.0)) loop = bool(anim_data.get("loop", False)) anim = Anim2D( anim=Animation(frames=frames, fps=fps, loop=loop), texture=frames[0] if frames else None, ) entity = BaseEntity( id=int(data.get("id", 0)), name=name, codename=codename, transform=Transform2D(center=center, size=size), shape=shape, z_index=int(data.get("z_index", 0)), style=style, rotation_deg=float(t.get("rotation_deg", 0.0)), kinematic=kinematic, collider=collider, sprite=sprite, anim=anim, life=life, tags=cls._get_tags(data), ) if "render_layer" in data: setattr( entity, "render_layer", str(data.get("render_layer") or "world"), ) if "render_visible" in data: setattr( entity, "render_visible", bool(data.get("render_visible", True)), ) return entity
# pylint: enable=too-many-instance-attributes