feat(bundle): ship a game as a macOS .app, with a splash it controls
`ludic build` produces a program. Double-clicked it opens a Terminal window, it wears the generic executable icon, it calls itself whatever the file is called, and it carries none of its assets. `ludic bundle` produces an application. Everything it needs is in package.ludic, so the command takes no arguments: an Info.plist and PkgInfo from `app` lines, an .icns built by sips and iconutil at all ten sizes macOS asks for from a single source PNG, the asset pack in Contents/Resources, and an ad-hoc signature - which is not optional on Apple silicon, where an unsigned binary is killed rather than warned about. The bundle identifier falls back to the package path reversed, so a project that never thinks about it still gets a defensible one instead of two apps sharing a key Launch Services hangs the Dock, saved state and permissions off. A bundled game is moved to ~/Library/Application Support/<name> before main, because Finder starts a .app with its working directory at "/" where no save could ever be written. Reads come out of the pack, writes land somewhere real and per-user, and the game's save code needs no change and no platform knowledge. The splash is the other half of looking like an application. A game that loads 165 MB spends a visible moment doing it with nothing on screen, which from the outside is indistinguishable from a launch that failed. splash_show puts a borderless window up from the same constructor that mounts the pack - before main, so it appears while the process is still starting rather than after the slow part it exists to cover - and reads the artwork out of the pack like any other asset. It turns the run loop enough times to be mapped and composited there and then; once composited the backing store survives a busy main thread, so it stays up for the whole load. Nothing hides it automatically. Only the game knows when its first real frame is ready, and a splash that vanishes before that leaves the same black gap it was covering, so the game calls App.splash_hide(). Headless there is no splash and the call lowers to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ce5bf0fe0e
commit
be74b4de6f
14 changed files with 34667 additions and 33621 deletions
173
docs/SHIPPING.md
Normal file
173
docs/SHIPPING.md
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# Shipping a Ludic game
|
||||
|
||||
`ludic build` gives you a program. `ludic bundle` gives you an application.
|
||||
|
||||
The difference matters more than it sounds. A built binary opens its assets by a
|
||||
path relative to the working directory, so it runs from the project root and
|
||||
nowhere else; double-clicked it opens a Terminal window; it wears the generic
|
||||
executable icon and calls itself whatever the file is called. None of that is
|
||||
something you can hand to a player.
|
||||
|
||||
```bash
|
||||
ludic pack # every asset into one file
|
||||
ludic bundle # ...and that, the binary, an icon and the metadata, as a .app
|
||||
```
|
||||
|
||||
## What goes in the manifest
|
||||
|
||||
Everything the bundler needs lives in `package.ludic`, so the command takes no
|
||||
arguments:
|
||||
|
||||
```ludic skip
|
||||
package "git.workshopsoft.io/workshopsoft/maroon-lake"
|
||||
version "0.1.0"
|
||||
|
||||
app name "Maroon Lake"
|
||||
app id "io.workshopsoft.maroon-lake"
|
||||
app icon "assets/app/icon.png"
|
||||
app splash "assets/app/splash.png"
|
||||
app splash_bg "#0d1b2a"
|
||||
app category "public.app-category.adventure-games"
|
||||
app copyright "(c) 2026 Workshopsoft"
|
||||
app min_macos "11.0"
|
||||
|
||||
pack "assets"
|
||||
```
|
||||
|
||||
| key | what it does | default |
|
||||
| --- | --- | --- |
|
||||
| `app name` | the name under the icon, spaces and all | the package's name |
|
||||
| `app id` | the bundle identifier | derived from the package path |
|
||||
| `app version` | `CFBundleVersion` | the manifest's `version` |
|
||||
| `app icon` | a source PNG, ideally 1024x1024 | no icon |
|
||||
| `app splash` | shown from process start until the game says otherwise | no splash |
|
||||
| `app splash_bg` | `#RRGGBB` painted behind the splash | black |
|
||||
| `app category` | `LSApplicationCategoryType` | omitted |
|
||||
| `app copyright` | `NSHumanReadableCopyright` | omitted |
|
||||
| `app min_macos` | `LSMinimumSystemVersion` | `11.0` |
|
||||
| `app sign` | a codesigning identity | ad-hoc (`-`) |
|
||||
| `app out` | where to write the bundle | `build/<name>.app` |
|
||||
| `pack` | an asset root, repeatable | `assets/` if it exists |
|
||||
|
||||
Only `app name` is worth setting deliberately. The identifier is derived from the
|
||||
package path when you omit it - `git.workshopsoft.io/workshopsoft/maroon-lake`
|
||||
becomes `io.workshopsoft.maroon-lake` - but it is worth pinning, because Launch
|
||||
Services keys the Dock, saved window state and permissions off it, and two apps
|
||||
sharing one identifier is a class of bug that looks like haunting.
|
||||
|
||||
## The asset pack
|
||||
|
||||
`ludic pack` writes a `.lpak`: a header, an entry table sorted by name, a name
|
||||
heap and the blobs. Entries are **stored, not compressed** - PNG, JPEG and glTF
|
||||
binary arrive compressed already, and a decompressor on the load path would
|
||||
spend CPU to make the file no smaller.
|
||||
|
||||
Nothing about how the game is written changes. This line:
|
||||
|
||||
```ludic skip
|
||||
let model = gltf_load("assets/kit/hiker", "hiker.gltf", "hiker")
|
||||
```
|
||||
|
||||
reads a file during development and a run of bytes inside
|
||||
`Maroon Lake.app/Contents/Resources/game.lpak` once shipped, and cannot tell
|
||||
which. That works because the pack is spliced in at `file_open`, the one place
|
||||
every asset in a Ludic program comes through - `gltf_load`, `tex_load`,
|
||||
`Audio.load`, `Fs.read_text` and the renderer's own shader loads all bottom out
|
||||
there. `Fs.exists` and `Fs.size` consult the packs too, so a game that guards a
|
||||
load with `Fs.exists` keeps finding its assets once they are packed.
|
||||
|
||||
The pack is `mmap`'d rather than read, so 165 MB of terrain costs one syscall at
|
||||
startup and pages in only what the game touches.
|
||||
|
||||
```bash
|
||||
ludic pack --list build/maroon-lake.lpak # what is in it
|
||||
ludic pack --verify build/maroon-lake.lpak # re-hash every entry
|
||||
```
|
||||
|
||||
### More than one pack
|
||||
|
||||
`Contents/Resources/packs.index` is a plain list, and the order is load-bearing:
|
||||
|
||||
```
|
||||
pack base.lpak
|
||||
pack patch.lpak
|
||||
```
|
||||
|
||||
Packs mount in the order listed and a later one **shadows** an earlier one, so a
|
||||
patch or an add-on replaces individual files without rewriting the base pack. An
|
||||
override is always a pack, never a loose file: the pack is searched before the
|
||||
filesystem, so a stray or corrupt file cannot shadow a shipped asset.
|
||||
|
||||
### Where a bundled game saves
|
||||
|
||||
A `.app` launched from Finder starts with its working directory at `/`, where
|
||||
nothing is writable, so `packs.index` also carries:
|
||||
|
||||
```
|
||||
home Maroon Lake
|
||||
```
|
||||
|
||||
and the runtime moves the process to `~/Library/Application Support/Maroon Lake`
|
||||
before `main`. Reads come out of the pack; writes land somewhere real and
|
||||
per-user. A game's save code needs no change and no platform knowledge.
|
||||
|
||||
## The splash
|
||||
|
||||
A game that loads 165 MB spends a noticeable moment doing it, and until it is
|
||||
done there is nothing on screen - the window is not open yet, because opening it
|
||||
is part of what the game does once it has something to draw. From the outside
|
||||
that is indistinguishable from a launch that failed.
|
||||
|
||||
The splash is raised from a constructor that runs **before `main`**, so it
|
||||
appears while the process is still starting rather than after the expensive
|
||||
part, which is the only ordering that helps. It is a borderless window holding
|
||||
the game's own artwork, read out of the pack like any other asset.
|
||||
|
||||
Nothing hides it automatically. Only the game knows when its first real frame is
|
||||
ready, so the game says so:
|
||||
|
||||
```ludic skip
|
||||
App.splash_hide()
|
||||
```
|
||||
|
||||
## During development
|
||||
|
||||
None of this is in the way. Without a `packs.index` beside the executable -
|
||||
which is every `ludic run` - nothing mounts, no directory change happens, no
|
||||
splash appears, and every open goes to the filesystem exactly as before.
|
||||
Packing is a shipping step, and it is invisible until you ship.
|
||||
|
||||
## Signing and distribution
|
||||
|
||||
`ludic bundle` ad-hoc signs the result. On Apple silicon that is not optional
|
||||
the way it was on Intel: an unsigned binary is killed outright rather than
|
||||
merely warned about, so without a signature the app will not run even on the
|
||||
machine that built it.
|
||||
|
||||
An ad-hoc signature is **not** enough to get the app past Gatekeeper on someone
|
||||
else's Mac. That needs a Developer ID certificate and notarisation, which are
|
||||
Apple's, not Ludic's:
|
||||
|
||||
```ludic skip
|
||||
app sign "Developer ID Application: Your Name (TEAMID)"
|
||||
```
|
||||
|
||||
```bash
|
||||
ludic bundle
|
||||
xcrun notarytool submit "build/Maroon Lake.app" --keychain-profile NOTARY --wait
|
||||
xcrun stapler staple "build/Maroon Lake.app"
|
||||
```
|
||||
|
||||
## The bundle it makes
|
||||
|
||||
```
|
||||
Maroon Lake.app/
|
||||
Contents/
|
||||
Info.plist the metadata Finder, the Dock and Launch Services read
|
||||
PkgInfo the eight bytes that predate Info.plist and are still read
|
||||
MacOS/Maroon Lake the game
|
||||
Resources/
|
||||
AppIcon.icns every size macOS asks for, from one source PNG
|
||||
game.lpak every asset the game opens
|
||||
packs.index what to mount, where to save, what to show at boot
|
||||
```
|
||||
7
docs/language/app/_section.md
Normal file
7
docs/language/app/_section.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: app
|
||||
title: App
|
||||
order: 48
|
||||
---
|
||||
|
||||
The running application, as distinct from its window. Today that is the boot splash: the runtime raises it before <code>main</code> from the game's asset pack, and <a href="app-splash_hide"><code>App.splash_hide</code></a> takes it down when the game has something to show instead.
|
||||
33
docs/language/app/app-splash_hide.md
Normal file
33
docs/language/app/app-splash_hide.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
id: app-splash_hide
|
||||
name: App.splash_hide
|
||||
category: app
|
||||
kind: namespace-method
|
||||
tokens: App.splash_hide
|
||||
sig: App.splash_hide() -> void
|
||||
tip: Dismiss the boot splash.
|
||||
order: 1
|
||||
ns: App
|
||||
member: splash_hide
|
||||
---
|
||||
|
||||
Dismisses the boot splash. Safe to call when there is no splash, and safe to call twice.
|
||||
|
||||
A bundled game shows a splash while it starts: `ludic bundle` records `app splash` from the manifest into the bundle, and the runtime puts it on screen before `main` runs, out of the asset pack. Nothing takes it down on its own, because nothing else knows when the game has a first frame worth showing - a splash that disappears while the terrain is still loading leaves the same black gap it was there to cover.
|
||||
|
||||
So the game says when:
|
||||
|
||||
```ludic skip
|
||||
program MyGame {
|
||||
entry {
|
||||
load_the_world() # the slow part the splash is covering
|
||||
open_the_window()
|
||||
draw_one_frame()
|
||||
App.splash_hide() # ...and only now
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Headless there is no splash and no window, and the call lowers to nothing.
|
||||
|
||||
See also: <a href="../../SHIPPING">shipping a game</a>.
|
||||
Loading…
Add table
Add a link
Reference in a new issue