pyengine-x 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyengine/engine.py ADDED
@@ -0,0 +1,910 @@
1
+ import pygame
2
+ import sys
3
+ import math
4
+ import time
5
+ from typing import Literal
6
+
7
+ from .tilemap import TileMap
8
+ from .figure import Figure
9
+ from .scene import Scene
10
+ from .sound import Sound
11
+
12
+
13
+ # ――――――――――――――――――――――――― type aliases ―――――――――――――――――――――――――
14
+
15
+ KeyLiteral = Literal[
16
+ 'leftMouseButton', 'rightMouseButton', 'middleMouseButton',
17
+ 'escape', 'space', 'tab', 'enter',
18
+ 'leftCtrl', 'rightCtrl', 'leftShift', 'rightShift', 'leftAlt', 'rightAlt',
19
+ 'left', 'right', 'up', 'down',
20
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
21
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
22
+ 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
23
+ ]
24
+ EventLiteral = Literal['quit', 'pressed', 'keyDown', 'keyUp', 'mouseButtonDown', 'mouseButtonUp', 'mouseMotion']
25
+ AlignX = Literal['left', 'middle', 'right']
26
+ AlignY = Literal['up', 'middle', 'down']
27
+ MouseButton = Literal['leftMouseButton', 'rightMouseButton', 'middleMouseButton']
28
+
29
+ # draw queue item: either a Figure or a (surface, pos) blit
30
+ DrawItem = tuple[Figure | tuple[pygame.Surface, tuple[int, int]], bool]
31
+
32
+
33
+ # ――――――――――――――――――――――――― engine ―――――――――――――――――――――――――
34
+
35
+
36
+ class PyEngine(Sound):
37
+
38
+ _KEYS_MAP: dict[str, int] = {
39
+ 'escape': pygame.K_ESCAPE, 'space': pygame.K_SPACE,
40
+ 'tab': pygame.K_TAB, 'enter': pygame.K_RETURN,
41
+ 'leftCtrl': pygame.K_LCTRL, 'rightCtrl': pygame.K_RCTRL,
42
+ 'leftShift': pygame.K_LSHIFT, 'rightShift': pygame.K_RSHIFT,
43
+ 'leftAlt': pygame.K_LALT, 'rightAlt': pygame.K_RALT,
44
+ 'left': pygame.K_LEFT, 'right': pygame.K_RIGHT,
45
+ 'up': pygame.K_UP, 'down': pygame.K_DOWN,
46
+ '0': pygame.K_0, '1': pygame.K_1, '2': pygame.K_2, '3': pygame.K_3, '4': pygame.K_4,
47
+ '5': pygame.K_5, '6': pygame.K_6, '7': pygame.K_7, '8': pygame.K_8, '9': pygame.K_9,
48
+ 'a': pygame.K_a, 'b': pygame.K_b, 'c': pygame.K_c, 'd': pygame.K_d, 'e': pygame.K_e,
49
+ 'f': pygame.K_f, 'g': pygame.K_g, 'h': pygame.K_h, 'i': pygame.K_i, 'j': pygame.K_j,
50
+ 'k': pygame.K_k, 'l': pygame.K_l, 'm': pygame.K_m, 'n': pygame.K_n, 'o': pygame.K_o,
51
+ 'p': pygame.K_p, 'q': pygame.K_q, 'r': pygame.K_r, 's': pygame.K_s, 't': pygame.K_t,
52
+ 'u': pygame.K_u, 'v': pygame.K_v, 'w': pygame.K_w, 'x': pygame.K_x, 'y': pygame.K_y,
53
+ 'z': pygame.K_z,
54
+ }
55
+ _MOUSE_MAP: dict[str, int] = {
56
+ 'leftMouseButton': 0,
57
+ 'middleMouseButton': 1,
58
+ 'rightMouseButton': 2,
59
+ }
60
+ _MOUSE_BTN_MAP: dict[str, int] = {
61
+ 'leftMouseButton': 1,
62
+ 'middleMouseButton': 2,
63
+ 'rightMouseButton': 3,
64
+ }
65
+ _EVENT_MAP: dict[str, int] = {
66
+ 'keyDown': pygame.KEYDOWN,
67
+ 'keyUp': pygame.KEYUP,
68
+ 'mouseButtonDown': pygame.MOUSEBUTTONDOWN,
69
+ 'mouseButtonUp': pygame.MOUSEBUTTONUP,
70
+ 'mouseMotion': pygame.MOUSEMOTION,
71
+ }
72
+
73
+ GRID_SNAP_EPSILON: float = 1.0
74
+
75
+ # ――――――――――――――――――――――――― init ―――――――――――――――――――――――――
76
+
77
+ def __init__(
78
+ self,
79
+ title: str = 'Py-Engine',
80
+ background: tuple = (50, 50, 50),
81
+ width: int = 800,
82
+ height: int = 600,
83
+ ):
84
+ super().__init__()
85
+
86
+ self.title = title
87
+ self.width = width
88
+ self.height = height
89
+ self.background = background
90
+
91
+ self.fonts: dict[str, pygame.font.Font] = {}
92
+ self.figures: dict[str, Figure] = {}
93
+ self.images: dict[str, pygame.Surface] = {}
94
+
95
+ self._draw_queue: list[DrawItem] = []
96
+ self._cooldowns: dict[str, float] = {}
97
+
98
+ self._scenes: dict[str, Scene] = {}
99
+ self._current_scene: Scene | None = None
100
+ self._next_scene: tuple[str, dict, bool] | None = None
101
+
102
+ self.screen: pygame.Surface | None = None
103
+ self.clock: pygame.time.Clock | None = None
104
+ self.seconds: float = 0.0
105
+ self.dt: float = 0.0
106
+ self.is_running: bool = False
107
+ self._start_ticks: int = 0
108
+
109
+ self.animations: dict[str, dict] = {}
110
+
111
+ self._tilemaps: dict[str, 'TileMap'] = {}
112
+ self._grid_targets: dict[str, tuple[float, float]] = {}
113
+ self._camera_target: str | None = None
114
+ self._camera_offset: tuple[float, float] = (0.0, 0.0)
115
+ self._camera_bounds: tuple[str, any] | None = None # ('map', TileMap) или ('window', None)
116
+
117
+ @property
118
+ def camera_offset(self) -> tuple[float, float]:
119
+ """Публичный доступ к смещению камеры"""
120
+ return self._camera_offset
121
+
122
+ def initialize(self, start_scene: str | None = None):
123
+ """
124
+ Инициализирует подсистемы pygame, создает окно рендеринга, настраивает таймер и переводит движок в состояние готовности к работе.
125
+ - start_scene (str | None): Имя предварительно зарегистрированной сцены, на которую нужно переключиться сразу после запуска.
126
+ """
127
+ pygame.init()
128
+ pygame.mixer.init()
129
+ self.screen = pygame.display.set_mode((self.width, self.height))
130
+ pygame.display.set_caption(self.title)
131
+ self.clock = pygame.time.Clock()
132
+ self.is_running = True
133
+ if start_scene and start_scene in self._scenes:
134
+ self._next_scene = (start_scene, {}, True)
135
+
136
+ # ――――――――――――――――――――――――― loop ―――――――――――――――――――――――――
137
+
138
+ def run(self):
139
+ """
140
+ Запускает главный игровой цикл. Берет на себя обработку событий, вызов update/draw сцен, расчет дельты времени (dt), очистку экрана, отрисовку очереди и ограничение FPS.
141
+ Блокирующий метод, работает до закрытия окна.
142
+ """
143
+ self._start_ticks = pygame.time.get_ticks()
144
+ self._switch_scene()
145
+
146
+ while self.is_running:
147
+ self._process_events()
148
+ self.seconds = (pygame.time.get_ticks() - self._start_ticks) / 1000
149
+ self.dt = self.clock.tick(60) / 1000
150
+ self._update_animations()
151
+ self.screen.fill(self.background)
152
+
153
+ if self._current_scene:
154
+ self._current_scene.update()
155
+ self._current_scene.draw()
156
+ else:
157
+ self.update()
158
+
159
+ self._update_camera()
160
+ self._flush_draw_queue()
161
+ self._switch_scene()
162
+ pygame.display.flip()
163
+
164
+ self._quit()
165
+
166
+ def _process_events(self):
167
+ for event in pygame.event.get():
168
+ if self._current_scene:
169
+ self._current_scene.events(event)
170
+ else:
171
+ self.events(event)
172
+ if event.type == pygame.QUIT:
173
+ self.is_running = False
174
+
175
+ def _flush_draw_queue(self):
176
+ ox, oy = self._camera_offset
177
+ for item, use_camera in self._draw_queue:
178
+ actual_ox, actual_oy = (ox, oy) if use_camera else (0.0, 0.0)
179
+ if isinstance(item, Figure):
180
+ item.draw(self.screen, offset=(actual_ox, actual_oy))
181
+ else:
182
+ self.screen.blit(item[0], (item[1][0] + actual_ox, item[1][1] + actual_oy))
183
+ self._draw_queue.clear()
184
+
185
+ def _quit(self):
186
+ pygame.quit()
187
+ sys.exit()
188
+
189
+ def update(self):
190
+ """Пользовательская логика"""
191
+ pass
192
+
193
+ def events(self, event: pygame.event.Event):
194
+ """Пользовательские события"""
195
+ pass
196
+
197
+ # ――――――――――――――――――――――――― scenes ―――――――――――――――――――――――――
198
+
199
+ def add_scene(self, name: str, scene: Scene):
200
+ """
201
+ Регистрирует экземпляр сцены в менеджере движка под уникальным именем для последующего переключения.
202
+ - name (str): Строковый идентификатор сцены.
203
+ - scene (Scene): Экземпляр класса, наследующего от базового класса Scene.
204
+ """
205
+ if not isinstance(scene, Scene):
206
+ raise TypeError("scene must be a Scene instance")
207
+ self._scenes[name] = scene
208
+
209
+ def add_scenes(self, scenes: dict[str, Scene]):
210
+ """
211
+ Позволяет массово зарегистрировать несколько сцен, передав их в виде словаря.
212
+ - scenes (dict[str, Scene]): Словарь, где ключи — имена сцен, а значения — их экземпляры.
213
+ """
214
+ for name, scene in scenes.items():
215
+ self.add_scene(name, scene)
216
+
217
+ def set_scene(self, name: str, destroy: bool = True, **kwargs):
218
+ """
219
+ Планирует безопасное переключение на указанную сцену в конце текущего кадра. Вызывает on_exit у текущей сцены и on_enter у новой.
220
+ - name (str): Имя целевой сцены.
221
+ - destroy (bool): Если True, полностью очищает словарь фигур (clear_figures) при выходе из старой сцены.
222
+ - **kwargs: Словарь аргументов, который будет передан в метод on_enter новой сцены.
223
+ """
224
+ if name not in self._scenes:
225
+ raise ValueError(f"scene '{name}' not registered")
226
+ self._next_scene = (name, kwargs, destroy)
227
+
228
+ def _switch_scene(self):
229
+ if self._next_scene is None:
230
+ return
231
+ name, kwargs, destroy = self._next_scene
232
+ self._next_scene = None
233
+
234
+ if self._current_scene:
235
+ self._current_scene.on_exit()
236
+ if destroy:
237
+ self.clear_figures()
238
+
239
+ self._current_scene = self._scenes.get(name)
240
+ if self._current_scene:
241
+ self._current_scene.on_enter(**kwargs)
242
+
243
+ # ――――――――――――――――――――――――― figures ―――――――――――――――――――――――――
244
+
245
+ def create_figure(
246
+ self,
247
+ name: str,
248
+ shape: Literal['rect', 'circle', 'ellipse', 'line', 'polygon', 'sprite'],
249
+ color: tuple,
250
+ params: tuple,
251
+ width: int = 0,
252
+ auto_draw: bool = False,
253
+ use_camera: bool = True
254
+ ):
255
+ """
256
+ Создает графический примитив или спрайт, инициализирует его геометрию и регистрирует в движке.
257
+ - name (str): Уникальное имя фигуры для дальнейшего взаимодействия (движение, коллизии).
258
+ - shape (Literal): Тип формы ('rect', 'circle', 'ellipse', 'line', 'polygon', 'sprite').
259
+ - color (tuple): Цвет в формате RGB.
260
+ - params (tuple): Геометрические параметры. Зависят от shape: (x,y,w,h) для rect, (x,y,r) для circle, (x,y,img_name) для sprite и т.д.
261
+ - width (int): Толщина обводки. 0 означает сплошную заливку.
262
+ - auto_draw (bool): Если True, фигура автоматически добавляется в очередь отрисовки на каждый кадр.
263
+ - use_camera (bool): Двигаться с камерой.
264
+
265
+ Figures param
266
+ -------------
267
+ :rect: (x, y, w, h)
268
+ :circle: (x, y, radius)
269
+ :ellipse: (x, y, w, h)
270
+ :line: (x1, y1, x2, y2) — width must be > 0
271
+ :polygon: ([points],)
272
+ :sprite: (x, y, image_name)
273
+ """
274
+ self.figures[name] = Figure(self.screen, shape, color, params, width, images=self.images, use_camera=use_camera)
275
+ if auto_draw:
276
+ self.draw_figure(name)
277
+
278
+ def draw_figure(self, name: str):
279
+ """
280
+ Добавляет указанную фигуру в очередь отрисовки (_draw_queue) для текущего кадра.
281
+ - name (str): Имя фигуры, которую нужно отрисовать.
282
+ """
283
+ if name in self.figures:
284
+ fig = self.figures[name]
285
+ self._draw_queue.append((fig, fig.use_camera))
286
+
287
+ def remove_figure(self, name: str):
288
+ """
289
+ Удаляет фигуру из памяти движка, освобождая ссылку на неё.
290
+ - name (str): Имя удаляемой фигуры.
291
+ """
292
+ self.figures.pop(name, None)
293
+
294
+ def clear_figures(self):
295
+ """
296
+ Полностью очищает внутренний словарь всех зарегистрированных фигур. Обычно вызывается автоматически при смене сцен.
297
+ """
298
+ self.figures.clear()
299
+
300
+ # ――――――――――――――――――――――――― text / fonts ―――――――――――――――――――――――――
301
+
302
+ def create_font(self, name: str, font: str = 'Arial', size: int = 36):
303
+ """
304
+ Инициализирует системный шрифт заданного размера и кэширует его в движке для многократного использования.
305
+ - name (str): Имя-идентификатор для доступа к шрифту.
306
+ - font (str): Название системного шрифта (например, 'Arial', 'Courier New').
307
+ - size (int): Размер шрифта в пунктах.
308
+ """
309
+ self.fonts[name] = pygame.font.SysFont(font, size)
310
+
311
+ def draw_text(
312
+ self,
313
+ font: str,
314
+ text: str,
315
+ x: int,
316
+ y: int,
317
+ color: tuple = (0, 0, 0),
318
+ antialias: bool = True,
319
+ use_camera: bool = False
320
+ ):
321
+ """
322
+ Рендерит текстовую строку в поверхность и добавляет её в очередь отрисовки.
323
+ - font (str): Имя предварительно созданного шрифта.
324
+ - text (str): Отображаемый текст.
325
+ - x, y (int): Координаты верхнего левого угла текста на экране.
326
+ - color (tuple): Цвет текста в RGB.
327
+ - antialias (bool): Включить ли сглаживание шрифта (рекомендуется True).
328
+ - use_camera (bool): Двигаться с камерой.
329
+ """
330
+ surface = self.fonts[font].render(text, antialias, color)
331
+ self._draw_queue.append(((surface, (x, y)), use_camera))
332
+
333
+ def draw_text_on_figure(self, fig_name: str, font: str, text: str, color: tuple = (0, 0, 0), use_camera: bool = False):
334
+ """
335
+ Автоматически вычисляет геометрический центр фигуры и отрисовывает текст строго по нему.
336
+ - fig_name (str): Имя фигуры-подложки.
337
+ - font (str): Имя шрифта.
338
+ - text (str): Текст.
339
+ - color (tuple): Цвет текста.
340
+ - use_camera (bool): Двигаться с камерой.
341
+ """
342
+ if fig_name not in self.figures:
343
+ return
344
+ cx, cy = self.figures[fig_name].get_center()
345
+ tw, th = self.fonts[font].size(text)
346
+ self.draw_text(font, text, int(cx - tw / 2), int(cy - th / 2), color, use_camera=use_camera)
347
+
348
+ def draw_overlay(self, color: tuple = (0, 0, 0), alpha: int = 180):
349
+ """
350
+ Рисует полупрозрачный прямоугольник поверх всего экрана. Идеально для создания эффекта паузы или затемнения фона при открытии меню.
351
+ - color (tuple): Цвет затемнения (обычно черный).
352
+ - alpha (int): Уровень непрозрачности от 0 (прозрачный) до 255 (глухой).
353
+ """
354
+ overlay = pygame.Surface((self.width, self.height))
355
+ overlay.set_alpha(alpha)
356
+ overlay.fill(color)
357
+ self._draw_queue.append(((overlay, (0, 0)), False))
358
+
359
+ # ――――――――――――――――――――――――― images ―――――――――――――――――――――――――
360
+
361
+ def load_image(self, name: str, path: str):
362
+ """
363
+ Загружает изображение с диска, конвертирует его для быстрого рендеринга и сохраняет в кэш движка.
364
+ - name (str): Идентификатор изображения.
365
+ - path (str): Путь к файлу на диске.
366
+ """
367
+ self.images[name] = pygame.image.load(path).convert_alpha()
368
+
369
+ def scale_image(self, name: str, scale: float):
370
+ """
371
+ Пропорционально масштабирует изображение относительно его текущего размера.
372
+ - name (str): Имя изображения.
373
+ - scale (float): Коэффициент масштабирования (например, 0.5 для уменьшения вдвое, 2.0 для увеличения).
374
+ """
375
+ if name in self.images:
376
+ img = self.images[name]
377
+ self.images[name] = pygame.transform.smoothscale(
378
+ img, (int(img.get_width() * scale), int(img.get_height() * scale))
379
+ )
380
+
381
+ def resize_image(self, name: str, width: int, height: int):
382
+ """
383
+ Изменяет размер изображения до точных пиксельных значений, игнорируя исходные пропорции.
384
+ - name (str): Имя изображения.
385
+ - width, height (int): Новые целевые размеры в пикселях.
386
+ """
387
+ if name in self.images:
388
+ self.images[name] = pygame.transform.smoothscale(self.images[name], (width, height))
389
+
390
+ def flip_image(self, name: str, new_name: str, flip_x: bool = False, flip_y: bool = False):
391
+ """
392
+ Зеркально отражает изображение по заданным осям и сохраняет результат как новую копию, не затрагивая оригинал.
393
+ - name (str): Имя исходного изображения.
394
+ - new_name (str): Имя для сохраненной копии.
395
+ - flip_x, flip_y (bool): Флаги отражения по горизонтали и вертикали.
396
+ """
397
+ if name in self.images:
398
+ self.images[new_name] = pygame.transform.flip(self.images[name], flip_x, flip_y)
399
+
400
+ def rotate_image(self, name: str, new_name: str, angle: float):
401
+ """
402
+ Поворачивает изображение на заданный угол и сохраняет результат как новую копию.
403
+ - name (str): Имя исходного изображения.
404
+ - new_name (str): Имя для сохраненной копии.
405
+ - angle (float): Угол поворота в градусах (против часовой стрелки).
406
+ """
407
+ if name in self.images:
408
+ self.images[new_name] = pygame.transform.rotate(self.images[name], angle)
409
+
410
+ # ――――――――――――――――――――――――― animations ―――――――――――――――――――――――――
411
+
412
+ def add_animation(self, name: str, frames: list[str], fps: float = 8.0, loop: bool = True):
413
+ """
414
+ Регистрирует анимацию для использования фигурами.
415
+ - name (str): Уникальное имя анимации.
416
+ - frames (list[str]): Список имен изображений (кадров), загруженных через load_image.
417
+ - fps (float): Количество кадров в секунду.
418
+ - loop (bool): Зацикливать ли анимацию. Если False, остановится на последнем кадре.
419
+ """
420
+ self.animations[name] = {'frames': frames, 'fps': fps, 'loop': loop}
421
+
422
+ def _update_animations(self):
423
+ """Автоматически обновляет кадры анимаций для всех фигур."""
424
+ for fig in self.figures.values():
425
+ fig._update_animation(self.animations, self.dt)
426
+
427
+ # ――――――――――――――――――――――――― input ―――――――――――――――――――――――――
428
+
429
+ def get_event(
430
+ self,
431
+ event: pygame.event.Event,
432
+ etype: EventLiteral = 'keyDown',
433
+ key: KeyLiteral | None = None,
434
+ ) -> bool:
435
+ """
436
+ Проверяет, соответствует ли переданное событие pygame заданному типу и конкретной клавише/кнопке.
437
+ - event (Event): Объект события из цикла обработки pygame.
438
+ - etype (EventLiteral): Тип ожидаемого события ('keyDown', 'mouseButtonDown' и т.д.).
439
+ - key (KeyLiteral | None): Конкретная клавиша или кнопка мыши. Если None, проверяется только тип события.
440
+ """
441
+ if etype == 'quit':
442
+ return event.type == pygame.QUIT
443
+
444
+ expected = self._EVENT_MAP.get(etype)
445
+ if expected is None or event.type != expected:
446
+ return False
447
+
448
+ if key is None:
449
+ return True
450
+
451
+ if etype in ('mouseButtonDown', 'mouseButtonUp'):
452
+ return event.button == self._MOUSE_BTN_MAP.get(key)
453
+
454
+ return event.key == self._KEYS_MAP.get(key)
455
+
456
+ def is_pressed(self, key: KeyLiteral) -> bool:
457
+ """
458
+ Возвращает статус удержания клавиши клавиатуры или кнопки мыши в данный конкретный момент (не событие, а состояние).
459
+ - key (KeyLiteral): Имя клавиши или кнопки мыши.
460
+ """
461
+ if key in self._MOUSE_MAP:
462
+ return bool(pygame.mouse.get_pressed()[self._MOUSE_MAP[key]])
463
+ k = self._KEYS_MAP.get(key)
464
+ return k is not None and bool(pygame.key.get_pressed()[k])
465
+
466
+ def is_mouse_over(self, name: str, cooldown: float = 0.0) -> bool:
467
+ """
468
+ Проверяет, находится ли курсор мыши в пределах ограничивающего прямоугольника (bounding box) указанной фигуры.
469
+ - name (str): Имя фигуры.
470
+ - cooldown (float): Опциональный кулдаун (в секундах). Если > 0, метод будет возвращать True не чаще указанного интервала (защита от дребезга при наведении).
471
+ """
472
+ if not self._is_mouse_over(name):
473
+ return False
474
+ if cooldown > 0 and not self.check_cooldown(f'_hover_{name}', cooldown):
475
+ return False
476
+ return True
477
+
478
+ def _is_mouse_over(self, name: str) -> bool:
479
+ if name not in self.figures:
480
+ return False
481
+ fx, fy, fw, fh = self.figures[name].get_bounds()
482
+ if fw == 0 or fh == 0:
483
+ return False
484
+ mx, my = pygame.mouse.get_pos()
485
+ return fx <= mx <= fx + fw and fy <= my <= fy + fh
486
+
487
+ def is_clicked(
488
+ self,
489
+ name: str,
490
+ cooldown: float = 0.0,
491
+ button: MouseButton = 'leftMouseButton',
492
+ ) -> bool:
493
+ """
494
+ Проверяет факт нажатия кнопки мыши, когда курсор находится над фигурой. Встроенный кулдаун предотвращает множественные срабатывания.
495
+ - name (str): Имя фигуры.
496
+ - cooldown (float): Интервал кулдауна в секундах.
497
+ - button (MouseButton): Кнопка мыши ('leftMouseButton', 'rightMouseButton', 'middleMouseButton').
498
+ """
499
+ if not self._is_mouse_over(name):
500
+ return False
501
+ if not pygame.mouse.get_pressed()[self._MOUSE_MAP.get(button, 0)]:
502
+ return False
503
+ if cooldown > 0 and not self.check_cooldown(f'_click_{name}_{button}', cooldown):
504
+ return False
505
+ return True
506
+
507
+ # ――――――――――――――――――――――――― collision ―――――――――――――――――――――――――
508
+
509
+ def _figures_collide(self, f1: Figure, f2: Figure) -> bool:
510
+ # Круг vs Круг
511
+ if f1.shape == 'circle' and f2.shape == 'circle':
512
+ return math.hypot(f1.x - f2.x, f1.y - f2.y) < (f1.radius + f2.radius)
513
+
514
+ # Круг vs Прямоугольник (или наоборот)
515
+ if f1.shape == 'circle' and f2.shape in ('rect', 'ellipse', 'sprite'):
516
+ cx, cy = f1.x, f1.y
517
+ rx, ry, rw, rh = f2.get_bounds()
518
+ closest_x = max(rx, min(cx, rx + rw))
519
+ closest_y = max(ry, min(cy, ry + rh))
520
+ return math.hypot(cx - closest_x, cy - closest_y) < f1.radius
521
+
522
+ if f2.shape == 'circle' and f1.shape in ('rect', 'ellipse', 'sprite'):
523
+ cx, cy = f2.x, f2.y
524
+ rx, ry, rw, rh = f1.get_bounds()
525
+ closest_x = max(rx, min(cx, rx + rw))
526
+ closest_y = max(ry, min(cy, ry + rh))
527
+ return math.hypot(cx - closest_x, cy - closest_y) < f2.radius
528
+
529
+ # Прямоугольник vs Прямоугольник (AABB)
530
+ x1, y1, w1, h1 = f1.get_bounds()
531
+ x2, y2, w2, h2 = f2.get_bounds()
532
+ return not (x1 > x2 + w2 or x1 + w1 < x2 or y1 > y2 + h2 or y1 + h1 < y2)
533
+
534
+ def check_collision(self, name1: str, name2: str, cooldown: float = 0.5) -> bool:
535
+ """
536
+ Проверяет факт геометрического пересечения двух фигур. Использует временные метки для реализации кулдауна, чтобы предотвратить многократное срабатывание в течение одного "контакта".
537
+ - name1, name2 (str): Имена проверяемых фигур.
538
+ - cooldown (float): Время в секундах, на которое блокируется повторное возвращение True после успешного столкновения.
539
+ """
540
+ if name1 not in self.figures or name2 not in self.figures:
541
+ return False
542
+ f1, f2 = self.figures[name1], self.figures[name2]
543
+ if not self._figures_collide(f1, f2):
544
+ return False
545
+ now = time.time()
546
+ if now - max(f1.last_contact_time, f2.last_contact_time) < cooldown:
547
+ return False
548
+ f1.last_contact_time = f2.last_contact_time = now
549
+ return True
550
+
551
+ def can_move_to(
552
+ self, name: str, new_x: float, new_y: float, obstacles: list[str]
553
+ ) -> tuple[bool, float, float]:
554
+ """
555
+ Симулирует перемещение фигуры в новые координаты и проверяет, не возникнет ли коллизия с указанными препятствиями. Если коллизия есть, отменяет перемещение.
556
+ - name (str): Имя фигуры.
557
+ - new_x, new_y (float): Проектируемые координаты.
558
+ - obstacles (list): Список имен фигур-препятствий.
559
+ """
560
+ if name not in self.figures:
561
+ return False, new_x, new_y
562
+ fig = self.figures[name]
563
+ old_x, old_y = fig.x, fig.y
564
+ fig.set_pos(new_x, new_y)
565
+ for obs_name in obstacles:
566
+ if obs_name == name or obs_name not in self.figures:
567
+ continue
568
+ if self._figures_collide(fig, self.figures[obs_name]):
569
+ fig.set_pos(old_x, old_y)
570
+ return False, old_x, old_y
571
+ return True, new_x, new_y
572
+
573
+ def resolve_collisions(self, movers: list[str], walls: list[str]):
574
+ """
575
+ Метод массового выталкивания (push-out). Итерирует список движущихся объектов и выталкивает их наружу, если они пересекаются с любым объектом из списка стен.
576
+ - movers (list): Имена фигур, которые нужно вытолкнуть (динамические объекты).
577
+ - walls (list): Имена фигур, выступающих в роли препятствий (статичные объекты).
578
+ """
579
+ for n1 in movers:
580
+ if n1 not in self.figures:
581
+ continue
582
+ f1 = self.figures[n1]
583
+ for n2 in walls:
584
+ if n2 == n1 or n2 not in self.figures:
585
+ continue
586
+ f2 = self.figures[n2]
587
+ if self._figures_collide(f1, f2):
588
+ f1.push_out(f2)
589
+
590
+ # ――――――――――――――――――――――――― cooldowns ―――――――――――――――――――――――――
591
+
592
+ def check_cooldown(self, name: str, seconds: float) -> bool:
593
+ """
594
+ Внутренний механизм таймеров. Проверяет, прошло ли заданное время с момента последнего вызова. Если да — перезапускает отсчет и возвращает True. Иначе возвращает False.
595
+ - name (str): Уникальный строковый идентификатор таймера.
596
+ - seconds (float): Длительность кулдауна в секундах.
597
+ """
598
+ now = time.time()
599
+ if now - self._cooldowns.get(name, 0) >= seconds:
600
+ self._cooldowns[name] = now
601
+ return True
602
+ return False
603
+
604
+ # ――――――――――――――――――――――――― movement helpers ―――――――――――――――――――――――――
605
+
606
+ def move_towards(
607
+ self,
608
+ current_x: float, current_y: float,
609
+ target_x: float, target_y: float,
610
+ speed: float = 0.1,
611
+ ) -> tuple[int, int]:
612
+ """
613
+ Выполняет линейную интерполяцию (lerp) координат для плавного, затухающего приближения к цели.
614
+ - current_x, current_y (float): Текущие координаты.
615
+ - target_x, target_y (float): Целевые координаты.
616
+ - speed (float): Коэффициент интерполяции от 0.0 до 1.0 (где 1.0 — мгновенное перемещение).
617
+ """
618
+ return (
619
+ int(current_x + (target_x - current_x) * speed),
620
+ int(current_y + (target_y - current_y) * speed),
621
+ )
622
+
623
+ def move_at_speed(
624
+ self,
625
+ current_x: float, current_y: float,
626
+ target_x: float, target_y: float,
627
+ speed: float = 5.0,
628
+ ) -> tuple[int, int]:
629
+ """
630
+ Вычисляет новую точку на пути к цели, смещаясь ровно на заданное расстояние в пикселях за один вызов (равномерное движение).
631
+ - current_x, current_y (float): Текущие координаты.
632
+ - target_x, target_y (float): Целевые координаты.
633
+ - speed (float): Точное смещение в пикселях за один вызов метода.
634
+ """
635
+ dx = target_x - current_x
636
+ dy = target_y - current_y
637
+ dist = math.hypot(dx, dy)
638
+ if dist < speed:
639
+ return int(target_x), int(target_y)
640
+ ratio = speed / dist
641
+ return int(current_x + dx * ratio), int(current_y + dy * ratio)
642
+
643
+ # ――――――――――――――――――――――――― layout helpers ―――――――――――――――――――――――――
644
+
645
+ def get_text_pos(
646
+ self,
647
+ font: str,
648
+ text: str,
649
+ align_x: AlignX,
650
+ align_y: AlignY,
651
+ offset_x: int = 0,
652
+ offset_y: int = 0,
653
+ ) -> tuple[int, int]:
654
+ """
655
+ Вычисляет точные координаты (x, y) для отрисовки текста с учетом его размеров, выравнивания относительно экрана и опционального смещения.
656
+ - font (str): Имя шрифта (необходим для расчета ширины и высоты текста).
657
+ - text (str): Сам текст.
658
+ - align_x, align_y (Literal): Горизонтальное ('left', 'middle', 'right') и вертикальное ('up', 'middle', 'down') выравнивание.
659
+ - offset_x, offset_y (int): Дополнительное смещение в пикселях от рассчитанной точки.
660
+ """
661
+ tw, th = self.fonts[font].size(text)
662
+ x = {'left': 0, 'middle': (self.width - tw) // 2, 'right': self.width - tw}.get(align_x, 0)
663
+ y = {'up': 0, 'middle': (self.height - th) // 2, 'down': self.height - th}.get(align_y, 0)
664
+ return (x + offset_x, y + offset_y)
665
+
666
+ def get_shape_pos(
667
+ self,
668
+ shape_w: int,
669
+ shape_h: int,
670
+ align_x: AlignX,
671
+ align_y: AlignY,
672
+ offset_x: int = 0,
673
+ offset_y: int = 0,
674
+ ) -> tuple[int, int]:
675
+ """
676
+ Вычисляет координаты (x, y) для размещения геометрической фигуры с учетом её размеров и выравнивания относительно экрана.
677
+ - shape_w, shape_h (int): Ширина и высота фигуры.
678
+ - align_x, align_y (Literal): Горизонтальное и вертикальное выравнивание.
679
+ - offset_x, offset_y (int): Дополнительное смещение в пикселях.
680
+ """
681
+ x = {'left': 0, 'middle': (self.width - shape_w) // 2, 'right': self.width - shape_w}.get(align_x, 0)
682
+ y = {'up': 0, 'middle': (self.height - shape_h) // 2, 'down': self.height - shape_h}.get(align_y, 0)
683
+ return (x + offset_x, y + offset_y)
684
+
685
+ # ――――――――――――――――――――――――― tilemap & camera ―――――――――――――――――――――――――
686
+ def _update_camera(self):
687
+ """Автоматически вычисляет смещение камеры с ограничением по границам"""
688
+ if self._camera_target and self._camera_target in self.figures:
689
+ fig = self.figures[self._camera_target]
690
+ cx, cy = fig.get_center()
691
+ offset_x = self.width / 2 - cx
692
+ offset_y = self.height / 2 - cy
693
+
694
+ if self._camera_bounds:
695
+ bounds_type, bounds_data = self._camera_bounds
696
+ if bounds_type == 'map' and bounds_data is not None:
697
+ tmap = bounds_data
698
+ map_width = tmap.width * tmap.tileset.tile_width
699
+ map_height = tmap.height * tmap.tileset.tile_height
700
+ if map_width < self.width: offset_x = (self.width - map_width) / 2
701
+ else: offset_x = max(min(offset_x, 0), self.width - map_width)
702
+ if map_height < self.height: offset_y = (self.height - map_height) / 2
703
+ else: offset_y = max(min(offset_y, 0), self.height - map_height)
704
+ elif bounds_type == 'window':
705
+ offset_x = min(offset_x, 0)
706
+ offset_y = min(offset_y, 0)
707
+ self._camera_offset = (offset_x, offset_y)
708
+ else:
709
+ self._camera_offset = (0.0, 0.0)
710
+
711
+ def set_camera_bounds(self, bounds_type: Literal['map', 'window'], bounds_data=None):
712
+ """
713
+ Устанавливает границы для камеры.
714
+ - 'map': камера ограничена границами карты (передайте TileMap)
715
+ - 'window': камера не уходит в отрицательные координаты
716
+ """
717
+ self._camera_bounds = (bounds_type, bounds_data)
718
+
719
+ def add_tilemap(self, name: str, tilemap: 'TileMap'):
720
+ """
721
+ Регистрирует карту тайлов в движке под указанным именем, чтобы методы движения (move) могли автоматически учитывать её для коллизий.
722
+ - name (str): Имя для обращения к карте.
723
+ - tilemap (TileMap): Экземпляр карты.
724
+ """
725
+ self._tilemaps[name] = tilemap
726
+
727
+ def set_camera_target(self, figure_name: str):
728
+ """
729
+ Привязывает виртуальную камеру к фигуре. Движок будет автоматически вычислять смещение (offset) так, чтобы указанная фигура всегда находилась в центре экрана.
730
+ - figure_name (str): Имя фигуры-цели.
731
+ """
732
+ self._camera_target = figure_name
733
+
734
+ # ――――――――――――――――――――――――― smart movement & collisions ―――――――――――――――――――――――――
735
+ def move(
736
+ self,
737
+ name: str,
738
+ speed: float,
739
+ dx: float = 0.0,
740
+ dy: float = 0.0,
741
+ target: tuple[float, float] | None = None,
742
+ map_name: str | None = None,
743
+ obstacles: list[str] | None = None,
744
+ use_dt: bool = True,
745
+ grid_mode: bool = False
746
+ ):
747
+ """
748
+ Универсальный метод перемещения фигуры. Автоматически умножает скорость на dt, вычисляет вектор движения и разрешает коллизии (выталкивание) с тайлами карты или другими фигурами.
749
+ - name (str): Имя перемещаемой фигуры.
750
+ - speed (float): Скорость (пикселей в секунду, если use_dt=True; или пикселей за кадр, если use_dt=False).
751
+ - dx, dy (float): Вектор направления (например, -1.0, 0.0, 1.0).
752
+ - target (tuple | None): Координаты (x, y) цели. Если указаны, игнорирует dx/dy и плавно стремится к цели.
753
+ - map_name (str | None): Имя TileMap для проверки коллизий с твердыми тайлами.
754
+ - obstacles (list | None): Список имен фигур, с которыми нужно сталкиваться (выталкиваться).
755
+ - use_dt (bool): Учитывать ли дельту времени. False используется для UI-анимаций, где важна стабильность, а не физическая скорость.
756
+ - grid_mode: если True, включает пошаговое движение от центра тайла к центру тайла.
757
+ """
758
+ if name not in self.figures: return
759
+ fig = self.figures[name]
760
+
761
+ # --- РЕЖИМ ПОТАГОВОГО ДВИЖЕНИЯ ПО СЕТКЕ ---
762
+ if grid_mode and map_name and map_name in self._tilemaps:
763
+ tmap = self._tilemaps[map_name]
764
+ tw, th = tmap.tileset.tile_width, tmap.tileset.tile_height
765
+
766
+ # Текущий тайл фигуры (считаем по её центру)
767
+ cx, cy = fig.get_center()
768
+ current_tx = int(cx // tw)
769
+ current_ty = int(cy // th)
770
+
771
+ # Центр текущего тайла
772
+ center_x = current_tx * tw + tw / 2
773
+ center_y = current_ty * th + th / 2
774
+
775
+ # Проверяем, находится ли фигура в центре тайла
776
+ in_center = (
777
+ abs(fig.x - center_x) < self.GRID_SNAP_EPSILON and
778
+ abs(fig.y - center_y) < self.GRID_SNAP_EPSILON
779
+ )
780
+
781
+ # Если в центре и есть ввод — ищем новую цель
782
+ if in_center and (dx != 0 or dy != 0):
783
+ step_x = 1 if dx > 0 else (-1 if dx < 0 else 0)
784
+ step_y = 1 if dy > 0 else (-1 if dy < 0 else 0)
785
+
786
+ target_tx = current_tx + step_x
787
+ target_ty = current_ty + step_y
788
+
789
+ # Проверяем, можно ли туда пойти
790
+ if not tmap.is_solid(target_tx, target_ty, figure_name=name):
791
+ target_x = target_tx * tw + tw / 2
792
+ target_y = target_ty * th + th / 2
793
+ self._grid_targets[name] = (target_x, target_y)
794
+
795
+ # Если есть активная цель — двигаемся к ней
796
+ if name in self._grid_targets:
797
+ tx, ty = self._grid_targets[name]
798
+ effective_speed = speed * self.dt if use_dt else speed
799
+
800
+ new_x, new_y = self.move_at_speed(fig.x, fig.y, tx, ty, effective_speed)
801
+ fig.set_pos(new_x, new_y)
802
+
803
+ # Если дошли до цели — фиксируем позицию и удаляем цель
804
+ if (
805
+ abs(fig.x - tx) < self.GRID_SNAP_EPSILON and
806
+ abs(fig.y - ty) < self.GRID_SNAP_EPSILON
807
+ ):
808
+ fig.set_pos(tx, ty)
809
+ del self._grid_targets[name]
810
+
811
+ return # Завершаем метод
812
+
813
+ # --- СТАНДАРТНОЕ НЕПРЕРЫВНОЕ ДВИЖЕНИЕ ---
814
+ if target is not None:
815
+ tx, ty = target
816
+ effective_speed = speed * self.dt if use_dt else speed
817
+ new_x, new_y = self.move_at_speed(fig.x, fig.y, tx, ty, effective_speed)
818
+ actual_dx = new_x - fig.x
819
+ actual_dy = new_y - fig.y
820
+ else:
821
+ mult = self.dt if use_dt else 1.0
822
+ actual_dx = dx * speed * mult
823
+ actual_dy = dy * speed * mult
824
+
825
+ # Используем set_pos вместо прямой модификации
826
+ fig.set_pos(fig.x + actual_dx, fig.y)
827
+ self._resolve_collisions_for_move(fig, name, actual_dx, 'x', map_name, obstacles)
828
+
829
+ fig.set_pos(fig.x, fig.y + actual_dy)
830
+ self._resolve_collisions_for_move(fig, name, actual_dy, 'y', map_name, obstacles)
831
+
832
+ def _resolve_collisions_for_move(
833
+ self,
834
+ fig: Figure,
835
+ fig_name: str,
836
+ d: float,
837
+ axis: str,
838
+ map_name: str | None,
839
+ obstacles: list[str] | None
840
+ ):
841
+ fx, fy, fw, fh = fig.get_bounds()
842
+
843
+ # --- Коллизии с TileMap ---
844
+ if map_name and map_name in self._tilemaps:
845
+ tilemap = self._tilemaps[map_name]
846
+ tw, th = tilemap.tileset.tile_width, tilemap.tileset.tile_height
847
+ start_x, end_x = int(fx // tw), int((fx + fw) // tw)
848
+ start_y, end_y = int(fy // th), int((fy + fh) // th)
849
+
850
+ for ty in range(start_y, end_y + 1):
851
+ for tx in range(start_x, end_x + 1):
852
+ if tilemap.is_solid(tx, ty, figure_name=fig_name):
853
+ tile_rect = pygame.Rect(tx * tw, ty * th, tw, th)
854
+ fig_rect = pygame.Rect(fig.x, fig.y, fw, fh)
855
+
856
+ if fig_rect.colliderect(tile_rect):
857
+ if axis == 'x':
858
+ new_x = tile_rect.left - fw if d > 0 else tile_rect.right
859
+ fig.set_pos(new_x, fig.y)
860
+ else:
861
+ new_y = tile_rect.top - fh if d > 0 else tile_rect.bottom
862
+ fig.set_pos(fig.x, new_y)
863
+ fx, fy, fw, fh = fig.get_bounds()
864
+
865
+ # --- Коллизии с другими фигурами ---
866
+ if obstacles:
867
+ for obs_name in obstacles:
868
+ if obs_name == fig_name or obs_name not in self.figures:
869
+ continue
870
+ obs_fig = self.figures[obs_name]
871
+ if self._figures_collide(fig, obs_fig):
872
+ fig.push_out(obs_fig)
873
+ fx, fy, fw, fh = fig.get_bounds()
874
+
875
+ def clamp_to_map(self, name: str, map_name: str):
876
+ """
877
+ Ограничивает координаты фигуры физическими границами указанной TileMap, не позволяя ей выйти за пределы карты.
878
+ - name (str): Имя фигуры.
879
+ - map_name (str): Имя карты.
880
+ """
881
+ if name not in self.figures or map_name not in self._tilemaps: return
882
+ fig = self.figures[name]
883
+ tmap = self._tilemaps[map_name]
884
+
885
+ max_x = tmap.width * tmap.tileset.tile_width - fig.get_bounds()[2]
886
+ max_y = tmap.height * tmap.tileset.tile_height - fig.get_bounds()[3]
887
+
888
+ fig.x = max(0, min(fig.x, max_x))
889
+ fig.y = max(0, min(fig.y, max_y))
890
+ fig.params[0], fig.params[1] = fig.x, fig.y
891
+
892
+ def check_tile_collision(self, name: str, map_name: str, tile_id: int, layer_index: int | None = None) -> bool:
893
+ """
894
+ Проверяет пересечение фигуры с тайлом указанного ID.
895
+ Если layer_index не указан, проверяет слой collision_layer_index.
896
+ """
897
+ if name not in self.figures or map_name not in self._tilemaps: return False
898
+ fig = self.figures[name]
899
+ tmap = self._tilemaps[map_name]
900
+
901
+ target_layer = layer_index if layer_index is not None else tmap.collision_layer_index
902
+
903
+ fx, fy, fw, fh = fig.get_bounds()
904
+ tw, th = tmap.tileset.tile_width, tmap.tileset.tile_height
905
+
906
+ for ty in range(int(fy // th), int((fy + fh) // th) + 1):
907
+ for tx in range(int(fx // tw), int((fx + fw) // tw) + 1):
908
+ if tmap.get_tile(tx, ty, target_layer) == tile_id:
909
+ return True
910
+ return False