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/__init__.py +21 -0
- pyengine/assets.py +65 -0
- pyengine/engine.py +910 -0
- pyengine/figure.py +231 -0
- pyengine/scene.py +51 -0
- pyengine/sound.py +102 -0
- pyengine/tilemap.py +224 -0
- pyengine_x-0.1.0.dist-info/METADATA +988 -0
- pyengine_x-0.1.0.dist-info/RECORD +12 -0
- pyengine_x-0.1.0.dist-info/WHEEL +5 -0
- pyengine_x-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyengine_x-0.1.0.dist-info/top_level.txt +1 -0
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)
|