import pygame
import math
from PyQt6.QtWidgets import QWidget, QPushButton, QVBoxLayout, QHBoxLayout, QLabel
from PyQt6.QtGui import QImage, QPainter
from PyQt6.QtCore import QTimer, Qt, pyqtSignal, QSize
import qtawesome as qta
from core.scene import Scene
from core.systems import RenderSystem, AnimationSystem, ParticleSystem
from core.systems.physics_system import PhysicsSystem
from core.systems.script_system import ScriptSystem
from core.systems.ui_system import UISystem
from core.systems.timer_system import TimerSystem
from core.systems.event_dispatch_system import EventDispatchSystem
from core.systems.lighting_system import LightingSystem
from core.serializer import SceneSerializer
from core.components import Transform, CameraComponent, SpriteRenderer, BoxCollider2D, CircleCollider2D, PolygonCollider2D, TilemapComponent
from core.components.light import PointLight2D, SpotLight2D, LightOccluder2D
from core.vector import Vector2
from core.input import Input
from editor.ui.gizmo import Gizmo
from editor.undo_manager import TransformCommand, DeleteEntitiesCommand, PropertyChangeCommand, MultiPropertyChangeCommand, TilemapEditCommand
[docs]
class PygameViewport(QWidget):
entity_modified = pyqtSignal(object)
entity_selected = pyqtSignal(object)
entity_deleted = pyqtSignal(object)
def __init__(self, scene: Scene, parent=None, project_config: dict | None = None):
super().__init__(parent)
self.scene = scene
self.project_config = project_config or {}
self.setMinimumSize(320, 240)
self.bg_color = (33, 33, 33)
self.game_resolution = (800, 600) # Default, will be updated by load_project_settings
# Accept focus so we can receive key events (Delete, Escape)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# Initialize pygame explicitly for off-screen rendering
if not pygame.get_init():
pygame.init()
self.surface = pygame.Surface((320, 240))
self.physics_system = PhysicsSystem()
self.animation_system = AnimationSystem()
self.particle_system = ParticleSystem()
self.ui_system = UISystem()
self.render_system = RenderSystem(self.surface)
self.render_system.use_camera_components = False
self.lighting_system = LightingSystem(self.surface, self.project_config)
self.lighting_system.enabled = True
# Shadow preview settings
self.shadow_extend = 2000 # Default, will be updated from project config
self._update_shadow_extend_from_config()
# Gizmo
self.gizmo = Gizmo()
# Use a QTimer to simulate the game loop for the editor preview
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_frame)
self.timer.start(16) # ~60 FPS
# Interaction variables
self.dragging = False
self.selected_entities = []
self.transform_start_states = []
self.gizmo_interaction_active = False
self.physics_debug_mode = False
self.collider_drag_state = None
self.collider_handle_size = 18
self.collider_handle_min_screen_distance = 88
self.polygon_point_add_entity = None
self._occ_point_add_entity = None # LightOccluder2D polygon point add mode
# Camera
self.camera_x = 0.0
self.camera_y = 0.0
self.camera_zoom = 1.0
self.panning = False
self.last_mouse_pos = None
# Enable mouse tracking to receive mouse move events even without button press
self.setMouseTracking(True)
# Play-in-editor state (P11-10)
self.simulating = False
self._saved_scene_json: str | None = None
self._sim_physics_system: PhysicsSystem | None = None
self._sim_script_system: ScriptSystem | None = None
self._sim_timer_system: TimerSystem | None = None
self._sim_event_dispatch: EventDispatchSystem | None = None
self._sim_accumulator: float = 0.0
self._sim_fixed_dt: float = 1.0 / 60.0
# Tilemap edit state
self.tilemap_edit_mode = False
self.tilemap_tool = "paint"
self.tilemap_selected_tile = 1
self.tilemap_active_layer = 0
self.tilemap_entity = None # Direct reference when using component UI
self._tilemap_stroke_changes = None # dict[(x,y)] = (old,new)
self._tilemap_rect_start = None # (tx, ty)
self.setup_ui()
self.bind_scene(self.scene)
# --- Tilemap editor API (called by MainWindow dock) ---
[docs]
def set_tilemap_edit_mode(self, enabled: bool):
self.tilemap_edit_mode = bool(enabled)
if not self.tilemap_edit_mode:
self._tilemap_stroke_changes = None
self._tilemap_rect_start = None
[docs]
def set_tilemap_selected_tile(self, tile_id: int):
try:
self.tilemap_selected_tile = int(tile_id)
except (ValueError, TypeError):
self.tilemap_selected_tile = 1
[docs]
def set_tilemap_entity(self, entity):
"""Set the tilemap entity for editing (used by component UI)"""
self.tilemap_entity = entity
[docs]
def set_tilemap_active_layer(self, layer_index: int):
try:
self.tilemap_active_layer = max(0, int(layer_index))
except (ValueError, TypeError):
self.tilemap_active_layer = 0
[docs]
def bind_scene(self, scene: Scene):
self.scene = scene
self.render_system.surface = self.surface
self.lighting_system.surface = self.surface
scene.world.systems = [
system for system in scene.world.systems
if not isinstance(system, (PhysicsSystem, AnimationSystem, ParticleSystem, RenderSystem, UISystem, LightingSystem))
]
scene.world.add_system(self.animation_system)
scene.world.add_system(self.particle_system)
scene.world.add_system(self.ui_system)
scene.world.add_system(self.render_system)
scene.world.add_system(self.lighting_system)
self.apply_scene_view_state(getattr(scene, "editor_view_state", {}))
def _update_shadow_extend_from_config(self):
"""Update shadow_extend from project config."""
if self.project_config:
lighting_cfg = self.project_config.get("lighting", {})
self.shadow_extend = int(lighting_cfg.get("shadow_extend", 2000))
else:
self.shadow_extend = 2000
[docs]
def update_project_config(self, config: dict):
"""Update project config and refresh shadow_extend setting."""
self.project_config = config or {}
self._update_shadow_extend_from_config()
# Also update lighting system's shadow_extend
if self.lighting_system:
self.lighting_system.shadow_extend = self.shadow_extend
[docs]
def setup_ui(self):
# Create an overlay layout for the toolbar
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(5, 5, 5, 5)
# Top toolbar layout
toolbar_layout = QHBoxLayout()
toolbar_layout.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)
# Style for toolbar buttons
btn_style = """
QPushButton {
background-color: rgba(50, 50, 50, 180);
color: white;
border: 1px solid rgba(100, 100, 100, 180);
border-radius: 4px;
padding: 4px 8px;
font-weight: bold;
}
QPushButton:hover {
background-color: rgba(80, 80, 80, 200);
}
QPushButton:pressed {
background-color: rgba(30, 30, 30, 200);
}
QPushButton:checked {
background-color: rgba(60, 170, 90, 220);
border: 1px solid rgba(120, 230, 150, 230);
}
QPushButton:checked:hover {
background-color: rgba(70, 190, 100, 220);
}
"""
_icon_size = QSize(16, 16)
# Zoom In Button
self.btn_zoom_in = QPushButton()
self.btn_zoom_in.setIcon(qta.icon("fa5s.search-plus", color="white"))
self.btn_zoom_in.setIconSize(_icon_size)
self.btn_zoom_in.setToolTip("Zoom In")
self.btn_zoom_in.setFixedSize(30, 30)
self.btn_zoom_in.setStyleSheet(btn_style)
self.btn_zoom_in.clicked.connect(lambda: self.apply_zoom(1.2))
# Zoom Out Button
self.btn_zoom_out = QPushButton()
self.btn_zoom_out.setIcon(qta.icon("fa5s.search-minus", color="white"))
self.btn_zoom_out.setIconSize(_icon_size)
self.btn_zoom_out.setToolTip("Zoom Out")
self.btn_zoom_out.setFixedSize(30, 30)
self.btn_zoom_out.setStyleSheet(btn_style)
self.btn_zoom_out.clicked.connect(lambda: self.apply_zoom(1.0 / 1.2))
# Reset View Button
self.btn_reset_view = QPushButton()
self.btn_reset_view.setIcon(qta.icon("fa5s.compress-arrows-alt", color="white"))
self.btn_reset_view.setIconSize(_icon_size)
self.btn_reset_view.setToolTip("Reset Zoom and Pan")
self.btn_reset_view.setFixedSize(30, 30)
self.btn_reset_view.setStyleSheet(btn_style)
self.btn_reset_view.clicked.connect(self.reset_camera)
# Gizmo Modes
self.btn_translate = QPushButton()
self.btn_translate.setIcon(qta.icon("fa5s.arrows-alt", color="white"))
self.btn_translate.setIconSize(_icon_size)
self.btn_translate.setToolTip("Translate Mode")
self.btn_translate.setFixedSize(30, 30)
self.btn_translate.setStyleSheet(btn_style)
self.btn_translate.setCheckable(True)
self.btn_translate.clicked.connect(lambda: self.set_gizmo_mode(Gizmo.MODE_TRANSLATE))
self.btn_rotate = QPushButton()
self.btn_rotate.setIcon(qta.icon("fa5s.sync-alt", color="white"))
self.btn_rotate.setIconSize(_icon_size)
self.btn_rotate.setToolTip("Rotate Mode")
self.btn_rotate.setFixedSize(30, 30)
self.btn_rotate.setStyleSheet(btn_style)
self.btn_rotate.setCheckable(True)
self.btn_rotate.clicked.connect(lambda: self.set_gizmo_mode(Gizmo.MODE_ROTATE))
self.btn_scale = QPushButton()
self.btn_scale.setIcon(qta.icon("fa5s.expand-alt", color="white"))
self.btn_scale.setIconSize(_icon_size)
self.btn_scale.setToolTip("Scale Mode")
self.btn_scale.setFixedSize(30, 30)
self.btn_scale.setStyleSheet(btn_style)
self.btn_scale.setCheckable(True)
self.btn_scale.clicked.connect(lambda: self.set_gizmo_mode(Gizmo.MODE_SCALE))
self.btn_physics_debug = QPushButton()
self.btn_physics_debug.setIcon(qta.icon("fa5s.bug", color="white"))
self.btn_physics_debug.setIconSize(_icon_size)
self.btn_physics_debug.setToolTip("Physics Debug Mode")
self.btn_physics_debug.setFixedSize(30, 30)
self.btn_physics_debug.setStyleSheet(btn_style)
self.btn_physics_debug.setCheckable(True)
self.btn_physics_debug.clicked.connect(self.set_physics_debug_mode)
# Zoom Label
self.zoom_label = QLabel("100%")
self.zoom_label.setStyleSheet("color: white; background-color: rgba(50, 50, 50, 180); border-radius: 4px; padding: 4px;")
toolbar_layout.addWidget(self.btn_translate)
toolbar_layout.addWidget(self.btn_rotate)
toolbar_layout.addWidget(self.btn_scale)
toolbar_layout.addWidget(self.btn_physics_debug)
# Play-in-editor button (P11-10)
self.btn_play = QPushButton()
self.btn_play.setIcon(qta.icon("fa5s.play", color="white"))
self.btn_play.setIconSize(_icon_size)
self.btn_play.setToolTip("Play / Stop Simulation")
self.btn_play.setFixedSize(30, 30)
self.btn_play.setStyleSheet(btn_style)
self.btn_play.setCheckable(True)
self.btn_play.clicked.connect(self._toggle_simulation)
toolbar_layout.addWidget(self.btn_play)
toolbar_layout.addSpacing(10)
toolbar_layout.addWidget(self.btn_zoom_out)
toolbar_layout.addWidget(self.btn_zoom_in)
toolbar_layout.addWidget(self.btn_reset_view)
toolbar_layout.addWidget(self.zoom_label)
main_layout.addLayout(toolbar_layout)
main_layout.addStretch()
self._sync_toolbar_mode_buttons()
[docs]
def update_zoom_label(self):
if hasattr(self, 'zoom_label'):
self.zoom_label.setText(f"{int(self.camera_zoom * 100)}%")
def _sync_toolbar_mode_buttons(self):
mode = self.gizmo.mode
self.btn_translate.setChecked(mode == Gizmo.MODE_TRANSLATE)
self.btn_rotate.setChecked(mode == Gizmo.MODE_ROTATE)
self.btn_scale.setChecked(mode == Gizmo.MODE_SCALE)
[docs]
def set_gizmo_mode(self, mode):
self.gizmo.set_mode(mode)
self._sync_toolbar_mode_buttons()
[docs]
def set_physics_debug_mode(self, enabled):
self.physics_debug_mode = bool(enabled)
if not self.physics_debug_mode:
self.collider_drag_state = None
if hasattr(self, 'btn_physics_debug'):
self.btn_physics_debug.setChecked(self.physics_debug_mode)
[docs]
def toggle_physics_debug(self):
self.set_physics_debug_mode(not self.physics_debug_mode)
[docs]
def get_scene_view_state(self):
return {
"gizmo_mode": self.gizmo.mode,
"physics_debug_mode": bool(self.physics_debug_mode),
"camera_zoom": float(self.camera_zoom),
"camera_x": float(self.camera_x),
"camera_y": float(self.camera_y)
}
[docs]
def apply_scene_view_state(self, state):
if not isinstance(state, dict):
return
mode = state.get("gizmo_mode", self.gizmo.mode)
try:
mode = int(mode)
except (TypeError, ValueError):
mode = self.gizmo.mode
if mode not in (Gizmo.MODE_TRANSLATE, Gizmo.MODE_ROTATE, Gizmo.MODE_SCALE):
mode = Gizmo.MODE_TRANSLATE
self.set_gizmo_mode(mode)
zoom = state.get("camera_zoom", self.camera_zoom)
cam_x = state.get("camera_x", self.camera_x)
cam_y = state.get("camera_y", self.camera_y)
try:
zoom = float(zoom)
except (TypeError, ValueError):
zoom = self.camera_zoom
try:
cam_x = float(cam_x)
except (TypeError, ValueError):
cam_x = self.camera_x
try:
cam_y = float(cam_y)
except (TypeError, ValueError):
cam_y = self.camera_y
self.camera_zoom = max(0.1, min(zoom, 10.0))
self.camera_x = cam_x
self.camera_y = cam_y
if self.render_system:
self.render_system.camera_zoom = self.camera_zoom
self.render_system.camera_x = self.camera_x
self.render_system.camera_y = self.camera_y
self.update_zoom_label()
self.set_physics_debug_mode(bool(state.get("physics_debug_mode", self.physics_debug_mode)))
[docs]
def is_polygon_point_add_active(self, entity=None):
if entity is None:
return self.polygon_point_add_entity is not None
return self.polygon_point_add_entity == entity
[docs]
def start_polygon_point_add_mode(self, entity):
if not entity:
self.polygon_point_add_entity = None
return
polygon = entity.get_component(PolygonCollider2D)
transform = entity.get_component(Transform)
if polygon is None or transform is None:
self.polygon_point_add_entity = None
return
self.polygon_point_add_entity = entity
if entity not in self.selected_entities:
self.selected_entities = [entity]
self.gizmo.set_targets(self.selected_entities)
self.entity_selected.emit(self.selected_entities)
[docs]
def stop_polygon_point_add_mode(self):
self.polygon_point_add_entity = None
def _add_polygon_point_from_screen(self, screen_x, screen_y):
entity = self.polygon_point_add_entity
if not entity:
return False
transform = entity.get_component(Transform)
polygon = entity.get_component(PolygonCollider2D)
if not transform or not polygon:
self.stop_polygon_point_add_mode()
return False
world_x, world_y = self._screen_to_world(screen_x, screen_y)
old_points = [Vector2(point.x, point.y) for point in polygon.points]
local_x = world_x - transform.x - polygon.offset_x
local_y = world_y - transform.y - polygon.offset_y
new_points = [Vector2(point.x, point.y) for point in polygon.points]
new_points.append(Vector2(local_x, local_y))
polygon.points = new_points
mw = self.window()
if hasattr(mw, "undo_manager"):
mw.undo_manager.push(PropertyChangeCommand(
[entity],
PolygonCollider2D,
"points",
[old_points],
[Vector2(point.x, point.y) for point in new_points]
))
self.entity_modified.emit(entity)
return True
def _add_occ_polygon_point_from_screen(self, screen_x, screen_y):
entity = self._occ_point_add_entity
if not entity:
return False
transform = entity.get_component(Transform)
occluder = entity.get_component(LightOccluder2D)
if not transform or not occluder or occluder.shape != "polygon":
self._occ_point_add_entity = None
return False
world_x, world_y = self._screen_to_world(screen_x, screen_y)
old_points = [Vector2(p.x, p.y) for p in occluder.points]
local_x = world_x - transform.x - occluder.offset_x
local_y = world_y - transform.y - occluder.offset_y
new_points = [Vector2(p.x, p.y) for p in occluder.points]
new_points.append(Vector2(local_x, local_y))
occluder.points = new_points
mw = self.window()
if hasattr(mw, "undo_manager"):
mw.undo_manager.push(PropertyChangeCommand(
[entity],
LightOccluder2D,
"points",
[old_points],
[Vector2(p.x, p.y) for p in new_points]
))
self.entity_modified.emit(entity)
return True
[docs]
def apply_zoom(self, factor):
# Zoom towards center of viewport
center_x = self.width() / 2
center_y = self.height() / 2
world_x_before, world_y_before = self._screen_to_world(center_x, center_y)
self.camera_zoom *= factor
self.camera_zoom = max(0.1, min(self.camera_zoom, 10.0))
if self.render_system:
self.render_system.camera_zoom = self.camera_zoom
self.render_system.camera_x = self.camera_x
self.render_system.camera_y = self.camera_y
world_x_after, world_y_after = self._screen_to_world(center_x, center_y)
self.camera_x += (world_x_before - world_x_after)
self.camera_y += (world_y_before - world_y_after)
if self.render_system:
self.render_system.camera_zoom = self.camera_zoom
self.render_system.camera_x = self.camera_x
self.render_system.camera_y = self.camera_y
self.update_zoom_label()
[docs]
def reset_camera(self):
self.camera_x = 0.0
self.camera_y = 0.0
self.camera_zoom = 1.0
if self.render_system:
self.render_system.camera_zoom = self.camera_zoom
self.render_system.camera_x = self.camera_x
self.render_system.camera_y = self.camera_y
self.update_zoom_label()
def _toggle_simulation(self):
if self.simulating:
self.stop_simulation()
else:
self.start_simulation()
[docs]
def start_simulation(self):
"""Snapshot the scene, attach runtime systems, and begin simulating."""
if self.simulating or not self.scene:
return
# Save current scene state
self._saved_scene_json = SceneSerializer.to_json(self.scene)
# Attach runtime systems for simulation
self._sim_physics_system = PhysicsSystem()
self._sim_script_system = ScriptSystem()
self._sim_timer_system = TimerSystem()
self._sim_event_dispatch = EventDispatchSystem()
self.scene.world.add_system(self._sim_physics_system)
self.scene.world.add_system(self._sim_script_system)
self.scene.world.add_system(self._sim_timer_system)
self.scene.world.add_system(self._sim_event_dispatch)
# Switch render system to use camera components
self.render_system.use_camera_components = True
self.render_system.skip_ui_render = False
# Clear editor camera override so lighting uses camera components
self.lighting_system.editor_camera_x = None
self.lighting_system.editor_camera_y = None
self.lighting_system.editor_camera_zoom = None
self._sim_accumulator = 0.0
self.simulating = True
if hasattr(self, 'btn_play'):
self.btn_play.setChecked(True)
self.btn_play.setIcon(qta.icon("fa5s.stop", color="#ff5555"))
self.btn_play.setToolTip("Stop Simulation")
[docs]
def stop_simulation(self):
"""Stop simulating and restore the scene to its pre-play state."""
if not self.simulating:
return
self.simulating = False
# Remove runtime systems
for sys in (self._sim_physics_system, self._sim_script_system,
self._sim_timer_system, self._sim_event_dispatch):
if sys is not None:
self.scene.world.remove_system(sys)
self._sim_physics_system = None
self._sim_script_system = None
self._sim_timer_system = None
self._sim_event_dispatch = None
# Restore scene from snapshot
if self._saved_scene_json:
restored = SceneSerializer.from_json(self._saved_scene_json)
self._saved_scene_json = None
self.bind_scene(restored)
# Notify editor to refresh
mw = self.window()
if mw and hasattr(mw, 'scene'):
mw.scene = self.scene
if hasattr(mw, 'hierarchy_dock'):
mw.hierarchy_dock.scene = self.scene
mw.hierarchy_dock.refresh()
if hasattr(mw, 'inspector_dock'):
mw.inspector_dock.set_entity(None)
# Restore editor render mode
self.render_system.use_camera_components = False
self.render_system.skip_ui_render = True
self.selected_entities = []
if hasattr(self, 'btn_play'):
self.btn_play.setChecked(False)
self.btn_play.setIcon(qta.icon("fa5s.play", color="white"))
self.btn_play.setToolTip("Play Simulation")
[docs]
def update_frame(self):
# Clear with background color
self.surface.fill(self.bg_color)
# P11-10: Simulation mode â run full game loop
if self.simulating and self.scene:
dt = 1.0 / 60.0
self._sim_accumulator += dt
step_count = 0
while self._sim_accumulator >= self._sim_fixed_dt and step_count < 4:
self.scene.world.simulate(self._sim_fixed_dt)
self._sim_accumulator -= self._sim_fixed_dt
step_count += 1
self.render_system.design_size = self.game_resolution if hasattr(self, 'game_resolution') and self.game_resolution else None
self.scene.world.render(dt, 1.0)
self.update()
return
# Update scene (with UI rendering deferred)
self.render_system.skip_ui_render = True
self.render_system.design_size = self.game_resolution if hasattr(self, 'game_resolution') and self.game_resolution else None
# Sync lighting system with editor camera
self.lighting_system.editor_camera_x = self.camera_x
self.lighting_system.editor_camera_y = self.camera_y
self.lighting_system.editor_camera_zoom = self.camera_zoom
if self.scene:
self.scene.update(1/60.0)
# Draw game resolution boundary and render UI inside it
if hasattr(self, 'game_resolution') and self.game_resolution:
tl_x, tl_y = self._world_to_screen(0.0, 0.0)
br_x, br_y = self._world_to_screen(float(self.game_resolution[0]), float(self.game_resolution[1]))
rect = pygame.Rect(
int(min(tl_x, br_x)),
int(min(tl_y, br_y)),
max(1, int(abs(br_x - tl_x))),
max(1, int(abs(br_y - tl_y)))
)
pygame.draw.rect(self.surface, (255, 255, 255), rect, max(1, int(2 * self.camera_zoom)))
# Render UI entities inside the game resolution rect (WYSIWYG)
if self.scene:
self.render_system.render_ui(self.scene.world.entities, viewport_rect=rect)
primary_capture_polygon = self._get_primary_camera_capture_polygon()
if primary_capture_polygon:
pygame.draw.polygon(self.surface, (255, 210, 80), primary_capture_polygon, 2)
# Draw selection bounding box
for entity in self.selected_entities:
if not entity:
continue
transform = entity.get_component(Transform)
sprite = entity.get_component(SpriteRenderer)
if transform:
# Use sprite size or default 50x50
if sprite:
# sprite.width/height are already scaled (world size)
scaled_w = abs(sprite.width * self.camera_zoom)
scaled_h = abs(sprite.height * self.camera_zoom)
else:
# Default 50x50 needs scaling
scaled_w = abs(50 * transform.scale_x * self.camera_zoom)
scaled_h = abs(50 * transform.scale_y * self.camera_zoom)
# Calculate rotated AABB dimensions
if transform.rotation != 0:
rad = math.radians(transform.rotation)
sin_a = abs(math.sin(rad))
cos_a = abs(math.cos(rad))
final_w = scaled_w * cos_a + scaled_h * sin_a
final_h = scaled_w * sin_a + scaled_h * cos_a
else:
final_w = scaled_w
final_h = scaled_h
# Screen position (center)
screen_x, screen_y = self._world_to_screen(transform.x, transform.y)
# Calculate color blending from green (0, 255, 0) to white (255, 255, 255)
# Oscilate factor between 0.0 and 1.0
t = pygame.time.get_ticks() / 200.0 # Adjust speed
factor = (math.sin(t) + 1) / 2
val = int(255 * factor)
color = (val, 255, val)
# Draw rect centered at screen_x, screen_y
rect = pygame.Rect(0, 0, int(final_w), int(final_h))
rect.center = (int(screen_x), int(screen_y))
pygame.draw.rect(self.surface, color, rect, 2)
if self.physics_debug_mode:
self._draw_physics_debug()
# Draw Gizmo
gizmo_cam_x, gizmo_cam_y, gizmo_zoom = self._get_gizmo_camera()
self.gizmo.render(self.surface, gizmo_cam_x, gizmo_cam_y, gizmo_zoom)
# Tilemap overlays (grid + hover) in editor mode
if self.tilemap_edit_mode:
self._draw_tilemap_overlay()
# Trigger PyQt repaint
self.update()
def _get_selected_tilemap_target(self):
# First check if we have a direct tilemap entity reference (from component UI)
if self.tilemap_entity:
entity = self.tilemap_entity
tilemap = entity.get_component(TilemapComponent)
transform = entity.get_component(Transform)
if not tilemap or not transform:
return None
if not tilemap.layers:
return None
layer_index = max(0, min(int(self.tilemap_active_layer), len(tilemap.layers) - 1))
return entity, transform, tilemap, tilemap.layers[layer_index], layer_index
# Fall back to selected entities
if not self.selected_entities:
return None
if len(self.selected_entities) != 1:
return None
entity = self.selected_entities[0]
if not entity:
return None
tilemap = entity.get_component(TilemapComponent)
transform = entity.get_component(Transform)
if not tilemap or not transform:
return None
if not tilemap.layers:
return None
layer_index = max(0, min(int(self.tilemap_active_layer), len(tilemap.layers) - 1))
return entity, transform, tilemap, tilemap.layers[layer_index], layer_index
def _world_to_tile(self, world_x: float, world_y: float, transform: Transform, tilemap: TilemapComponent):
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)))
origin_x = float(transform.x)
origin_y = float(transform.y)
tx = int((world_x - origin_x) // cell_w)
ty = int((world_y - origin_y) // cell_h)
return tx, ty
def _apply_tile_at(self, layer, tx: int, ty: int, value: int):
# Use infinite expansion - no bounds checking needed
old_value = layer.get_world(tx, ty)
if int(old_value) == int(value):
return False
if self._tilemap_stroke_changes is None:
self._tilemap_stroke_changes = {}
key = (int(tx), int(ty))
if key in self._tilemap_stroke_changes:
old = self._tilemap_stroke_changes[key][0]
self._tilemap_stroke_changes[key] = (old, int(value))
else:
self._tilemap_stroke_changes[key] = (int(old_value), int(value))
layer.set_world(tx, ty, int(value))
return True
def _commit_tilemap_stroke(self, entity, layer_index: int):
if not self._tilemap_stroke_changes:
self._tilemap_stroke_changes = None
return
changes = [(x, y, old, new) for (x, y), (old, new) in self._tilemap_stroke_changes.items() if int(old) != int(new)]
self._tilemap_stroke_changes = None
if not changes:
return
mw = self.window()
if hasattr(mw, "undo_manager"):
mw.undo_manager.push(TilemapEditCommand(entity, layer_index, changes))
def _flood_fill(self, layer, start_x: int, start_y: int, new_value: int):
start_x = int(start_x)
start_y = int(start_y)
# Use world coordinates for infinite expansion
target = layer.get_world(start_x, start_y)
if int(target) == int(new_value):
return
stack = [(start_x, start_y)]
visited = set()
while stack:
x, y = stack.pop()
if (x, y) in visited:
continue
visited.add((x, y))
if layer.get_world(x, y) != target:
continue
self._apply_tile_at(layer, x, y, new_value)
# Expand in all four directions
stack.append((x - 1, y))
stack.append((x + 1, y))
stack.append((x, y - 1))
stack.append((x, y + 1))
def _draw_tilemap_overlay(self):
target = self._get_selected_tilemap_target()
if not target:
return
entity, transform, tilemap, layer, _layer_index = target
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)))
origin_x = float(transform.x)
origin_y = float(transform.y)
# Visible bounds in world space (approx)
left_world, top_world = self._screen_to_world(0, 0)
right_world, bottom_world = self._screen_to_world(self.surface.get_width(), self.surface.get_height())
min_x = min(left_world, right_world)
max_x = max(left_world, right_world)
min_y = min(top_world, bottom_world)
max_y = max(top_world, bottom_world)
# For infinite tilemap, draw grid within visible bounds
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
# Grid lines
grid_color = (255, 255, 255, 70)
line_w = max(1, int(1 * self.camera_zoom))
for tx in range(start_tx, end_tx + 2):
wx = origin_x + (tx * cell_w)
x1, y1 = self._world_to_screen(wx, origin_y + start_ty * cell_h)
x2, y2 = self._world_to_screen(wx, origin_y + (end_ty + 1) * cell_h)
pygame.draw.line(self.surface, grid_color, (int(x1), int(y1)), (int(x2), int(y2)), line_w)
for ty in range(start_ty, end_ty + 2):
wy = origin_y + (ty * cell_h)
x1, y1 = self._world_to_screen(origin_x + start_tx * cell_w, wy)
x2, y2 = self._world_to_screen(origin_x + (end_tx + 1) * cell_w, wy)
pygame.draw.line(self.surface, grid_color, (int(x1), int(y1)), (int(x2), int(y2)), line_w)
# Hover cell highlight
if self.last_mouse_pos:
world_x, world_y = self._screen_to_world(self.last_mouse_pos.x(), self.last_mouse_pos.y())
tx, ty = self._world_to_tile(world_x, world_y, transform, tilemap)
wx0 = origin_x + tx * cell_w
wy0 = origin_y + ty * cell_h
sx0, sy0 = self._world_to_screen(wx0, wy0)
sx1, sy1 = self._world_to_screen(wx0 + cell_w, wy0 + cell_h)
rect = pygame.Rect(
int(min(sx0, sx1)),
int(min(sy0, sy1)),
max(1, int(abs(sx1 - sx0))),
max(1, int(abs(sy1 - sy0))),
)
hover_color = (255, 255, 100, 40)
pygame.draw.rect(self.surface, hover_color, rect, max(1, int(2 * self.camera_zoom)))
def _get_primary_camera_capture_polygon(self):
if not self.scene or not self.scene.world:
return None
camera_entries = []
for entity in self.scene.world.entities:
transform = entity.get_component(Transform)
camera = entity.get_component(CameraComponent)
if not transform or not camera or not camera.active:
continue
if camera.viewport_width <= 0 or camera.viewport_height <= 0:
continue
camera_entries.append((transform, camera))
if not camera_entries:
return None
camera_entries.sort(key=lambda item: item[1].priority)
transform, camera = camera_entries[0]
render_w = int(self.game_resolution[0]) if self.game_resolution else self.surface.get_width()
render_h = int(self.game_resolution[1]) if self.game_resolution else self.surface.get_height()
viewport_w_px = max(1.0, float(render_w) * max(0.0, min(1.0, float(camera.viewport_width))))
viewport_h_px = max(1.0, float(render_h) * max(0.0, min(1.0, float(camera.viewport_height))))
camera_zoom = max(0.01, float(camera.zoom))
world_w = viewport_w_px / camera_zoom
world_h = viewport_h_px / camera_zoom
half_w = world_w * 0.5
half_h = world_h * 0.5
angle_rad = math.radians(float(transform.rotation) + float(camera.rotation))
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
center_x = float(transform.x)
center_y = float(transform.y)
local_corners = [
(-half_w, -half_h),
(half_w, -half_h),
(half_w, half_h),
(-half_w, half_h)
]
screen_points = []
for local_x, local_y in local_corners:
world_x = center_x + (local_x * cos_a) - (local_y * sin_a)
world_y = center_y + (local_x * sin_a) + (local_y * cos_a)
screen_x, screen_y = self._world_to_screen(world_x, world_y)
screen_points.append((int(screen_x), int(screen_y)))
if len(screen_points) != 4:
return None
return screen_points
def _get_gizmo_camera(self):
zoom = max(0.1, float(self.camera_zoom))
center_x = self.surface.get_width() * 0.5
center_y = self.surface.get_height() * 0.5
return (
self.camera_x - (center_x / zoom),
self.camera_y - (center_y / zoom),
zoom
)
[docs]
def resizeEvent(self, event):
# Resize internal pygame surface
w, h = event.size().width(), event.size().height()
if w > 0 and h > 0:
self.surface = pygame.Surface((w, h))
# Update the render system and lighting system with the new surface
if self.render_system:
self.render_system.surface = self.surface
if self.lighting_system:
self.lighting_system.surface = self.surface
super().resizeEvent(event)
[docs]
def paintEvent(self, event):
# Convert pygame surface to QImage
w, h = self.surface.get_width(), self.surface.get_height()
# Use tostring to ensure consistent format
data = pygame.image.tostring(self.surface, 'RGB')
# QImage needs bytesPerLine for packed RGB data (width * 3)
image = QImage(data, w, h, w * 3, QImage.Format.Format_RGB888)
# Draw onto the widget
painter = QPainter(self)
painter.drawImage(0, 0, image)
def _screen_to_world(self, screen_x, screen_y):
if self.render_system and self.scene and self.scene.world:
return self.render_system.screen_to_world(
screen_x,
screen_y,
entities=self.scene.world.entities
)
return (
(screen_x / self.camera_zoom) + self.camera_x,
(screen_y / self.camera_zoom) + self.camera_y
)
def _world_to_screen(self, world_x, world_y):
if self.render_system and self.scene and self.scene.world:
return self.render_system.world_to_screen(
world_x,
world_y,
entities=self.scene.world.entities
)
return (
(world_x - self.camera_x) * self.camera_zoom,
(world_y - self.camera_y) * self.camera_zoom
)
def _build_collider_handles(self):
handles = []
for entity in self.selected_entities:
transform = entity.get_component(Transform)
if not transform:
continue
box = entity.get_component(BoxCollider2D)
circle = entity.get_component(CircleCollider2D)
polygon = entity.get_component(PolygonCollider2D)
occluder = entity.get_component(LightOccluder2D)
spot_light = entity.get_component(SpotLight2D)
if not box and not circle and not polygon and not occluder and not spot_light:
continue
# Detect if both collider and occluder exist on same entity to offset handles
has_collider = box or circle or polygon
has_occluder = occluder is not None
collider_offset_angle = 45 if (has_collider and has_occluder) else 0
occluder_offset_angle = 225 if (has_collider and has_occluder) else 0
collider_offset_dist = 12 if (has_collider and has_occluder) else 0
occluder_offset_dist = 12 if (has_collider and has_occluder) else 0
if box:
center_x = transform.x + box.offset_x
center_y = transform.y + box.offset_y
half_w = max(0.5, abs(box.width) * 0.5)
half_h = max(0.5, abs(box.height) * 0.5)
handle_defs = [
("width", 1, center_x + half_w, center_y),
("width", -1, center_x - half_w, center_y),
("height", 1, center_x, center_y + half_h),
("height", -1, center_x, center_y - half_h),
]
for attr, direction, world_x, world_y in handle_defs:
screen_x, screen_y = self._world_to_screen(world_x, world_y)
center_screen_x, center_screen_y = self._world_to_screen(center_x, center_y)
dx = screen_x - center_screen_x
dy = screen_y - center_screen_y
distance = math.hypot(dx, dy)
if distance < self.collider_handle_min_screen_distance:
if distance == 0:
if attr == "width":
dx = direction
dy = 0
else:
dx = 0
dy = direction
distance = 1.0
scale = self.collider_handle_min_screen_distance / distance
screen_x = center_screen_x + (dx * scale)
screen_y = center_screen_y + (dy * scale)
# Apply collider offset if both collider and occluder exist
if collider_offset_dist > 0:
offset_rad = math.radians(collider_offset_angle)
screen_x += math.cos(offset_rad) * collider_offset_dist
screen_y += math.sin(offset_rad) * collider_offset_dist
handles.append({
"entity": entity,
"component": box,
"component_type": BoxCollider2D,
"attr": attr,
"direction": direction,
"center_x": center_x,
"center_y": center_y,
"screen_x": screen_x,
"screen_y": screen_y
})
center_screen_x, center_screen_y = self._world_to_screen(center_x, center_y)
move_offset = self.collider_handle_min_screen_distance * 0.75
offset_sx = center_screen_x - move_offset
offset_sy = center_screen_y - move_offset
# Apply collider offset if both collider and occluder exist
if collider_offset_dist > 0:
offset_rad = math.radians(collider_offset_angle)
offset_sx += math.cos(offset_rad) * collider_offset_dist
offset_sy += math.sin(offset_rad) * collider_offset_dist
handles.append({
"entity": entity,
"transform": transform,
"component": box,
"component_type": BoxCollider2D,
"attr": "offset",
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": offset_sx,
"screen_y": offset_sy
})
# Rotation handle for box
rot_offset = self.collider_handle_min_screen_distance * 1.2
rot_sx = center_screen_x + rot_offset
rot_sy = center_screen_y
# Apply collider offset if both collider and occluder exist
if collider_offset_dist > 0:
offset_rad = math.radians(collider_offset_angle)
rot_sx += math.cos(offset_rad) * collider_offset_dist
rot_sy += math.sin(offset_rad) * collider_offset_dist
handles.append({
"entity": entity,
"transform": transform,
"component": box,
"component_type": BoxCollider2D,
"attr": "rotation",
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": rot_sx,
"screen_y": rot_sy
})
if circle:
center_x = transform.x + circle.offset_x
center_y = transform.y + circle.offset_y
radius = max(0.5, abs(circle.radius))
handle_defs = [
("radius", 1, center_x + radius, center_y),
("radius", -1, center_x - radius, center_y),
("radius", 1, center_x, center_y + radius),
("radius", -1, center_x, center_y - radius),
]
for _, direction, world_x, world_y in handle_defs:
screen_x, screen_y = self._world_to_screen(world_x, world_y)
center_screen_x, center_screen_y = self._world_to_screen(center_x, center_y)
dx = screen_x - center_screen_x
dy = screen_y - center_screen_y
distance = math.hypot(dx, dy)
if distance < self.collider_handle_min_screen_distance:
if distance == 0:
dx = direction
dy = 0
distance = 1.0
scale = self.collider_handle_min_screen_distance / distance
screen_x = center_screen_x + (dx * scale)
screen_y = center_screen_y + (dy * scale)
# Apply collider offset if both collider and occluder exist
if collider_offset_dist > 0:
offset_rad = math.radians(collider_offset_angle)
screen_x += math.cos(offset_rad) * collider_offset_dist
screen_y += math.sin(offset_rad) * collider_offset_dist
handles.append({
"entity": entity,
"component": circle,
"component_type": CircleCollider2D,
"attr": "radius",
"direction": direction,
"center_x": center_x,
"center_y": center_y,
"screen_x": screen_x,
"screen_y": screen_y
})
center_screen_x, center_screen_y = self._world_to_screen(center_x, center_y)
move_offset = self.collider_handle_min_screen_distance * 0.75
offset_sx = center_screen_x - move_offset
offset_sy = center_screen_y - move_offset
# Apply collider offset if both collider and occluder exist
if collider_offset_dist > 0:
offset_rad = math.radians(collider_offset_angle)
offset_sx += math.cos(offset_rad) * collider_offset_dist
offset_sy += math.sin(offset_rad) * collider_offset_dist
handles.append({
"entity": entity,
"transform": transform,
"component": circle,
"component_type": CircleCollider2D,
"attr": "offset",
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": offset_sx,
"screen_y": offset_sy
})
# Rotation handle for circle
rot_offset = self.collider_handle_min_screen_distance * 1.2
rot_sx = center_screen_x + rot_offset
rot_sy = center_screen_y
# Apply collider offset if both collider and occluder exist
if collider_offset_dist > 0:
offset_rad = math.radians(collider_offset_angle)
rot_sx += math.cos(offset_rad) * collider_offset_dist
rot_sy += math.sin(offset_rad) * collider_offset_dist
handles.append({
"entity": entity,
"transform": transform,
"component": circle,
"component_type": CircleCollider2D,
"attr": "rotation",
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": rot_sx,
"screen_y": rot_sy
})
if polygon:
points = polygon.points
if len(points) >= 3:
world_points = [
(transform.x + polygon.offset_x + point.x, transform.y + polygon.offset_y + point.y)
for point in points
]
center_x = sum(point[0] for point in world_points) / len(world_points)
center_y = sum(point[1] for point in world_points) / len(world_points)
for index, (world_x, world_y) in enumerate(world_points):
screen_x, screen_y = self._world_to_screen(world_x, world_y)
# Apply collider offset if both collider and occluder exist
if collider_offset_dist > 0:
offset_rad = math.radians(collider_offset_angle)
screen_x += math.cos(offset_rad) * collider_offset_dist
screen_y += math.sin(offset_rad) * collider_offset_dist
handles.append({
"entity": entity,
"transform": transform,
"component": polygon,
"component_type": PolygonCollider2D,
"attr": "point",
"point_index": index,
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": screen_x,
"screen_y": screen_y
})
center_screen_x, center_screen_y = self._world_to_screen(center_x, center_y)
move_offset = self.collider_handle_min_screen_distance * 0.75
offset_sx = center_screen_x - move_offset
offset_sy = center_screen_y - move_offset
# Apply collider offset if both collider and occluder exist
if collider_offset_dist > 0:
offset_rad = math.radians(collider_offset_angle)
offset_sx += math.cos(offset_rad) * collider_offset_dist
offset_sy += math.sin(offset_rad) * collider_offset_dist
handles.append({
"entity": entity,
"transform": transform,
"component": polygon,
"component_type": PolygonCollider2D,
"attr": "offset",
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": offset_sx,
"screen_y": offset_sy
})
# Rotation handle for polygon
rot_offset = self.collider_handle_min_screen_distance * 1.2
rot_sx = center_screen_x + rot_offset
rot_sy = center_screen_y
# Apply collider offset if both collider and occluder exist
if collider_offset_dist > 0:
offset_rad = math.radians(collider_offset_angle)
rot_sx += math.cos(offset_rad) * collider_offset_dist
rot_sy += math.sin(offset_rad) * collider_offset_dist
handles.append({
"entity": entity,
"transform": transform,
"component": polygon,
"component_type": PolygonCollider2D,
"attr": "rotation",
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": rot_sx,
"screen_y": rot_sy
})
# LightOccluder2D handles
occluder = entity.get_component(LightOccluder2D)
if occluder:
center_x = transform.x + occluder.offset_x
center_y = transform.y + occluder.offset_y
if occluder.shape == "box":
half_w = max(0.5, abs(occluder.width) * 0.5)
half_h = max(0.5, abs(occluder.height) * 0.5)
handle_defs = [
("width", 1, center_x + half_w, center_y),
("width", -1, center_x - half_w, center_y),
("height", 1, center_x, center_y + half_h),
("height", -1, center_x, center_y - half_h),
]
for attr, direction, world_x, world_y in handle_defs:
screen_x, screen_y = self._world_to_screen(world_x, world_y)
center_screen_x, center_screen_y = self._world_to_screen(center_x, center_y)
dx = screen_x - center_screen_x
dy = screen_y - center_screen_y
distance = math.hypot(dx, dy)
if distance < self.collider_handle_min_screen_distance:
if distance == 0:
if attr == "width":
dx = direction
dy = 0
else:
dx = 0
dy = direction
distance = 1.0
scale = self.collider_handle_min_screen_distance / distance
screen_x = center_screen_x + (dx * scale)
screen_y = center_screen_y + (dy * scale)
# Apply occluder offset if both collider and occluder exist
if occluder_offset_dist > 0:
offset_rad = math.radians(occluder_offset_angle)
screen_x += math.cos(offset_rad) * occluder_offset_dist
screen_y += math.sin(offset_rad) * occluder_offset_dist
handles.append({
"entity": entity,
"component": occluder,
"component_type": LightOccluder2D,
"attr": attr,
"direction": direction,
"center_x": center_x,
"center_y": center_y,
"screen_x": screen_x,
"screen_y": screen_y
})
elif occluder.shape == "circle":
occ_radius = max(0.5, abs(occluder.radius))
handle_defs = [
("radius", 1, center_x + occ_radius, center_y),
("radius", -1, center_x - occ_radius, center_y),
("radius", 1, center_x, center_y + occ_radius),
("radius", -1, center_x, center_y - occ_radius),
]
for _, direction, world_x, world_y in handle_defs:
screen_x, screen_y = self._world_to_screen(world_x, world_y)
center_screen_x, center_screen_y = self._world_to_screen(center_x, center_y)
dx = screen_x - center_screen_x
dy = screen_y - center_screen_y
distance = math.hypot(dx, dy)
if distance < self.collider_handle_min_screen_distance:
if distance == 0:
dx = direction
dy = 0
distance = 1.0
scale = self.collider_handle_min_screen_distance / distance
screen_x = center_screen_x + (dx * scale)
screen_y = center_screen_y + (dy * scale)
# Apply occluder offset if both collider and occluder exist
if occluder_offset_dist > 0:
offset_rad = math.radians(occluder_offset_angle)
screen_x += math.cos(offset_rad) * occluder_offset_dist
screen_y += math.sin(offset_rad) * occluder_offset_dist
handles.append({
"entity": entity,
"component": occluder,
"component_type": LightOccluder2D,
"attr": "radius",
"direction": direction,
"center_x": center_x,
"center_y": center_y,
"screen_x": screen_x,
"screen_y": screen_y
})
elif occluder.shape == "polygon" and len(occluder.points) >= 3:
world_points = [
(transform.x + occluder.offset_x + p.x, transform.y + occluder.offset_y + p.y)
for p in occluder.points
]
center_x = sum(p[0] for p in world_points) / len(world_points)
center_y = sum(p[1] for p in world_points) / len(world_points)
for index, (world_x, world_y) in enumerate(world_points):
screen_x, screen_y = self._world_to_screen(world_x, world_y)
# Apply occluder offset if both collider and occluder exist
if occluder_offset_dist > 0:
offset_rad = math.radians(occluder_offset_angle)
screen_x += math.cos(offset_rad) * occluder_offset_dist
screen_y += math.sin(offset_rad) * occluder_offset_dist
handles.append({
"entity": entity,
"transform": transform,
"component": occluder,
"component_type": LightOccluder2D,
"attr": "point",
"point_index": index,
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": screen_x,
"screen_y": screen_y
})
# Offset handle for all occluder shapes
center_screen_x, center_screen_y = self._world_to_screen(center_x, center_y)
move_offset = self.collider_handle_min_screen_distance * 0.75
offset_sx = center_screen_x - move_offset
offset_sy = center_screen_y - move_offset
# Apply occluder offset if both collider and occluder exist
if occluder_offset_dist > 0:
offset_rad = math.radians(occluder_offset_angle)
offset_sx += math.cos(offset_rad) * occluder_offset_dist
offset_sy += math.sin(offset_rad) * occluder_offset_dist
handles.append({
"entity": entity,
"transform": transform,
"component": occluder,
"component_type": LightOccluder2D,
"attr": "offset",
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": offset_sx,
"screen_y": offset_sy
})
# Rotation handle for occluder
rot_offset = self.collider_handle_min_screen_distance * 1.2
rot_sx = center_screen_x + rot_offset
rot_sy = center_screen_y
# Apply occluder offset if both collider and occluder exist
if occluder_offset_dist > 0:
offset_rad = math.radians(occluder_offset_angle)
rot_sx += math.cos(offset_rad) * occluder_offset_dist
rot_sy += math.sin(offset_rad) * occluder_offset_dist
handles.append({
"entity": entity,
"transform": transform,
"component": occluder,
"component_type": LightOccluder2D,
"attr": "rotation",
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": rot_sx,
"screen_y": rot_sy
})
# SpotLight2D handles: angle, cone_angle, offset
if spot_light:
sl_cx = transform.x + spot_light.offset_x
sl_cy = transform.y + spot_light.offset_y
sl_screen_cx, sl_screen_cy = self._world_to_screen(sl_cx, sl_cy)
sl_radius_screen = max(self.collider_handle_min_screen_distance, spot_light.radius * self.camera_zoom)
# Angle handle â placed at the tip of the direction vector
angle_rad = math.radians(spot_light.angle)
angle_hx = sl_screen_cx + math.cos(angle_rad) * sl_radius_screen
angle_hy = sl_screen_cy + math.sin(angle_rad) * sl_radius_screen
handles.append({
"entity": entity,
"transform": transform,
"component": spot_light,
"component_type": SpotLight2D,
"attr": "angle",
"direction": 0,
"center_x": sl_cx,
"center_y": sl_cy,
"screen_x": angle_hx,
"screen_y": angle_hy
})
# Cone angle handles â placed at the edges of the cone
half_cone = spot_light.cone_angle
for cone_dir in (1, -1):
cone_edge_rad = math.radians(spot_light.angle + half_cone * cone_dir)
cone_dist = sl_radius_screen * 0.7
cone_hx = sl_screen_cx + math.cos(cone_edge_rad) * cone_dist
cone_hy = sl_screen_cy + math.sin(cone_edge_rad) * cone_dist
handles.append({
"entity": entity,
"transform": transform,
"component": spot_light,
"component_type": SpotLight2D,
"attr": "cone_angle",
"direction": cone_dir,
"center_x": sl_cx,
"center_y": sl_cy,
"screen_x": cone_hx,
"screen_y": cone_hy
})
# Offset handle â at the center of the light
move_offset = self.collider_handle_min_screen_distance * 0.75
handles.append({
"entity": entity,
"transform": transform,
"component": spot_light,
"component_type": SpotLight2D,
"attr": "offset",
"direction": 0,
"center_x": sl_cx,
"center_y": sl_cy,
"screen_x": sl_screen_cx - move_offset,
"screen_y": sl_screen_cy - move_offset
})
return handles
def _draw_physics_debug(self):
selected_collider_color = (255, 230, 80)
handle_color = (255, 130, 80)
move_handle_color = (90, 245, 120)
rotation_handle_color = (255, 200, 100)
handles = self._build_collider_handles()
for entity in self.selected_entities:
transform = entity.get_component(Transform)
if not transform:
continue
box = entity.get_component(BoxCollider2D)
circle = entity.get_component(CircleCollider2D)
polygon = entity.get_component(PolygonCollider2D)
occluder = entity.get_component(LightOccluder2D)
spot_light = entity.get_component(SpotLight2D)
if not box and not circle and not polygon and not occluder and not spot_light:
continue
color = selected_collider_color
if box:
center_x = transform.x + box.offset_x
center_y = transform.y + box.offset_y
half_w = max(0.5, abs(box.width) * 0.5)
half_h = max(0.5, abs(box.height) * 0.5)
# Apply component rotation
total_rot = (transform.rotation + box.rotation) % 360
if abs(total_rot) < 0.001 or abs(total_rot - 360) < 0.001:
# Axis-aligned
left_top = self._world_to_screen(center_x - half_w, center_y - half_h)
right_bottom = self._world_to_screen(center_x + half_w, center_y + half_h)
rect = pygame.Rect(
int(min(left_top[0], right_bottom[0])),
int(min(left_top[1], right_bottom[1])),
max(1, int(abs(right_bottom[0] - left_top[0]))),
max(1, int(abs(right_bottom[1] - left_top[1])))
)
pygame.draw.rect(self.surface, color, rect, 2)
else:
# Rotated box
rad = math.radians(total_rot)
cos_a = math.cos(rad)
sin_a = math.sin(rad)
corners = [(-half_w, -half_h), (half_w, -half_h), (half_w, half_h), (-half_w, half_h)]
screen_pts = []
for lx, ly in corners:
rx = lx * cos_a - ly * sin_a + center_x
ry = lx * sin_a + ly * cos_a + center_y
sx, sy = self._world_to_screen(rx, ry)
screen_pts.append((int(sx), int(sy)))
if len(screen_pts) >= 3:
pygame.draw.polygon(self.surface, color, screen_pts, 2)
if circle:
center_x = transform.x + circle.offset_x
center_y = transform.y + circle.offset_y
screen_x, screen_y = self._world_to_screen(center_x, center_y)
screen_radius = max(1, int(abs(circle.radius) * self.camera_zoom))
pygame.draw.circle(self.surface, color, (int(screen_x), int(screen_y)), screen_radius, 2)
if polygon and len(polygon.points) >= 3:
screen_points = []
for point in polygon.points:
world_x = transform.x + polygon.offset_x + point.x
world_y = transform.y + polygon.offset_y + point.y
screen_x, screen_y = self._world_to_screen(world_x, world_y)
screen_points.append((int(screen_x), int(screen_y)))
if len(screen_points) >= 3:
pygame.draw.polygon(self.surface, color, screen_points, 2)
if occluder:
occ_color = (200, 180, 60) # Distinct from collider yellow
cx = transform.x + occluder.offset_x
cy = transform.y + occluder.offset_y
if occluder.shape == "box":
hw = max(0.5, abs(occluder.width) * 0.5)
hh = max(0.5, abs(occluder.height) * 0.5)
# Apply component rotation
total_rot = (transform.rotation + occluder.rotation) % 360
if abs(total_rot) < 0.001 or abs(total_rot - 360) < 0.001:
# Axis-aligned
lt = self._world_to_screen(cx - hw, cy - hh)
rb = self._world_to_screen(cx + hw, cy + hh)
r = pygame.Rect(
int(min(lt[0], rb[0])), int(min(lt[1], rb[1])),
max(1, int(abs(rb[0] - lt[0]))), max(1, int(abs(rb[1] - lt[1])))
)
pygame.draw.rect(self.surface, occ_color, r, 2)
else:
# Rotated box
rad = math.radians(total_rot)
cos_a = math.cos(rad)
sin_a = math.sin(rad)
corners = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]
screen_pts = []
for lx, ly in corners:
rx = lx * cos_a - ly * sin_a + cx
ry = lx * sin_a + ly * cos_a + cy
sx, sy = self._world_to_screen(rx, ry)
screen_pts.append((int(sx), int(sy)))
if len(screen_pts) >= 3:
pygame.draw.polygon(self.surface, occ_color, screen_pts, 2)
elif occluder.shape == "circle":
sx, sy = self._world_to_screen(cx, cy)
sr = max(1, int(abs(occluder.radius) * self.camera_zoom))
pygame.draw.circle(self.surface, occ_color, (int(sx), int(sy)), sr, 2)
elif occluder.shape == "polygon" and len(occluder.points) >= 3:
pts = []
for p in occluder.points:
wx = transform.x + occluder.offset_x + p.x
wy = transform.y + occluder.offset_y + p.y
sx, sy = self._world_to_screen(wx, wy)
pts.append((int(sx), int(sy)))
if len(pts) >= 3:
pygame.draw.polygon(self.surface, occ_color, pts, 2)
# Draw shadow preview for occluder
self._draw_shadow_preview_for_occluder(occluder, transform)
if spot_light:
sl_color = (120, 200, 255) # Light blue for spot light
sl_cx = transform.x + spot_light.offset_x
sl_cy = transform.y + spot_light.offset_y
sl_scx, sl_scy = self._world_to_screen(sl_cx, sl_cy)
sl_sr = max(1, int(spot_light.radius * self.camera_zoom))
# Draw outer radius circle (dashed feel via thinner line)
pygame.draw.circle(self.surface, sl_color, (int(sl_scx), int(sl_scy)), sl_sr, 1)
# Draw cone edges
angle_rad = math.radians(spot_light.angle)
half_cone = spot_light.cone_angle
for cone_dir in (1, -1):
edge_rad = math.radians(spot_light.angle + half_cone * cone_dir)
ex = sl_scx + math.cos(edge_rad) * sl_sr
ey = sl_scy + math.sin(edge_rad) * sl_sr
pygame.draw.line(self.surface, sl_color, (int(sl_scx), int(sl_scy)), (int(ex), int(ey)), 2)
# Draw direction line
dir_ex = sl_scx + math.cos(angle_rad) * sl_sr
dir_ey = sl_scy + math.sin(angle_rad) * sl_sr
pygame.draw.line(self.surface, sl_color, (int(sl_scx), int(sl_scy)), (int(dir_ex), int(dir_ey)), 1)
# Draw cone arc
n_arc = 20
start_angle = spot_light.angle - half_cone
end_angle = spot_light.angle + half_cone
arc_pts = []
for i in range(n_arc + 1):
t = start_angle + (end_angle - start_angle) * i / n_arc
r = math.radians(t)
arc_pts.append((int(sl_scx + math.cos(r) * sl_sr), int(sl_scy + math.sin(r) * sl_sr)))
if len(arc_pts) >= 2:
pygame.draw.lines(self.surface, sl_color, False, arc_pts, 2)
spot_angle_color = (100, 180, 255) # Blue for spot angle handle
spot_cone_color = (160, 120, 255) # Purple for spot cone handles
for handle in handles:
is_spot = handle.get("component_type") is SpotLight2D
if handle["attr"] == "offset":
color = move_handle_color
handle_size = self.collider_handle_size + 4
elif handle["attr"] == "rotation":
color = rotation_handle_color
handle_size = self.collider_handle_size
elif is_spot and handle["attr"] == "angle":
color = spot_angle_color
handle_size = self.collider_handle_size + 2
elif is_spot and handle["attr"] == "cone_angle":
color = spot_cone_color
handle_size = self.collider_handle_size
else:
color = handle_color
handle_size = self.collider_handle_size
rect = pygame.Rect(
int(handle["screen_x"]) - (handle_size // 2),
int(handle["screen_y"]) - (handle_size // 2),
handle_size,
handle_size
)
if handle["attr"] == "offset":
pygame.draw.circle(self.surface, color, rect.center, handle_size // 2)
pygame.draw.circle(self.surface, (20, 20, 20), rect.center, handle_size // 2, 1)
elif handle["attr"] == "rotation":
pygame.draw.circle(self.surface, color, rect.center, handle_size // 2)
pygame.draw.circle(self.surface, (20, 20, 20), rect.center, handle_size // 2, 2)
elif is_spot and handle["attr"] == "angle":
# Diamond shape for angle handle
cx, cy = rect.center
hs = handle_size // 2
diamond = [(cx, cy - hs), (cx + hs, cy), (cx, cy + hs), (cx - hs, cy)]
pygame.draw.polygon(self.surface, color, diamond)
pygame.draw.polygon(self.surface, (20, 20, 20), diamond, 1)
elif is_spot and handle["attr"] == "cone_angle":
# Triangle shape for cone handles
cx, cy = rect.center
hs = handle_size // 2
triangle = [(cx, cy - hs), (cx + hs, cy + hs), (cx - hs, cy + hs)]
pygame.draw.polygon(self.surface, color, triangle)
pygame.draw.polygon(self.surface, (20, 20, 20), triangle, 1)
else:
pygame.draw.rect(self.surface, color, rect)
pygame.draw.rect(self.surface, (20, 20, 20), rect, 1)
def _draw_shadow_preview_for_occluder(self, occluder: LightOccluder2D, transform: Transform):
"""Draw shadow preview extending from occluder using shadow_extend setting."""
shadow_color = (50, 30, 80, 100) # Semi-transparent purple for shadow preview
# Get occluder polygon in world space
occluder_poly = []
cx = transform.x + occluder.offset_x
cy = transform.y + occluder.offset_y
if occluder.shape == "box":
hw = max(0.5, abs(occluder.width) * 0.5)
hh = max(0.5, abs(occluder.height) * 0.5)
total_rot = (transform.rotation + occluder.rotation) % 360
rad = math.radians(total_rot)
cos_a = math.cos(rad)
sin_a = math.sin(rad)
corners = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]
for lx, ly in corners:
rx = lx * cos_a - ly * sin_a + cx
ry = lx * sin_a + ly * cos_a + cy
occluder_poly.append((rx, ry))
elif occluder.shape == "circle":
# For circle, draw a simple shadow cone extending outward
sr = max(0.5, abs(occluder.radius))
for i in range(8):
angle = (i / 8) * 2 * math.pi
x = cx + sr * math.cos(angle)
y = cy + sr * math.sin(angle)
occluder_poly.append((x, y))
elif occluder.shape == "polygon" and len(occluder.points) >= 3:
for p in occluder.points:
wx = cx + p.x
wy = cy + p.y
occluder_poly.append((wx, wy))
if len(occluder_poly) < 2:
return
# Draw shadow volume extending from each edge
shadow_color_rgb = shadow_color[:3]
for i in range(len(occluder_poly)):
p1 = occluder_poly[i]
p2 = occluder_poly[(i + 1) % len(occluder_poly)]
# Calculate edge midpoint and direction
mid_x = (p1[0] + p2[0]) * 0.5
mid_y = (p1[1] + p2[1]) * 0.5
# Direction from center to edge midpoint
dx = mid_x - cx
dy = mid_y - cy
dist = math.hypot(dx, dy)
if dist < 0.001:
continue
# Normalize and extend
nx = dx / dist
ny = dy / dist
# Shadow volume extends outward
shadow_end_x = mid_x + nx * self.shadow_extend
shadow_end_y = mid_y + ny * self.shadow_extend
# Convert to screen coordinates
screen_mid = self._world_to_screen(mid_x, mid_y)
screen_end = self._world_to_screen(shadow_end_x, shadow_end_y)
# Draw shadow line (thin, semi-transparent)
pygame.draw.line(self.surface, shadow_color_rgb,
(int(screen_mid[0]), int(screen_mid[1])),
(int(screen_end[0]), int(screen_end[1])), 1)
def _hit_test_collider_handle(self, mouse_pos):
x, y = mouse_pos
best = None
best_dist = float("inf")
for handle in self._build_collider_handles():
if handle["attr"] == "offset":
hit_radius = ((self.collider_handle_size + 4) * 0.5) + 4
else:
hit_radius = (self.collider_handle_size * 0.5) + 4
dx = handle["screen_x"] - x
dy = handle["screen_y"] - y
dist = math.hypot(dx, dy)
if dist <= hit_radius and dist < best_dist:
best = handle
best_dist = dist
return best
def _begin_collider_resize(self, handle, mouse_pos):
entity = handle["entity"]
if entity not in self.selected_entities:
self.selected_entities = [entity]
self.gizmo.set_targets(self.selected_entities)
self.entity_selected.emit(self.selected_entities)
comp = handle["component"]
attr = handle["attr"]
state = {
"entity": entity,
"component": comp,
"component_type": handle["component_type"],
"attr": attr,
"direction": handle["direction"],
"center_x": handle["center_x"],
"center_y": handle["center_y"],
}
if attr == "offset":
transform = entity.get_component(Transform)
if not transform:
return
mouse_world_x, mouse_world_y = self._screen_to_world(mouse_pos[0], mouse_pos[1])
state["transform"] = transform
state["grab_dx"] = mouse_world_x - handle["center_x"]
state["grab_dy"] = mouse_world_y - handle["center_y"]
state["old_offset_x"] = comp.offset_x
state["old_offset_y"] = comp.offset_y
elif attr == "rotation":
comp = handle["component"]
state["old_rotation"] = comp.rotation
mouse_world_x, mouse_world_y = self._screen_to_world(mouse_pos[0], mouse_pos[1])
state["initial_angle"] = math.atan2(
mouse_world_y - handle["center_y"],
mouse_world_x - handle["center_x"]
)
elif attr == "angle" and handle["component_type"] is SpotLight2D:
state["old_value"] = comp.angle
elif attr == "cone_angle" and handle["component_type"] is SpotLight2D:
state["old_value"] = comp.cone_angle
elif attr == "point":
transform = entity.get_component(Transform)
if not transform:
return
state["transform"] = transform
state["point_index"] = handle.get("point_index", -1)
state["old_points"] = [Vector2(point.x, point.y) for point in comp.points]
else:
state["old_value"] = getattr(comp, attr)
self.collider_drag_state = state
def _update_collider_resize(self, mouse_pos):
if not self.collider_drag_state:
return
world_x, world_y = self._screen_to_world(mouse_pos[0], mouse_pos[1])
state = self.collider_drag_state
comp = state["component"]
attr = state["attr"]
direction = state["direction"]
cx = state["center_x"]
cy = state["center_y"]
if attr == "width":
if direction > 0:
new_width = max(1.0, (world_x - cx) * 2.0)
else:
new_width = max(1.0, (cx - world_x) * 2.0)
comp.width = new_width
elif attr == "height":
if direction > 0:
new_height = max(1.0, (world_y - cy) * 2.0)
else:
new_height = max(1.0, (cy - world_y) * 2.0)
comp.height = new_height
elif attr == "radius":
dx = world_x - cx
dy = world_y - cy
comp.radius = max(0.5, math.hypot(dx, dy))
elif attr == "rotation":
comp = state["component"]
current_angle = math.atan2(
world_y - cy,
world_x - cx
)
angle_delta = current_angle - state["initial_angle"]
comp.rotation = state["old_rotation"] + math.degrees(angle_delta)
elif attr == "angle" and state["component_type"] is SpotLight2D:
# Angle = direction from center to mouse in degrees
screen_cx, screen_cy = self._world_to_screen(cx, cy)
mouse_sx, mouse_sy = mouse_pos
comp.angle = math.degrees(math.atan2(mouse_sy - screen_cy, mouse_sx - screen_cx))
elif attr == "cone_angle" and state["component_type"] is SpotLight2D:
# Cone angle = angular distance from direction to mouse
screen_cx, screen_cy = self._world_to_screen(cx, cy)
mouse_sx, mouse_sy = mouse_pos
mouse_angle = math.degrees(math.atan2(mouse_sy - screen_cy, mouse_sx - screen_cx))
delta = abs(mouse_angle - comp.angle)
if delta > 180:
delta = 360 - delta
comp.cone_angle = max(1.0, min(180.0, delta))
elif attr == "offset":
transform = state["transform"]
center_x = world_x - state["grab_dx"]
center_y = world_y - state["grab_dy"]
comp.offset_x = center_x - transform.x
comp.offset_y = center_y - transform.y
elif attr == "point":
transform = state["transform"]
point_index = state["point_index"]
if point_index < 0 or point_index >= len(comp.points):
return
new_points = [Vector2(point.x, point.y) for point in comp.points]
new_points[point_index] = Vector2(
world_x - transform.x - comp.offset_x,
world_y - transform.y - comp.offset_y
)
comp.points = new_points
def _commit_collider_resize(self):
state = self.collider_drag_state
if not state:
return
comp = state["component"]
attr = state["attr"]
mw = self.window()
if not hasattr(mw, "undo_manager"):
return
if attr == "rotation":
comp = state["component"]
old_rotation = state["old_rotation"]
new_rotation = comp.rotation
if abs(new_rotation - old_rotation) > 1e-6:
mw.undo_manager.push(PropertyChangeCommand(
[state["entity"]],
state["component_type"],
"rotation",
[old_rotation],
new_rotation
))
return
if attr == "offset":
old_offset_x = state["old_offset_x"]
old_offset_y = state["old_offset_y"]
new_offset_x = comp.offset_x
new_offset_y = comp.offset_y
if abs(new_offset_x - old_offset_x) > 1e-6 or abs(new_offset_y - old_offset_y) > 1e-6:
mw.undo_manager.push(MultiPropertyChangeCommand(
[state["entity"]],
state["component_type"],
["offset_x", "offset_y"],
[[old_offset_x], [old_offset_y]],
[new_offset_x, new_offset_y]
))
return
if attr == "point":
old_points = state["old_points"]
new_points = [Vector2(point.x, point.y) for point in comp.points]
if len(old_points) != len(new_points):
changed = True
else:
changed = any(
abs(old_points[index].x - new_points[index].x) > 1e-6
or abs(old_points[index].y - new_points[index].y) > 1e-6
for index in range(len(new_points))
)
if changed:
mw.undo_manager.push(PropertyChangeCommand(
[state["entity"]],
state["component_type"],
"points",
[old_points],
new_points
))
return
old_value = state["old_value"]
new_value = getattr(comp, attr)
if abs(new_value - old_value) <= 1e-6:
return
mw.undo_manager.push(PropertyChangeCommand(
[state["entity"]],
state["component_type"],
attr,
[old_value],
new_value
))
[docs]
def mousePressEvent(self, event):
mouse_pos = (event.position().x(), event.position().y())
# Tilemap edit interaction (takes precedence over selection/gizmo when enabled)
if event.button() == Qt.MouseButton.LeftButton and self.tilemap_edit_mode:
target = self._get_selected_tilemap_target()
if target:
entity, transform, tilemap, layer, layer_index = target
world_x, world_y = self._screen_to_world(mouse_pos[0], mouse_pos[1])
tx, ty = self._world_to_tile(world_x, world_y, transform, tilemap)
tool = str(self.tilemap_tool)
if tool in ("paint", "erase"):
value = self.tilemap_selected_tile if tool == "paint" else 0
self._tilemap_stroke_changes = {}
if self._apply_tile_at(layer, tx, ty, value):
self.entity_modified.emit(entity)
self.last_mouse_pos = event.position()
return
if tool == "picker":
picked = layer.get_world(tx, ty)
mw = self.window()
if mw and hasattr(mw, "tilemap_editor_dock"):
mw.tilemap_editor_dock.preview._selected_tile_id = int(picked)
mw.tilemap_editor_dock.selected_tile_changed.emit(int(picked))
mw.tilemap_editor_dock.preview.update()
self.last_mouse_pos = event.position()
return
if tool == "rect":
self._tilemap_stroke_changes = {}
self._tilemap_rect_start = (tx, ty)
self.last_mouse_pos = event.position()
return
if tool == "fill":
self._tilemap_stroke_changes = {}
self._flood_fill(layer, tx, ty, int(self.tilemap_selected_tile))
self._commit_tilemap_stroke(entity, layer_index)
self.entity_modified.emit(entity)
self.last_mouse_pos = event.position()
return
if event.button() == Qt.MouseButton.LeftButton and self.polygon_point_add_entity:
if self._add_polygon_point_from_screen(mouse_pos[0], mouse_pos[1]):
self.last_mouse_pos = event.position()
return
if event.button() == Qt.MouseButton.LeftButton and self._occ_point_add_entity:
if self._add_occ_polygon_point_from_screen(mouse_pos[0], mouse_pos[1]):
self.last_mouse_pos = event.position()
return
if event.button() == Qt.MouseButton.LeftButton:
if self.physics_debug_mode:
handle = self._hit_test_collider_handle(mouse_pos)
if handle:
self._begin_collider_resize(handle, mouse_pos)
self.last_mouse_pos = event.position()
return
gizmo_cam_x, gizmo_cam_y, gizmo_zoom = self._get_gizmo_camera()
if self.gizmo.handle_event("MOUSEBUTTONDOWN", mouse_pos, gizmo_cam_x, gizmo_cam_y, gizmo_zoom):
self.gizmo_interaction_active = True
self.capture_start_states()
return
if event.button() == Qt.MouseButton.LeftButton:
# Picking logic
x, y = mouse_pos
world_x, world_y = self._screen_to_world(x, y)
clicked_entity = None
# Iterate in reverse order to select top-most entity first
for entity in reversed(self.scene.world.entities):
transform = entity.get_component(Transform)
if transform:
sprite = entity.get_component(SpriteRenderer)
if sprite:
# sprite.width/height are already scaled (world size)
w = abs(sprite.width)
h = abs(sprite.height)
else:
# Default 50x50 needs scaling
w = abs(50 * transform.scale_x)
h = abs(50 * transform.scale_y)
# Rotate AABB
if transform.rotation != 0:
rad = math.radians(transform.rotation)
sin_a = abs(math.sin(rad))
cos_a = abs(math.cos(rad))
final_w = w * cos_a + h * sin_a
final_h = w * sin_a + h * cos_a
else:
final_w = w
final_h = h
left = transform.x - final_w / 2
right = transform.x + final_w / 2
top = transform.y - final_h / 2
bottom = transform.y + final_h / 2
if left <= world_x <= right and top <= world_y <= bottom:
clicked_entity = entity
break
modifiers = event.modifiers()
ctrl_pressed = modifiers & Qt.KeyboardModifier.ControlModifier
if clicked_entity:
if ctrl_pressed:
if clicked_entity in self.selected_entities:
self.selected_entities.remove(clicked_entity)
else:
self.selected_entities.append(clicked_entity)
else:
self.selected_entities = [clicked_entity]
# If we clicked an entity (even if already selected), start dragging
# Only if not Ctrl-click (which toggles selection)
if not ctrl_pressed:
self.dragging = True
self.capture_start_states()
else:
if not ctrl_pressed:
self.selected_entities = []
self.gizmo.set_targets(self.selected_entities)
self.entity_selected.emit(self.selected_entities)
elif event.button() == Qt.MouseButton.MiddleButton or event.button() == Qt.MouseButton.RightButton:
self.panning = True
self.last_mouse_pos = event.position()
super().mousePressEvent(event)
[docs]
def mouseMoveEvent(self, event):
mouse_pos = (event.position().x(), event.position().y())
if self.tilemap_edit_mode and self._tilemap_stroke_changes is not None:
target = self._get_selected_tilemap_target()
if target:
entity, transform, tilemap, layer, _layer_index = target
tool = str(self.tilemap_tool)
if tool in ("paint", "erase"):
world_x, world_y = self._screen_to_world(mouse_pos[0], mouse_pos[1])
tx, ty = self._world_to_tile(world_x, world_y, transform, tilemap)
value = self.tilemap_selected_tile if tool == "paint" else 0
if self._apply_tile_at(layer, tx, ty, value):
self.entity_modified.emit(entity)
self.last_mouse_pos = event.position()
return
if self.collider_drag_state:
self._update_collider_resize(mouse_pos)
if self.selected_entities:
self.entity_modified.emit(self.selected_entities[0])
self.last_mouse_pos = event.position()
return
# Handle Gizmo hover/drag
gizmo_cam_x, gizmo_cam_y, gizmo_zoom = self._get_gizmo_camera()
if self.gizmo.handle_event("MOUSEMOTION", mouse_pos, gizmo_cam_x, gizmo_cam_y, gizmo_zoom):
if self.gizmo.active_axis != Gizmo.AXIS_NONE and self.selected_entities:
if self.selected_entities:
self.entity_modified.emit(self.selected_entities[0])
# Even if gizmo handled it, update last_mouse_pos for next frame consistency
self.last_mouse_pos = event.position()
return
if self.panning and self.last_mouse_pos:
dx = event.position().x() - self.last_mouse_pos.x()
dy = event.position().y() - self.last_mouse_pos.y()
self.camera_x -= dx / self.camera_zoom
self.camera_y -= dy / self.camera_zoom
if self.render_system:
self.render_system.camera_x = self.camera_x
self.render_system.camera_y = self.camera_y
elif self.dragging and self.selected_entities and self.last_mouse_pos:
dx = (event.position().x() - self.last_mouse_pos.x()) / self.camera_zoom
dy = (event.position().y() - self.last_mouse_pos.y()) / self.camera_zoom
for entity in self.selected_entities:
transform = entity.get_component(Transform)
if transform:
transform.x += dx
transform.y += dy
if self.selected_entities:
self.entity_modified.emit(self.selected_entities[0])
self.last_mouse_pos = event.position()
super().mouseMoveEvent(event)
[docs]
def mouseReleaseEvent(self, event):
mouse_pos = (event.position().x(), event.position().y())
if event.button() == Qt.MouseButton.LeftButton:
if self.tilemap_edit_mode:
target = self._get_selected_tilemap_target()
if target:
entity, transform, tilemap, layer, layer_index = target
tool = str(self.tilemap_tool)
if tool == "rect" and self._tilemap_rect_start is not None:
world_x, world_y = self._screen_to_world(mouse_pos[0], mouse_pos[1])
end_tx, end_ty = self._world_to_tile(world_x, world_y, transform, tilemap)
start_tx, start_ty = self._tilemap_rect_start
self._tilemap_rect_start = None
# No bounds checking for infinite tilemap
x0 = min(start_tx, end_tx)
x1 = max(start_tx, end_tx)
y0 = min(start_ty, end_ty)
y1 = max(start_ty, end_ty)
value = int(self.tilemap_selected_tile)
for ty in range(y0, y1 + 1):
for tx in range(x0, x1 + 1):
self._apply_tile_at(layer, tx, ty, value)
self._commit_tilemap_stroke(entity, layer_index)
if self.selected_entities:
self.entity_modified.emit(self.selected_entities[0])
super().mouseReleaseEvent(event)
return
if self.collider_drag_state:
self._commit_collider_resize()
self.collider_drag_state = None
super().mouseReleaseEvent(event)
return
# Check if we need to commit changes
if self.gizmo_interaction_active or self.dragging:
self.commit_transform()
gizmo_cam_x, gizmo_cam_y, gizmo_zoom = self._get_gizmo_camera()
self.gizmo.handle_event("MOUSEBUTTONUP", mouse_pos, gizmo_cam_x, gizmo_cam_y, gizmo_zoom)
self.gizmo_interaction_active = False
self.dragging = False
elif event.button() == Qt.MouseButton.MiddleButton or event.button() == Qt.MouseButton.RightButton:
self.panning = False
# Don't clear last_mouse_pos here as move event needs it for continuity?
# Actually standard practice is clear on release if dragging ended.
# But we set it on move.
pass
super().mouseReleaseEvent(event)
[docs]
def wheelEvent(self, event):
zoom_factor = 1.1 if event.angleDelta().y() > 0 else 0.9
mouse_x = event.position().x()
mouse_y = event.position().y()
world_x_before, world_y_before = self._screen_to_world(mouse_x, mouse_y)
self.camera_zoom *= zoom_factor
# clamp zoom
self.camera_zoom = max(0.1, min(self.camera_zoom, 10.0))
if self.render_system:
self.render_system.camera_zoom = self.camera_zoom
self.render_system.camera_x = self.camera_x
self.render_system.camera_y = self.camera_y
world_x_after, world_y_after = self._screen_to_world(mouse_x, mouse_y)
self.camera_x += (world_x_before - world_x_after)
self.camera_y += (world_y_before - world_y_after)
if self.render_system:
self.render_system.camera_zoom = self.camera_zoom
self.render_system.camera_x = self.camera_x
self.render_system.camera_y = self.camera_y
self.update_zoom_label()
super().wheelEvent(event)
[docs]
def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_F3:
self.toggle_physics_debug()
elif event.key() == Qt.Key.Key_Delete:
if self.selected_entities:
deletable_entities = [entity for entity in self.selected_entities if not self._is_protected_entity(entity)]
if not deletable_entities:
super().keyPressEvent(event)
return
mw = self.window()
if hasattr(mw, 'undo_manager'):
cmd = DeleteEntitiesCommand(self.scene.world, deletable_entities)
# Execute first, then push? Or push executes?
# Command pattern usually: create, execute, push.
# But UndoManager.push doesn't execute.
# DeleteEntitiesCommand.execute() does the deletion.
cmd.execute()
mw.undo_manager.push(cmd)
else:
# Fallback
for entity in deletable_entities:
self.scene.world.destroy_entity(entity)
# Deselect and hide gizmo
self.selected_entities = []
self.gizmo.set_targets([])
self.entity_selected.emit([])
# Notify main window to refresh hierarchy
self.entity_deleted.emit(None)
elif event.key() == Qt.Key.Key_Escape:
if self.polygon_point_add_entity:
self.stop_polygon_point_add_mode()
if self._occ_point_add_entity:
self._occ_point_add_entity = None
if self.selected_entities:
# Deselect and hide gizmo
self.selected_entities = []
self.gizmo.set_targets([])
self.entity_selected.emit([])
elif event.key() == Qt.Key.Key_T:
self.set_gizmo_mode(Gizmo.MODE_TRANSLATE)
elif event.key() == Qt.Key.Key_R:
self.set_gizmo_mode(Gizmo.MODE_ROTATE)
elif event.key() == Qt.Key.Key_S:
self.set_gizmo_mode(Gizmo.MODE_SCALE)
super().keyPressEvent(event)
def _is_protected_entity(self, entity):
if not entity:
return False
if entity.name != "Main Camera":
return False
return entity.get_component(CameraComponent) is not None
[docs]
def capture_start_states(self):
self.transform_start_states = []
for entity in self.selected_entities:
t = entity.get_component(Transform)
if t:
self.transform_start_states.append({
'x': t.x,
'y': t.y,
'rotation': t.rotation,
'scale_x': t.scale_x,
'scale_y': t.scale_y
})
else:
self.transform_start_states.append(None)