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/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).