Add justfile, ols/odinfmt config, tests and Gitea workflows; drop odin.json

- justfile with build/run/check/test/release/package/fmt/clean recipes
- ols.json and odinfmt.json for neovim (ols)
- Tests for memory game rules and card animation; memory.deal and
  memory.select_card split out so logic is testable without a window
- Gitea workflows: build+test on main/develop, package and upload to the
  generic package registry on version branches
- Game changes to the executable's directory so packaged builds find assets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-01 21:04:08 +02:00
1 parent 148570a4e6
commit 9ee2eabe9a
12 files changed
+428 -23

No files matched your search

+53
View File
@@ -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
+58
View File
@@ -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
+3
View File
@@ -16,3 +16,6 @@ HanjaKanaMun
.vscode/
*.swp
*.swo
# Build output
build/
+11 -4
View File
@@ -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 <version>`
## 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
+28 -1
View File
@@ -1,3 +1,30 @@
# HanjaKanaMun
Odin + raylib multilingual application (Thai, Korean, Japanese)
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_<os>_<arch>.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/<version>`). Needs a `TOKEN` secret with package write access.
## Credits
Font: M PLUS Rounded 1c, SIL Open Font License (`assets/MPLUSRounded1c-OFL.txt`).
+43
View File
@@ -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/<name>_<version>_<os>_<arch>.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}}
+2
View File
@@ -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()
-12
View File
@@ -1,12 +0,0 @@
{
"name": "HanjaKanaMun",
"path": ".",
"source_dir": ".",
"include_dirs": ["vendor", "src"],
"rays": [
{
"name": "raylib",
"path": "vendor:raylib"
}
]
}
+7
View File
@@ -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"
}
+13
View File
@@ -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"
}
+12 -6
View File
@@ -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
}
}
+198
View File
@@ -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)
}