diff --git a/.gitea/workflows/gitea-build.yaml b/.gitea/workflows/gitea-build.yaml index 8f5d7cc..ca48965 100644 --- a/.gitea/workflows/gitea-build.yaml +++ b/.gitea/workflows/gitea-build.yaml @@ -49,7 +49,7 @@ jobs: - name: Setup Toolchain uses: ./.gitea/actions/setup with: - packages: lld zip + packages: lld - name: Cache Windows SDK uses: actions/cache@v4 diff --git a/.gitea/workflows/gitea-release.yaml b/.gitea/workflows/gitea-release.yaml index 285f61d..c6561d5 100644 --- a/.gitea/workflows/gitea-release.yaml +++ b/.gitea/workflows/gitea-release.yaml @@ -24,7 +24,7 @@ jobs: - name: Setup Toolchain uses: ./.gitea/actions/setup with: - packages: lld zip + packages: lld - name: Set Version run: echo "GAME_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" @@ -60,5 +60,4 @@ jobs: NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18 with: files: |- - build/${{ env.PROJECT_NAME }}_*.tar.gz - build/${{ env.PROJECT_NAME }}_*.zip + build/${{ env.PROJECT_NAME }}_* diff --git a/AGENTS.md b/AGENTS.md index d53314d..ea353b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Commands (see `justfile`): `just run`, `just check`, `just test`, `just package ### Completed - [x] Code split into modules under `src/` (themes, assets, audio, ui, card, decks, memory, profile, screens) - [x] `.odin` files tracked in git (`.gitignore` used to ignore `*.odin`) -- [x] Fonts: M PLUS Rounded 1c (UI + kana), Jua (Hangul), Mali (Thai), all OFL, loaded via `LoadFontEx` with explicit codepoints +- [x] Fonts: M PLUS Rounded 1c (UI + kana), Jua (Hangul), Mali (Thai), all OFL, embedded via `#load` and loaded with `LoadFontFromMemory` + explicit codepoints - release builds are a single self-contained executable - [x] Per-language themes (`themes.Theme`) with flag-based card backs: Japan (Hinomaru), Korea (Taegukgi), Thailand (Trairong) - [x] Card flip animation (art rendered to textures and squashed), lift, shadow, lighting; matched cards pulse; cards are dealt in at game start - [x] Sound effects synthesized in code (flip, match, mismatch, win, button click), no audio files needed @@ -30,15 +30,16 @@ Commands (see `justfile`): `just run`, `just check`, `just test`, `just package - [ ] macOS release build (would need a macOS runner or osxcross) ## Known Issues / Gotchas -1. **Font glyphs must be listed**: raylib only bakes the codepoints passed to `LoadFontEx`. Any new non-ASCII text needs its characters added in `assets.codepoints_for` (or the `EXTRA_*` strings), otherwise it renders as `?`. The `fonts_cover_every_deck_character` test checks deck text. +1. **Font glyphs must be listed**: raylib only bakes the codepoints passed to `LoadFontFromMemory`. Any new non-ASCII text needs its characters added in `assets.codepoints_for` (or the `EXTRA_*` strings), otherwise it renders as `?`. The `fonts_cover_every_deck_character` test checks deck text. 2. **Old font failure root cause**: `DroidSansJapanese.ttf` was a GitHub HTML page saved from a blob URL, not a font. Always download fonts from raw URLs and check them with `file`. 3. **Thai shaping**: raylib does no text shaping, so Thai combining vowels/tone marks would be misplaced. Thai cards use standalone consonants; Thai UI text avoids marks (win message is English). 4. Tests share the memory game's global state, so they must run single-threaded (`just test` sets `ODIN_TEST_THREADS=1`). -5. The game changes directory to the executable's folder at startup so `assets/` is found from anywhere. -6. **Windows builds are cross-compiled**: Odin can't link Windows binaries on Linux, so `just windows` builds objects with `-build-mode:obj` and links with `lld-link` against an xwin SDK. The VC runtime is linked statically; only Windows system DLLs are needed. New Odin core usage can need new import libs (e.g. `ole32.lib` for `os.user_data_dir`); the CI `windows` job catches this. Not yet tested on a real Windows machine. -7. **Odin constant arrays can't be indexed at runtime**: lookup tables (`DECKS`, `LAYOUTS`, …) are `@(rodata)` globals instead of `::` constants. -8. **Profile memory**: `profile` always allocates from the heap allocator, because the test runner frees each test's allocator afterwards. -9. **`odin test` segfaults if the stack rlimit is "unlimited"**: the CI runner's container sets `ulimit -s unlimited`, which makes `getrlimit(RLIMIT_STACK).rlim_cur` come back as `RLIM_INFINITY` (a huge unsigned value). Odin's `core:thread` passes that straight to `pthread_attr_setstacksize`, `pthread_create` then fails, and `thread.create()` returns `nil` - which `core:testing`'s `pool_init` dereferences unconditionally, segfaulting the whole test binary (exit 139) before it prints anything. `just test` works around it with `ulimit -s 8192` before running `odin test`. Upstream Odin bug (core:thread/core:testing don't handle a failed thread creation); not our code. +5. **Windows builds are cross-compiled**: Odin can't link Windows binaries on Linux, so `just windows` builds objects with `-build-mode:obj` and links with `lld-link` against an xwin SDK. The VC runtime is linked statically; only Windows system DLLs are needed. New Odin core usage can need new import libs (e.g. `ole32.lib` for `os.user_data_dir`); the CI `windows` job catches this. Not yet tested on a real Windows machine. +6. **Odin constant arrays can't be indexed at runtime**: lookup tables (`DECKS`, `LAYOUTS`, …) are `@(rodata)` globals instead of `::` constants. +7. **Profile memory**: `profile` always allocates from the heap allocator, because the test runner frees each test's allocator afterwards. +8. **`odin test` segfaults if the stack rlimit is "unlimited"**: the CI runner's container sets `ulimit -s unlimited`, which makes `getrlimit(RLIMIT_STACK).rlim_cur` come back as `RLIM_INFINITY` (a huge unsigned value). Odin's `core:thread` passes that straight to `pthread_attr_setstacksize`, `pthread_create` then fails, and `thread.create()` returns `nil` - which `core:testing`'s `pool_init` dereferences unconditionally, segfaulting the whole test binary (exit 139) before it prints anything. `just test` works around it with `ulimit -s 8192` on the same line as `odin test` (`just` runs each recipe line in its own shell, so a separate line wouldn't stick). Upstream Odin bug (core:thread/core:testing don't handle a failed thread creation); not our code. +9. **CI jobs share one self-hosted runner**: `build` and `windows` in `gitea-build.yaml` both clone `actions/checkout`/`actions/cache` into the same cache directory on the runner. Starting them at the same time can race on that clone and fail one job outright, so `windows` runs with `needs: build`. +10. **Release artifacts are bare executables**, not archives: fonts are embedded in the binary (see gotcha 1), so there's nothing else to ship alongside it. ## File Structure ``` diff --git a/README.md b/README.md index 779ea4d..fad39b7 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ Requires [Odin](https://odin-lang.org) (dev-2026-09) and [just](https://just.sys just run # build and play (debug) just check # type-check with -vet just test # run tests -just package 1.0 # native release build -> build/HanjaKanaMun_1.0__.tar.gz +just package 1.0 # native release build -> build/HanjaKanaMun_1.0__ (self-contained executable) just windows-deps # Linux only: fetch Windows SDK/CRT (xwin) and raylib.lib -just package-windows 1.0 # cross-compiled Windows build -> build/HanjaKanaMun_1.0_windows_x86_64.zip +just package-windows 1.0 # cross-compiled Windows build -> build/HanjaKanaMun_1.0_windows_x86_64.exe ``` The Windows build needs `lld-link`. On macOS, `brew install lld` and set `LLD_LINK`, and point `XWIN_SDK` and diff --git a/justfile b/justfile index b31d541..bfe0690 100644 --- a/justfile +++ b/justfile @@ -36,16 +36,15 @@ test: # `ulimit` line wouldn't carry over to the `odin test` line.) ulimit -s 8192 && odin test tests -vet -define:ODIN_TEST_THREADS=1 -out:{{build_dir}}/tests -# Optimized native build into build//, with the assets it needs at runtime +# Optimized native build into build// (self-contained: fonts are embedded) release: - mkdir -p {{build_dir}}/{{os()}}/{{name}} - odin build . -vet -o:speed -out:{{build_dir}}/{{os()}}/{{name}}/{{name}} - cp -R assets {{build_dir}}/{{os()}}/{{name}}/ + mkdir -p {{build_dir}}/{{os()}} + odin build . -vet -o:speed -out:{{build_dir}}/{{os()}}/{{name}} -# Native release build packed as build/___.tar.gz +# Native release build as build/___ package version="dev": release - tar -czf {{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}}.tar.gz -C {{build_dir}}/{{os()}} {{name}} - @echo "{{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}}.tar.gz" + cp {{build_dir}}/{{os()}}/{{name}} {{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}} + @echo "{{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}}" # Download the Windows SDK + CRT (via xwin, accepting Microsoft's license) and raylib.lib. Linux x86_64 only. windows-deps: @@ -67,12 +66,12 @@ windows-deps: unzip -o -j {{build_dir}}/odin-windows.zip 'dist/vendor/raylib/windows/raylib.lib' -d "{{raylib_windows}}" fi -# Cross-compile a Windows x86_64 build into build/windows/ (run `just windows-deps` first) +# Cross-compile a self-contained Windows x86_64 build into build/windows/ (run `just windows-deps` first) windows: #!/usr/bin/env bash set -euo pipefail obj_dir={{build_dir}}/obj-windows - out_dir={{build_dir}}/windows/{{name}} + out_dir={{build_dir}}/windows mkdir -p "$obj_dir" "$out_dir" rm -f "$obj_dir"/*.obj odin build . -vet -o:speed -target:windows_amd64 -build-mode:obj -out:"$obj_dir/{{name}}.obj" @@ -88,12 +87,11 @@ windows: libcmt.lib libvcruntime.lib ucrt.lib \ kernel32.lib user32.lib gdi32.lib winmm.lib shell32.lib opengl32.lib \ advapi32.lib bcrypt.lib ntdll.lib synchronization.lib ole32.lib - cp -R assets "$out_dir/" -# Windows build packed as build/__windows_x86_64.zip +# Windows build as build/__windows_x86_64.exe package-windows version="dev": windows - cd {{build_dir}}/windows && zip -qr ../{{name}}_{{version}}_windows_x86_64.zip {{name}} - @echo "{{build_dir}}/{{name}}_{{version}}_windows_x86_64.zip" + cp {{build_dir}}/windows/{{name}}.exe {{build_dir}}/{{name}}_{{version}}_windows_x86_64.exe + @echo "{{build_dir}}/{{name}}_{{version}}_windows_x86_64.exe" # Format all Odin files with odinfmt (settings in odinfmt.json) fmt: diff --git a/main.odin b/main.odin index c9fbb23..e3c8182 100644 --- a/main.odin +++ b/main.odin @@ -30,8 +30,6 @@ main :: proc() { raylib.InitWindow(themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, "HanjaKanaMun") defer raylib.CloseWindow() raylib.SetExitKey(.KEY_NULL) - // Assets are loaded relative to the executable, so it can be started from anywhere - raylib.ChangeDirectory(raylib.GetApplicationDirectory()) raylib.SetTargetFPS(60) raylib.InitAudioDevice() diff --git a/src/assets/assets.odin b/src/assets/assets.odin index e554b47..9afdf21 100644 --- a/src/assets/assets.odin +++ b/src/assets/assets.odin @@ -12,12 +12,14 @@ Font_Style :: enum { THAI, // Thai script (Mali SemiBold) } +// Fonts are baked into the executable so a release is a single self-contained +// binary: no assets/ folder has to ship (or be found) alongside it at runtime. @(rodata) -FONT_PATHS := [Font_Style]cstring{ - .REGULAR = "assets/MPLUSRounded1c-Medium.ttf", - .BOLD = "assets/MPLUSRounded1c-ExtraBold.ttf", - .KOREAN = "assets/Jua-Regular.ttf", - .THAI = "assets/Mali-SemiBold.ttf", +FONT_DATA := [Font_Style][]u8{ + .REGULAR = #load("../../assets/MPLUSRounded1c-Medium.ttf"), + .BOLD = #load("../../assets/MPLUSRounded1c-ExtraBold.ttf"), + .KOREAN = #load("../../assets/Jua-Regular.ttf"), + .THAI = #load("../../assets/Mali-SemiBold.ttf"), } fonts: [Font_Style]raylib.Font @@ -56,10 +58,12 @@ codepoints_for :: proc(style: Font_Style, allocator := context.allocator) -> [dy load_font :: proc(style: Font_Style) -> raylib.Font { codepoints := codepoints_for(style) defer delete(codepoints) - path := FONT_PATHS[style] - font := raylib.LoadFontEx(path, FONT_BASE_SIZE, raw_data(codepoints), i32(len(codepoints))) + data := FONT_DATA[style] + font := raylib.LoadFontFromMemory( + ".ttf", raw_data(data), i32(len(data)), FONT_BASE_SIZE, raw_data(codepoints), i32(len(codepoints)), + ) if !raylib.IsFontValid(font) || font.texture.id == 0 { - raylib.TraceLog(.WARNING, "ASSETS: Falling back to default font, could not load %s", path) + raylib.TraceLog(.WARNING, "ASSETS: Falling back to default font, could not load style %v", style) return raylib.GetFontDefault() } // Mipmaps keep small UI text smooth when the large atlas is scaled down