from __future__ import annotations
from collections import OrderedDict
import math
import pygame
from core.ecs import System, Entity
from core.components.transform import Transform
from core.components.camera import CameraComponent
from core.components.sprite_renderer import SpriteRenderer
from core.components.animator import AnimatorComponent
from core.components.particle_emitter import ParticleEmitterComponent
from core.components.tilemap import TilemapComponent
from core.components.ui import (
TextRenderer, ButtonComponent, TextInputComponent, SliderComponent,
ProgressBarComponent, CheckBoxComponent, ImageRenderer,
HBoxContainerComponent, VBoxContainerComponent, GridBoxContainerComponent
)
[docs]
class RenderSystem(System):
def __init__(self, surface: pygame.Surface):
super().__init__()
self.update_phase = "render"
self.surface = surface
self.use_camera_components = True
self.camera_x = 0.0
self.camera_y = 0.0
self.camera_zoom = 1.0
self.camera_rotation = 0.0
self.design_size = None # (width, height) of the game design resolution
self.skip_ui_render = False # When True, update() won't call render_ui()
self._particle_surface_cache = OrderedDict()
self._particle_cache_max = 1024
self.interpolation_alpha = 1.0
self._sorted_entities_cache = None
self._sort_entity_count: int = -1
self._sort_layer_snapshot: list | None = None
self._ui_entity_cache: set | None = None
self._ui_cache_entity_count: int = -1
self._cached_surface_size = self._surface_size()
self._font_cache: dict[tuple, pygame.font.Font] = {}
self._sprite_scale_cache: OrderedDict[tuple, pygame.Surface] = OrderedDict()
self._sprite_scale_cache_max: int = 512
self.smooth_present = True
def _get_font(self, font_path, font_size: int) -> pygame.font.Font:
"""Return a cached pygame.font.Font for the given path and size."""
key = (font_path, font_size)
cached = self._font_cache.get(key)
if cached is not None:
return cached
font = pygame.font.Font(font_path, font_size)
self._font_cache[key] = font
return font
def _get_sorted_entities(self, entities: list[Entity]) -> list[Entity]:
"""Return entities sorted by layer order, cached until entity list or layers change."""
current_count = len(entities)
current_layers = getattr(self.world, "layers", None) if self.world else None
if (
self._sorted_entities_cache is not None
and current_count == self._sort_entity_count
and current_layers is self._sort_layer_snapshot
):
return self._sorted_entities_cache
if current_layers:
layer_indices = {name: i for i, name in enumerate(current_layers)}
self._sorted_entities_cache = sorted(entities, key=lambda e: layer_indices.get(e.layer, 0))
else:
self._sorted_entities_cache = list(entities)
self._sort_entity_count = current_count
self._sort_layer_snapshot = current_layers
return self._sorted_entities_cache
[docs]
def update(self, dt: float, entities: list[Entity]):
self.interpolation_alpha = max(0.0, min(1.0, float(self.interpolation_alpha)))
self._cached_surface_size = self._surface_size()
for camera_view in self.get_camera_views(entities, dt):
self._render_camera_view(camera_view, entities)
if not self.skip_ui_render:
self.render_ui(entities)
# UI component types for targeted iteration
_UI_TYPES = (
ImageRenderer, ButtonComponent, TextInputComponent,
SliderComponent, ProgressBarComponent, CheckBoxComponent, TextRenderer,
)
[docs]
def render_ui(self, entities: list[Entity], viewport_rect=None):
# Render UI components in screen space (overlay)
# Using Transform as screen coordinates (pixels)
# When viewport_rect is provided, UI is mapped into that rect (editor WYSIWYG)
if not pygame.font.get_init():
pygame.font.init()
# P9-5: Collect UI entities via aggregate cache — only rebuild when
# entity count changes, avoiding 7 set unions every frame.
if self.world:
current_count = len(entities)
if self._ui_entity_cache is not None and current_count == self._ui_cache_entity_count:
ui_entity_set = self._ui_entity_cache
else:
ui_entity_set = set()
_cache = self.world._component_cache
for ui_type in self._UI_TYPES:
cached = _cache.get(ui_type)
if cached:
ui_entity_set.update(cached)
self._ui_entity_cache = ui_entity_set
self._ui_cache_entity_count = current_count
if not ui_entity_set:
return
sorted_all = self._get_sorted_entities(entities)
sorted_entities = [e for e in sorted_all if e in ui_entity_set]
else:
sorted_entities = self._get_sorted_entities(entities)
if not sorted_entities:
return
# Compute mapping factors
design_w, design_h = self._get_design_size()
if viewport_rect:
sx = viewport_rect.width / max(1, design_w)
sy = viewport_rect.height / max(1, design_h)
ox, oy = viewport_rect.x, viewport_rect.y
clip_backup = self.surface.get_clip()
self.surface.set_clip(viewport_rect)
else:
sx, sy = 1.0, 1.0
ox, oy = 0, 0
for entity in sorted_entities:
if not entity.is_visible():
continue
comps = entity.components
transform = comps.get(Transform)
if not transform:
continue
transform_values = self._get_entity_transform_values(entity, transform)
x = ox + transform_values[0] * sx
y = oy + transform_values[1] * sy
rotation = transform_values[2]
scale_x = transform_values[3]
scale_y = transform_values[4]
# 1. ImageRenderer (UI)
ui_image = comps.get(ImageRenderer)
if ui_image and ui_image.image:
w = ui_image.width * scale_x * sx
h = ui_image.height * scale_y * sy
if w > 0 and h > 0:
scaled_img = pygame.transform.scale(ui_image.image, (int(w), int(h)))
if rotation != 0:
scaled_img = pygame.transform.rotate(scaled_img, -rotation)
self.surface.blit(scaled_img, (x, y))
# 2. ButtonComponent
btn = comps.get(ButtonComponent)
if btn:
w = btn.width * scale_x * sx
h = btn.height * scale_y * sy
rect = pygame.Rect(x, y, w, h)
color = btn.normal_color
if btn.is_pressed: color = btn.pressed_color
elif btn.is_hovered: color = btn.hover_color
pygame.draw.rect(self.surface, color, rect)
if btn.text:
font_size = max(8, int(24 * min(sx, sy)))
font = self._get_font(None, font_size)
text_surf = font.render(btn.text, True, btn.text_color)
text_rect = text_surf.get_rect(center=rect.center)
self.surface.blit(text_surf, text_rect)
# 3. TextInputComponent
inp = comps.get(TextInputComponent)
if inp:
w = inp.width * scale_x * sx
h = inp.height * scale_y * sy
rect = pygame.Rect(x, y, w, h)
pygame.draw.rect(self.surface, inp.bg_color, rect)
pygame.draw.rect(self.surface, (0, 0, 0), rect, 1)
text_str = inp.text
color = inp.text_color
if not text_str and inp.placeholder:
text_str = inp.placeholder
color = (150, 150, 150)
font_size = max(8, int(24 * min(sx, sy)))
font = self._get_font(None, font_size)
surf = font.render(text_str, True, color)
self.surface.blit(surf, (x + 5 * sx, y + (h - surf.get_height()) // 2))
# 4. SliderComponent
slider = comps.get(SliderComponent)
if slider:
w = slider.width * scale_x * sx
h = slider.height * scale_y * sy
rect = pygame.Rect(x, y, w, h)
pygame.draw.rect(self.surface, slider.track_color, rect)
val_range = slider.max_value - slider.min_value
if val_range == 0: val_range = 1
pct = (slider.value - slider.min_value) / val_range
pct = max(0.0, min(1.0, pct))
handle_w = 10 * sx
handle_x = x + (pct * w) - (handle_w / 2)
handle_rect = pygame.Rect(handle_x, y - 5 * sy, handle_w, h + 10 * sy)
pygame.draw.rect(self.surface, slider.handle_color, handle_rect)
# 5. ProgressBarComponent
pbar = comps.get(ProgressBarComponent)
if pbar:
w = pbar.width * scale_x * sx
h = pbar.height * scale_y * sy
rect = pygame.Rect(x, y, w, h)
pygame.draw.rect(self.surface, pbar.bg_color, rect)
val_range = pbar.max_value - pbar.min_value
if val_range == 0: val_range = 1
pct = (pbar.value - pbar.min_value) / val_range
pct = max(0.0, min(1.0, pct))
fill_rect = pygame.Rect(x, y, w * pct, h)
pygame.draw.rect(self.surface, pbar.fill_color, fill_rect)
# 6. CheckBoxComponent
chk = comps.get(CheckBoxComponent)
if chk:
size = chk.size * scale_x * min(sx, sy)
rect = pygame.Rect(x, y, size, size)
color = chk.checked_color if chk.checked else chk.unchecked_color
pygame.draw.rect(self.surface, color, rect)
pygame.draw.rect(self.surface, (0, 0, 0), rect, 1)
if chk.checked:
inner_rect = rect.inflate(-4, -4)
pygame.draw.rect(self.surface, (255, 255, 255), inner_rect)
# 7. TextRenderer
txt = comps.get(TextRenderer)
if txt:
font_size = max(8, int(txt.font_size * min(sx, sy)))
font = self._get_font(txt.font_path, font_size)
surf = font.render(txt.text, True, txt.color)
if scale_x != 1.0 or scale_y != 1.0:
w = surf.get_width() * scale_x
h = surf.get_height() * scale_y
surf = pygame.transform.scale(surf, (int(w), int(h)))
if rotation != 0:
surf = pygame.transform.rotate(surf, -rotation)
self.surface.blit(surf, (x, y))
if viewport_rect:
self.surface.set_clip(clip_backup)
def _get_design_size(self):
if self.design_size:
return self.design_size
return self._cached_surface_size
[docs]
def get_camera_views(self, entities: list[Entity], dt: float = 0.0):
surface_w, surface_h = self._cached_surface_size
camera_entities = []
if self.use_camera_components:
if self.world:
cam_entities = self.world.get_entities_with(CameraComponent)
else:
cam_entities = entities
for entity in cam_entities:
camera = entity.get_component(CameraComponent)
if not camera or not camera.active or camera.viewport_width <= 0 or camera.viewport_height <= 0:
continue
transform = entity.get_component(Transform)
if not transform:
continue
camera_entities.append((entity, camera))
if not camera_entities:
viewport = pygame.Rect(0, 0, surface_w, surface_h)
return [{
"entity": None,
"transform": None,
"camera": None,
"x": self.camera_x,
"y": self.camera_y,
"zoom": max(0.01, self.camera_zoom),
"rotation": self.camera_rotation,
"viewport": viewport
}]
# Build transforms only for camera entities and their follow targets
transforms_by_id = {}
needed_ids = set()
for entity, camera in camera_entities:
needed_ids.add(entity.id)
target_id = getattr(camera, "follow_target_id", "")
if target_id:
needed_ids.add(target_id)
for entity in entities:
if entity.id in needed_ids:
transform = entity.get_component(Transform)
if transform:
transforms_by_id[entity.id] = self._get_entity_transform_values(entity, transform)
camera_entities.sort(key=lambda item: item[1].priority)
views = []
for entity, camera in camera_entities:
camera_state = self._resolve_followed_camera_state(entity, camera, transforms_by_id)
if camera_state is None:
continue
viewport = self._resolve_camera_viewport(camera, surface_w, surface_h)
if viewport.width <= 0 or viewport.height <= 0:
continue
# P11-1: Update camera shake and apply offset
cam_x = camera_state[0]
cam_y = camera_state[1]
if dt > 0.0:
camera.update_shake(dt)
shake_ox, shake_oy = camera.shake_offset
views.append({
"entity": entity,
"transform": camera_state,
"camera": camera,
"x": cam_x + shake_ox,
"y": cam_y + shake_oy,
"zoom": max(0.01, camera.zoom),
"rotation": camera_state[2] + camera.rotation,
"viewport": viewport
})
return views
[docs]
def get_primary_camera_view(self, entities: list[Entity]):
views = self.get_camera_views(entities)
if not views:
surface_w, surface_h = self._surface_size()
viewport = pygame.Rect(0, 0, surface_w, surface_h)
return {
"entity": None,
"transform": None,
"camera": None,
"x": self.camera_x,
"y": self.camera_y,
"zoom": max(0.01, self.camera_zoom),
"rotation": self.camera_rotation,
"viewport": viewport
}
return views[0]
[docs]
def world_to_screen(self, world_x: float, world_y: float, entities: list[Entity] = None, camera_view: dict = None):
view = camera_view or self.get_primary_camera_view(entities or [])
dx = world_x - view["x"]
dy = world_y - view["y"]
theta = math.radians(view["rotation"])
cos_t = math.cos(theta)
sin_t = math.sin(theta)
cam_x = (dx * cos_t) + (dy * sin_t)
cam_y = (-dx * sin_t) + (dy * cos_t)
viewport = view["viewport"]
center_x = viewport.x + (viewport.width * 0.5)
center_y = viewport.y + (viewport.height * 0.5)
return (
center_x + (cam_x * view["zoom"]),
center_y + (cam_y * view["zoom"])
)
[docs]
def screen_to_world(self, screen_x: float, screen_y: float, entities: list[Entity] = None, camera_view: dict = None):
view = camera_view or self.get_primary_camera_view(entities or [])
viewport = view["viewport"]
center_x = viewport.x + (viewport.width * 0.5)
center_y = viewport.y + (viewport.height * 0.5)
cam_x = (screen_x - center_x) / view["zoom"]
cam_y = (screen_y - center_y) / view["zoom"]
theta = math.radians(view["rotation"])
cos_t = math.cos(theta)
sin_t = math.sin(theta)
world_dx = (cam_x * cos_t) - (cam_y * sin_t)
world_dy = (cam_x * sin_t) + (cam_y * cos_t)
return (
view["x"] + world_dx,
view["y"] + world_dy
)
def _resolve_camera_viewport(self, camera: CameraComponent, surface_w: int, surface_h: int):
x = int(max(0.0, min(1.0, camera.viewport_x)) * surface_w)
y = int(max(0.0, min(1.0, camera.viewport_y)) * surface_h)
w = int(max(0.0, min(1.0, camera.viewport_width)) * surface_w)
h = int(max(0.0, min(1.0, camera.viewport_height)) * surface_h)
if x + w > surface_w:
w = max(0, surface_w - x)
if y + h > surface_h:
h = max(0, surface_h - y)
return pygame.Rect(x, y, w, h)
def _surface_size(self):
if self.surface is None:
return (800, 600)
if hasattr(self.surface, "get_size"):
size = self.surface.get_size()
if isinstance(size, (tuple, list)) and len(size) == 2:
return int(size[0]), int(size[1])
width = 800
height = 600
if hasattr(self.surface, "get_width"):
width = int(self.surface.get_width())
if hasattr(self.surface, "get_height"):
height = int(self.surface.get_height())
return width, height
def _resolve_followed_camera_state(self, entity: Entity, camera: CameraComponent, transforms_by_id: dict):
camera_state = transforms_by_id.get(entity.id)
if camera_state is None:
return None
camera_transform = entity.get_component(Transform)
target_id = getattr(camera, "follow_target_id", "")
if not target_id or target_id == entity.id:
if camera_transform:
camera_transform.x = camera_state[0]
camera_transform.y = camera_state[1]
camera_transform.rotation = camera_state[2]
return camera_state
target_state = transforms_by_id.get(target_id)
if target_state is None:
if camera_transform:
camera_transform.x = camera_state[0]
camera_transform.y = camera_state[1]
camera_transform.rotation = camera_state[2]
return camera_state
target_rotation = target_state[2] if getattr(camera, "follow_rotation", True) else camera_state[2]
if camera_transform:
camera_transform.x = target_state[0]
camera_transform.y = target_state[1]
camera_transform.rotation = target_rotation
return (target_state[0], target_state[1], target_rotation, camera_state[3], camera_state[4])
def _get_entity_transform_values(self, entity: Entity, transform: Transform = None):
if transform is None:
transform = entity.get_component(Transform)
if not transform:
return None
if self.world and hasattr(self.world, "get_interpolated_transform"):
interpolated = self.world.get_interpolated_transform(entity, self.interpolation_alpha)
if interpolated is not None:
return interpolated
return (
float(transform.x),
float(transform.y),
float(transform.rotation),
float(transform.scale_x),
float(transform.scale_y)
)
def _render_camera_view(self, camera_view: dict, entities: list[Entity]):
viewport = camera_view["viewport"]
if viewport.width <= 0 or viewport.height <= 0:
return
clip_backup = self.surface.get_clip()
self.surface.set_clip(viewport)
self._render_particles_layer(camera_view, entities, ParticleEmitterComponent.LAYER_BEHIND)
sorted_entities = self._get_sorted_entities(entities)
# Pre-compute camera transform constants for frustum culling
cam_zoom = camera_view["zoom"]
vp_cx = viewport.x + viewport.width * 0.5
vp_cy = viewport.y + viewport.height * 0.5
cam_rotation = camera_view["rotation"]
theta = math.radians(cam_rotation)
cos_t = math.cos(theta)
sin_t = math.sin(theta)
cam_x = camera_view["x"]
cam_y = camera_view["y"]
# Frustum half-extents with generous margin
half_w = viewport.width * 0.5 + 200
half_h = viewport.height * 0.5 + 200
# P11-5: Collect blit pairs for batching via surface.blits()
_blit_sequence: list[tuple] = []
for entity in sorted_entities:
if not entity.is_visible():
continue
comps = entity.components
transform = comps.get(Transform)
if not transform:
continue
tilemap = comps.get(TilemapComponent)
if tilemap:
self._render_tilemap_entity(entity, transform, tilemap, camera_view)
continue
sprite = comps.get(SpriteRenderer)
animator = comps.get(AnimatorComponent)
if not sprite and not animator:
continue
transform_values = self._get_entity_transform_values(entity, transform)
if transform_values is None:
continue
# Inline world_to_screen for frustum culling
dx = transform_values[0] - cam_x
dy = transform_values[1] - cam_y
scr_x = vp_cx + ((dx * cos_t) + (dy * sin_t)) * cam_zoom
scr_y = vp_cy + ((-dx * sin_t) + (dy * cos_t)) * cam_zoom
image_to_draw = None
width = 0
height = 0
# Check animator first
if animator:
frame = animator.get_current_frame()
if frame is not None:
image_to_draw = frame
width = frame.get_width() * abs(transform_values[3])
height = frame.get_height() * abs(transform_values[4])
# Fallback to sprite if no animation frame
if image_to_draw is None and sprite:
image_to_draw = sprite.image
width = sprite.width
height = sprite.height
if image_to_draw is None:
continue
scaled_w = int(width * cam_zoom)
scaled_h = int(height * cam_zoom)
if scaled_w <= 0 or scaled_h <= 0:
continue
# RO-8: Frustum culling — skip if fully off-screen
half_sprite = max(scaled_w, scaled_h) * 0.75
if (scr_x + half_sprite < viewport.x or scr_x - half_sprite > viewport.x + viewport.width or
scr_y + half_sprite < viewport.y or scr_y - half_sprite > viewport.y + viewport.height):
continue
# RO-3: Cached sprite scaling (P9-6: LRU eviction instead of per-frame flush)
img_id = id(image_to_draw)
scale_key = (img_id, scaled_w, scaled_h)
image = self._sprite_scale_cache.get(scale_key)
if image is not None:
self._sprite_scale_cache.move_to_end(scale_key)
else:
image = pygame.transform.scale(image_to_draw, (scaled_w, scaled_h))
self._sprite_scale_cache[scale_key] = image
if len(self._sprite_scale_cache) > self._sprite_scale_cache_max:
self._sprite_scale_cache.popitem(last=False)
flip_x = transform_values[3] < 0
flip_y = transform_values[4] < 0
if flip_x or flip_y:
image = pygame.transform.flip(image, flip_x, flip_y)
relative_rotation = transform_values[2] - cam_rotation
if relative_rotation != 0:
image = pygame.transform.rotate(image, -relative_rotation)
rect = image.get_rect(center=(int(scr_x), int(scr_y)))
_blit_sequence.append((image, rect))
# P11-5: Batch all sprite blits in a single call
if _blit_sequence:
self.surface.blits(_blit_sequence, doreturn=False)
self._render_particles_layer(camera_view, entities, ParticleEmitterComponent.LAYER_FRONT)
self.surface.set_clip(clip_backup)
def _render_tilemap_entity(self, entity: Entity, transform: Transform, tilemap: TilemapComponent, camera_view: dict):
frames = tilemap.get_tileset_frames()
if not frames:
return
tilemap.ensure_layer_sizes()
cell_w = max(1, int(getattr(tilemap, "cell_width", tilemap.tileset.tile_width)))
cell_h = max(1, int(getattr(tilemap, "cell_height", tilemap.tileset.tile_height)))
# Treat tilemap transform as top-left anchor in world space.
origin_x = float(transform.x)
origin_y = float(transform.y)
# Culling: compute visible tile bounds based on camera view and viewport size.
viewport = camera_view["viewport"]
top_left = self.screen_to_world(float(viewport.left), float(viewport.top), camera_view=camera_view)
bottom_right = self.screen_to_world(float(viewport.right), float(viewport.bottom), camera_view=camera_view)
min_x = min(top_left[0], bottom_right[0])
max_x = max(top_left[0], bottom_right[0])
min_y = min(top_left[1], bottom_right[1])
max_y = max(top_left[1], bottom_right[1])
# For infinite tilemap, no bounds checking
start_tx = int((min_x - origin_x) // cell_w) - 1
end_tx = int((max_x - origin_x) // cell_w) + 1
start_ty = int((min_y - origin_y) // cell_h) - 1
end_ty = int((max_y - origin_y) // cell_h) + 1
zoom = max(0.01, float(camera_view["zoom"]))
scaled_cell_w = max(1, int(cell_w * zoom))
scaled_cell_h = max(1, int(cell_h * zoom))
# Cache scaled frames per zoom bucket to reduce repeated scaling.
if not hasattr(tilemap, "_scaled_frame_cache"):
tilemap._scaled_frame_cache = {}
scale_key = (scaled_cell_w, scaled_cell_h)
scaled_frames = tilemap._scaled_frame_cache.get(scale_key)
if scaled_frames is None:
scaled_frames = []
for frame in frames:
try:
scaled_frames.append(pygame.transform.scale(frame, (scaled_cell_w, scaled_cell_h)))
except Exception:
scaled_frames.append(frame)
# Keep cache bounded
if len(tilemap._scaled_frame_cache) > 12:
tilemap._scaled_frame_cache.clear()
tilemap._scaled_frame_cache[scale_key] = scaled_frames
# P9-4: Precompute affine transform constants once instead of
# calling world_to_screen() per tile (avoids per-tile trig + dict lookups).
cam_vx = float(camera_view["x"])
cam_vy = float(camera_view["y"])
cam_rot = math.radians(camera_view["rotation"])
cos_t = math.cos(cam_rot)
sin_t = math.sin(cam_rot)
cam_zoom = float(camera_view["zoom"])
vp = camera_view["viewport"]
vp_cx = vp.x + vp.width * 0.5
vp_cy = vp.y + vp.height * 0.5
blit = self.surface.blit
for layer in tilemap.layers:
if not getattr(layer, "visible", True):
continue
for ty in range(start_ty, end_ty + 1):
world_y = origin_y + (ty * cell_h) + (cell_h * 0.5)
dy = world_y - cam_vy
for tx in range(start_tx, end_tx + 1):
tile_id = layer.get_world(tx, ty)
if not tile_id:
continue
frame_index = int(tile_id) - 1
if frame_index < 0 or frame_index >= len(scaled_frames):
continue
world_x = origin_x + (tx * cell_w) + (cell_w * 0.5)
dx = world_x - cam_vx
sx = vp_cx + (dx * cos_t + dy * sin_t) * cam_zoom
sy = vp_cy + (-dx * sin_t + dy * cos_t) * cam_zoom
img = scaled_frames[frame_index]
rect = img.get_rect(center=(sx, sy))
blit(img, rect)
def _render_particles_layer(self, camera_view: dict, entities: list[Entity], layer: str):
if self.world:
particle_entities = self.world.get_entities_with(Transform, ParticleEmitterComponent)
else:
particle_entities = entities
# Pre-compute camera constants once for all particles
cam_x = camera_view["x"]
cam_y = camera_view["y"]
cam_zoom = max(0.01, camera_view["zoom"])
viewport = camera_view["viewport"]
vp_cx = viewport.x + viewport.width * 0.5
vp_cy = viewport.y + viewport.height * 0.5
theta = math.radians(camera_view["rotation"])
cos_t = math.cos(theta)
sin_t = math.sin(theta)
surf_w = self._cached_surface_size[0]
surf_h = self._cached_surface_size[1]
surface = self.surface
blit = surface.blit
for entity in particle_entities:
if not entity.is_visible():
continue
comps = entity.components
transform = comps.get(Transform)
emitter = comps.get(ParticleEmitterComponent)
if not transform or not emitter or emitter.render_layer != layer:
continue
transform_values = self._get_entity_transform_values(entity, transform)
if transform_values is None:
continue
state = emitter._particle_state
if not state:
continue
alive = state["alive"]
if alive <= 0:
continue
# Local refs for hot loop
s_x = state["x"]; s_y = state["y"]
s_age = state["age"]; s_life = state["life"]
s_size0 = state["size0"]; s_size1 = state["size1"]
s_r0 = state["r0"]; s_g0 = state["g0"]; s_b0 = state["b0"]; s_a0 = state["a0"]
s_r1 = state["r1"]; s_g1 = state["g1"]; s_b1 = state["b1"]; s_a1 = state["a1"]
local_space = emitter.local_space
tx_0 = transform_values[0] if local_space else 0.0
ty_0 = transform_values[1] if local_space else 0.0
shape = emitter.shape
blend_add = emitter.blend_additive
SHAPE_PIXEL = ParticleEmitterComponent.SHAPE_PIXEL
SHAPE_SQUARE = ParticleEmitterComponent.SHAPE_SQUARE
for i in range(alive):
life = s_life[i]
if life <= 0:
continue
t = s_age[i] / life
if t > 1.0: t = 1.0
size = s_size0[i] + ((s_size1[i] - s_size0[i]) * t)
radius = int(size * cam_zoom * 0.5)
if radius < 1:
radius = 1
a = int(s_a0[i] + ((s_a1[i] - s_a0[i]) * t))
if a <= 0:
continue
# Inline world_to_screen
px = s_x[i] + tx_0 - cam_x
py = s_y[i] + ty_0 - cam_y
ix = int(vp_cx + (px * cos_t + py * sin_t) * cam_zoom)
iy = int(vp_cy + (-px * sin_t + py * cos_t) * cam_zoom)
if shape == SHAPE_PIXEL:
if 0 <= ix < surf_w and 0 <= iy < surf_h:
surface.set_at((ix, iy), (
int(s_r0[i] + ((s_r1[i] - s_r0[i]) * t)),
int(s_g0[i] + ((s_g1[i] - s_g0[i]) * t)),
int(s_b0[i] + ((s_b1[i] - s_b0[i]) * t)),
a))
continue
r = int(s_r0[i] + ((s_r1[i] - s_r0[i]) * t))
g = int(s_g0[i] + ((s_g1[i] - s_g0[i]) * t))
b = int(s_b0[i] + ((s_b1[i] - s_b0[i]) * t))
if shape == SHAPE_SQUARE:
w = max(1, radius * 2)
pygame.draw.rect(surface, (r, g, b, a), (ix - radius, iy - radius, w, w))
continue
particle_surface = self._get_particle_surface(radius, r, g, b, a)
pos = (ix - radius, iy - radius)
if blend_add:
blit(particle_surface, pos, special_flags=pygame.BLEND_RGBA_ADD)
else:
blit(particle_surface, pos)
def _get_particle_surface(self, radius: int, r: int, g: int, b: int, a: int):
key = (
int(max(1, min(96, radius))),
int(max(0, min(255, (r // 8) * 8))),
int(max(0, min(255, (g // 8) * 8))),
int(max(0, min(255, (b // 8) * 8))),
int(max(0, min(255, (a // 8) * 8)))
)
cached = self._particle_surface_cache.get(key)
if cached is not None:
self._particle_surface_cache.move_to_end(key)
return cached
radius = key[0]
diameter = radius * 2
surface = pygame.Surface((diameter, diameter), pygame.SRCALPHA)
center = radius
steps = max(2, radius)
for step in range(steps, 0, -1):
t = step / steps
alpha = int(key[4] * (t * t))
if alpha <= 0:
continue
pygame.draw.circle(surface, (key[1], key[2], key[3], alpha), (center, center), max(1, int(radius * t)))
self._particle_surface_cache[key] = surface
while len(self._particle_surface_cache) > self._particle_cache_max:
self._particle_surface_cache.popitem(last=False)
return surface