Skip to content

Commit 774d9fa

Browse files
committed
Merge branch 'dev' of https://github.com/Natuworkguy/ABS-Engine into dev
2 parents 2c760d7 + a547ef7 commit 774d9fa

9 files changed

Lines changed: 192 additions & 20 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: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,58 @@ 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

14-
To package the game into a single executable file,
15-
use a tool like **Pyinstaller**. The command should look like this:
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+
27+
## Git Ignore Recommendations
28+
29+
Add the following generated build outputs to your project's
30+
`.gitignore` file:
31+
32+
```gitignore
33+
data/images/abs_* # Remove if using ABS Engine's logo or other assets
34+
engine/
35+
launch_game.py
36+
run.py
37+
build/
38+
dist/
39+
*.spec
40+
```
41+
42+
These files and directories may change in future ABS Engine updates.
43+
They should stay out of source control and only be included in
44+
compiled production executables.
45+
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:
1652

1753
```bash
1854
pyinstaller --onefile --noconsole --name MyGame --add-data "game.absp:." --add-data "scripts:scripts" --add-data "data:data" run.py
1955
```
2056

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+
2162
Pyinstaller adds `engine/` without any additional flags because
2263
it is directly imported.
2364
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 & 2 deletions
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)
@@ -595,7 +599,6 @@ def run(self, fps: int = 60) -> None:
595599
self.step(dt)
596600

597601
pygame.quit()
598-
sys.exit(0)
599602

600603
def quit(self) -> None:
601604
"""

engine/gui/__init__.py

Lines changed: 73 additions & 8 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())
@@ -176,7 +181,7 @@ def load_theme(self) -> None:
176181

177182
def build_game(self) -> None:
178183
do_build = messagebox.askyesno(
179-
"Build Tools | ABS Engine",
184+
"Build Tools",
180185
"This will build to the folder containing the .absp project file. Do you want to continue?",
181186
)
182187

@@ -185,16 +190,76 @@ 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", "The build has been completed.")
251+
else:
252+
logger("Build Tools: Build failed", status=LoggerStatus.WARNING)
253+
messagebox.showerror(
254+
"Build Tools",
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)
197-
self.game_settings_popup.wm_title("Game Settings | ABS Engine")
262+
self.game_settings_popup.wm_title("Game Settings")
198263
self.game_settings_popup.resizable(False, False)
199264

200265
self.game_settings_dimensions_section = ttk.LabelFrame(
@@ -331,7 +396,7 @@ def view_entity(self, entity_list: tk.Listbox) -> None:
331396
selected_item = entity_list.get(entity_list.curselection()[0]) # type: ignore[no-untyped-call]
332397

333398
self.view_popup = tk.Toplevel(self.root)
334-
self.view_popup.wm_title("Entity Data | ABS Engine")
399+
self.view_popup.wm_title("Entity Data")
335400
self.view_popup.resizable(False, False)
336401

337402
fields = {

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:

engine/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.3.6"
1+
__version__ = "0.3.7"

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)