Compare commits

..
Author SHA1 Message Date
Richard ZinkandClaude Opus 5.5 d8aa0e5fc1 Bump version to v202611-00076
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:33:43 +01:00
Richard ZinkandClaude Opus 5.5 98b78eb518 Desktop release builds: Linux AppImage, Windows exe and macOS app via tools/build_desktop.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:22:11 +01:00
5 changed files with 267 additions and 1 deletions

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ Cargo.lock.bak
www/dungeoncraft.wasm www/dungeoncraft.wasm
www/mq_js_bundle.js www/mq_js_bundle.js
play/ play/
dist/
+11
View File
@@ -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 *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. 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-<version>-x86_64.AppImage`, built against glibc 2.28 (distributions from about 2019 on).
- **Windows**: `DungeonCraft-<version>-windows-x64.exe`, a single file for Windows 10 and newer, cross-compiled on Linux.
- **macOS**: `DungeonCraft-<version>-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 ### 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: The tests run natively: a headless simulation of whole games, a savegame round trip, rendering of all music modules and the audio engine:
+1 -1
View File
@@ -1 +1 @@
v202611-00075 v202611-00076
+2
View File
@@ -1,4 +1,6 @@
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM). // 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 achievements;
mod ai; mod ai;
+252
View File
@@ -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-<version>-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-<version>-windows-x64.exe (one file, Windows 10 and newer)
cross-compiled with cargo-zigbuild for x86_64-pc-windows-gnu
- macos: dist/DungeonCraft-<version>-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 = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>DungeonCraft</string>
<key>CFBundleDisplayName</key><string>DungeonCraft</string>
<key>CFBundleIdentifier</key><string>org.familie-zink.dungeoncraft</string>
<key>CFBundleExecutable</key><string>DungeonCraft</string>
<key>CFBundleIconFile</key><string>DungeonCraft</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>{short}</string>
<key>CFBundleVersion</key><string>{version}</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>LSApplicationCategoryType</key><string>public.app-category.strategy-games</string>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
"""
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:]))