Skip to content

Commit ee1a191

Browse files
committed
Enhance build process and documentation
1 parent f5694e5 commit ee1a191

8 files changed

Lines changed: 172 additions & 15 deletions

File tree

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[flake8]
2-
ignore = E501
2+
ignore = E501, W503
33
exclude =
44
.venv

docs/using_build_tools.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,23 @@ the game's root folder.
77
Click "Build Game", then "Yes".
88

99
ABS Engine will now compile the game and all of its dependencies
10-
to the folder that contains the project file.
10+
to the folder that contains the project file, using Pyinstaller
11+
under the hood. A "Building Game" window stays open with a progress
12+
bar while this happens, and only closes once Pyinstaller has
13+
actually finished.
14+
1115
When you run the new `run.py` file in that folder,
1216
ABS Engine will emulate its original environment.
1317

18+
The finished build is written to a `dist/<ProjectName>/` folder
19+
next to your project file (spaces in the project name are replaced
20+
with hyphens), containing the game's executable alongside an
21+
`_internal/` folder with its bundled dependencies. Pyinstaller's
22+
intermediate files are written to a `build/` folder and a generated
23+
`<ProjectName>.spec` file, also next to your project file.
24+
Building again automatically replaces a previous `dist/<ProjectName>/`
25+
folder, even if it still exists from an earlier build.
26+
1427
## Git Ignore Recommendations
1528

1629
Add the following generated build outputs to your project's
@@ -21,19 +34,31 @@ data/images/abs_* # Remove if using ABS Engine's logo or other assets
2134
engine/
2235
launch_game.py
2336
run.py
37+
build/
38+
dist/
39+
*.spec
2440
```
2541

2642
These files and directories may change in future ABS Engine updates.
2743
They should stay out of source control and only be included in
2844
compiled production executables.
2945

30-
To package the game into a single executable file,
31-
use a tool like **Pyinstaller**. The command should look like this:
46+
## Building Manually
47+
48+
Build Game already runs Pyinstaller for you, but if you'd rather
49+
package the game yourself, for example to produce a single
50+
executable file instead of a folder, you can invoke Pyinstaller
51+
directly:
3252

3353
```bash
3454
pyinstaller --onefile --noconsole --name MyGame --add-data "game.absp:." --add-data "scripts:scripts" --add-data "data:data" run.py
3555
```
3656

57+
>[!NOTE]
58+
> On Windows, use a semicolon (`;`) instead of a colon between the
59+
> source and destination of each `--add-data` flag, since Windows
60+
> paths already use a colon after the drive letter (e.g. `C:\`).
61+
3762
Pyinstaller adds `engine/` without any additional flags because
3863
it is directly imported.
3964
Use `--icon <file>` to add an icon.

engine/build_tools.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,78 @@
55
Build and development utilities for the engine.
66
"""
77

8+
import os
89
import shutil
10+
import stat
11+
import time
12+
from PyInstaller.__main__ import run as pyinstaller
13+
from multiprocessing import Process
914

1015
from tkinter import messagebox
1116
from pathlib import Path
17+
from typing import Optional
1218

1319
from .saveload import resource_path
1420
from .logger import logger, Status
1521

1622
engine_path = Path(__file__).parent
1723

1824

19-
def build(directory: Path, ENGINE_DATA_PATH: str) -> None:
25+
def _clear_readonly(func, path, exc: BaseException) -> None:
26+
os.chmod(path, stat.S_IWRITE)
27+
func(path)
28+
29+
30+
def _remove_previous_build(path: Path, retries: int = 5, delay: float = 0.5) -> None:
31+
if not path.exists():
32+
return
33+
34+
for attempt in range(retries):
35+
try:
36+
shutil.rmtree(path, onexc=_clear_readonly) # ty: ignore[unknown-argument]
37+
return
38+
except PermissionError:
39+
if attempt == retries - 1:
40+
raise
41+
time.sleep(delay)
42+
43+
44+
def _build_pyinstaller(name: str, directory: Path) -> None:
45+
_remove_previous_build(directory / "dist" / name)
46+
47+
pyi_args = [
48+
"--onedir",
49+
"--noconsole",
50+
"--noconfirm",
51+
"--name", name,
52+
"--distpath", str(directory / "dist"),
53+
"--workpath", str(directory / "build"),
54+
"--specpath", str(directory),
55+
f"--add-data={directory / 'game.absp'!s}{os.pathsep}.",
56+
]
57+
58+
if (directory / "scripts").exists():
59+
pyi_args.append(f"--add-data={directory / 'scripts'!s}{os.pathsep}scripts")
60+
61+
pyi_args.append(f"--add-data={directory / 'data'!s}{os.pathsep}data")
62+
pyi_args.append(str(directory / "run.py"))
63+
64+
pyinstaller(pyi_args=pyi_args)
65+
66+
67+
def build(name: str, directory: Path, ENGINE_DATA_PATH: str) -> Optional[Process]:
2068
"""
2169
Build the game
2270
2371
Args:
72+
name (str): The project's name
2473
directory (Path): Path to build the game to
2574
ENGINE_DATA_PATH (str): Path of the data directory
75+
76+
Returns:
77+
Optional[Process]: The process running the PyInstaller build, or None if
78+
the build could not be started. Callers can poll `process.is_alive()` to
79+
know when the build has actually finished.
2680
"""
2781

2882
if not directory.exists():
@@ -33,7 +87,7 @@ def build(directory: Path, ENGINE_DATA_PATH: str) -> None:
3387
"Build Error",
3488
f'Build directory "{str(directory.resolve())}" does not exist. Save the project to a valid location and try again.',
3589
)
36-
return
90+
return None
3791

3892
launch_game_script = Path(resource_path("data/scripts/launch_game.py")).read_text(
3993
encoding="utf-8"
@@ -44,3 +98,10 @@ def build(directory: Path, ENGINE_DATA_PATH: str) -> None:
4498

4599
shutil.copytree(engine_path, directory / "engine", dirs_exist_ok=True, ignore=ignore)
46100
shutil.copytree(Path(ENGINE_DATA_PATH), directory / "data", dirs_exist_ok=True, ignore=ignore)
101+
102+
name = name.replace(" ", "-")
103+
104+
process = Process(target=_build_pyinstaller, args=(name, directory,))
105+
process.start()
106+
107+
return process

engine/core/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,11 @@ def __init__(
440440
self.screen: pygame.Surface = pygame.display.set_mode(self.wsize, display_flags)
441441
pygame.display.set_caption(title)
442442

443-
if not IS_EDITOR and sys.stdout.isatty():
443+
if (
444+
not IS_EDITOR
445+
and sys.stdout is not None
446+
and sys.stdout.isatty()
447+
):
444448
print(colorama.ansi.set_title(title), end="")
445449

446450
self.set_icon(icon_path)

engine/gui/__init__.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ def __init__(self) -> None:
6363
self.root.geometry("530x700")
6464
self.load_theme()
6565

66+
try:
67+
ttk.Style(self.root).theme_use("vista")
68+
except TclError:
69+
pass
70+
6671
self.root.bind("<Control-Shift-S>", lambda *args: self.save_project_as())
6772
self.root.bind("<Control-s>", lambda *args: self.save_project())
6873
self.root.bind("<Control-o>", lambda *args: self.load_project())
@@ -185,12 +190,72 @@ def build_game(self) -> None:
185190

186191
logger("Build Tools: Starting build")
187192

188-
build(Path(GP_BASE_PATH), ENGINE_DATA_PATH=ENGINE_DATA_PATH)
193+
process = build(
194+
name=self.project_name_input.get(),
195+
directory=Path(GP_BASE_PATH),
196+
ENGINE_DATA_PATH=ENGINE_DATA_PATH,
197+
)
198+
199+
if process is None:
200+
return
201+
202+
progress_popup = tk.Toplevel(self.root)
203+
progress_popup.wm_title("Building Game")
204+
progress_popup.resizable(False, False)
205+
progress_popup.protocol("WM_DELETE_WINDOW", lambda: None)
206+
progress_popup.transient(self.root)
207+
208+
progress_content = ttk.Frame(progress_popup, padding=(24, 20))
209+
progress_content.pack(fill="both", expand=True)
210+
211+
ttk.Label(
212+
progress_content, text="Building Game", font=("Segoe UI", 12, "bold")
213+
).pack(anchor="w")
214+
215+
ttk.Label(
216+
progress_content,
217+
text="This may take a few moments, please wait...",
218+
foreground="#666666",
219+
).pack(anchor="w", pady=(2, 14))
220+
221+
progress_bar = ttk.Progressbar(progress_content, mode="indeterminate", length=300)
222+
progress_bar.pack(fill="x")
223+
progress_bar.start(10)
224+
225+
ttk.Label(
226+
progress_content,
227+
text="See the console for detailed logs.",
228+
foreground="#999999",
229+
font=("Segoe UI", 8),
230+
).pack(anchor="w", pady=(12, 0))
231+
232+
progress_popup.update_idletasks()
233+
popup_x = self.root.winfo_x() + (self.root.winfo_width() - progress_popup.winfo_width()) // 2
234+
popup_y = self.root.winfo_y() + (self.root.winfo_height() - progress_popup.winfo_height()) // 2
235+
progress_popup.geometry(f"+{popup_x}+{popup_y}")
236+
237+
progress_popup.grab_set()
238+
239+
def poll_build() -> None:
240+
if process.is_alive():
241+
self.root.after(200, poll_build)
242+
return
243+
244+
progress_bar.stop()
245+
progress_popup.grab_release()
246+
progress_popup.destroy()
247+
248+
if process.exitcode == 0:
249+
logger("Build Tools: Build completed")
250+
messagebox.showinfo("Build Tools | ABS Engine", "The build has been completed.")
251+
else:
252+
logger("Build Tools: Build failed", status=LoggerStatus.WARNING)
253+
messagebox.showerror(
254+
"Build Tools | ABS Engine",
255+
"The build failed. Check the console/log output for details.",
256+
)
189257

190-
logger("Build Tools: Waiting for root")
191-
self.root.after(3000, lambda: None)
192-
logger("Build Tools: Build completed")
193-
messagebox.showinfo("Build Tools | ABS Engine", "The build has been completed.")
258+
self.root.after(200, poll_build)
194259

195260
def game_settings(self) -> None:
196261
self.game_settings_popup = tk.Toplevel(self.root, height=150)

engine/logger.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def logger(message: str, *, status: Status = Status.INFO) -> None:
5353
"""
5454

5555
source = _get_caller_module().upper()
56-
is_tty: bool = sys.stdout.isatty()
56+
is_tty: bool = sys.stdout is not None and sys.stdout.isatty()
5757

5858
if is_tty:
5959
if status == Status.CRITICAL:

requirements-dev.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ interrogate
88
ty
99

1010
# Typeshed packages
11-
types-colorama
11+
types-colorama
12+
types-pyinstaller

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pygame-ce
2-
colorama
2+
colorama
3+
pyinstaller

0 commit comments

Comments
 (0)