exp-runtime-player 0.5.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.
- exp_runtime/app_icon.py +12 -0
- exp_runtime/app_info.py +16 -0
- exp_runtime/application.py +825 -0
- exp_runtime/assets/kiwi.svg +49 -0
- exp_runtime/desktop.py +726 -0
- exp_runtime/desktop_choice.py +202 -0
- exp_runtime/desktop_dialogue.py +271 -0
- exp_runtime/desktop_input.py +176 -0
- exp_runtime/desktop_loading.py +45 -0
- exp_runtime/desktop_menu.py +448 -0
- exp_runtime/desktop_message.py +123 -0
- exp_runtime/desktop_pause.py +105 -0
- exp_runtime/desktop_picker.py +68 -0
- exp_runtime/desktop_text.py +88 -0
- exp_runtime/desktop_title.py +82 -0
- exp_runtime/graphics.py +79 -0
- exp_runtime/platforms/__init__.py +1 -0
- exp_runtime/platforms/drawing.py +612 -0
- exp_runtime/platforms/ios.py +225 -0
- exp_runtime/pointer.py +25 -0
- exp_runtime/projection.py +68 -0
- exp_runtime/settings.py +46 -0
- exp_runtime/updates.py +144 -0
- exp_runtime_player-0.5.0.dist-info/METADATA +49 -0
- exp_runtime_player-0.5.0.dist-info/RECORD +29 -0
- exp_runtime_player-0.5.0.dist-info/WHEEL +5 -0
- exp_runtime_player-0.5.0.dist-info/entry_points.txt +3 -0
- exp_runtime_player-0.5.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_player-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""Retained drawing commands for native hosts, using the shared renderer API.
|
|
2
|
+
|
|
3
|
+
Only the primitives used by our presentation code are implemented. This is not
|
|
4
|
+
an SDL emulator. Immutable snapshots keep cached layers and fades independent
|
|
5
|
+
of later writes. Native hosts rasterize commands; Pillow decodes source images
|
|
6
|
+
and performs the small, byte-exact asset operations used by masks and atlases.
|
|
7
|
+
"""
|
|
8
|
+
import base64
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from io import BytesIO
|
|
12
|
+
from itertools import count
|
|
13
|
+
import math
|
|
14
|
+
import struct
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
|
|
17
|
+
from PIL import Image, ImageDraw
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DrawingError(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Rect:
|
|
25
|
+
cast = int
|
|
26
|
+
|
|
27
|
+
def __init__(self, *values):
|
|
28
|
+
if len(values) == 1:
|
|
29
|
+
values = tuple(values[0])
|
|
30
|
+
if len(values) == 2:
|
|
31
|
+
values = (*values[0], *values[1])
|
|
32
|
+
if len(values) != 4:
|
|
33
|
+
raise TypeError('Rectangle requires x, y, width, height')
|
|
34
|
+
self.x, self.y, self.width, self.height = map(self.cast, values)
|
|
35
|
+
|
|
36
|
+
def __setattr__(self, key, value):
|
|
37
|
+
object.__setattr__(self, key, self.cast(value) if key in ('x', 'y', 'width', 'height') else value)
|
|
38
|
+
|
|
39
|
+
def __iter__(self):
|
|
40
|
+
return iter((self.x, self.y, self.width, self.height))
|
|
41
|
+
|
|
42
|
+
def __getitem__(self, index):
|
|
43
|
+
return tuple(self)[index]
|
|
44
|
+
|
|
45
|
+
def __eq__(self, other):
|
|
46
|
+
return tuple(self) == tuple(other)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def size(self):
|
|
50
|
+
return self.width, self.height
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def topleft(self):
|
|
54
|
+
return self.x, self.y
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def top(self):
|
|
58
|
+
return self.y
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def bottom(self):
|
|
62
|
+
return self.y + self.height
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def left(self):
|
|
66
|
+
return self.x
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def right(self):
|
|
70
|
+
return self.x + self.width
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def centerx(self):
|
|
74
|
+
return self.x + (self.width // 2 if self.cast is int else self.width / 2)
|
|
75
|
+
|
|
76
|
+
@centerx.setter
|
|
77
|
+
def centerx(self, value):
|
|
78
|
+
self.x = self.cast(value) - (self.width // 2 if self.cast is int else self.width / 2)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def centery(self):
|
|
82
|
+
return self.y + (self.height // 2 if self.cast is int else self.height / 2)
|
|
83
|
+
|
|
84
|
+
@centery.setter
|
|
85
|
+
def centery(self, value):
|
|
86
|
+
self.y = self.cast(value) - (self.height // 2 if self.cast is int else self.height / 2)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def center(self):
|
|
90
|
+
return self.centerx, self.centery
|
|
91
|
+
|
|
92
|
+
@center.setter
|
|
93
|
+
def center(self, point):
|
|
94
|
+
self.centerx, self.centery = point
|
|
95
|
+
|
|
96
|
+
def clip(self, other):
|
|
97
|
+
other = type(self)(other)
|
|
98
|
+
x, y = max(self.x, other.x), max(self.y, other.y)
|
|
99
|
+
w, h = min(self.right, other.right) - x, min(self.bottom, other.bottom) - y
|
|
100
|
+
return type(self)(x, y, w, h) if w > 0 and h > 0 else type(self)(self.x, self.y, 0, 0)
|
|
101
|
+
|
|
102
|
+
def inflate(self, x, y):
|
|
103
|
+
x, y = self.cast(x), self.cast(y)
|
|
104
|
+
return type(self)(self.x - self.cast(x / 2), self.y - self.cast(y / 2), self.width + x, self.height + y)
|
|
105
|
+
|
|
106
|
+
def inflate_ip(self, x, y):
|
|
107
|
+
self.x, self.y, self.width, self.height = self.inflate(x, y)
|
|
108
|
+
|
|
109
|
+
def move(self, x, y):
|
|
110
|
+
return type(self)(self.x + self.cast(x), self.y + self.cast(y), self.width, self.height)
|
|
111
|
+
|
|
112
|
+
def unionall(self, rectangles):
|
|
113
|
+
rectangles = [self, *(type(self)(rect) for rect in rectangles)]
|
|
114
|
+
x, y = min(rect.x for rect in rectangles), min(rect.y for rect in rectangles)
|
|
115
|
+
right, bottom = max(rect.right for rect in rectangles), max(rect.bottom for rect in rectangles)
|
|
116
|
+
return type(self)(x, y, right - x, bottom - y)
|
|
117
|
+
|
|
118
|
+
def copy(self):
|
|
119
|
+
return type(self)(self)
|
|
120
|
+
|
|
121
|
+
def collidepoint(self, *point):
|
|
122
|
+
x, y = point[0] if len(point) == 1 else point
|
|
123
|
+
return self.x <= x < self.right and self.y <= y < self.bottom
|
|
124
|
+
|
|
125
|
+
def colliderect(self, other):
|
|
126
|
+
other = type(self)(other)
|
|
127
|
+
return self.x < other.right and self.right > other.x and self.y < other.bottom and self.bottom > other.y
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class FRect(Rect):
|
|
131
|
+
cast = float
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
_ids = count(1)
|
|
135
|
+
SRCALPHA, BLEND_RGBA_MULT, RESIZABLE = 1, 2, 4
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def rgba(color):
|
|
139
|
+
return tuple(color) if len(color) == 4 else (*color, 255)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(eq=False)
|
|
143
|
+
class Node:
|
|
144
|
+
size: tuple
|
|
145
|
+
ops: tuple = ()
|
|
146
|
+
pixels: Image.Image | None = None
|
|
147
|
+
identifier: int = field(default_factory=lambda: next(_ids))
|
|
148
|
+
encoded: str | None = None
|
|
149
|
+
|
|
150
|
+
def record(self):
|
|
151
|
+
record = dict(id=self.identifier, size=self.size)
|
|
152
|
+
if self.pixels is not None:
|
|
153
|
+
if self.encoded is None:
|
|
154
|
+
stream = BytesIO()
|
|
155
|
+
self.pixels.save(stream, 'PNG')
|
|
156
|
+
self.encoded = base64.b64encode(stream.getvalue()).decode('ascii')
|
|
157
|
+
record['png'] = self.encoded
|
|
158
|
+
else:
|
|
159
|
+
record['ops'] = [dict(op, source=op['source'].identifier) if 'source' in op else op for op in self.ops]
|
|
160
|
+
return record
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Surface:
|
|
164
|
+
def __init__(self, size, flags=0):
|
|
165
|
+
self._size = tuple(map(int, size))
|
|
166
|
+
if len(self._size) != 2 or min(self._size) < 0:
|
|
167
|
+
raise DrawingError('Invalid surface dimensions')
|
|
168
|
+
self._alpha = 255
|
|
169
|
+
self._clip = Rect(0, 0, *self._size)
|
|
170
|
+
self._ops = []
|
|
171
|
+
self._node = None
|
|
172
|
+
self.opaque = not bool(flags & SRCALPHA)
|
|
173
|
+
self.fill((0, 0, 0, 255 if self.opaque else 0))
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_pixels(cls, pixels):
|
|
177
|
+
result = cls(pixels.size, SRCALPHA)
|
|
178
|
+
result._ops = []
|
|
179
|
+
result._node = Node(pixels.size, pixels=pixels.convert('RGBA'))
|
|
180
|
+
result.opaque = result._node.pixels.getextrema()[3] == (255, 255)
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def from_node(cls, node):
|
|
185
|
+
result = cls(node.size, SRCALPHA)
|
|
186
|
+
result._ops, result._node = [], node
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
def snapshot(self):
|
|
190
|
+
if self._node is None:
|
|
191
|
+
self._node = Node(self._size, tuple(self._ops))
|
|
192
|
+
return self._node
|
|
193
|
+
|
|
194
|
+
def _append(self, op):
|
|
195
|
+
if not self._ops and self._node is not None:
|
|
196
|
+
self._ops = [dict(kind='blit', source=self._node, at=(0, 0), alpha=255,
|
|
197
|
+
clip=(0, 0, *self._size))]
|
|
198
|
+
self._node = None
|
|
199
|
+
self._ops.append(dict(op, clip=tuple(self._clip)))
|
|
200
|
+
|
|
201
|
+
def get_size(self):
|
|
202
|
+
return self._size
|
|
203
|
+
|
|
204
|
+
def get_width(self):
|
|
205
|
+
return self._size[0]
|
|
206
|
+
|
|
207
|
+
def get_height(self):
|
|
208
|
+
return self._size[1]
|
|
209
|
+
|
|
210
|
+
def get_rect(self, **keywords):
|
|
211
|
+
rect = Rect(0, 0, *self._size)
|
|
212
|
+
for key, value in keywords.items():
|
|
213
|
+
setattr(rect, key, value)
|
|
214
|
+
return rect
|
|
215
|
+
|
|
216
|
+
def copy(self):
|
|
217
|
+
result = self.from_node(self.snapshot())
|
|
218
|
+
result._alpha, result.opaque = self._alpha, self.opaque
|
|
219
|
+
result._clip = Rect(self._clip)
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
def convert(self, depth=32):
|
|
223
|
+
return self
|
|
224
|
+
|
|
225
|
+
def convert_alpha(self):
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
def set_alpha(self, alpha):
|
|
229
|
+
self._alpha = 255 if alpha is None else max(0, min(255, int(alpha)))
|
|
230
|
+
|
|
231
|
+
def set_clip(self, rect=None):
|
|
232
|
+
self._clip = self.get_rect() if rect is None else self.get_rect().clip(rect)
|
|
233
|
+
|
|
234
|
+
def get_clip(self):
|
|
235
|
+
return Rect(self._clip)
|
|
236
|
+
|
|
237
|
+
def fill(self, color, rect=None, special_flags=0):
|
|
238
|
+
region = self._clip if rect is None else self._clip.clip(rect)
|
|
239
|
+
if region.width <= 0 or region.height <= 0:
|
|
240
|
+
return region
|
|
241
|
+
if special_flags:
|
|
242
|
+
if special_flags != BLEND_RGBA_MULT:
|
|
243
|
+
raise DrawingError('Unsupported fill blend')
|
|
244
|
+
pixels = raster(self.snapshot())
|
|
245
|
+
box = (region.x, region.y, region.right, region.bottom)
|
|
246
|
+
patch = pixels.crop(box)
|
|
247
|
+
# SDL's multiply is (a*b + 255) >> 8, including alpha.
|
|
248
|
+
patch = Image.merge('RGBA', [channel.point(lambda a, b=b: (a * b + 255) >> 8)
|
|
249
|
+
for channel, b in zip(patch.split(), rgba(color))])
|
|
250
|
+
pixels.paste(patch, box)
|
|
251
|
+
self._ops, self._node = [], Node(self._size, pixels=pixels)
|
|
252
|
+
self.opaque = pixels.getextrema()[3] == (255, 255)
|
|
253
|
+
else:
|
|
254
|
+
if region == self.get_rect():
|
|
255
|
+
self._ops, self._node = [], None
|
|
256
|
+
self.opaque = rgba(color)[3] == 255
|
|
257
|
+
self._append(dict(kind='fill', color=rgba(color), rect=tuple(region)))
|
|
258
|
+
return region
|
|
259
|
+
|
|
260
|
+
def blit(self, source, destination, area=None, special_flags=0):
|
|
261
|
+
if area is not None:
|
|
262
|
+
source = source.subsurface(area)
|
|
263
|
+
x, y = tuple(destination)[:2]
|
|
264
|
+
at = int(x), int(y)
|
|
265
|
+
bounds = Rect(*at, *source.get_size()).clip(self._clip)
|
|
266
|
+
if special_flags:
|
|
267
|
+
if special_flags != BLEND_RGBA_MULT or at != (0, 0) or source.get_size() != self._size:
|
|
268
|
+
raise DrawingError('Unsupported image blend')
|
|
269
|
+
a, b = raster(self.snapshot()), raster(source.snapshot())
|
|
270
|
+
# The timer mask has binary channels. General multiply uses the
|
|
271
|
+
# same rounded multiplication as the atlas modulation above.
|
|
272
|
+
pixels = Image.frombytes('RGBA', self._size,
|
|
273
|
+
bytes((x * y + 255) >> 8 for x, y in zip(a.tobytes(), b.tobytes())))
|
|
274
|
+
self._ops, self._node = [], Node(self._size, pixels=pixels)
|
|
275
|
+
self.opaque = pixels.getextrema()[3] == (255, 255)
|
|
276
|
+
elif bounds.width > 0 and bounds.height > 0:
|
|
277
|
+
node = source.snapshot()
|
|
278
|
+
if source.opaque and source._alpha == 255 and bounds == self.get_rect():
|
|
279
|
+
self._ops, self._node = [], None
|
|
280
|
+
self.opaque = True
|
|
281
|
+
self._append(dict(kind='blit', source=node, at=at, alpha=source._alpha))
|
|
282
|
+
return bounds
|
|
283
|
+
|
|
284
|
+
def subsurface(self, rect):
|
|
285
|
+
rect = Rect(rect)
|
|
286
|
+
if rect.x < 0 or rect.y < 0 or rect.right > self.get_width() or rect.bottom > self.get_height():
|
|
287
|
+
raise DrawingError('Subsurface lies outside its source')
|
|
288
|
+
node = self.snapshot()
|
|
289
|
+
if node.pixels is not None:
|
|
290
|
+
return self.from_pixels(node.pixels.crop((rect.x, rect.y, rect.right, rect.bottom)))
|
|
291
|
+
result = Surface(rect.size, SRCALPHA)
|
|
292
|
+
result.blit(self, (-rect.x, -rect.y))
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class Draw:
|
|
297
|
+
@staticmethod
|
|
298
|
+
def textured_quad(surface, image, bounds, coefficients):
|
|
299
|
+
surface._append(dict(kind='textured_quad', source=image.snapshot(), alpha=image._alpha,
|
|
300
|
+
bounds=bounds, coefficients=coefficients))
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def textured_rows(surface, image, top, rows):
|
|
304
|
+
surface._append(dict(kind='textured_rows', source=image.snapshot(),
|
|
305
|
+
alpha=image._alpha, top=top, rows=tuple(rows)))
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def rect(surface, color, rect, width=0, border_radius=0):
|
|
309
|
+
surface._append(dict(kind='rect', color=rgba(color), rect=tuple(Rect(rect)),
|
|
310
|
+
width=width, radius=border_radius))
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def line(surface, color, start, end, width=1):
|
|
314
|
+
surface._append(dict(kind='line', color=rgba(color), points=(start, end), width=width))
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def polygon(surface, color, points, width=0):
|
|
318
|
+
surface._append(dict(kind='polygon', color=rgba(color), points=tuple(points), width=width))
|
|
319
|
+
|
|
320
|
+
@staticmethod
|
|
321
|
+
def circle(surface, color, center, radius, width=0):
|
|
322
|
+
surface._append(dict(kind='circle', color=rgba(color), center=center, radius=radius, width=width))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class Transform:
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _scale(surface, size, smooth):
|
|
328
|
+
size = tuple(map(int, size))
|
|
329
|
+
node = surface.snapshot()
|
|
330
|
+
if min(surface.get_size()) == 0:
|
|
331
|
+
return Surface(size, SRCALPHA)
|
|
332
|
+
# Keep source pixels stable. Realizing resized sprites here encoded
|
|
333
|
+
# dozens of fresh PNGs per football frame (including four large haze
|
|
334
|
+
# tiles). The native canvas already implements both resampling modes.
|
|
335
|
+
result = Surface.from_node(Node(size, (dict(kind='scale', source=node, smooth=smooth),)))
|
|
336
|
+
result._alpha, result.opaque = surface._alpha, surface.opaque
|
|
337
|
+
return result
|
|
338
|
+
|
|
339
|
+
@classmethod
|
|
340
|
+
def scale(cls, surface, size):
|
|
341
|
+
return cls._scale(surface, size, False)
|
|
342
|
+
|
|
343
|
+
@classmethod
|
|
344
|
+
def smoothscale(cls, surface, size):
|
|
345
|
+
return cls._scale(surface, size, True)
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def flip(surface, horizontal, vertical):
|
|
349
|
+
node = surface.snapshot()
|
|
350
|
+
if node.pixels is not None:
|
|
351
|
+
pixels = node.pixels
|
|
352
|
+
if horizontal:
|
|
353
|
+
pixels = pixels.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
|
354
|
+
if vertical:
|
|
355
|
+
pixels = pixels.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
|
|
356
|
+
result = Surface.from_pixels(pixels)
|
|
357
|
+
else:
|
|
358
|
+
result = Surface.from_node(Node(surface.get_size(), (dict(kind='flip', source=node,
|
|
359
|
+
horizontal=horizontal, vertical=vertical),)))
|
|
360
|
+
result._alpha, result.opaque = surface._alpha, surface.opaque
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
@staticmethod
|
|
364
|
+
def _rotate(surface, angle, scale, smooth):
|
|
365
|
+
w, h = surface.get_size()
|
|
366
|
+
if smooth:
|
|
367
|
+
# pygame's rotozoom binding accepts C floats before calculating
|
|
368
|
+
# the output bounds. Preserve that rounding at integer edges.
|
|
369
|
+
angle, scale = struct.unpack('ff', struct.pack('ff', angle, scale))
|
|
370
|
+
cosine, sine = abs(math.cos(math.radians(angle))), abs(math.sin(math.radians(angle)))
|
|
371
|
+
if smooth and angle % 360:
|
|
372
|
+
size = (max(1, math.ceil((w // 2 * cosine + h // 2 * sine) * scale)) * 2,
|
|
373
|
+
max(1, math.ceil((w // 2 * sine + h // 2 * cosine) * scale)) * 2)
|
|
374
|
+
else:
|
|
375
|
+
size = (max(1, int((w * cosine + h * sine) * scale + 1e-9)),
|
|
376
|
+
max(1, int((w * sine + h * cosine) * scale + 1e-9)))
|
|
377
|
+
result = Surface.from_node(Node(size, (dict(kind='rotate', source=surface.snapshot(),
|
|
378
|
+
angle=angle, scale=scale, smooth=smooth),)))
|
|
379
|
+
result._alpha = surface._alpha
|
|
380
|
+
return result
|
|
381
|
+
|
|
382
|
+
@classmethod
|
|
383
|
+
def rotate(cls, surface, angle):
|
|
384
|
+
return cls._rotate(surface, angle, 1, False)
|
|
385
|
+
|
|
386
|
+
@classmethod
|
|
387
|
+
def rotozoom(cls, surface, angle, scale):
|
|
388
|
+
return cls._rotate(surface, angle, scale, True)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def raster(node):
|
|
392
|
+
"""Realize a small asset mask for the shared portrait/tint algorithms.
|
|
393
|
+
|
|
394
|
+
Whole frames are drawn by the native host, not encoded as Python bitmaps.
|
|
395
|
+
This also provides a reference image for protocol tests.
|
|
396
|
+
"""
|
|
397
|
+
if node.pixels is not None:
|
|
398
|
+
return node.pixels.copy()
|
|
399
|
+
image = Image.new('RGBA', node.size)
|
|
400
|
+
for op in node.ops:
|
|
401
|
+
kind = op['kind']
|
|
402
|
+
if kind in ('scale', 'rotate', 'flip'):
|
|
403
|
+
source = raster(op['source'])
|
|
404
|
+
if kind == 'scale':
|
|
405
|
+
image = source.resize(node.size, Image.Resampling.BILINEAR if op['smooth'] else Image.Resampling.NEAREST)
|
|
406
|
+
elif kind == 'flip':
|
|
407
|
+
image = source
|
|
408
|
+
if op['horizontal']:
|
|
409
|
+
image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
|
410
|
+
if op['vertical']:
|
|
411
|
+
image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
|
|
412
|
+
else:
|
|
413
|
+
scale = op['scale']
|
|
414
|
+
if scale != 1:
|
|
415
|
+
source = source.resize((max(1, round(source.width * scale)), max(1, round(source.height * scale))))
|
|
416
|
+
rotated = source.rotate(op['angle'], Image.Resampling.BICUBIC if op['smooth'] else Image.Resampling.NEAREST, expand=True)
|
|
417
|
+
image.paste(rotated, ((image.width - rotated.width) // 2, (image.height - rotated.height) // 2))
|
|
418
|
+
continue
|
|
419
|
+
layer = image.copy()
|
|
420
|
+
if kind == 'blit':
|
|
421
|
+
source = raster(op['source'])
|
|
422
|
+
if op['alpha'] != 255:
|
|
423
|
+
source.putalpha(source.getchannel('A').point(lambda a: a * op['alpha'] // 255))
|
|
424
|
+
layer.alpha_composite(source, op['at'])
|
|
425
|
+
elif kind == 'textured_quad':
|
|
426
|
+
from ..projection import warp
|
|
427
|
+
x, y, w, h = op['bounds']
|
|
428
|
+
source = warp(raster(op['source']), (w, h), op['coefficients'])
|
|
429
|
+
if op['alpha'] != 255:
|
|
430
|
+
source.putalpha(source.getchannel('A').point(lambda a: a * op['alpha'] // 255))
|
|
431
|
+
layer.alpha_composite(source, (x, y))
|
|
432
|
+
elif kind == 'textured_rows':
|
|
433
|
+
source = raster(op['source'])
|
|
434
|
+
source = source.resize((source.width, len(op['rows'])), Image.Resampling.BILINEAR)
|
|
435
|
+
for y, (left, width) in enumerate(op['rows']):
|
|
436
|
+
row = source.crop((0, y, source.width, y + 1)).resize((width, 1), Image.Resampling.BILINEAR)
|
|
437
|
+
if op['alpha'] != 255:
|
|
438
|
+
row.putalpha(row.getchannel('A').point(lambda a: a * op['alpha'] // 255))
|
|
439
|
+
layer.alpha_composite(row, (left, op['top'] + y))
|
|
440
|
+
else:
|
|
441
|
+
draw = ImageDraw.Draw(layer)
|
|
442
|
+
color, width = op['color'], op.get('width', 0)
|
|
443
|
+
if kind in ('fill', 'rect'):
|
|
444
|
+
x, y, w, h = op['rect']
|
|
445
|
+
if w > 0 and h > 0:
|
|
446
|
+
draw.rounded_rectangle((x, y, x + w - 1, y + h - 1), radius=op.get('radius', 0),
|
|
447
|
+
fill=None if width else color, outline=color, width=width)
|
|
448
|
+
elif kind == 'line':
|
|
449
|
+
draw.line(op['points'], fill=color, width=width)
|
|
450
|
+
elif kind == 'polygon':
|
|
451
|
+
draw.polygon(op['points'], fill=None if width else color, outline=color, width=width or 1)
|
|
452
|
+
elif kind == 'circle':
|
|
453
|
+
x, y = op['center']
|
|
454
|
+
r = op['radius']
|
|
455
|
+
draw.ellipse((x - r, y - r, x + r - 1, y + r - 1), fill=None if width else color, outline=color, width=width or 1)
|
|
456
|
+
else:
|
|
457
|
+
raise DrawingError(f'Unsupported drawing operation: {kind}')
|
|
458
|
+
x, y, w, h = op['clip']
|
|
459
|
+
box = x, y, x + w, y + h
|
|
460
|
+
image.paste(layer.crop(box), box)
|
|
461
|
+
return image
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class ImageAPI:
|
|
465
|
+
def __init__(self, icon_path):
|
|
466
|
+
self.icon_path = icon_path
|
|
467
|
+
|
|
468
|
+
@staticmethod
|
|
469
|
+
def frombytes(data, size, mode):
|
|
470
|
+
return Surface.from_pixels(Image.frombytes(mode, size, data))
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def load(source):
|
|
474
|
+
try:
|
|
475
|
+
with Image.open(source) as image:
|
|
476
|
+
return Surface.from_pixels(image.convert('RGBA'))
|
|
477
|
+
except (OSError, ValueError) as error:
|
|
478
|
+
raise DrawingError(str(error)) from error
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def tobytes(surface, mode):
|
|
482
|
+
return raster(surface.snapshot()).convert(mode).tobytes()
|
|
483
|
+
|
|
484
|
+
def load_sized_svg(self, source, size):
|
|
485
|
+
# The build renders the project's authored SVG; no game image is bundled.
|
|
486
|
+
return Transform.smoothscale(self.load(self.icon_path), size)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class FrameEncoder:
|
|
490
|
+
def __init__(self):
|
|
491
|
+
self.previous = set()
|
|
492
|
+
|
|
493
|
+
def encode(self, surface):
|
|
494
|
+
nodes, visited = [], set()
|
|
495
|
+
def visit(node):
|
|
496
|
+
if node.identifier in visited:
|
|
497
|
+
return
|
|
498
|
+
visited.add(node.identifier)
|
|
499
|
+
for op in node.ops:
|
|
500
|
+
if 'source' in op:
|
|
501
|
+
visit(op['source'])
|
|
502
|
+
if node.identifier not in self.previous:
|
|
503
|
+
nodes.append(node.record())
|
|
504
|
+
root = surface.snapshot()
|
|
505
|
+
visit(root)
|
|
506
|
+
released = sorted(self.previous - visited)
|
|
507
|
+
self.previous = visited
|
|
508
|
+
return dict(root=root.identifier, nodes=nodes, release=released)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class PlatformFont:
|
|
512
|
+
def __init__(self, bridge, face, size):
|
|
513
|
+
self.bridge, self.face, self.points = bridge, face, max(1, round(size * .75)) if face is None else size
|
|
514
|
+
|
|
515
|
+
@lru_cache(maxsize=512)
|
|
516
|
+
def metrics(self, text):
|
|
517
|
+
return self.bridge(dict(operation='text', face=self.face, size=self.points, text=text, measure=True))
|
|
518
|
+
|
|
519
|
+
def size(self, text):
|
|
520
|
+
result = self.metrics(text)
|
|
521
|
+
return result['width'], result['height']
|
|
522
|
+
|
|
523
|
+
def get_linesize(self):
|
|
524
|
+
return self.metrics('Mg')['height']
|
|
525
|
+
|
|
526
|
+
@lru_cache(maxsize=128)
|
|
527
|
+
def render(self, text, antialias, color):
|
|
528
|
+
result = self.bridge(dict(operation='text', face=self.face, size=self.points, text=text, color=rgba(color)))
|
|
529
|
+
return ImageAPI.load(BytesIO(base64.b64decode(result['png'])))
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class Music:
|
|
533
|
+
def __init__(self, owner, channel='music'):
|
|
534
|
+
self.owner, self.channel = owner, channel
|
|
535
|
+
|
|
536
|
+
def command(self, action, **details):
|
|
537
|
+
self.owner.commands.append(dict(kind=f'{self.channel}_{action}', **details))
|
|
538
|
+
|
|
539
|
+
def load(self, stream):
|
|
540
|
+
self.command('load', data=base64.b64encode(stream.read()).decode('ascii'))
|
|
541
|
+
|
|
542
|
+
def play(self, loops=0, *, start=0):
|
|
543
|
+
self.command('play', loops=loops, start=start)
|
|
544
|
+
|
|
545
|
+
def stop(self):
|
|
546
|
+
self.command('stop')
|
|
547
|
+
|
|
548
|
+
def pause(self):
|
|
549
|
+
self.command('pause')
|
|
550
|
+
|
|
551
|
+
def unpause(self):
|
|
552
|
+
self.command('resume')
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class Mixer:
|
|
556
|
+
def __init__(self):
|
|
557
|
+
self.commands = []
|
|
558
|
+
self.music = Music(self)
|
|
559
|
+
self.menu_music = Music(self, 'menu')
|
|
560
|
+
|
|
561
|
+
def init(self):
|
|
562
|
+
pass # AVAudioSession is owned by the native host.
|
|
563
|
+
|
|
564
|
+
def stop(self):
|
|
565
|
+
self.commands.append(dict(kind='sounds_stop'))
|
|
566
|
+
|
|
567
|
+
def Sound(self, *, file):
|
|
568
|
+
data = base64.b64encode(file.read()).decode('ascii')
|
|
569
|
+
return SimpleNamespace(play=lambda: self.commands.append(dict(kind='sound', data=data)))
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class Backend:
|
|
573
|
+
error, Rect, FRect, Surface = DrawingError, Rect, FRect, Surface
|
|
574
|
+
SRCALPHA, BLEND_RGBA_MULT, RESIZABLE = SRCALPHA, BLEND_RGBA_MULT, RESIZABLE
|
|
575
|
+
# Event values are private to this host. Keyboard digit ordering is used by
|
|
576
|
+
# the shared accessibility/shortcut path, so keep ASCII digits contiguous.
|
|
577
|
+
QUIT, WINDOWFOCUSLOST, WINDOWFOCUSGAINED, DROPBEGIN, DROPFILE, DROPCOMPLETE = range(1, 7)
|
|
578
|
+
KEYDOWN, TEXTINPUT, MOUSEBUTTONDOWN, MOUSEBUTTONUP, MOUSEMOTION, MOUSEWHEEL = range(7, 13)
|
|
579
|
+
K_1, K_5, K_9 = 49, 53, 57
|
|
580
|
+
K_BACKSPACE, K_RETURN, K_ESCAPE, K_SPACE, K_TAB = 8, 13, 27, 32, 9
|
|
581
|
+
K_UP, K_DOWN, K_PAGEUP, K_PAGEDOWN, K_F5, K_F9 = range(256, 262)
|
|
582
|
+
|
|
583
|
+
def __init__(self, bridge, icon_path):
|
|
584
|
+
self.bridge = bridge
|
|
585
|
+
self.ticks = 0
|
|
586
|
+
self.keyboard = False
|
|
587
|
+
self.pointer = (-1, -1)
|
|
588
|
+
self.window = None
|
|
589
|
+
self.image = ImageAPI(icon_path)
|
|
590
|
+
self.draw, self.transform = Draw(), Transform()
|
|
591
|
+
self.mixer = Mixer()
|
|
592
|
+
self.font = SimpleNamespace(init=lambda: None, match_font=lambda *a, **k: 'ArialRoundedMTBold',
|
|
593
|
+
Font=lambda face, size: PlatformFont(bridge, face, size))
|
|
594
|
+
self.time = SimpleNamespace(get_ticks=lambda: self.ticks)
|
|
595
|
+
self.mouse = SimpleNamespace(get_pos=lambda: self.pointer)
|
|
596
|
+
self.key = SimpleNamespace(start_text_input=lambda: self._keyboard(True),
|
|
597
|
+
stop_text_input=lambda: self._keyboard(False), set_text_input_rect=lambda rect: None)
|
|
598
|
+
self.display = SimpleNamespace(init=lambda: None, set_mode=self._window, flip=lambda: None,
|
|
599
|
+
set_icon=lambda icon: None, set_caption=lambda caption: None)
|
|
600
|
+
|
|
601
|
+
def _keyboard(self, enabled):
|
|
602
|
+
self.keyboard = enabled
|
|
603
|
+
|
|
604
|
+
def _window(self, size, flags=0):
|
|
605
|
+
self.window = Surface(size)
|
|
606
|
+
return self.window
|
|
607
|
+
|
|
608
|
+
def quit(self):
|
|
609
|
+
self.mixer.music.stop()
|
|
610
|
+
self.mixer.menu_music.stop()
|
|
611
|
+
self.mixer.stop()
|
|
612
|
+
self.keyboard = False
|