pgzero-plus 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.
Potentially problematic release.
This version of pgzero-plus might be problematic. Click here for more details.
- pgzero/__init__.py +41 -0
- pgzero/__main__.py +3 -0
- pgzero/actor.py +597 -0
- pgzero/animation.py +244 -0
- pgzero/builtins.py +45 -0
- pgzero/clock.py +200 -0
- pgzero/constants.py +33 -0
- pgzero/data/icon.png +0 -0
- pgzero/data/joypad.png +0 -0
- pgzero/game.py +573 -0
- pgzero/keyboard.py +67 -0
- pgzero/loaders.py +308 -0
- pgzero/music.py +180 -0
- pgzero/ptext.py +500 -0
- pgzero/rect.py +523 -0
- pgzero/runner.py +233 -0
- pgzero/screen.py +210 -0
- pgzero/soundfmt.py +105 -0
- pgzero/spellcheck.py +173 -0
- pgzero/sprites.py +182 -0
- pgzero/storage.py +191 -0
- pgzero/tilemaps.py +307 -0
- pgzero/tone.py +132 -0
- pgzero_plus-0.1.0.dist-info/METADATA +259 -0
- pgzero_plus-0.1.0.dist-info/RECORD +30 -0
- pgzero_plus-0.1.0.dist-info/WHEEL +5 -0
- pgzero_plus-0.1.0.dist-info/entry_points.txt +2 -0
- pgzero_plus-0.1.0.dist-info/licenses/COPYING +165 -0
- pgzero_plus-0.1.0.dist-info/top_level.txt +2 -0
- pgzrun.py +34 -0
pgzero/tilemaps.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pygame
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
|
|
5
|
+
from . import loaders
|
|
6
|
+
from .actor import Actor
|
|
7
|
+
|
|
8
|
+
FLIP_H = 0x80000000
|
|
9
|
+
FLIP_V = 0x40000000
|
|
10
|
+
FLIP_D = 0x20000000
|
|
11
|
+
GID_MASK = 0x0FFFFFFF
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_tile_surface(tileset, tile_col, tile_row, map_tile_width, map_tile_height):
|
|
15
|
+
tx = tileset["margin"] + tile_col * (tileset["tile_width"] + tileset["spacing"])
|
|
16
|
+
ty = tileset["margin"] + tile_row * (tileset["tile_height"] + tileset["spacing"])
|
|
17
|
+
|
|
18
|
+
tile_rect = (tx, ty, tileset["tile_width"], tileset["tile_height"])
|
|
19
|
+
tile_surface = tileset["image"].subsurface(tile_rect)
|
|
20
|
+
|
|
21
|
+
return pygame.transform.scale(tile_surface, (map_tile_width, map_tile_height))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Tile(Actor):
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
tileset,
|
|
28
|
+
tileset_firstgid,
|
|
29
|
+
tile_col,
|
|
30
|
+
tile_row,
|
|
31
|
+
map_col,
|
|
32
|
+
map_row,
|
|
33
|
+
map_tile_width,
|
|
34
|
+
map_tile_height,
|
|
35
|
+
scale=1.0,
|
|
36
|
+
flip_h=False,
|
|
37
|
+
flip_v=False,
|
|
38
|
+
flip_d=False,
|
|
39
|
+
):
|
|
40
|
+
self._tileset = tileset
|
|
41
|
+
self.tileset_firstgid = tileset_firstgid
|
|
42
|
+
self._map_tile_width = map_tile_width
|
|
43
|
+
self._map_tile_height = map_tile_height
|
|
44
|
+
|
|
45
|
+
super().__init__(self._tile_surface(tile_col, tile_row))
|
|
46
|
+
|
|
47
|
+
self.scale = scale
|
|
48
|
+
self.flip_h = flip_h
|
|
49
|
+
self.flip_v = flip_v
|
|
50
|
+
self.flip_d = flip_d
|
|
51
|
+
|
|
52
|
+
self.set_map_position(map_col, map_row)
|
|
53
|
+
|
|
54
|
+
def set_tile_image(self, tile_col, tile_row):
|
|
55
|
+
self.image = self._tile_surface(tile_col, tile_row)
|
|
56
|
+
|
|
57
|
+
def _tile_surface(self, tile_col, tile_row):
|
|
58
|
+
"""Check (tile_col, tile_row), record the tile ids and return the tile's surface."""
|
|
59
|
+
columns = self._tileset["columns"]
|
|
60
|
+
rows = self._tileset["rows"]
|
|
61
|
+
tile_count = self._tileset["tile_count"]
|
|
62
|
+
|
|
63
|
+
if tile_col < 0 or tile_col >= columns:
|
|
64
|
+
raise ValueError(f"tile_col must be between 0 and {columns - 1}")
|
|
65
|
+
|
|
66
|
+
if tile_row < 0 or tile_row >= rows:
|
|
67
|
+
raise ValueError(f"tile_row must be between 0 and {rows - 1}")
|
|
68
|
+
|
|
69
|
+
tile_id = tile_row * columns + tile_col
|
|
70
|
+
|
|
71
|
+
if tile_id >= tile_count:
|
|
72
|
+
raise ValueError(f"tile_col={tile_col}, tile_row={tile_row} points past the tileset's last tile.")
|
|
73
|
+
|
|
74
|
+
self.tile_col = tile_col
|
|
75
|
+
self.tile_row = tile_row
|
|
76
|
+
self.tile_id = tile_id
|
|
77
|
+
self.tile_gid = self.tileset_firstgid + self.tile_id
|
|
78
|
+
|
|
79
|
+
return _get_tile_surface(
|
|
80
|
+
self._tileset,
|
|
81
|
+
tile_col,
|
|
82
|
+
tile_row,
|
|
83
|
+
self._map_tile_width,
|
|
84
|
+
self._map_tile_height,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def set_map_position(self, map_col, map_row):
|
|
88
|
+
self.map_col = map_col
|
|
89
|
+
self.map_row = map_row
|
|
90
|
+
|
|
91
|
+
self.topleft = (
|
|
92
|
+
self._map_tile_width * map_col * self.scale,
|
|
93
|
+
self._map_tile_height * map_row * self.scale,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def copy(self, map_col=None, map_row=None):
|
|
97
|
+
if map_col is None:
|
|
98
|
+
map_col = self.map_col
|
|
99
|
+
|
|
100
|
+
if map_row is None:
|
|
101
|
+
map_row = self.map_row
|
|
102
|
+
|
|
103
|
+
return Tile(
|
|
104
|
+
tileset=self._tileset,
|
|
105
|
+
tileset_firstgid=self.tileset_firstgid,
|
|
106
|
+
tile_col=self.tile_col,
|
|
107
|
+
tile_row=self.tile_row,
|
|
108
|
+
map_col=map_col,
|
|
109
|
+
map_row=map_row,
|
|
110
|
+
map_tile_width=self._map_tile_width,
|
|
111
|
+
map_tile_height=self._map_tile_height,
|
|
112
|
+
scale=self.scale,
|
|
113
|
+
flip_h=self.flip_h,
|
|
114
|
+
flip_v=self.flip_v,
|
|
115
|
+
flip_d=self.flip_d,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _load_xml_from_maps(name):
|
|
120
|
+
text = loaders.maps.load(name)
|
|
121
|
+
return ET.fromstring(text)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_relative_map_path(base_name, relative_path):
|
|
125
|
+
base_dir = os.path.dirname(base_name)
|
|
126
|
+
if base_dir:
|
|
127
|
+
return os.path.normpath(os.path.join(base_dir, relative_path)).replace("\\", "/")
|
|
128
|
+
return relative_path
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _count_tiles_that_fit(image_length, tile_length, margin, spacing):
|
|
132
|
+
"""
|
|
133
|
+
Count how many tiles fit in one direction of a Tiled tileset image.
|
|
134
|
+
|
|
135
|
+
In Tiled, margin is the offset from the top/left edge to the first tile.
|
|
136
|
+
It is not necessarily repeated on the far right/bottom edge. Any extra
|
|
137
|
+
pixels after the final tile are just unused image space.
|
|
138
|
+
"""
|
|
139
|
+
if tile_length <= 0:
|
|
140
|
+
raise ValueError("tile_length must be greater than 0")
|
|
141
|
+
|
|
142
|
+
if margin < 0:
|
|
143
|
+
raise ValueError("margin cannot be negative")
|
|
144
|
+
|
|
145
|
+
if spacing < 0:
|
|
146
|
+
raise ValueError("spacing cannot be negative")
|
|
147
|
+
|
|
148
|
+
usable_length = image_length - margin
|
|
149
|
+
if usable_length < tile_length:
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
return (usable_length + spacing) // (tile_length + spacing)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _load_tilesets(root, tmx_name):
|
|
156
|
+
tilesets = {}
|
|
157
|
+
|
|
158
|
+
for ts in root.findall("tileset"):
|
|
159
|
+
firstgid = int(ts.attrib["firstgid"])
|
|
160
|
+
|
|
161
|
+
if "source" in ts.attrib:
|
|
162
|
+
tsx_name = _resolve_relative_map_path(tmx_name, ts.attrib["source"])
|
|
163
|
+
ts_root = _load_xml_from_maps(tsx_name)
|
|
164
|
+
tileset_base_name = tsx_name
|
|
165
|
+
else:
|
|
166
|
+
ts_root = ts
|
|
167
|
+
tileset_base_name = tmx_name
|
|
168
|
+
|
|
169
|
+
image = ts_root.find("image")
|
|
170
|
+
if image is None:
|
|
171
|
+
raise ValueError("Tileset is missing an <image> tag")
|
|
172
|
+
|
|
173
|
+
# For external TSX files, the image path is relative to the TSX file,
|
|
174
|
+
# not necessarily the TMX map that imports it.
|
|
175
|
+
image_name = _resolve_relative_map_path(tileset_base_name, image.attrib["source"])
|
|
176
|
+
tilesheet = loaders.mapimages.load(image_name)
|
|
177
|
+
|
|
178
|
+
tile_width = int(ts_root.attrib["tilewidth"])
|
|
179
|
+
tile_height = int(ts_root.attrib["tileheight"])
|
|
180
|
+
spacing = int(ts_root.attrib.get("spacing", 0))
|
|
181
|
+
margin = int(ts_root.attrib.get("margin", 0))
|
|
182
|
+
|
|
183
|
+
calculated_columns = _count_tiles_that_fit(
|
|
184
|
+
tilesheet.get_width(),
|
|
185
|
+
tile_width,
|
|
186
|
+
margin,
|
|
187
|
+
spacing,
|
|
188
|
+
)
|
|
189
|
+
calculated_rows = _count_tiles_that_fit(
|
|
190
|
+
tilesheet.get_height(),
|
|
191
|
+
tile_height,
|
|
192
|
+
margin,
|
|
193
|
+
spacing,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Tiled writes columns and tilecount into image-based TSX tilesets.
|
|
197
|
+
# Use values when present because they exactly describe how Tiled
|
|
198
|
+
# assigned local tile ids. Fall back to image-based calculation for
|
|
199
|
+
# older or hand-written files.
|
|
200
|
+
columns = int(ts_root.attrib.get("columns", calculated_columns))
|
|
201
|
+
if columns <= 0:
|
|
202
|
+
columns = calculated_columns
|
|
203
|
+
|
|
204
|
+
if columns <= 0:
|
|
205
|
+
raise ValueError("Tileset does not contain any columns of tiles")
|
|
206
|
+
|
|
207
|
+
tile_count = int(ts_root.attrib.get("tilecount", columns * calculated_rows))
|
|
208
|
+
rows = (tile_count + columns - 1) // columns
|
|
209
|
+
|
|
210
|
+
if tile_count <= 0:
|
|
211
|
+
raise ValueError("Tileset does not contain any tiles")
|
|
212
|
+
|
|
213
|
+
tilesets[firstgid] = {
|
|
214
|
+
"image": tilesheet,
|
|
215
|
+
"image_name": image_name,
|
|
216
|
+
"tile_width": tile_width,
|
|
217
|
+
"tile_height": tile_height,
|
|
218
|
+
"columns": columns,
|
|
219
|
+
"rows": rows,
|
|
220
|
+
"tile_count": tile_count,
|
|
221
|
+
# Keep these aliases so older student code that checks sheet_w or
|
|
222
|
+
# sheet_h does not immediately break.
|
|
223
|
+
"sheet_w": columns,
|
|
224
|
+
"sheet_h": rows,
|
|
225
|
+
"spacing": spacing,
|
|
226
|
+
"margin": margin,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return tilesets
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def load_tile_map_actors(tmx_name, scale=1):
|
|
233
|
+
"""
|
|
234
|
+
Load a Tiled TMX map into a dict of:
|
|
235
|
+
{layer_name: [Actor, Actor, ...]}
|
|
236
|
+
|
|
237
|
+
The TMX/TSX/image files are loaded through pgzero loaders from the maps folder.
|
|
238
|
+
"""
|
|
239
|
+
root = _load_xml_from_maps(tmx_name)
|
|
240
|
+
|
|
241
|
+
map_tile_width = int(root.attrib["tilewidth"])
|
|
242
|
+
map_tile_height = int(root.attrib["tileheight"])
|
|
243
|
+
|
|
244
|
+
tilesets = _load_tilesets(root, tmx_name)
|
|
245
|
+
|
|
246
|
+
layers_dict = {}
|
|
247
|
+
|
|
248
|
+
for layer in root.findall("layer"):
|
|
249
|
+
name = layer.attrib["name"]
|
|
250
|
+
width = int(layer.attrib["width"])
|
|
251
|
+
height = int(layer.attrib["height"])
|
|
252
|
+
data = layer.find("data")
|
|
253
|
+
|
|
254
|
+
if data is None or data.text is None:
|
|
255
|
+
layers_dict[name] = []
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
encoding = data.attrib.get("encoding")
|
|
259
|
+
if encoding != "csv":
|
|
260
|
+
raise ValueError(f"Layer '{name}' must use CSV encoding. Found: {encoding!r}")
|
|
261
|
+
|
|
262
|
+
gids = [int(v) for v in data.text.split(",") if v.strip()]
|
|
263
|
+
|
|
264
|
+
items = []
|
|
265
|
+
|
|
266
|
+
for row in range(height):
|
|
267
|
+
for col in range(width):
|
|
268
|
+
tile_gid = gids[row * width + col]
|
|
269
|
+
if tile_gid == 0:
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
flipped_h = bool(tile_gid & FLIP_H)
|
|
273
|
+
flipped_v = bool(tile_gid & FLIP_V)
|
|
274
|
+
flipped_d = bool(tile_gid & FLIP_D)
|
|
275
|
+
tile_gid &= GID_MASK
|
|
276
|
+
|
|
277
|
+
ts_firstgid = max(gid for gid in tilesets if gid <= tile_gid)
|
|
278
|
+
tileset = tilesets[ts_firstgid]
|
|
279
|
+
local_id = tile_gid - ts_firstgid
|
|
280
|
+
|
|
281
|
+
if local_id >= tileset["tile_count"]:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"Tile gid {tile_gid} points past the end of tileset " f"starting at firstgid {ts_firstgid}."
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
tile_col = local_id % tileset["columns"]
|
|
287
|
+
tile_row = local_id // tileset["columns"]
|
|
288
|
+
|
|
289
|
+
tile = Tile(
|
|
290
|
+
tileset=tileset,
|
|
291
|
+
tileset_firstgid=ts_firstgid,
|
|
292
|
+
tile_col=tile_col,
|
|
293
|
+
tile_row=tile_row,
|
|
294
|
+
map_col=col,
|
|
295
|
+
map_row=row,
|
|
296
|
+
map_tile_width=map_tile_width,
|
|
297
|
+
map_tile_height=map_tile_height,
|
|
298
|
+
scale=scale,
|
|
299
|
+
flip_h=flipped_h,
|
|
300
|
+
flip_v=flipped_v,
|
|
301
|
+
flip_d=flipped_d,
|
|
302
|
+
)
|
|
303
|
+
items.append(tile)
|
|
304
|
+
|
|
305
|
+
layers_dict[name] = items
|
|
306
|
+
|
|
307
|
+
return layers_dict
|
pgzero/tone.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Tone generator for Pygame Zero.
|
|
2
|
+
|
|
3
|
+
This tone generator is a wrapper for pyfxr, a custom Cython library for sound
|
|
4
|
+
generation. Tones are kept in a LRU cache which in typical applications
|
|
5
|
+
will reduce the number of times they need to be regenerated.
|
|
6
|
+
|
|
7
|
+
To minimise the extent that pauses affect gameplay, the ``play()`` function
|
|
8
|
+
offloads tone generation to a separate thread. Because tones are generated
|
|
9
|
+
with numpy operations this should allow at least part of this work to happen
|
|
10
|
+
on another CPU core, if present.
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from collections import namedtuple
|
|
15
|
+
from threading import Thread, Lock
|
|
16
|
+
from queue import Queue
|
|
17
|
+
from enum import Enum
|
|
18
|
+
|
|
19
|
+
import pygame
|
|
20
|
+
import pyfxr
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = (
|
|
24
|
+
'play',
|
|
25
|
+
'create',
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Longest note to allow
|
|
30
|
+
MAX_DURATION = 4
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Waveform(Enum):
|
|
34
|
+
SIN = pyfxr.Wavetable.sine()
|
|
35
|
+
SQUARE = pyfxr.Wavetable.square()
|
|
36
|
+
SAW = pyfxr.Wavetable.saw()
|
|
37
|
+
TRIANGLE = pyfxr.Wavetable.triangle()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ToneParams = namedtuple('ToneParams', 'hz duration waveform volume')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# lru_cache isn't threadsafe until Python 3.7, so protect it ourselves
|
|
44
|
+
# https://bugs.python.org/issue28969
|
|
45
|
+
cache_lock = Lock()
|
|
46
|
+
note_queue = Queue()
|
|
47
|
+
player_thread = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _play_thread():
|
|
51
|
+
"""Play any notes requested by the game thread.
|
|
52
|
+
|
|
53
|
+
Multithreading is useful because numpy releases the GIL while performing
|
|
54
|
+
many C operations.
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
while True:
|
|
58
|
+
params = note_queue.get()
|
|
59
|
+
with cache_lock:
|
|
60
|
+
note = _create(params)
|
|
61
|
+
note.play()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def create(*args, **kwargs):
|
|
65
|
+
"""Create a tone of a given duration at the given pitch.
|
|
66
|
+
|
|
67
|
+
Return a Sound which can be played later.
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
params = _convert_args(*args, **kwargs)
|
|
71
|
+
with cache_lock:
|
|
72
|
+
return _create(params)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@lru_cache()
|
|
76
|
+
def _create(params):
|
|
77
|
+
"""Actually create a tone."""
|
|
78
|
+
# Construct a mono tone of the right length
|
|
79
|
+
tone = pyfxr.tone(
|
|
80
|
+
pitch=params.hz,
|
|
81
|
+
sustain=max(0, params.duration - 0.2),
|
|
82
|
+
wavetable=params.waveform.value,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# NB. pygame assumes that the sound format of any buffer object matches
|
|
86
|
+
# that of the current mixer settings. We use mixer.pre_init(22050, -16, 2)
|
|
87
|
+
# which means that it is expecting 22kHz audio stereo, but we're feeding it
|
|
88
|
+
# 44kHz mono - but, perhaps surprisingly, that works Ok. The extra samples
|
|
89
|
+
# get interpreted as the second channel.
|
|
90
|
+
#
|
|
91
|
+
# If we change the mixer to 44kHz we'd need to convert to stereo here by
|
|
92
|
+
# doubling samples.
|
|
93
|
+
#
|
|
94
|
+
# Really this is a mess and Pygame should support converting the format
|
|
95
|
+
# of buffers (as it does for .wav files).
|
|
96
|
+
snd = pygame.mixer.Sound(buffer=tone)
|
|
97
|
+
snd.set_volume(params.volume)
|
|
98
|
+
return snd
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _convert_args(pitch, duration, *, waveform=Waveform.SIN, volume=0.8):
|
|
102
|
+
"""Convert the given arguments to _create parameters."""
|
|
103
|
+
if duration > MAX_DURATION:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
'Note duration %ss is too long: notes may be at most %ss long' %
|
|
106
|
+
(duration, MAX_DURATION)
|
|
107
|
+
)
|
|
108
|
+
if not duration:
|
|
109
|
+
raise ValueError("Note has zero duration")
|
|
110
|
+
if isinstance(pitch, str):
|
|
111
|
+
pitch = pyfxr.note_to_hertz(pitch)
|
|
112
|
+
return ToneParams(pitch, duration, Waveform(waveform), volume)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def play(*args, **kwargs):
|
|
116
|
+
"""Plays a tone of a certain length from a note or frequency in hertz.
|
|
117
|
+
|
|
118
|
+
Tones have a maximum duration of 4 seconds. This limitation is imposed to
|
|
119
|
+
avoid accidentally creating sounds that take too long to generate and
|
|
120
|
+
require a lot of memory.
|
|
121
|
+
|
|
122
|
+
To work around this, create the sounds you want to use up-front with
|
|
123
|
+
create() and hold onto them, perhaps in an array.
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
global player_thread
|
|
127
|
+
params = _convert_args(*args, **kwargs)
|
|
128
|
+
if not player_thread or not player_thread.is_alive():
|
|
129
|
+
pygame.mixer.init()
|
|
130
|
+
player_thread = Thread(target=_play_thread, daemon=True)
|
|
131
|
+
player_thread.start()
|
|
132
|
+
note_queue.put(params)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pgzero-plus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An enhanced fork of Pygame Zero with additional features for teaching and developing 2D games
|
|
5
|
+
Author-email: Michael D'Argenio <mjdargen@gmail.com>
|
|
6
|
+
License-Expression: LGPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/mjdargen/pgzero
|
|
8
|
+
Project-URL: Documentation, https://pygame-zero.readthedocs.io/
|
|
9
|
+
Project-URL: Source, https://github.com/mjdargen/pgzero
|
|
10
|
+
Project-URL: Issues, https://github.com/mjdargen/pgzero/issues
|
|
11
|
+
Keywords: pygame,pgzero,games,education
|
|
12
|
+
Classifier: Intended Audience :: Education
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Education
|
|
15
|
+
Classifier: Topic :: Games/Entertainment
|
|
16
|
+
Requires-Python: <3.14,>=3.6
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: COPYING
|
|
19
|
+
Requires-Dist: pygame>=2.6.1
|
|
20
|
+
Requires-Dist: numpy
|
|
21
|
+
Requires-Dist: pyfxr>=0.3.0
|
|
22
|
+
Requires-Dist: importlib-metadata==4.8.3; python_version < "3.8"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Pygame Zero Plus
|
|
26
|
+
|
|
27
|
+
**Pygame Zero Plus** (`pgzero-plus`) is an independent fork of [Pygame Zero](https://github.com/lordmauve/pgzero), a beginner-friendly Python game development framework that reduces boilerplate code.
|
|
28
|
+
|
|
29
|
+
This fork adds features and quality-of-life improvements for introductory computer science and game design courses while retaining the familiar Pygame Zero API, including `Actor`, `screen`, and `pgzrun`.
|
|
30
|
+
|
|
31
|
+
## What's New
|
|
32
|
+
|
|
33
|
+
- **Enhanced Actors:** Scaling, flipping, opacity, and customizable collision rectangles.
|
|
34
|
+
- **Sprite Animations:** `Sprite` and `SpriteActor` classes for animated sprite sheets.
|
|
35
|
+
- **Tilemaps:** Loading and rendering maps created with [Tiled Map Editor](https://www.mapeditor.org/), including tilesets and tile transformations.
|
|
36
|
+
- **Game Management:** A `Game` object for tracking scores, levels, game states, and restarting games.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
The PyPI distribution is named `pgzero-plus`, but it provides the same `pgzero` Python package and `pgzrun` command as the original Pygame Zero. **Do not install both distributions in the same Python environment.**
|
|
41
|
+
|
|
42
|
+
### Install or Update
|
|
43
|
+
|
|
44
|
+
Once published on PyPI:
|
|
45
|
+
|
|
46
|
+
**Windows:**
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python -m pip install --upgrade pgzero-plus
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**macOS / Linux:**
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python3 -m pip install --upgrade pgzero-plus
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Switching from Pygame Zero or an Older Fork
|
|
59
|
+
|
|
60
|
+
If you previously installed `pgzero` or `pgzero-mjd`, uninstall any overlapping distributions before installing Pygame Zero Plus. This also helps repair environments where multiple forks have been installed together.
|
|
61
|
+
|
|
62
|
+
**Windows:**
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python -m pip uninstall -y pgzero pgzero-mjd pgzero-plus
|
|
66
|
+
python -m pip install pgzero-plus
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**macOS / Linux:**
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python3 -m pip uninstall -y pgzero pgzero-mjd pgzero-plus
|
|
73
|
+
python3 -m pip install pgzero-plus
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`--force-reinstall` does **not** remove a differently named distribution, so it is not a substitute for uninstalling the original `pgzero` package.
|
|
77
|
+
|
|
78
|
+
### Running Games
|
|
79
|
+
|
|
80
|
+
Pygame Zero Plus supports running games as regular Python scripts, including with the Run button in VS Code.
|
|
81
|
+
|
|
82
|
+
Start your program with:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
import pgzrun
|
|
86
|
+
from pgzero.builtins import *
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The explicit `pgzero.builtins` import helps editors such as VS Code (Pylance) recognize names like `Actor` and `screen`.
|
|
90
|
+
|
|
91
|
+
Define your variables and functions, such as `start()`, `draw()`, and `update()`, then end the program with:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
pgzrun.go()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Run the script through your editor or with:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
python game.py
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Alternatively, use the traditional Pygame Zero command:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pgzrun game.py
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
Clone the repository:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
git clone https://github.com/mjdargen/pgzero.git
|
|
115
|
+
cd pgzero
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Install the fork in editable mode **and** install all development tools with one command:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
python -m pip install -r requirements-dev.txt
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`requirements-dev.txt` includes `-e .`, which installs the local package and its runtime dependencies. Source changes are available immediately without rebuilding the wheel. If the original `pgzero` is installed in this environment, uninstall it first as described above.
|
|
125
|
+
|
|
126
|
+
### Pre-commit Checks
|
|
127
|
+
|
|
128
|
+
[Pre-commit](https://pre-commit.com/) checks whitespace, file endings, YAML/TOML syntax, merge conflict markers, and accidentally added large files.
|
|
129
|
+
|
|
130
|
+
Activate it **once per clone**, after installing the development dependencies:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
python -m pre_commit install
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
It then runs automatically on staged files before each `git commit`. If a hook modifies a file, review it with `git diff`, stage the fix with `git add`, and retry the commit.
|
|
137
|
+
|
|
138
|
+
To check all tracked files manually at any time:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
python -m pre_commit run --all-files
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Using `python -m pre_commit` avoids Windows PATH issues where the `pre-commit` command is not recognized.
|
|
145
|
+
|
|
146
|
+
### Testing
|
|
147
|
+
|
|
148
|
+
Run all tests from the repository root:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
python -m pytest
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
For detailed output or one test file:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
python -m pytest -v
|
|
158
|
+
python -m pytest test/test_actor.py
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Documentation
|
|
162
|
+
|
|
163
|
+
Documentation is written in [Sphinx](https://www.sphinx-doc.org/) using the `.rst` files in `doc/`. Update these pages when features or behavior change, and add new pages to `doc/index.rst` so they appear in the navigation.
|
|
164
|
+
|
|
165
|
+
Build and preview locally from the repository root:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
python -m sphinx -b html doc doc/_build/html
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
On Windows (PowerShell), open the generated documentation with:
|
|
172
|
+
|
|
173
|
+
```powershell
|
|
174
|
+
start doc/_build/html/index.html
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
On macOS or Linux, open that HTML file in your browser. The Sphinx configuration lives in `doc/conf.py`.
|
|
178
|
+
|
|
179
|
+
Once this repository is connected to [Read the Docs](https://readthedocs.org/), its `.readthedocs.yaml` configuration allows the hosted documentation to rebuild automatically after changes are pushed. GitHub Actions verifies that the documentation builds; Read the Docs hosts and publishes the website.
|
|
180
|
+
|
|
181
|
+
### Automated Checks on GitHub
|
|
182
|
+
|
|
183
|
+
The [Code Checks workflow](https://github.com/mjdargen/pgzero/actions) (`.github/workflows/checks.yml`) runs automatically on pushes to `main` and on pull requests. It runs pre-commit, pytest, Sphinx, the package build, and distribution validation.
|
|
184
|
+
|
|
185
|
+
These checks run **after** a push, so a failing check does not undo the push. Check the Actions page for results and fix any failures before publishing a release.
|
|
186
|
+
|
|
187
|
+
## Building and Verification
|
|
188
|
+
|
|
189
|
+
The automated release workflow builds distributions for publishing, so **manual builds are optional** during everyday development. To check a release locally, run:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
python -m pre_commit run --all-files
|
|
193
|
+
python -m pytest
|
|
194
|
+
python -m sphinx -b html doc doc/_build/html
|
|
195
|
+
python -m build
|
|
196
|
+
python -m twine check --strict dist/*
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The wheel (`.whl`) and source archive (`.tar.gz`) are written to `dist/`. Clear out older distributions from `dist/` before building a new version so you do not accidentally inspect or upload old files.
|
|
200
|
+
|
|
201
|
+
### Verify the Built Wheel
|
|
202
|
+
|
|
203
|
+
Install the built wheel in a **fresh virtual environment** to check the distribution itself instead of your editable development installation. The example filename below is for version `0.1.0`; update it when the version changes.
|
|
204
|
+
|
|
205
|
+
**Windows (PowerShell):**
|
|
206
|
+
|
|
207
|
+
```powershell
|
|
208
|
+
python -m venv .venv-wheel-check
|
|
209
|
+
.\.venv-wheel-check\Scripts\python.exe -m pip install dist/pgzero_plus-0.1.0-py3-none-any.whl
|
|
210
|
+
.\.venv-wheel-check\Scripts\python.exe -m pip check
|
|
211
|
+
.\.venv-wheel-check\Scripts\python.exe -c "import pgzero, pgzrun; print(pgzero.__file__)"
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**macOS / Linux:**
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
python3 -m venv .venv-wheel-check
|
|
218
|
+
.venv-wheel-check/bin/python -m pip install dist/pgzero_plus-0.1.0-py3-none-any.whl
|
|
219
|
+
.venv-wheel-check/bin/python -m pip check
|
|
220
|
+
.venv-wheel-check/bin/python -c "import pgzero, pgzrun; print(pgzero.__file__)"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Python 3.6 compatibility:** This fork targets Python 3.6 through 3.13, including older Codio environments. The modern build backend does not support building from source on Python 3.6. Before a release, install the **built wheel** in Codio and run a representative game there; passing tests on Python 3.13 does not establish compatibility with Python 3.6.
|
|
224
|
+
|
|
225
|
+
## Publishing Releases
|
|
226
|
+
|
|
227
|
+
GitHub Actions handles publishing through `.github/workflows/release.yml`. A new package is **not** published on every push to `main`; publishing starts only when you push a version tag.
|
|
228
|
+
|
|
229
|
+
### One-time PyPI Setup
|
|
230
|
+
|
|
231
|
+
1. Create a GitHub repository environment named `pypi` under **Settings → Environments**.
|
|
232
|
+
2. Configure [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) for project `pgzero-plus`, GitHub owner `mjdargen`, repository `pgzero`, workflow `release.yml`, and environment `pypi`. For a first release, use a _pending trusted publisher_ if the project has not yet been created on PyPI.
|
|
233
|
+
|
|
234
|
+
This allows GitHub Actions to publish without storing a PyPI API token.
|
|
235
|
+
|
|
236
|
+
### Create a Release
|
|
237
|
+
|
|
238
|
+
1. Set the new `version` in `pyproject.toml`. For example, `version = "0.1.0"`.
|
|
239
|
+
2. Commit and push your changes to `main`, then confirm that **Code Checks** passes on the [Actions page](https://github.com/mjdargen/pgzero/actions).
|
|
240
|
+
3. Create and push a Git tag **matching** that version, with a leading `v`:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
git switch main
|
|
244
|
+
git pull
|
|
245
|
+
git tag -a v0.1.0 -m "Release v0.1.0"
|
|
246
|
+
git push origin v0.1.0
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The release workflow verifies the version, runs the checks, builds the wheel and source archive, publishes to PyPI, and creates a GitHub Release with the built files attached. If a check or build fails, publishing stops.
|
|
250
|
+
|
|
251
|
+
For a later release, update the version number and use its matching tag (for example, `0.1.1` and `v0.1.1`). Use patch releases for bug fixes and minor releases for new features. **PyPI does not allow replacing files for an existing published version.**
|
|
252
|
+
|
|
253
|
+
## Credits & License
|
|
254
|
+
|
|
255
|
+
This project is an independent fork of [Pygame Zero](https://github.com/lordmauve/pgzero), originally developed by Daniel Pope and contributors.
|
|
256
|
+
|
|
257
|
+
Original documentation: https://pygame-zero.readthedocs.io/
|
|
258
|
+
|
|
259
|
+
Licensed under the GNU Lesser General Public License v3.0 or later (LGPL-3.0-or-later).
|