diff --git a/.gitignore b/.gitignore index d532c55..da1e380 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ Cargo.lock.bak www/dungeoncraft.wasm www/mq_js_bundle.js play/ +dist/ diff --git a/INSTALL.md b/INSTALL.md index 079cd84..19fb988 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -53,6 +53,17 @@ data folder: `~/.local/share/dungeoncraft/` (Linux, or `$XDG_DATA_HOME/dungeoncr *Save Game* and saved campaigns land in its `saves/` folder; *Load Game* opens the system file dialog there (on Linux through the desktop portal), and dropping a file onto the window works as well. +Release packages are built by `python3 tools/build_desktop.py [linux] [windows] [macos]` into `dist/`: + +- **Linux**: `DungeonCraft--x86_64.AppImage`, built against glibc 2.28 (distributions from about 2019 on). +- **Windows**: `DungeonCraft--windows-x64.exe`, a single file for Windows 10 and newer, cross-compiled on Linux. +- **macOS**: `DungeonCraft--macos.zip` with a universal `DungeonCraft.app` (Apple silicon and Intel); only on a + Mac. It is signed ad hoc, not notarised: on first start open it with right click -> *Open*. + +Linux and Windows need [zig](https://ziglang.org/) (`mise install zig@0.15` works) and +`cargo install --locked cargo-zigbuild`, plus `rustup target add x86_64-pc-windows-gnu`; the AppImage tool is downloaded +once into `~/.cache/dungeoncraft/`. + ### Testing the game logic The tests run natively: a headless simulation of whole games, a savegame round trip, rendering of all music modules and the audio engine: diff --git a/src/main.rs b/src/main.rs index 6bdb8a3..f42ae6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,6 @@ // DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM). +// Windows release builds are a window program: no console opens beside the game +#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] mod achievements; mod ai; diff --git a/tools/build_desktop.py b/tools/build_desktop.py new file mode 100644 index 0000000..19bd957 --- /dev/null +++ b/tools/build_desktop.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Build the desktop versions of DungeonCraft into dist/. + + python3 tools/build_desktop.py # everything this machine can build + python3 tools/build_desktop.py linux windows # only these + +- linux: dist/DungeonCraft--x86_64.AppImage + built with cargo-zigbuild against glibc 2.28, so it runs on distributions from about 2019 on; + needs on the build machine: zig, cargo-zigbuild, the ALSA headers (alsa-lib / libasound2-dev); + appimagetool is downloaded once into ~/.cache/dungeoncraft/ +- windows: dist/DungeonCraft--windows-x64.exe (one file, Windows 10 and newer) + cross-compiled with cargo-zigbuild for x86_64-pc-windows-gnu +- macos: dist/DungeonCraft--macos.zip (DungeonCraft.app, Apple silicon + Intel) + only on a Mac (Apple's SDK is needed); the app is signed ad hoc, not notarised + +Everything the game needs (graphics, music, languages) is inside the program; nothing else is packed. +The tools: `cargo install --locked cargo-zigbuild`, zig from the package manager or `mise install zig@0.15` +(found on PATH, in $CARGO_ZIGBUILD_ZIG_PATH or in mise's install folder), and the Rust targets +(`rustup target add ...`, the script tells which). +""" +import os +import pathlib +import platform +import shutil +import stat +import subprocess +import sys +import urllib.request + +ROOT = pathlib.Path(__file__).resolve().parent.parent +DIST = ROOT / "dist" +CACHE = pathlib.Path(os.environ.get("XDG_CACHE_HOME") or pathlib.Path.home() / ".cache") / "dungeoncraft" +ICON = ROOT / "www" / "icons" / "icon-512.png" +GLIBC = "2.28" +APPIMAGETOOL_URL = "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" + +DESKTOP_ENTRY = """[Desktop Entry] +Type=Application +Name=DungeonCraft +Comment=Dig. Build. Defend. Conquer. +Exec=dungeoncraft +Icon=dungeoncraft +Categories=Game;StrategyGame; +Terminal=false +""" + +INFO_PLIST = """ + + + + CFBundleNameDungeonCraft + CFBundleDisplayNameDungeonCraft + CFBundleIdentifierorg.familie-zink.dungeoncraft + CFBundleExecutableDungeonCraft + CFBundleIconFileDungeonCraft + CFBundlePackageTypeAPPL + CFBundleShortVersionString{short} + CFBundleVersion{version} + LSMinimumSystemVersion11.0 + LSApplicationCategoryTypepublic.app-category.strategy-games + NSHighResolutionCapable + + +""" + + +class BuildError(Exception): + pass + + +def log(msg): + print(f"==> {msg}", flush=True) + + +def run(cmd, env=None, cwd=ROOT): + print(" $", " ".join(str(c) for c in cmd), flush=True) + r = subprocess.run([str(c) for c in cmd], cwd=cwd, env=env) + if r.returncode != 0: + raise BuildError(f"{pathlib.Path(str(cmd[0])).name} failed (exit {r.returncode})") + + +def version(): + return (ROOT / "VERSION").read_text(encoding="utf-8").strip() + + +def cargo(): + c = shutil.which("cargo") or str(pathlib.Path.home() / ".cargo" / "bin" / "cargo") + if not pathlib.Path(c).exists(): + raise BuildError("cargo not found: install Rust with rustup") + return c + + +def tool_env(): + env = dict(os.environ) + env["PATH"] = str(pathlib.Path(cargo()).parent) + os.pathsep + env.get("PATH", "") + return env + + +def need_target(triple): + rustup = shutil.which("rustup", path=tool_env()["PATH"]) + if not rustup: + return + have = subprocess.run([rustup, "target", "list", "--installed"], capture_output=True, text=True).stdout.split() + if triple not in have: + raise BuildError(f"Rust target missing: rustup target add {triple}") + + +def zig_env(): + env = tool_env() + if not shutil.which("cargo-zigbuild", path=env["PATH"]): + raise BuildError("cargo-zigbuild missing: cargo install --locked cargo-zigbuild") + installs = pathlib.Path.home() / ".local" / "share" / "mise" / "installs" / "zig" + from_mise = sorted(installs.glob("*/zig"), key=lambda p: [int(x) if x.isdigit() else 0 for x in p.parent.name.split(".")]) + candidates = [env.get("CARGO_ZIGBUILD_ZIG_PATH"), shutil.which("zig", path=env["PATH"])] + [str(p) for p in reversed(from_mise)] + + def works(z): # a mise shim without an active version is on PATH but does not run + try: + return subprocess.run([z, "version"], capture_output=True, timeout=30).returncode == 0 + except OSError: + return False + + zig = next((z for z in candidates if z and works(z)), None) + if not zig: + raise BuildError("zig not found: install it (package manager, or `mise install zig@0.15`)") + env["CARGO_ZIGBUILD_ZIG_PATH"] = zig + return env + + +# ------------------------------------------------------------------ Linux: AppImage + +def appimagetool(): + tool = CACHE / "appimagetool-x86_64.AppImage" + if not tool.exists(): + log(f"downloading appimagetool to {tool}") + CACHE.mkdir(parents=True, exist_ok=True) + tmp = tool.with_suffix(".part") + with urllib.request.urlopen(APPIMAGETOOL_URL, timeout=120) as r, open(tmp, "wb") as f: + shutil.copyfileobj(r, f) + tmp.rename(tool) + tool.chmod(tool.stat().st_mode | stat.S_IXUSR) + return tool + + +def build_linux(ver): + if platform.system() != "Linux" or platform.machine() not in ("x86_64", "AMD64"): + raise BuildError("the AppImage is built on x86_64 Linux") + need_target("x86_64-unknown-linux-gnu") + env = zig_env() + # libasound is only linked by name: its own glibc references are the user's system's business + env["RUSTFLAGS"] = (env.get("RUSTFLAGS", "") + " -C link-arg=-Wl,--allow-shlib-undefined").strip() + log(f"Linux build (glibc {GLIBC})") + run([cargo(), "zigbuild", "--release", "--target", f"x86_64-unknown-linux-gnu.{GLIBC}"], env=env) + binary = ROOT / "target" / "x86_64-unknown-linux-gnu" / "release" / "dungeoncraft" + + appdir = ROOT / "target" / "AppDir" + shutil.rmtree(appdir, ignore_errors=True) + (appdir / "usr" / "bin").mkdir(parents=True) + shutil.copy2(binary, appdir / "usr" / "bin" / "dungeoncraft") + (appdir / "dungeoncraft.desktop").write_text(DESKTOP_ENTRY, encoding="utf-8") + shutil.copy2(ICON, appdir / "dungeoncraft.png") + os.symlink("dungeoncraft.png", appdir / ".DirIcon") + os.symlink("usr/bin/dungeoncraft", appdir / "AppRun") + + out = DIST / f"DungeonCraft-{ver}-x86_64.AppImage" + out.unlink(missing_ok=True) + tenv = dict(os.environ, ARCH="x86_64", APPIMAGE_EXTRACT_AND_RUN="1") # no FUSE needed for the tool + log("packing the AppImage") + run([appimagetool(), "--no-appstream", appdir, out], env=tenv) + return out + + +# ------------------------------------------------------------------ Windows: one exe + +def build_windows(ver): + need_target("x86_64-pc-windows-gnu") + env = zig_env() + log("Windows build") + run([cargo(), "zigbuild", "--release", "--target", "x86_64-pc-windows-gnu"], env=env) + out = DIST / f"DungeonCraft-{ver}-windows-x64.exe" + shutil.copy2(ROOT / "target" / "x86_64-pc-windows-gnu" / "release" / "dungeoncraft.exe", out) + return out + + +# ------------------------------------------------------------------ macOS: universal .app in a zip + +def build_macos(ver): + if platform.system() != "Darwin": + raise BuildError("the macOS app can only be built on a Mac (Apple's SDK is needed)") + env = tool_env() + arches = ["aarch64-apple-darwin", "x86_64-apple-darwin"] + for t in arches: + need_target(t) + env["MACOSX_DEPLOYMENT_TARGET"] = "11.0" + for t in arches: + log(f"macOS build {t}") + run([cargo(), "build", "--release", "--target", t], env=env) + + app = ROOT / "target" / "DungeonCraft.app" + shutil.rmtree(app, ignore_errors=True) + (app / "Contents" / "MacOS").mkdir(parents=True) + (app / "Contents" / "Resources").mkdir(parents=True) + run(["lipo", "-create", "-output", app / "Contents" / "MacOS" / "DungeonCraft"] + [ROOT / "target" / t / "release" / "dungeoncraft" for t in arches]) + short = ver.lstrip("v").replace("-", ".") + (app / "Contents" / "Info.plist").write_text(INFO_PLIST.format(short=short, version=ver), encoding="utf-8") + + iconset = ROOT / "target" / "DungeonCraft.iconset" + shutil.rmtree(iconset, ignore_errors=True) + iconset.mkdir() + for size in (16, 32, 128, 256, 512): + run(["sips", "-z", size, size, ICON, "--out", iconset / f"icon_{size}x{size}.png"]) + if size <= 256: + run(["sips", "-z", size * 2, size * 2, ICON, "--out", iconset / f"icon_{size}x{size}@2x.png"]) + run(["iconutil", "-c", "icns", iconset, "-o", app / "Contents" / "Resources" / "DungeonCraft.icns"]) + + run(["codesign", "--force", "--deep", "--sign", "-", app]) + out = DIST / f"DungeonCraft-{ver}-macos.zip" + out.unlink(missing_ok=True) + run(["ditto", "-c", "-k", "--keepParent", app, out]) + return out + + +BUILDERS = {"linux": build_linux, "windows": build_windows, "macos": build_macos} + + +def main(argv): + if any(a in ("-h", "--help") for a in argv): + print(__doc__) + return 0 + wanted = argv or (["macos"] if platform.system() == "Darwin" else ["linux", "windows"]) + unknown = [w for w in wanted if w not in BUILDERS] + if unknown: + print(f"unknown target(s): {', '.join(unknown)} (linux, windows, macos)", file=sys.stderr) + return 2 + DIST.mkdir(exist_ok=True) + ver = version() + done, failed = [], [] + for w in wanted: + try: + done.append(BUILDERS[w](ver)) + except BuildError as e: + failed.append(f"{w}: {e}") + print(f"!! {w}: {e}", file=sys.stderr) + print() + for p in done: + print(f"built {p.relative_to(ROOT)} ({p.stat().st_size / 1e6:.1f} MB)") + for f in failed: + print(f"failed {f}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))