Source code for mini_arcade_core.scenes.systems.builtins.brick_breaker

"""
Reusable brick-breaker gameplay helpers.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Callable, Generic, Iterable, TypeVar

from mini_arcade_core.engine.entities import BaseEntity
from mini_arcade_core.scenes.systems.builtins.grid import GridCoord, GridLayout
from mini_arcade_core.scenes.systems.phases import SystemPhase

# pylint: disable=invalid-name
TCtx = TypeVar("TCtx")
# pylint: enable=invalid-name


def _default_enabled_when(_ctx: object) -> bool:
    return True


def _default_predicate(_ctx: object, _entity: BaseEntity) -> bool:
    return True


def _entity_rect(entity: BaseEntity) -> tuple[float, float, float, float]:
    return (
        float(entity.transform.center.x),
        float(entity.transform.center.y),
        float(entity.transform.size.width),
        float(entity.transform.size.height),
    )


def _rect_center(
    rect: tuple[float, float, float, float],
) -> tuple[float, float]:
    x, y, w, h = rect
    return (x + (w * 0.5), y + (h * 0.5))


def _rect_overlap(
    a: tuple[float, float, float, float],
    b: tuple[float, float, float, float],
) -> bool:
    ax, ay, aw, ah = a
    bx, by, bw, bh = b
    return not (
        (ax + aw) <= bx
        or ax >= (bx + bw)
        or (ay + ah) <= by
        or ay >= (by + bh)
    )


[docs] @dataclass(frozen=True) class BounceHit: """ Resolved bounce collision information. """ axis: str normal_x: float normal_y: float penetration: float
# pylint: disable=too-many-locals
[docs] def resolve_rect_bounce( mover_rect: tuple[float, float, float, float], target_rect: tuple[float, float, float, float], ) -> BounceHit | None: """ Resolve the shallowest-axis bounce between two overlapping rects. """ if not _rect_overlap(mover_rect, target_rect): return None ax, ay, aw, ah = mover_rect bx, by, bw, bh = target_rect acx, acy = _rect_center(mover_rect) bcx, bcy = _rect_center(target_rect) overlap_left = (ax + aw) - bx overlap_right = (bx + bw) - ax overlap_top = (ay + ah) - by overlap_bottom = (by + bh) - ay pen_x = min(overlap_left, overlap_right) pen_y = min(overlap_top, overlap_bottom) if pen_x < pen_y: normal_x = -1.0 if acx < bcx else 1.0 return BounceHit( axis="x", normal_x=normal_x, normal_y=0.0, penetration=float(pen_x), ) normal_y = -1.0 if acy < bcy else 1.0 return BounceHit( axis="y", normal_x=0.0, normal_y=normal_y, penetration=float(pen_y), )
[docs] def apply_bounce_hit(entity: BaseEntity, hit: BounceHit) -> None: """ Reposition and reflect one kinematic entity according to a resolved hit. """ if entity.kinematic is None: return if hit.axis == "x": entity.transform.center.x += hit.normal_x * hit.penetration entity.kinematic.velocity.x = -float(entity.kinematic.velocity.x) return entity.transform.center.y += hit.normal_y * hit.penetration entity.kinematic.velocity.y = -float(entity.kinematic.velocity.y)
# pylint: disable=too-many-arguments
[docs] def reflect_from_viewport( entity: BaseEntity, *, viewport: tuple[float, float], bounce_left: bool = True, bounce_right: bool = True, bounce_top: bool = True, bounce_bottom: bool = False, ) -> tuple[str, ...]: """ Reflect one entity from selected viewport sides. """ if entity.kinematic is None: return () vw, vh = viewport x, y, w, h = _entity_rect(entity) hit_sides: list[str] = [] if bounce_left and x < 0.0: entity.transform.center.x = 0.0 entity.kinematic.velocity.x = abs(float(entity.kinematic.velocity.x)) hit_sides.append("left") if bounce_right and (x + w) > float(vw): entity.transform.center.x = float(vw) - w entity.kinematic.velocity.x = -abs(float(entity.kinematic.velocity.x)) hit_sides.append("right") if bounce_top and y < 0.0: entity.transform.center.y = 0.0 entity.kinematic.velocity.y = abs(float(entity.kinematic.velocity.y)) hit_sides.append("top") if bounce_bottom and (y + h) > float(vh): entity.transform.center.y = float(vh) - h entity.kinematic.velocity.y = -abs(float(entity.kinematic.velocity.y)) hit_sides.append("bottom") return tuple(hit_sides)
[docs] @dataclass(frozen=True) class PaddleBouncePolicy: """ Shape outgoing ball direction based on paddle contact point. """ max_bounce_angle_deg: float = 70.0 min_speed: float = 180.0 max_speed: float = 420.0 speed_gain: float = 1.04 vertical_bias: float = 1.0 paddle_velocity_influence: float = 0.25 # pylint: disable=too-many-locals
[docs] def apply(self, ball: BaseEntity, paddle: BaseEntity) -> None: """ Apply paddle-shaped bounce to a ball-like entity. """ if ball.kinematic is None: return bx, _, bw, _ = _entity_rect(ball) px, _, pw, _ = _entity_rect(paddle) ball_center_x = bx + (bw * 0.5) paddle_center_x = px + (pw * 0.5) paddle_half = max(1.0, pw * 0.5) normalized = max( -1.0, min(1.0, (ball_center_x - paddle_center_x) / paddle_half), ) vx = float(ball.kinematic.velocity.x) vy = float(ball.kinematic.velocity.y) current_speed = max(self.min_speed, (vx * vx + vy * vy) ** 0.5) speed = max( self.min_speed, min(self.max_speed, current_speed * float(self.speed_gain)), ) paddle_vx = ( float(paddle.kinematic.velocity.x) if paddle.kinematic is not None else 0.0 ) target_vx = (normalized * speed) + ( paddle_vx * float(self.paddle_velocity_influence) ) target_vx = max(-self.max_speed, min(self.max_speed, target_vx)) vertical = max( self.min_speed * 0.35, speed - abs(target_vx), ) * float(self.vertical_bias) ball.kinematic.velocity.x = target_vx ball.kinematic.velocity.y = -abs(vertical)
[docs] @dataclass class BrickState: """ Mutable brick metadata stored inside a brick field. """ hit_points: int = 1 payload: Any = None @property def alive(self) -> bool: """Return whether this brick still has hit points remaining.""" return int(self.hit_points) > 0
[docs] @dataclass class BrickField: """ Dense brick layout with per-cell hit points. """ layout: GridLayout bricks: dict[GridCoord, BrickState] = field(default_factory=dict)
[docs] def brick_at(self, coord: GridCoord) -> BrickState | None: """ Return the brick state at one cell, if alive. """ brick = self.bricks.get(coord) if brick is None or not brick.alive: return None return brick
[docs] def occupied_cells(self) -> tuple[GridCoord, ...]: """ Return the currently alive brick cells. """ return tuple( coord for coord, brick in self.bricks.items() if brick.alive )
[docs] def brick_rect( self, coord: GridCoord ) -> tuple[float, float, float, float]: """ Return the world-space rect for one brick cell. """ return self.layout.cell_rect(coord)
[docs] def apply_damage( self, coord: GridCoord, amount: int = 1 ) -> BrickState | None: """ Damage one brick cell and delete it when hp reaches zero. """ brick = self.brick_at(coord) if brick is None: return None brick.hit_points = max(0, int(brick.hit_points) - int(amount)) if not brick.alive: del self.bricks[coord] return None return brick
[docs] @dataclass(frozen=True) class ViewportBounceBinding(Generic[TCtx]): """ Declarative viewport bounce rule for one or more ball-like entities. """ entities_getter: Callable[[TCtx], Iterable[BaseEntity]] viewport_getter: Callable[[TCtx], tuple[float, float]] = lambda ctx: tuple( getattr(ctx.world, "viewport", (0.0, 0.0)) ) predicate: Callable[[TCtx, BaseEntity], bool] = _default_predicate bounce_left: bool = True bounce_right: bool = True bounce_top: bool = True bounce_bottom: bool = False on_bounce: Callable[[TCtx, BaseEntity, tuple[str, ...]], None] | None = ( None )
[docs] @dataclass class ViewportBounceSystem(Generic[TCtx]): """ Reflect ball-like entities from selected viewport sides. """ name: str = "common_viewport_bounce" phase: int = SystemPhase.SIMULATION order: int = 40 enabled_when: Callable[[TCtx], bool] = _default_enabled_when bindings: tuple[ViewportBounceBinding[TCtx], ...] = ()
[docs] def step(self, ctx: TCtx) -> None: """Reflect configured entities from the active viewport edges.""" if not self.enabled_when(ctx): return for binding in self.bindings: viewport = binding.viewport_getter(ctx) for entity in binding.entities_getter(ctx): if not binding.predicate(ctx, entity): continue sides = reflect_from_viewport( entity, viewport=viewport, bounce_left=binding.bounce_left, bounce_right=binding.bounce_right, bounce_top=binding.bounce_top, bounce_bottom=binding.bounce_bottom, ) if sides and binding.on_bounce is not None: binding.on_bounce(ctx, entity, sides)
[docs] @dataclass(frozen=True) class BounceCollisionBinding(Generic[TCtx]): """ Declarative ball-vs-rect bounce rule. """ mover_getter: Callable[[TCtx], BaseEntity | None] targets_getter: Callable[[TCtx], Iterable[BaseEntity]] predicate: Callable[[TCtx, BaseEntity], bool] = _default_predicate stop_after_first_hit: bool = True on_bounce: ( Callable[[TCtx, BaseEntity, BaseEntity, BounceHit], None] | None ) = None
[docs] @dataclass class BounceCollisionSystem(Generic[TCtx]): """ Reflect one moving rect from one or more target rects. """ name: str = "common_bounce_collision" phase: int = SystemPhase.SIMULATION order: int = 45 enabled_when: Callable[[TCtx], bool] = _default_enabled_when bindings: tuple[BounceCollisionBinding[TCtx], ...] = ()
[docs] def step(self, ctx: TCtx) -> None: """Resolve mover collisions against rect targets and bounce them.""" if not self.enabled_when(ctx): return for binding in self.bindings: mover = binding.mover_getter(ctx) if mover is None or mover.kinematic is None: continue mover_rect = _entity_rect(mover) for target in binding.targets_getter(ctx): if not binding.predicate(ctx, target): continue hit = resolve_rect_bounce(mover_rect, _entity_rect(target)) if hit is None: continue apply_bounce_hit(mover, hit) if binding.on_bounce is not None: binding.on_bounce(ctx, mover, target, hit) mover_rect = _entity_rect(mover) if binding.stop_after_first_hit: break
[docs] @dataclass(frozen=True) class BrickFieldCollisionBinding(Generic[TCtx]): """ Declarative ball-vs-brick-field bounce and damage rule. """ mover_getter: Callable[[TCtx], BaseEntity | None] field_getter: Callable[[TCtx], BrickField | None] damage: int = 1 on_hit: ( Callable[ [TCtx, BaseEntity, GridCoord, BrickState | None, BounceHit], None ] | None ) = None
[docs] @dataclass class BrickFieldCollisionSystem(Generic[TCtx]): """ Reflect a ball-like entity from the first hit brick and damage the field. """ name: str = "common_brick_field_collision" phase: int = SystemPhase.SIMULATION order: int = 46 enabled_when: Callable[[TCtx], bool] = _default_enabled_when bindings: tuple[BrickFieldCollisionBinding[TCtx], ...] = () # pylint: disable=too-many-locals
[docs] def step(self, ctx: TCtx) -> None: """Bounce a mover from the first hit brick cell and apply damage.""" if not self.enabled_when(ctx): return for binding in self.bindings: mover = binding.mover_getter(ctx) brick_field = binding.field_getter(ctx) if mover is None or mover.kinematic is None or brick_field is None: continue mover_rect = _entity_rect(mover) hit_cell: GridCoord | None = None hit_result: BounceHit | None = None for cell in brick_field.occupied_cells(): hit = resolve_rect_bounce( mover_rect, brick_field.brick_rect(cell), ) if hit is None: continue apply_bounce_hit(mover, hit) hit_cell = cell hit_result = hit break if hit_cell is None or hit_result is None: continue remaining = brick_field.apply_damage(hit_cell, binding.damage) if binding.on_hit is not None: binding.on_hit(ctx, mover, hit_cell, remaining, hit_result)
__all__ = [ "BounceCollisionBinding", "BounceCollisionSystem", "BounceHit", "BrickField", "BrickFieldCollisionBinding", "BrickFieldCollisionSystem", "BrickState", "PaddleBouncePolicy", "ViewportBounceBinding", "ViewportBounceSystem", "apply_bounce_hit", "reflect_from_viewport", "resolve_rect_bounce", ]