diff --git a/.gitea/workflows/gitea-build.yaml b/.gitea/workflows/gitea-build.yaml new file mode 100644 index 0000000..d17d05a --- /dev/null +++ b/.gitea/workflows/gitea-build.yaml @@ -0,0 +1,53 @@ +# +# .gitea/workflows/gitea-build.yaml +# +# Builds and tests on every push and pull request to main and develop. +# + +name: "Build and Test" +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +env: + ODIN_VERSION: dev-2026-09 + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install System Dependencies + run: | + SUDO=""; command -v sudo >/dev/null && SUDO=sudo + $SUDO apt-get update + # clang links Odin programs; raylib needs X11 on Linux + $SUDO apt-get install -y --no-install-recommends clang libx11-dev curl ca-certificates + + - name: Install Odin + run: | + curl -fsSL -o odin.tar.gz https://github.com/odin-lang/Odin/releases/download/$ODIN_VERSION/odin-linux-amd64-$ODIN_VERSION.tar.gz + mkdir -p "$HOME/odin" + tar -xzf odin.tar.gz -C "$HOME/odin" --strip-components=1 + echo "$HOME/odin" >> "$GITHUB_PATH" + + - name: Install just + run: | + mkdir -p "$HOME/.local/bin" + curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Check + run: | + odin version + just check + + - name: Test + run: just test + + - name: Build + run: just release diff --git a/.gitea/workflows/gitea-release.yaml b/.gitea/workflows/gitea-release.yaml new file mode 100644 index 0000000..8131e27 --- /dev/null +++ b/.gitea/workflows/gitea-release.yaml @@ -0,0 +1,58 @@ +# +# .gitea/workflows/gitea-release.yaml +# +# Builds a release package on version branches (e.g. 1.0) and uploads it to +# the Gitea generic package registry. +# + +name: "Publish" +on: + push: + branches: + - '*.*' + +env: + ODIN_VERSION: dev-2026-09 + PROJECT_NAME: HanjaKanaMun + GAME_VERSION: ${{gitea.ref_name}} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install System Dependencies + run: | + SUDO=""; command -v sudo >/dev/null && SUDO=sudo + $SUDO apt-get update + # clang links Odin programs; raylib needs X11 on Linux + $SUDO apt-get install -y --no-install-recommends clang libx11-dev curl ca-certificates + + - name: Install Odin + run: | + curl -fsSL -o odin.tar.gz https://github.com/odin-lang/Odin/releases/download/$ODIN_VERSION/odin-linux-amd64-$ODIN_VERSION.tar.gz + mkdir -p "$HOME/odin" + tar -xzf odin.tar.gz -C "$HOME/odin" --strip-components=1 + echo "$HOME/odin" >> "$GITHUB_PATH" + + - name: Install just + run: | + mkdir -p "$HOME/.local/bin" + curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Test + run: just test + + - name: Linux Build + run: just package "$GAME_VERSION" + + - name: Upload Artifact Linux + run: | + ls -la build + FILE="${PROJECT_NAME}_${GAME_VERSION}_linux_x86_64.tar.gz" + curl --fail --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \ + --upload-file "build/$FILE" \ + https://gitea.sanplex.xyz/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$FILE diff --git a/.gitignore b/.gitignore index 630e722..46331d4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ HanjaKanaMun .vscode/ *.swp *.swo + +# Build output +build/ diff --git a/AGENTS.md b/AGENTS.md index e020544..c4cf186 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Overview Odin + raylib multilingual memory card game for learning Japanese (Hiragana/Katakana), Korean (Hangul), and Thai scripts. -Build and run: `odin run .` (check with `odin check . -vet`) +Commands (see `justfile`): `just run`, `just check`, `just test`, `just package ` ## Current Status @@ -32,14 +32,20 @@ Build and run: `odin run .` (check with `odin check . -vet`) 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.build_codepoints`, otherwise it renders as `?`. 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 may be misplaced. Prefer standalone consonants on Thai cards. -4. `odin.json` is not read by the Odin compiler (imports use relative paths); it is only informational. +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. ## File Structure ``` HanjaKanaMun/ ├── AGENTS.md # Project plan (this file) ├── README.md -├── odin.json # Informational only, not used by the compiler +├── justfile # build/run/check/test/package recipes +├── ols.json # ols language server settings (neovim) +├── odinfmt.json # odinfmt formatter settings (4 spaces, LF) +├── .gitea/workflows/ +│ ├── gitea-build.yaml # check + test + build on main/develop +│ └── gitea-release.yaml # package on version branches (*.*), upload to Gitea packages ├── main.odin # Window/audio setup, screen state machine ├── src/ │ ├── themes/themes.odin # Theme struct, flag colors, Screen/Flag enums, layout constants @@ -49,6 +55,7 @@ HanjaKanaMun/ │ ├── card/card.odin # Card struct, flag card-back art, face textures, flip animation │ ├── memory/memory.odin # Memory game state, logic and drawing │ └── screens/screens.odin # Main menu and language menus +├── tests/memory_test.odin # Game logic and card animation tests └── assets/ ├── MPLUSRounded1c-Medium.ttf ├── MPLUSRounded1c-ExtraBold.ttf @@ -58,6 +65,6 @@ HanjaKanaMun/ ## Notes - NO rm -rf or starting over - Make small, incremental changes -- Test after each change (`odin check . -vet`, then run) +- Test after each change (`just check`, `just test`, then `just run`) - Keep existing functionality working - Commit after each working step diff --git a/README.md b/README.md index 2e1e42c..bccfba4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ # HanjaKanaMun -Odin + raylib multilingual application (Thai, Korean, Japanese) \ No newline at end of file +Odin + raylib memory card game for learning Japanese, Korean and Thai scripts. + +## Development + +Requires [Odin](https://odin-lang.org) (dev-2026-09) and [just](https://just.systems). + +```sh +just run # build and play (debug) +just check # type-check with -vet +just test # run tests +just package 1.0 # release build -> build/HanjaKanaMun_1.0__.tar.gz +``` + +### Neovim + +`ols.json` and `odinfmt.json` configure the [ols](https://github.com/DanielGavin/ols) language server and formatter. +Install ols (e.g. `:MasonInstall ols`) and enable it with nvim-lspconfig: `vim.lsp.enable('ols')`. +Formatting goes through ols (`vim.lsp.buf.format()`), or `just fmt` if `odinfmt` is on your PATH. + +## CI + +- `.gitea/workflows/gitea-build.yaml`: check, test and build on pushes and PRs to `main` and `develop`. +- `.gitea/workflows/gitea-release.yaml`: on version branches like `1.0`, packages the Linux build and uploads it to + the Gitea generic package registry (`HanjaKanaMun/`). Needs a `TOKEN` secret with package write access. + +## Credits + +Font: M PLUS Rounded 1c, SIL Open Font License (`assets/MPLUSRounded1c-OFL.txt`). diff --git a/justfile b/justfile new file mode 100644 index 0000000..1b466e3 --- /dev/null +++ b/justfile @@ -0,0 +1,43 @@ +name := "HanjaKanaMun" +build_dir := "build" + +# List available recipes +default: + @just --list + +# Debug build +build: + odin build . -vet -debug -out:{{name}} + +# Build and run (debug) +run: + odin run . -vet -debug -out:{{name}} + +# Type-check with vet, no binary +check: + odin check . -vet + +# Run the test suite (single-threaded: the tests share the memory game's global state) +test: + mkdir -p {{build_dir}} + odin test tests -vet -define:ODIN_TEST_THREADS=1 -out:{{build_dir}}/tests + +# Optimized build into build/, with the assets it needs at runtime +release: + mkdir -p {{build_dir}}/{{name}} + odin build . -vet -o:speed -out:{{build_dir}}/{{name}}/{{name}} + cp -R assets {{build_dir}}/{{name}}/ + +# Release build packed as build/___.tar.gz +package version="dev": release + tar -czf {{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}}.tar.gz -C {{build_dir}} {{name}} + @echo "{{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}}.tar.gz" + +# Format all Odin files with odinfmt (settings in odinfmt.json) +fmt: + odinfmt -w . + +# Remove build output +clean: + if [ -d {{build_dir}} ]; then rm -r -- {{build_dir}}; fi + rm -f -- {{name}} diff --git a/main.odin b/main.odin index bb097a5..d7ad2fb 100644 --- a/main.odin +++ b/main.odin @@ -24,6 +24,8 @@ 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/odin.json b/odin.json deleted file mode 100644 index 1bd70ac..0000000 --- a/odin.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "HanjaKanaMun", - "path": ".", - "source_dir": ".", - "include_dirs": ["vendor", "src"], - "rays": [ - { - "name": "raylib", - "path": "vendor:raylib" - } - ] -} diff --git a/odinfmt.json b/odinfmt.json new file mode 100644 index 0000000..481b183 --- /dev/null +++ b/odinfmt.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://raw.githubusercontent.com/DanielGavin/ols/master/misc/odinfmt.schema.json", + "character_width": 120, + "tabs": false, + "spaces": 4, + "newline_style": "LF" +} diff --git a/ols.json b/ols.json new file mode 100644 index 0000000..8a50e82 --- /dev/null +++ b/ols.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/DanielGavin/ols/master/misc/ols.schema.json", + "enable_semantic_tokens": true, + "enable_document_symbols": true, + "enable_hover": true, + "enable_snippets": true, + "enable_references": true, + "enable_format": true, + "enable_inlay_hints_params": true, + "enable_inlay_hints_default_params": true, + "enable_unused_imports_reporting": true, + "checker_args": "-vet" +} diff --git a/src/memory/memory.odin b/src/memory/memory.odin index c173301..11cd9dd 100644 --- a/src/memory/memory.odin +++ b/src/memory/memory.odin @@ -51,7 +51,14 @@ resolve_timer: f32 win_t: f32 // fades in the win panel restart_requested: bool +// Deals a new game and renders the card faces. Needs the window to exist. start :: proc(game_theme: themes.Theme) { + deal(game_theme) + for &c in cards { card.build_face(&c, theme) } +} + +// Shuffles, lays out and resets the game state without touching the GPU +deal :: proc(game_theme: themes.Theme) { theme = game_theme deck := HIRAGANA @@ -75,7 +82,6 @@ start :: proc(game_theme: themes.Theme) { themes.CARD_WIDTH, themes.CARD_HEIGHT, } - card.build_face(&c, theme) } state = .WAITING @@ -93,14 +99,14 @@ unload :: proc() { for &c in cards { card.unload_face(&c) } } -@(private) can_pick :: proc(i: int) -> bool { c := &cards[i] return (state == .WAITING || state == .FIRST) && !c.face_up && !c.matched } -@(private) -pick :: proc(i: int) { +// Turns card i face up if it can be picked. Returns false when the pick is not allowed. +select_card :: proc(i: int) -> bool { + if !can_pick(i) { return false } cards[i].face_up = true audio.play(.FLIP) if state == .WAITING { @@ -113,6 +119,7 @@ pick :: proc(i: int) { resolve_timer = is_match ? MATCH_DELAY : MISMATCH_DELAY state = .RESOLVING } + return true } @(private) @@ -155,8 +162,7 @@ update :: proc(dt: f32) { elapsed += dt if raylib.IsMouseButtonPressed(.LEFT) { for &c, i in cards { - if can_pick(i) && card.is_hovered(&c) { - pick(i) + if card.is_hovered(&c) && select_card(i) { break } } diff --git a/tests/memory_test.odin b/tests/memory_test.odin new file mode 100644 index 0000000..eb1a360 --- /dev/null +++ b/tests/memory_test.odin @@ -0,0 +1,198 @@ +package tests + +import "core:testing" +import card "../src/card" +import memory "../src/memory" +import themes "../src/themes" + +// Runs the game logic forward in small steps, like frames +@(private) +advance :: proc(seconds: f32) { + dt: f32 = 1.0 / 60 + for t: f32 = 0; t < seconds; t += dt { + memory.update(dt) + } +} + +// Finds the card index holding the other half of card i's pair +@(private) +partner_of :: proc(i: int) -> int { + for c, j in memory.cards { + if j != i && c.pair_id == memory.cards[i].pair_id { return j } + } + return -1 +} + +// Finds a card index that does not pair with card i +@(private) +non_partner_of :: proc(i: int) -> int { + for c, j in memory.cards { + if j != i && c.pair_id != memory.cards[i].pair_id { return j } + } + return -1 +} + +@(test) +hiragana_table_is_complete_and_unique :: proc(t: ^testing.T) { + table := memory.HIRAGANA + testing.expect_value(t, len(table), 46) + for a, i in table { + for b, j in table { + if i == j { continue } + testing.expectf(t, a.kana != b.kana, "duplicate kana %s", a.kana) + testing.expectf(t, a.romaji != b.romaji, "duplicate romaji %s", a.romaji) + } + } +} + +@(test) +deal_creates_kana_romaji_pairs :: proc(t: ^testing.T) { + memory.deal(themes.JAPAN) + testing.expect_value(t, memory.state, memory.State.WAITING) + testing.expect_value(t, memory.moves, 0) + testing.expect_value(t, memory.matches, 0) + + for pair in 0 ..< memory.PAIR_COUNT { + kana_count, romaji_count := 0, 0 + kana, romaji: cstring + for c in memory.cards { + if c.pair_id != pair { continue } + testing.expect(t, !c.face_up && !c.matched) + if c.is_kana { + kana_count += 1 + kana = c.text + } else { + romaji_count += 1 + romaji = c.text + } + } + testing.expect_value(t, kana_count, 1) + testing.expect_value(t, romaji_count, 1) + + // The two halves must belong to the same table entry + found := false + for entry in memory.HIRAGANA { + if entry.kana == kana && entry.romaji == romaji { found = true } + } + testing.expectf(t, found, "pair %d is %s/%s, which is not in the table", pair, kana, romaji) + } +} + +@(test) +deal_lays_out_cards_on_screen_without_overlap :: proc(t: ^testing.T) { + memory.deal(themes.JAPAN) + for a, i in memory.cards { + testing.expect(t, a.rect.x >= 0 && a.rect.x + a.rect.width <= themes.SCREEN_WIDTH) + testing.expect(t, a.rect.y >= themes.GRID_TOP && a.rect.y + a.rect.height <= themes.SCREEN_HEIGHT) + for b, j in memory.cards { + if i == j { continue } + overlap := a.rect.x < b.rect.x + b.rect.width && b.rect.x < a.rect.x + a.rect.width && + a.rect.y < b.rect.y + b.rect.height && b.rect.y < a.rect.y + a.rect.height + testing.expectf(t, !overlap, "cards %d and %d overlap", i, j) + } + } +} + +@(test) +matching_pair_stays_up :: proc(t: ^testing.T) { + memory.deal(themes.JAPAN) + a := 0 + b := partner_of(a) + + testing.expect(t, memory.select_card(a)) + testing.expect_value(t, memory.state, memory.State.FIRST) + testing.expect(t, memory.select_card(b)) + testing.expect_value(t, memory.state, memory.State.RESOLVING) + testing.expect_value(t, memory.moves, 1) + + advance(2) + testing.expect_value(t, memory.state, memory.State.WAITING) + testing.expect_value(t, memory.matches, 1) + testing.expect(t, memory.cards[a].matched && memory.cards[b].matched) + testing.expect(t, memory.cards[a].face_up && memory.cards[b].face_up) +} + +@(test) +mismatched_pair_flips_back :: proc(t: ^testing.T) { + memory.deal(themes.JAPAN) + a := 0 + b := non_partner_of(a) + + memory.select_card(a) + memory.select_card(b) + advance(3) + testing.expect_value(t, memory.state, memory.State.WAITING) + testing.expect_value(t, memory.matches, 0) + testing.expect_value(t, memory.moves, 1) + testing.expect(t, !memory.cards[a].face_up && !memory.cards[b].face_up) + testing.expect_value(t, memory.cards[a].flip_t, 0) +} + +@(test) +invalid_picks_are_rejected :: proc(t: ^testing.T) { + memory.deal(themes.JAPAN) + a := 0 + testing.expect(t, memory.select_card(a)) + testing.expect(t, !memory.select_card(a)) // same card twice + + b := non_partner_of(a) + memory.select_card(b) + c := non_partner_of(b) + for c == a { c += 1 } + testing.expect(t, !memory.select_card(c)) // a third card while resolving + testing.expect_value(t, memory.moves, 1) + + memory.deal(themes.JAPAN) + p := partner_of(0) + memory.select_card(0) + memory.select_card(p) + advance(2) + testing.expect(t, !memory.select_card(0)) // already matched +} + +@(test) +finding_all_pairs_wins :: proc(t: ^testing.T) { + memory.deal(themes.JAPAN) + for i in 0 ..< memory.CARD_COUNT { + if memory.cards[i].matched { continue } + memory.select_card(i) + memory.select_card(partner_of(i)) + advance(2) + } + testing.expect_value(t, memory.state, memory.State.OVER) + testing.expect_value(t, memory.matches, memory.PAIR_COUNT) + testing.expect_value(t, memory.moves, memory.PAIR_COUNT) + for &c in memory.cards { testing.expect(t, c.matched) } + + // The clock stops once the game is over + elapsed := memory.elapsed + advance(1) + testing.expect_value(t, memory.elapsed, elapsed) +} + +@(test) +card_flip_animation_reaches_target :: proc(t: ^testing.T) { + c := card.Card{} + testing.expect(t, !card.is_animating(&c)) + + c.face_up = true + testing.expect(t, card.is_animating(&c)) + card.update(&c, 0.1) + testing.expect(t, c.flip_t > 0 && c.flip_t < 1) + for _ in 0 ..< 60 { card.update(&c, 1.0 / 60) } + testing.expect_value(t, c.flip_t, 1) + testing.expect(t, !card.is_animating(&c)) + + c.face_up = false + for _ in 0 ..< 60 { card.update(&c, 1.0 / 60) } + testing.expect_value(t, c.flip_t, 0) +} + +@(test) +card_pulse_counts_down :: proc(t: ^testing.T) { + c := card.Card{} + card.start_pulse(&c) + testing.expect_value(t, c.pulse_t, card.PULSE_DURATION) + card.update(&c, 1) + testing.expect_value(t, c.pulse_t, 0) +}