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/figure.py ADDED
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+ import math
3
+ import pygame
4
+ from typing import Literal, TYPE_CHECKING
5
+
6
+ ShapeType = Literal['rect', 'circle', 'ellipse', 'line', 'polygon', 'sprite']
7
+
8
+ # ――――――――――――――――――――――――― figure ―――――――――――――――――――――――――
9
+ class Figure:
10
+ def __init__(
11
+ self,
12
+ screen: pygame.Surface,
13
+ shape: ShapeType,
14
+ color: tuple,
15
+ params: tuple,
16
+ width: int = 0,
17
+ images: dict[str, pygame.Surface] | None = None,
18
+ use_camera: bool = True,
19
+ ):
20
+ self.screen = screen
21
+ self.shape = shape
22
+ self.color = color
23
+ self.params: list = list(params)
24
+ self.width = width
25
+ self.images = images
26
+ self.last_contact_time: float = 0
27
+ self.use_camera: bool = use_camera
28
+
29
+ # Анимации
30
+ self.current_anim: str | None = None
31
+ self.anim_frame: int = 0
32
+ self.anim_timer: float = 0.0
33
+ self.anim_finished: bool = False
34
+
35
+ # x/y are always kept in sync with params[0]/[1] via set_pos
36
+ self.x: float = float(params[0])
37
+ self.y: float = float(params[1])
38
+ self.image_name: str | None = params[2] if shape == 'sprite' and len(params) >= 3 else None
39
+
40
+ # ――――――――――――――――――――――――― animations ―――――――――――――――――――――――――
41
+ def play(self, anim_name: str):
42
+ """
43
+ Запускает указанную анимацию, сбрасывая текущий кадр и таймер.
44
+ - anim_name (str): Имя анимации, зарегистрированной в engine.add_animation.
45
+ """
46
+ self.current_anim = anim_name
47
+ self.anim_frame = 0
48
+ self.anim_timer = 0.0
49
+ self.anim_finished = False
50
+
51
+ def _update_animation(self, animations: dict, dt: float):
52
+ """
53
+ Внутренний метод: обновляет кадр анимации на основе прошедшего времени.
54
+ Вызывается автоматически движком в главном цикле.
55
+ """
56
+ if self.current_anim and self.current_anim in animations:
57
+ if self.anim_finished:
58
+ return
59
+
60
+ anim = animations[self.current_anim]
61
+ self.anim_timer += dt
62
+ frame_duration = 1.0 / anim['fps']
63
+
64
+ if self.anim_timer >= frame_duration:
65
+ self.anim_timer -= frame_duration
66
+
67
+ if self.anim_frame < len(anim['frames']) - 1:
68
+ self.anim_frame += 1
69
+ self.set_image(anim['frames'][self.anim_frame])
70
+ else:
71
+ if anim.get('loop', True):
72
+ self.anim_frame = 0
73
+ self.set_image(anim['frames'][0])
74
+ else:
75
+ self.anim_finished = True
76
+
77
+ # ――――――――――――――――――――――――― position ―――――――――――――――――――――――――
78
+ def set_pos(self, x: float, y: float):
79
+ """
80
+ Устанавливает новые координаты фигуры, автоматически синхронизируя внутренние переменные и параметры отрисовки.
81
+ """
82
+ self.x = x
83
+ self.y = y
84
+ self.params[0] = x
85
+ self.params[1] = y
86
+ if self.shape == 'sprite':
87
+ self.params[2] = self.image_name
88
+
89
+ def move(self, dx: float, dy: float):
90
+ """
91
+ Смещает фигуру на заданное расстояние (dx, dy) по осям X и Y относительно её текущей позиции.
92
+ """
93
+ self.set_pos(self.x + dx, self.y + dy)
94
+
95
+ def clamp_to_screen(self):
96
+ """
97
+ Ограничивает позицию фигуры границами экрана, не позволяя ей визуально выйти за его пределы.
98
+ """
99
+ fx, fy, fw, fh = self.get_bounds()
100
+ sw, sh = self.screen.get_width(), self.screen.get_height()
101
+ nx, ny = self.x, self.y
102
+ if fx + fw > sw:
103
+ nx = self.x + (sw - (fx + fw))
104
+ elif fx < 0:
105
+ nx = self.x - fx
106
+ if fy + fh > sh:
107
+ ny = self.y + (sh - (fy + fh))
108
+ elif fy < 0:
109
+ ny = self.y - fy
110
+ self.set_pos(nx, ny)
111
+
112
+ # ――――――――――――――――――――――――― draw ―――――――――――――――――――――――――
113
+ def draw(self, screen: pygame.Surface, offset: tuple[float, float] = (0.0, 0.0)):
114
+ """
115
+ Отрисовывает фигуру на экране. Принимает опциональный параметр offset для учёта смещения камеры.
116
+ """
117
+ ox, oy = offset
118
+ p = self.params
119
+ draw_x = p[0] + ox
120
+ draw_y = p[1] + oy
121
+
122
+ if self.shape == 'rect':
123
+ pygame.draw.rect(screen, self.color, (draw_x, draw_y, p[2], p[3]), self.width)
124
+ elif self.shape == 'circle':
125
+ pygame.draw.circle(screen, self.color, (int(draw_x), int(draw_y)), int(p[2]), self.width)
126
+ elif self.shape == 'ellipse':
127
+ pygame.draw.ellipse(screen, self.color, (draw_x, draw_y, p[2], p[3]), self.width)
128
+ elif self.shape == 'line':
129
+ pygame.draw.line(screen, self.color, (draw_x, draw_y), (p[2] + ox, p[3] + oy), max(1, self.width))
130
+ elif self.shape == 'polygon':
131
+ shifted_pts = [(pt[0] + ox, pt[1] + oy) for pt in p[0]]
132
+ pygame.draw.polygon(screen, self.color, shifted_pts, self.width)
133
+ elif self.shape == 'sprite':
134
+ if self.images and self.image_name and self.image_name in self.images:
135
+ screen.blit(self.images[self.image_name], (int(draw_x), int(draw_y)))
136
+
137
+ def set_image(self, image_name: str):
138
+ """
139
+ Меняет изображение спрайта на другое из загруженного набора (работает только для фигур с shape='sprite').
140
+ """
141
+ if self.shape == 'sprite' and self.images and image_name in self.images:
142
+ self.image_name = image_name
143
+ self.params[2] = image_name
144
+
145
+ # ――――――――――――――――――――――――― geometry ―――――――――――――――――――――――――
146
+ @property
147
+ def radius(self) -> float:
148
+ """Возвращает радиус фигуры. Работает только для круга. Для остальных форм возвращает 0.0."""
149
+ return float(self.params[2]) if self.shape == 'circle' else 0.0
150
+
151
+ def get_center(self) -> tuple[float, float]:
152
+ """
153
+ Возвращает координаты (x, y) геометрического центра фигуры.
154
+ """
155
+ p = self.params
156
+ if self.shape == 'circle':
157
+ return (p[0], p[1])
158
+ if self.shape in ('rect', 'ellipse'):
159
+ return (p[0] + p[2] / 2, p[1] + p[3] / 2)
160
+ if self.shape == 'sprite' and self.images and self.image_name in self.images:
161
+ img = self.images[self.image_name]
162
+ return (p[0] + img.get_width() / 2, p[1] + img.get_height() / 2)
163
+ if self.shape == 'line':
164
+ return ((p[0] + p[2]) / 2, (p[1] + p[3]) / 2)
165
+ return (0.0, 0.0)
166
+
167
+ def get_bounds(self) -> tuple[float, float, float, float]:
168
+ """
169
+ Возвращает ограничивающий прямоугольник фигуры в формате (x, y, ширина, высота) для расчетов коллизий.
170
+ """
171
+ p = self.params
172
+ if self.shape in ('rect', 'ellipse'):
173
+ return (p[0], p[1], p[2], p[3])
174
+ if self.shape == 'circle':
175
+ r = p[2]
176
+ return (p[0] - r, p[1] - r, r * 2, r * 2)
177
+ if self.shape == 'sprite' and self.images and self.image_name in self.images:
178
+ img = self.images[self.image_name]
179
+ return (p[0], p[1], float(img.get_width()), float(img.get_height()))
180
+ if self.shape == 'line':
181
+ x1, y1, x2, y2 = p[0], p[1], p[2], p[3]
182
+ w = abs(x2 - x1) or 1.0
183
+ h = abs(y2 - y1) or 1.0
184
+ return (min(x1, x2), min(y1, y2), w, h)
185
+ if self.shape == 'polygon':
186
+ pts = p[0]
187
+ xs = [pt[0] for pt in pts]
188
+ ys = [pt[1] for pt in pts]
189
+ return (min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys))
190
+ return (0.0, 0.0, 0.0, 0.0)
191
+
192
+ # ――――――――――――――――――――――――― collision response ―――――――――――――――――――――――――
193
+ def push_out(self, other: Figure):
194
+ """
195
+ Выталкивает текущую фигуру из другой фигуры, если они пересеклись. Используется для физического разрешения коллизий.
196
+ """
197
+ if self.shape == 'circle' and other.shape == 'circle':
198
+ dx = self.x - other.x
199
+ dy = self.y - other.y
200
+ dist = math.hypot(dx, dy)
201
+ min_dist = self.radius + other.radius
202
+ if dist == 0:
203
+ self.set_pos(self.x + min_dist, self.y)
204
+ return
205
+ if dist < min_dist:
206
+ overlap = min_dist - dist
207
+ nx, ny = dx / dist, dy / dist
208
+ self.set_pos(self.x + nx * overlap, self.y + ny * overlap)
209
+ return
210
+
211
+ x1, y1, w1, h1 = self.get_bounds()
212
+ x2, y2, w2, h2 = other.get_bounds()
213
+ if w1 == 0 or w2 == 0:
214
+ return
215
+
216
+ ol = (x1 + w1) - x2
217
+ or_ = (x2 + w2) - x1
218
+ ot = (y1 + h1) - y2
219
+ ob = (y2 + h2) - y1
220
+ if ol <= 0 or or_ <= 0 or ot <= 0 or ob <= 0:
221
+ return
222
+
223
+ m = min(ol, or_, ot, ob)
224
+ if m == ol:
225
+ self.set_pos(self.x - ol, self.y)
226
+ elif m == or_:
227
+ self.set_pos(self.x + or_, self.y)
228
+ elif m == ot:
229
+ self.set_pos(self.x, self.y - ot)
230
+ else:
231
+ self.set_pos(self.x, self.y + ob)
pyengine/scene.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+ import pygame
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from .engine import PyEngine
7
+
8
+
9
+ # ――――――――――――――――――――――――― scene base ―――――――――――――――――――――――――
10
+
11
+
12
+ class Scene:
13
+ def __init__(self, engine: PyEngine):
14
+ self.engine = engine
15
+ self.screen = engine.screen
16
+ self.width = engine.width
17
+ self.height = engine.height
18
+
19
+ # ――――――――――――――――――――――――― lifecycle ―――――――――――――――――――――――――
20
+
21
+ def on_enter(self, **kwargs):
22
+ """
23
+ Вызывается при активации (входе) в сцену. Используется для инициализации объектов, загрузки ресурсов и настройки начального состояния.
24
+ """
25
+ pass
26
+
27
+ def on_exit(self):
28
+ """
29
+ Вызывается при выходе из сцены. Используется для очистки ресурсов, сохранения состояния или остановки музыки.
30
+ """
31
+ pass
32
+
33
+ # ――――――――――――――――――――――――― per-frame ―――――――――――――――――――――――――
34
+
35
+ def events(self, event: pygame.event.Event):
36
+ """
37
+ Обрабатывает события pygame (нажатия клавиш, движения мыши и т.д.) для текущей активной сцены.
38
+ """
39
+ pass
40
+
41
+ def update(self):
42
+ """
43
+ Обновляет внутреннюю логику сцены (физика, ИИ, состояния объектов) перед каждым кадром отрисовки.
44
+ """
45
+ pass
46
+
47
+ def draw(self):
48
+ """
49
+ Отрисовывает все визуальные элементы текущей сцены на экран.
50
+ """
51
+ pass
pyengine/sound.py ADDED
@@ -0,0 +1,102 @@
1
+ import pygame
2
+
3
+
4
+ # ――――――――――――――――――――――――― sound mixin ―――――――――――――――――――――――――
5
+
6
+
7
+ class Sound:
8
+ def __init__(self):
9
+ self.sounds: dict[str, pygame.mixer.Sound] = {}
10
+ self.playing_sounds: dict[str, pygame.mixer.Sound] = {}
11
+
12
+ # ――――――――――――――――――――――――― sfx ―――――――――――――――――――――――――
13
+
14
+ def load_sound(self, name: str, path: str):
15
+ """
16
+ Загружает звуковой эффект (sfx) из файла и сохраняет его в памяти под указанным именем.
17
+ """
18
+ self.sounds[name] = pygame.mixer.Sound(path)
19
+
20
+ def play_sound(self, name: str, loop: bool = False):
21
+ """
22
+ Воспроизводит загруженный звуковой эффект. Если loop=True, зацикливает его воспроизведение.
23
+ """
24
+ if name not in self.sounds:
25
+ print(f"sound '{name}' not found")
26
+ return
27
+ sound = self.sounds[name]
28
+ sound.play(loops=-1 if loop else 0)
29
+ self.playing_sounds[name] = sound
30
+
31
+ def stop_sound(self, name: str):
32
+ """
33
+ Останавливает воспроизведение указанного звукового эффекта.
34
+ """
35
+ if name in self.playing_sounds:
36
+ self.playing_sounds[name].stop()
37
+ del self.playing_sounds[name]
38
+
39
+ def set_sound_volume(self, name: str, volume: float):
40
+ """
41
+ Устанавливает громкость для конкретного звукового эффекта (значение от 0.0 до 1.0).
42
+ """
43
+ if name in self.sounds:
44
+ self.sounds[name].set_volume(volume)
45
+
46
+ # ――――――――――――――――――――――――― music ―――――――――――――――――――――――――
47
+
48
+ def load_music(self, path: str):
49
+ """
50
+ Загружает фоновую музыкальную композицию из файла (поддерживается только один трек одновременно).
51
+ """
52
+ pygame.mixer.music.load(path)
53
+
54
+ def play_music(self, loop: bool = False):
55
+ """
56
+ Начинает воспроизведение загруженной фоновой музыки. Если loop=True, зацикливает её.
57
+ """
58
+ pygame.mixer.music.play(loops=-1 if loop else 0)
59
+
60
+ def stop_music(self):
61
+ """
62
+ Полностью останавливает воспроизведение фоновой музыки.
63
+ """
64
+ pygame.mixer.music.stop()
65
+
66
+ def pause_music(self):
67
+ """
68
+ Приостанавливает воспроизведение музыки (можно продолжить позже через resume_music).
69
+ """
70
+ pygame.mixer.music.pause()
71
+
72
+ def resume_music(self):
73
+ """
74
+ Продолжает воспроизведение музыки с момента паузы.
75
+ """
76
+ pygame.mixer.music.unpause()
77
+
78
+ def set_music_volume(self, volume: float):
79
+ """
80
+ Устанавливает громкость фоновой музыки (значение от 0.0 до 1.0).
81
+ """
82
+ pygame.mixer.music.set_volume(volume)
83
+
84
+ def fade_in_music(self, seconds: float = 2.0, loop: bool = False):
85
+ """
86
+ Запускает воспроизведение музыки с плавным нарастанием громкости за указанное время (в секундах).
87
+ """
88
+ pygame.mixer.music.play(loops=-1 if loop else 0, fade_ms=int(seconds * 1000))
89
+
90
+ def fade_out_music(self, seconds: float = 2.0):
91
+ """
92
+ Плавно затухает и останавливает музыку за указанное время (в секундах).
93
+ """
94
+ pygame.mixer.music.fadeout(int(seconds * 1000))
95
+
96
+ def set_all_volume(self, volume: float):
97
+ """
98
+ Устанавливает заданную громкость одновременно для всех загруженных звуков и фоновой музыки.
99
+ """
100
+ for s in self.sounds.values():
101
+ s.set_volume(volume)
102
+ pygame.mixer.music.set_volume(volume)
pyengine/tilemap.py ADDED
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+ import pygame
3
+ from typing import TYPE_CHECKING
4
+ if TYPE_CHECKING:
5
+ from .engine import PyEngine
6
+
7
+ # ――――――――――――――――――――――――― tile ―――――――――――――――――――――――――
8
+ class Tile:
9
+ """Один тип тайла (плитки)"""
10
+ def __init__(
11
+ self,
12
+ tile_id: int,
13
+ color: tuple | None = None,
14
+ image_name: str | None = None,
15
+ solid: bool = False,
16
+ exceptions: list[str] | None = None
17
+ ):
18
+ """
19
+ :tile_id: уникальный ID тайла
20
+ :color: цвет тайла (используется, если нет image_name)
21
+ :image_name: имя изображения из engine.images (приоритетнее цвета)
22
+ :solid: если True, фигуры не смогут проходить сквозь этот тайл
23
+ :exceptions: список названий фигур, которые МОГУТ проходить сквозь этот тайл
24
+ """
25
+ self.id = tile_id
26
+ self.color = color if color else (100, 100, 100)
27
+ self.image_name = image_name
28
+ self.solid = solid
29
+ self.exceptions = exceptions if exceptions else []
30
+
31
+ # ――――――――――――――――――――――――― tileset ―――――――――――――――――――――――――
32
+ class TileSet:
33
+ """Набор тайлов с общим размером"""
34
+ def __init__(self, tile_width: int = 32, tile_height: int = 32):
35
+ """
36
+ :tile_width: ширина одного тайла в пикселях
37
+ :tile_height: высота одного тайла в пикселях
38
+ """
39
+ self.tile_width = tile_width
40
+ self.tile_height = tile_height
41
+ self.tiles: dict[int, Tile] = {}
42
+
43
+ def add_tile(self, tile: Tile):
44
+ """Добавляет тайл в набор. Если тайл с таким ID уже существует, он будет перезаписан."""
45
+ self.tiles[tile.id] = tile
46
+
47
+ # ――――――――――――――――――――――――― tilelayer ―――――――――――――――――――――――――
48
+ class TileLayer:
49
+ """Один слой тайлов"""
50
+ def __init__(self, width: int, height: int, default_tile: int = 0):
51
+ """
52
+ :width: ширина слоя в тайлах
53
+ :height: высота слоя в тайлах
54
+ :default_tile: ID тайла по умолчанию (используется при создании и удалении)
55
+ """
56
+ self.width = width
57
+ self.height = height
58
+ self.default_tile = default_tile
59
+ self.data: list[list[int]] = [[default_tile for _ in range(width)] for _ in range(height)]
60
+
61
+ def set_tile(self, x: int, y: int, tile_id: int):
62
+ """
63
+ Устанавливает тайл с указанным ID в позицию (x, y).
64
+ Если координаты выходят за границы слоя, метод ничего не делает.
65
+ """
66
+ if 0 <= x < self.width and 0 <= y < self.height:
67
+ self.data[y][x] = tile_id
68
+
69
+ def get_tile(self, x: int, y: int) -> int:
70
+ """
71
+ Возвращает ID тайла в позиции (x, y).
72
+ Если координаты выходят за границы слоя, возвращает 0.
73
+ """
74
+ if 0 <= x < self.width and 0 <= y < self.height:
75
+ return self.data[y][x]
76
+ return 0
77
+
78
+ def remove_tile(self, x: int, y: int):
79
+ """
80
+ Удаляет тайл из позиции (x, y), делая ячейку пустой (ID=0).
81
+ Пустые тайлы не отрисовываются, что позволяет видеть слои под ними.
82
+ Если координаты выходят за границы слоя, метод ничего не делает.
83
+ """
84
+ if 0 <= x < self.width and 0 <= y < self.height:
85
+ self.data[y][x] = self.default_tile
86
+
87
+ # ――――――――――――――――――――――――― tilemap ―――――――――――――――――――――――――
88
+ class TileMap:
89
+ """Двумерная карта тайлов с поддержкой нескольких слоев"""
90
+ def __init__(self, engine: PyEngine, tileset: TileSet, width: int, height: int):
91
+ """
92
+ :engine: экземпляр PyEngine
93
+ :tileset: набор тайлов (TileSet)
94
+ :width: ширина карты в тайлах
95
+ :height: высота карты в тайлах
96
+ """
97
+ self.engine = engine
98
+ self.tileset = tileset
99
+ self.width = width
100
+ self.height = height
101
+
102
+ self.layers: list[TileLayer] = []
103
+ self.collision_layer_index: int = 0
104
+
105
+ # Кеш отрендеренных слоев для ускорения отрисовки
106
+ self._layer_cache: dict[int, pygame.Surface | None] = {}
107
+
108
+ def add_layer(self, default_tile: int = 0) -> int:
109
+ """
110
+ Добавляет новый слой тайлов в карту.
111
+ Возвращает индекс созданного слоя (начинается с 0).
112
+ """
113
+ layer = TileLayer(self.width, self.height, default_tile)
114
+ self.layers.append(layer)
115
+ return len(self.layers) - 1
116
+
117
+ def set_tile(self, x: int, y: int, tile_id: int, layer_index: int = 0):
118
+ """
119
+ Устанавливает тайл с указанным ID в позицию (x, y) на заданном слое.
120
+ Если layer_index не указан, используется слой 0.
121
+ """
122
+ if 0 <= layer_index < len(self.layers):
123
+ self.layers[layer_index].set_tile(x, y, tile_id)
124
+ self._layer_cache[layer_index] = None # Сбрасываем кеш
125
+
126
+ def get_tile(self, x: int, y: int, layer_index: int = 0) -> int:
127
+ """
128
+ Возвращает ID тайла в позиции (x, y) на заданном слое.
129
+ Если layer_index не указан, используется слой 0.
130
+ Если координаты или индекс слоя некорректны, возвращает 0.
131
+ """
132
+ if 0 <= layer_index < len(self.layers):
133
+ return self.layers[layer_index].get_tile(x, y)
134
+ return 0
135
+
136
+ def remove_tile(self, x: int, y: int, layer_index: int = 0):
137
+ """
138
+ Удаляет тайл из позиции (x, y) на заданном слое, делая ячейку пустой.
139
+ Пустые тайлы не отрисовываются, позволяя видеть нижние слои.
140
+ Если layer_index не указан, используется слой 0.
141
+ """
142
+ if 0 <= layer_index < len(self.layers):
143
+ self.layers[layer_index].remove_tile(x, y)
144
+ self._layer_cache[layer_index] = None # Сбрасываем кеш
145
+
146
+ def is_solid(self, x: int, y: int, figure_name: str | None = None) -> bool:
147
+ """
148
+ Проверяет, является ли тайл в позиции (x, y) непроходимым.
149
+ Проверка выполняется только на слое collision_layer_index.
150
+ Если figure_name указан и находится в списке exceptions тайла, возвращается False.
151
+ """
152
+ if self.collision_layer_index >= len(self.layers):
153
+ return False
154
+ tile_id = self.get_tile(x, y, self.collision_layer_index)
155
+ tile = self.tileset.tiles.get(tile_id)
156
+ if tile is None or not tile.solid:
157
+ return False
158
+ if figure_name and figure_name in tile.exceptions:
159
+ return False
160
+ return True
161
+
162
+ def _render_layer(self, layer_index: int) -> pygame.Surface:
163
+ """Рендерит весь слой в отдельную Surface (используется для кеширования)."""
164
+ layer = self.layers[layer_index]
165
+ surf = pygame.Surface(
166
+ (self.width * self.tileset.tile_width, self.height * self.tileset.tile_height),
167
+ pygame.SRCALPHA
168
+ )
169
+ tw, th = self.tileset.tile_width, self.tileset.tile_height
170
+
171
+ for y in range(self.height):
172
+ for x in range(self.width):
173
+ tile_id = layer.data[y][x]
174
+ if tile_id == 0: continue
175
+
176
+ tile = self.tileset.tiles.get(tile_id)
177
+ if not tile: continue
178
+
179
+ px, py = x * tw, y * th
180
+ if tile.image_name and tile.image_name in self.engine.images:
181
+ surf.blit(self.engine.images[tile.image_name], (px, py))
182
+ else:
183
+ pygame.draw.rect(surf, tile.color, (px, py, tw, th))
184
+ return surf
185
+
186
+ def draw(self):
187
+ """
188
+ Отрисовывает все слои карты на экран с учётом смещения камеры.
189
+ Слои рисуются по порядку их добавления (от 0 до последнего).
190
+ Оптимизировано: использует кеш, если слой не менялся.
191
+ """
192
+ ox, oy = self.engine.camera_offset
193
+
194
+ for i in range(len(self.layers)):
195
+ if self._layer_cache.get(i) is None:
196
+ self._layer_cache[i] = self._render_layer(i)
197
+
198
+ self.engine.screen.blit(self._layer_cache[i], (int(ox), int(oy)))
199
+
200
+ # ――――――――――――――――――――――――― utils ―――――――――――――――――――――――――
201
+ def cell_to_pixel(self, col: int, row: int) -> tuple[int, int]:
202
+ """
203
+ Преобразует координаты ячейки (col, row) в пиксельные координаты (x, y).
204
+ """
205
+ return (col * self.tileset.tile_width, row * self.tileset.tile_height)
206
+
207
+ def pixel_to_cell(self, x: float, y: float) -> tuple[int, int]:
208
+ """
209
+ Преобразует пиксельные координаты (x, y) в координаты ячейки (col, row).
210
+ Использует целочисленное деление, корректно работает с отрицательными координатами.
211
+ """
212
+ return (int(x // self.tileset.tile_width), int(y // self.tileset.tile_height))
213
+
214
+ def load_from_lines(self, lines: list[str], legend: dict[str, int], layer_index: int = 0):
215
+ """
216
+ Загружает карту из списка строк (текстовый уровень).
217
+ - lines (list[str]): Список строк, где каждый символ представляет тайл.
218
+ - legend (dict[str, int]): Словарь соответствия символа и ID тайла (например, {'#': 1, '.': 0}).
219
+ - layer_index (int): Индекс слоя для записи данных.
220
+ """
221
+ for row, line in enumerate(lines):
222
+ for col, char in enumerate(line):
223
+ if char in legend:
224
+ self.set_tile(col, row, legend[char], layer_index)