19 Commits
Author SHA1 Message Date
Sansan eda2258fde Ignore and untrack dSYM debug bundles
Build and Test / build (push) Successful in 23s
Build and Test / windows (push) Successful in 23s
2026-10-05 18:49:16 +02:00
SansanandClaude Sonnet 5 ad7d19b85a Embed fonts into the executable, ship release artifacts as bare binaries
Build and Test / build (push) Successful in 22s
Build and Test / windows (push) Successful in 1m8s
Fonts are now baked in with #load and loaded via LoadFontFromMemory
instead of LoadFontEx reading assets/*.ttf from disk, so a build no
longer needs an assets/ folder next to it at runtime - the chdir to
the executable's own directory in main.odin is dead code, removed.

just package / just package-windows now produce a single executable
(build/<name>_<version>_<os>_<arch>[.exe]) instead of a tar.gz/zip,
and the release workflow's upload/release-asset globs and the lld/zip
toolchain package list are updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 20:11:47 +02:00
SansanandClaude Sonnet 5 f39ed1684b Sequence windows after build to avoid shared action-cache race
Build and Test / build (push) Successful in 22s
Build and Test / windows (push) Successful in 27s
Release / release (push) Successful in 1m45s
Run 136's build job failed immediately (not a segfault) because it
and windows both clone actions/checkout@v4 and actions/cache@v4 into
the same cache directory on the runner at the same time; one job's
git fetch loses the race and ends up with a missing dist/index.js.
needs: build serializes them so they don't race on that shared cache.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 19:43:30 +02:00
SansanandClaude Sonnet 5 f53ec2d71d Fix: ulimit and odin test must be on the same justfile line
Build and Test / build (push) Failing after 4s
Build and Test / windows (push) Successful in 1m9s
just runs each recipe line in its own shell invocation (no shebang on
this recipe), so the previous commit's separate ulimit -s 8192 line
never actually affected the odin test line - CI segfaulted again the
same way. Confirmed fixed in an ubuntu:24.04 container running the
actual `just test` recipe with ulimit -s unlimited set beforehand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 19:41:09 +02:00
SansanandClaude Sonnet 5 06f4e9ed11 Fix CI test segfault: clamp stack ulimit before odin test
Build and Test / build (push) Failing after 19s
Build and Test / windows (push) Successful in 1m34s
Root cause found via gdb backtrace on the runner (thread::pool_init
crashed from main -> testing runner): the CI container sets
`ulimit -s unlimited`, so getrlimit(RLIMIT_STACK) returns RLIM_INFINITY.
core:thread passes that to pthread_attr_setstacksize, pthread_create
fails, and thread.create() returns nil, which core:testing's pool_init
dereferences without a nil check. Reproduced and confirmed fixed in an
ubuntu:24.04 container with the same Odin build. `just test` now clamps
the stack limit before running odin test. Also removes the gdb
diagnostic step added to debug this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 19:35:43 +02:00
SansanandClaude Sonnet 5 0776e8e4b9 Revert runner-contention theory, add gdb diagnostics for the test segfault
Build and Test / build (push) Failing after 25s
Build and Test / windows (push) Successful in 10m28s
The windows job hadn't even started when build's odin test segfaulted
again on its own (run 132), so concurrency wasn't the cause. Add a
Debug Segfault step that runs the already-built test binary under gdb
to get a backtrace, since this doesn't reproduce locally or in a plain
ubuntu:24.04 container with the same Odin build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 18:02:11 +02:00
SansanandClaude Sonnet 5 458f0edccb Sequence windows after build job to avoid CI runner contention
Build and Test / build (push) Failing after 23s
Build and Test / windows (push) Skipped
Both jobs land on the same self-hosted runner. Run 129 segfaulted
odin test in build while windows was mid (uncached) SDK download/
splat at the same time; odin check passed and the test suite
reproduces fine in isolation, so this is resource contention from
running concurrently for the first time, not a code or toolchain bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 15:56:42 +02:00
SansanandClaude Opus 5.5 26904b6852 Windows build links ole32; CI cross-builds Windows on main/develop; docs
Build and Test / build (push) Failing after 23s
Build and Test / windows (push) Successful in 11m6s
- os.user_data_dir needs ole32.lib on Windows
- Build workflow gets a Windows cross-build job with a cached SDK
- Drop the backtick from Jua's codepoints (the font lacks it)
- Update AGENTS.md and README for decks, controls, scores and layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:38:07 +02:00
SansanandClaude Opus 5.5 3794a9e2e9 Add screen fade-in and card deal-in animations
Cards slide in one after another when a game starts and can't be picked
until they have landed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:36:54 +02:00
SansanandClaude Opus 5.5 83ff9511ec Save best scores and last difficulty to a JSON profile
- profile package: best moves, best time and games played per
  deck/difficulty, stored in the user data folder
- Win panel shows the best (or New best!), language menu shows bests
- Profile memory always comes from the heap allocator (it outlives callers)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:35:43 +02:00
SansanandClaude Opus 5.5 685182b25d Add keyboard play: arrows/WASD/hjkl focus, Enter/Space flips
Focus ring appears on the first key press and hides when the mouse moves.
Enter also triggers Play Again on the win panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:34:00 +02:00
SansanandClaude Opus 5.5 8214fda8f6 Add Katakana, Korean and Thai games and difficulty levels
- decks package: Hiragana, Katakana, Hangul jamo and Thai consonants
  (Thai readings carry the letter's name word, since sounds repeat)
- Per-script fonts: Jua for Hangul, Mali for Thai (both OFL)
- Memory game works with any deck; Easy 3x4, Normal 4x6, Hard 4x8
- Language menus pick a deck and difficulty, with number/arrow key shortcuts
- Tests cover every deck, difficulty, and font glyph coverage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:33:16 +02:00
SansanandClaude Opus 5.5 4cfe73c9eb Fix AGENTS.md gotcha ordering
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:27:28 +02:00
SansanandClaude Opus 5.5 e893c45d4f Add Windows cross-build and Gitea release; share CI setup
- just windows-deps / windows / package-windows: cross-compile with Odin
  objects + lld-link against an xwin SDK, static VC runtime
- Release workflow runs on v* tags, builds Linux and Windows packages,
  uploads them to the package registry and creates a Gitea release
- Shared toolchain setup as a local composite action

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:27:22 +02:00
SansanandClaude Opus 5.5 9ee2eabe9a 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>
2026-10-01 21:04:08 +02:00
SansanandClaude Opus 5.5 148570a4e6 Update AGENTS.md for new structure, themes, font and audio
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:47:25 +02:00
SansanandClaude Opus 5.5 5806f0498c Restructure into themed screens with flag card backs, real font, flip animation and sound
- Replace the bogus DroidSansJapanese.ttf (an HTML page) with M PLUS Rounded 1c,
  loaded with LoadFontEx and explicit kana codepoints
- Theme struct per language with flag-based card backs (Hinomaru, Taegukgi, Trairong)
- Card art rendered to textures so the flip squashes the whole card
- Synthesized flip/match/mismatch/win/click sounds (new audio package)
- Shared ui package for text and buttons; Screen enum replaces i32 ids
- Kana <-> romaji pairs drawn from all 46 basic hiragana

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:47:04 +02:00
SansanandClaude Opus 5.5 5557de62a3 Fix ESC quitting, memory layout overlap, shuffle and move counting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:40:54 +02:00
SansanandClaude Opus 5.5 964d9987be Track src modules; stop ignoring .odin files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:40:13 +02:00
29 changed files with 2487 additions and 475 deletions

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
#
# .gitea/actions/setup/action.yaml
#
# Installs the toolchain shared by all workflows: system packages, Odin and just.
#
name: "Setup Odin toolchain"
description: "Install system packages, Odin and just"
inputs:
odin-version:
description: "Odin release tag (keep in sync with odin_version in the justfile)"
default: "dev-2026-09"
packages:
description: "Extra apt packages to install"
default: ""
runs:
using: "composite"
steps:
- name: Install System Packages
shell: bash
run: |
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
$SUDO apt-get update
# clang links Odin programs on Linux; raylib needs X11
$SUDO apt-get install -y --no-install-recommends \
clang libx11-dev curl ca-certificates unzip ${{ inputs.packages }}
- name: Install Odin
shell: bash
run: |
curl -fsSL -o /tmp/odin.tar.gz \
https://github.com/odin-lang/Odin/releases/download/${{ inputs.odin-version }}/odin-linux-amd64-${{ inputs.odin-version }}.tar.gz
mkdir -p "$HOME/odin"
tar -xzf /tmp/odin.tar.gz -C "$HOME/odin" --strip-components=1
echo "$HOME/odin" >> "$GITHUB_PATH"
- name: Install just
shell: bash
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"
+69
View File
@@ -0,0 +1,69 @@
#
# .gitea/workflows/gitea-build.yaml
#
# Checks, tests and builds (Linux, plus a Windows cross-build) on every push and
# pull request to main and develop.
#
name: "Build and Test"
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Toolchain
uses: ./.gitea/actions/setup
- name: Check
run: |
odin version
just check
- name: Test
run: just test
- name: Build
run: just release
# Cross-compiles for Windows so link breakages show up before a release.
# Runs after `build`: both jobs clone the same cached actions/checkout and
# actions/cache repos into one shared directory on the runner, and starting
# them at the same time can race on that clone/fetch - one job then gets a
# missing or corrupted action and fails immediately (seen on run 136, not
# related to the odin test fix above). Sequencing avoids the race.
windows:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Toolchain
uses: ./.gitea/actions/setup
with:
packages: lld
- name: Cache Windows SDK
uses: actions/cache@v4
with:
path: |
build/xwin
build/xwin-sdk
build/raylib-windows
key: windows-sdk-${{ hashFiles('justfile') }}
- name: Windows Build
run: |
# Debian/Ubuntu may only ship a versioned lld-link
LLD_LINK="$(command -v lld-link || ls /usr/lib/llvm-*/bin/lld-link | sort -V | tail -1)"
export LLD_LINK
just windows-deps
just windows
+63
View File
@@ -0,0 +1,63 @@
#
# .gitea/workflows/gitea-release.yaml
#
# On a version tag (e.g. v1.0): tests, builds Linux and Windows packages,
# uploads them to the Gitea generic package registry and creates a Gitea release.
#
name: "Release"
on:
push:
tags:
- 'v*'
env:
PROJECT_NAME: HanjaKanaMun
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Toolchain
uses: ./.gitea/actions/setup
with:
packages: lld
- name: Set Version
run: echo "GAME_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
- name: Test
run: just test
- name: Linux Build
run: just package "$GAME_VERSION"
- name: Windows Build
run: |
# Debian/Ubuntu may only ship a versioned lld-link
LLD_LINK="$(command -v lld-link || ls /usr/lib/llvm-*/bin/lld-link | sort -V | tail -1)"
export LLD_LINK
just windows-deps
just package-windows "$GAME_VERSION"
- name: Upload Artifacts
run: |
ls -la build
for FILE in build/${PROJECT_NAME}_${GAME_VERSION}_*; do
NAME="$(basename "$FILE")"
echo "Uploading $NAME"
curl --fail --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \
--upload-file "$FILE" \
https://gitea.sanplex.xyz/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$NAME
done
- name: Create Release
uses: akkuman/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
with:
files: |-
build/${{ env.PROJECT_NAME }}_*
+4
View File
@@ -16,3 +16,7 @@ HanjaKanaMun
.vscode/
*.swp
*.swo
# Build output
build/
*.dSYM/
+57 -55
View File
@@ -3,75 +3,77 @@
## Overview
Odin + raylib multilingual memory card game for learning Japanese (Hiragana/Katakana), Korean (Hangul), and Thai scripts.
Commands (see `justfile`): `just run`, `just check`, `just test`, `just package <version>`, `just package-windows <version>`
## Current Status
### Completed
- [x] Created AGENTS.md project plan
- [x] Created src/ directory structure with subdirectories (themes, card, memory, screens, assets)
- [x] Separated themes into themes/themes.odin (colors, constants)
- [x] Separated card logic into card/card.odin (Card struct, rendering, click detection)
- [x] Separated memory game into memory/memory.odin (game state, logic)
- [x] Separated screens into screens/screens.odin (UI screens, buttons)
- [x] Added font loading in assets/assets.odin
- [x] Fixed card positioning (removed -40 offset that pushed cards off screen)
- [x] Fixed card selection (now allows selection in both WAITING and FIRST states)
- [x] Added flip animations for cards (visual feedback when flipping)
- [x] Improved visual styling (rounded corners, theme colors)
- [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, 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
- [x] Decks: Hiragana (46), Katakana (46), Hangul basic jamo (24), Thai consonants (42); symbol ↔ reading pairs
- [x] Difficulty levels: Easy 3x4 (6 pairs), Normal 4x6 (12), Hard 4x8 (16)
- [x] Keyboard play: arrows / WASD / hjkl move focus, Enter/Space flips; number keys pick menu entries; Esc goes back (quits from main menu)
- [x] High scores: best moves, best time and games played per deck + difficulty, plus last difficulty, saved as JSON in the user data folder
- [x] Screen fade-in transitions
- [x] Win panel with best score / "New best!", Play Again (Enter) / Menu
- [x] Tooling: justfile, ols/odinfmt config for neovim, tests, Gitea CI (build/test, Windows cross-build, tagged releases)
### In Progress
- [ ] Fix Japanese font rendering (TTF loading fails in raylib with "Failed to process TTF font data")
### Ideas / Next
- [ ] Hangul syllable blocks deck (e.g. 가 ga, 나 na), Thai vowels (needs care: combining marks)
- [ ] Hiragana ↔ katakana pairing mode, dakuten (が, ぱ…) deck
- [ ] Settings: sound on/off, volume
- [ ] Test the Windows build on a real machine
- [ ] macOS release build (would need a macOS runner or osxcross)
### Pending
- [ ] Add keyboard support for card selection (1-9, A-L, etc.)
- [ ] Add proper Japanese font that works with raylib
- [ ] Add Korean and Thai language support
- [ ] Add more animations (win screen, transitions)
- [ ] Add sound effects
- [ ] Add score tracking and high scores
- [ ] Add difficulty levels
- [ ] Test all changes work together
## Known Issues
1. **Font rendering**: Japanese characters show as "?" - raylib's TTF loader fails with CJK fonts. Current workaround: using default font with romanji characters (A, I, U, etc.)
2. **Keyboard support**: Not yet implemented
## Known Issues / Gotchas
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. **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
```
HanjaKanaMun/
├── AGENTS.md # Project plan (this file)
├── README.md # Project overview
├── odin.json # Odin config
├── main.odin # Entry point only
├── README.md
├── justfile # build/run/check/test/package/windows recipes
├── ols.json # ols language server settings (neovim)
├── odinfmt.json # odinfmt formatter settings (4 spaces, LF)
├── .gitea/
│ ├── actions/setup/ # shared CI toolchain setup (apt, Odin, just)
│ └── workflows/
│ ├── gitea-build.yaml # check + test + build, and Windows cross-build, on main/develop
│ └── gitea-release.yaml # on tags v*: Linux + Windows packages, package registry upload, Gitea release
├── main.odin # Window/audio setup, screen state machine, transitions
├── src/
│ ├── themes/
│ │ └── themes.odin # Color themes, styling constants
│ ├── card/
│ │ └── card.odin # Card struct and rendering
│ ├── memory/
│ │ └── memory.odin # Memory game logic
│ ├── screens/
│ │ └── screens.odin # Screen management (main menu, language screens)
│ └── assets/
│ └── assets.odin # Font loading, resource management
└── assets/ # Font files, images (future)
└── DroidSansJapanese.ttf
│ ├── themes/themes.odin # Theme struct, flag colors, Screen/Flag enums, layout constants
│ ├── assets/assets.odin # Font styles and codepoint lists, font loading
│ ├── audio/audio.odin # Synthesized sound effects, play()
│ ├── ui/ui.odin # Text helpers, buttons (with hotkeys), hover cursor
│ ├── card/card.odin # Card struct, flag card-back art, face textures, flip/deal animation
│ ├── decks/decks.odin # Languages, decks and their entries (symbol, reading, hint)
│ ├── memory/memory.odin # Memory game state, logic, keyboard focus, drawing
│ ├── profile/profile.odin # Best scores and settings, JSON in the user data folder
│ └── screens/screens.odin # Main menu and language menus
├── tests/
│ ├── memory_test.odin # Decks, font coverage, game rules, layout, keyboard focus, card animation
│ └── profile_test.odin # Score records, JSON round trip, win recording
└── assets/ # Fonts (+ OFL license files)
```
## Next Steps
1. **Font rendering**: Try alternative approaches:
- Use raylib's LoadFontFromImage to create font from bitmap
- Use pre-converted font files
- Use system fonts via platform-specific APIs
- Use a different font format that raylib supports
2. **Keyboard support**: Add number/letter keys to select cards
3. **Testing**: Verify all current changes work together
## Notes
- NO rm -rf or starting over
- Make small, incremental changes
- Test after each change
- Test after each change (`just check`, `just test`, then `just run`)
- Keep existing functionality working
- All code is now properly separated into modules
- Game is playable with mouse (romanji characters display correctly)
- Commit after each working step
+58 -1
View File
@@ -1,3 +1,60 @@
# HanjaKanaMun
Odin + raylib multilingual application (Thai, Korean, Japanese)
Odin + raylib memory card game for learning Japanese, Korean and Thai scripts.
Match each symbol with its reading: hiragana and katakana with romaji, Hangul letters with their
Revised Romanization, and Thai consonants with their sound and name word. Three difficulties
(6, 12 or 16 pairs), best scores per deck, and a card deck themed on each country's flag.
## Controls
| | |
|---|---|
| Mouse | Click cards and buttons |
| Arrows / WASD / hjkl | Move between cards |
| Enter / Space | Flip the selected card (Enter also plays again) |
| 1-3 | Pick a language or deck in menus |
| Left / Right | Change difficulty |
| Esc | Back (quits from the main menu) |
Scores are saved to `profile.json` in the user data folder (`~/Library/Application Support/HanjaKanaMun`
on macOS, `~/.local/share/HanjaKanaMun` on Linux, `%APPDATA%\HanjaKanaMun` on Windows).
## 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 # native release build -> build/HanjaKanaMun_1.0_<os>_<arch> (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.exe
```
The Windows build needs `lld-link`. On macOS, `brew install lld` and set `LLD_LINK`, and point `XWIN_SDK` and
`RAYLIB_WINDOWS` at an xwin splat and Odin's Windows `vendor/raylib/windows` folder (the Linux/macOS Odin releases
don't include `raylib.lib`).
### 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`, plus a
Windows cross-build so link problems show up before a release.
- `.gitea/workflows/gitea-release.yaml`: on a version tag like `v1.0`, builds Linux and Windows packages, uploads them
to the Gitea generic package registry (`HanjaKanaMun/1.0`) and creates a Gitea release with both attached.
Needs a `TOKEN` secret with package write access.
- `.gitea/actions/setup`: shared toolchain setup (apt packages, Odin, just).
Release: `git tag v1.0 && git push origin v1.0`
## Credits
Fonts, all under the SIL Open Font License (license files in `assets/`):
M PLUS Rounded 1c (UI and kana), Jua (Hangul), Mali (Thai).
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Jua Project Authors
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
Copyright 2016 The Rounded M+ Project Authors.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Copyright 2016 The Rounded M+ Project Authors.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Mali Project Authors (https://github.com/cadsondemak/Mali)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+103
View File
@@ -0,0 +1,103 @@
name := "HanjaKanaMun"
build_dir := "build"
odin_version := "dev-2026-09"
xwin_version := "0.10.0"
# Windows cross-compilation inputs (see `windows-deps`)
xwin := env("XWIN", build_dir / "xwin")
xwin_sdk := env("XWIN_SDK", build_dir / "xwin-sdk")
raylib_windows := env("RAYLIB_WINDOWS", build_dir / "raylib-windows")
lld_link := env("LLD_LINK", "lld-link")
# 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}}
# If the stack rlimit is "unlimited" (RLIM_INFINITY, as some CI/container hosts set it),
# 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. Clamp it.
# (One line: just runs each recipe line in its own shell, so a separate
# `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/<os>/<name> (self-contained: fonts are embedded)
release:
mkdir -p {{build_dir}}/{{os()}}
odin build . -vet -o:speed -out:{{build_dir}}/{{os()}}/{{name}}
# Native release build as build/<name>_<version>_<os>_<arch>
package version="dev": release
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:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p {{build_dir}}
if [ ! -x "{{xwin}}" ]; then
curl -fsSL https://github.com/Jake-Shadle/xwin/releases/download/{{xwin_version}}/xwin-{{xwin_version}}-x86_64-unknown-linux-musl.tar.gz \
| tar -xz -C {{build_dir}} --strip-components=1 xwin-{{xwin_version}}-x86_64-unknown-linux-musl/xwin
fi
if [ ! -d "{{xwin_sdk}}/crt" ]; then
"{{xwin}}" --accept-license --arch x86_64 --cache-dir {{build_dir}}/xwin-cache splat --output "{{xwin_sdk}}"
fi
# Odin's Linux/macOS releases don't ship raylib's Windows library, so take it from the Windows release
if [ ! -f "{{raylib_windows}}/raylib.lib" ]; then
mkdir -p "{{raylib_windows}}"
curl -fsSL -o {{build_dir}}/odin-windows.zip \
https://github.com/odin-lang/Odin/releases/download/{{odin_version}}/odin-windows-amd64-{{odin_version}}.zip
unzip -o -j {{build_dir}}/odin-windows.zip 'dist/vendor/raylib/windows/raylib.lib' -d "{{raylib_windows}}"
fi
# 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
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"
# Odin can't link for Windows from other platforms, so link with lld-link.
# The VC runtime is linked statically (no VCRUNTIME140.dll needed); the
# Universal CRT is used from Windows itself (built into Windows 10+).
{{lld_link}} /nologo /machine:x64 /subsystem:windows /entry:mainCRTStartup \
/out:"$out_dir/{{name}}.exe" "$obj_dir"/*.obj "{{raylib_windows}}/raylib.lib" \
/libpath:"{{xwin_sdk}}/crt/lib/x86_64" \
/libpath:"{{xwin_sdk}}/sdk/lib/um/x86_64" \
/libpath:"{{xwin_sdk}}/sdk/lib/ucrt/x86_64" \
/nodefaultlib:msvcrt /nodefaultlib:vcruntime /nodefaultlib:libucrt \
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
# Windows build as build/<name>_<version>_windows_x86_64.exe
package-windows version="dev": windows
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:
odinfmt -w .
# Remove build output
clean:
if [ -d {{build_dir}} ]; then rm -r -- {{build_dir}}; fi
rm -f -- {{name}}
+88 -31
View File
@@ -1,55 +1,112 @@
package main
import "core:fmt"
import "core:reflect"
import raylib "vendor:raylib"
import themes "src/themes"
import screens "src/screens"
import memory "src/memory"
import assets "src/assets"
import audio "src/audio"
import card "src/card"
import decks "src/decks"
import memory "src/memory"
import profile "src/profile"
import screens "src/screens"
import themes "src/themes"
import ui "src/ui"
current_screen: i32 = themes.MAIN_MENU
mouse_x: i32
mouse_y: i32
TRANSITION_SPEED :: 4 // screen fade-ins per second
// Where Escape takes you from each screen (nil = quit)
parent_screen :: proc(screen: themes.Screen) -> Maybe(themes.Screen) {
switch screen {
case .MAIN_MENU: return nil
case .LANGUAGE_MENU: return .MAIN_MENU
case .MEMORY: return .LANGUAGE_MENU
}
return nil
}
main :: proc() {
raylib.InitWindow(themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, cstring("HanjaKanaMun"))
raylib.SetConfigFlags({.MSAA_4X_HINT, .VSYNC_HINT})
raylib.InitWindow(themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, "HanjaKanaMun")
defer raylib.CloseWindow()
raylib.SetExitKey(.KEY_NULL)
raylib.SetTargetFPS(60)
raylib.InitAudioDevice()
defer raylib.CloseAudioDevice()
audio.load()
defer audio.unload()
assets.load_fonts()
defer assets.unload_fonts()
card.load_art()
defer card.unload_art()
defer memory.unload()
raylib.SetTargetFPS(60)
memory.init_memory_game()
if !profile.init() {
raylib.TraceLog(.WARNING, "PROFILE: Could not read saved scores, starting fresh")
}
for !raylib.WindowShouldClose() {
mouse_x = raylib.GetMouseX()
mouse_y = raylib.GetMouseY()
screen := themes.Screen.MAIN_MENU
language := decks.Language.JAPANESE
difficulty := memory.Difficulty.NORMAL
if saved, ok := reflect.enum_from_name(memory.Difficulty, profile.data.difficulty); ok {
difficulty = saved
}
transition_t: f32 = 1 // fades the screen in from the background color
running := true
for running && !raylib.WindowShouldClose() {
dt := raylib.GetFrameTime()
if raylib.IsKeyPressed(raylib.KeyboardKey.ESCAPE) {
current_screen = themes.MAIN_MENU
if raylib.IsKeyPressed(.ESCAPE) {
if parent, ok := parent_screen(screen).?; ok {
screen = parent
transition_t = 1
} else {
running = false
}
}
if current_screen == themes.JAPANESE_MEMORY {
delta_time := raylib.GetFrameTime()
memory.update_memory_game(delta_time, mouse_x, mouse_y)
if screen == .MEMORY {
memory.update(dt)
}
raylib.BeginDrawing()
if current_screen == themes.MAIN_MENU {
current_screen = screens.draw_main_menu(mouse_x, mouse_y)
} else if current_screen == themes.JAPANESE_SCREEN {
current_screen = screens.draw_japanese_screen(mouse_x, mouse_y)
if current_screen == themes.JAPANESE_MEMORY {
memory.init_memory_game()
ui.begin_frame()
next := screen
start_deck: Maybe(decks.Deck_Id)
switch screen {
case .MAIN_MENU:
if picked, ok := screens.main_menu().?; ok {
language = picked
next = .LANGUAGE_MENU
}
} else if current_screen == themes.JAPANESE_MEMORY {
current_screen = memory.draw_memory_game(mouse_x, mouse_y)
} else if current_screen == themes.KOREAN_SCREEN {
current_screen = screens.draw_korean_screen(mouse_x, mouse_y)
} else if current_screen == themes.THAI_SCREEN {
current_screen = screens.draw_thai_screen(mouse_x, mouse_y)
case .LANGUAGE_MENU:
switch result in screens.language_menu(language, &difficulty) {
case decks.Deck_Id:
start_deck = result
next = .MEMORY
case themes.Screen:
next = result
}
case .MEMORY:
next = memory.draw()
}
if transition_t > 0 {
alpha := transition_t * transition_t
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, raylib.Fade(themes.MENU_BACKGROUND, alpha))
transition_t = max(transition_t - dt * TRANSITION_SPEED, 0)
}
ui.end_frame()
raylib.EndDrawing()
free_all(context.temp_allocator)
profile.set_difficulty(fmt.tprintf("%v", difficulty))
// Building card faces renders into textures, so do it outside of drawing
if deck, ok := start_deck.?; ok {
memory.start(deck, difficulty)
}
if next != screen { transition_t = 1 }
screen = next
}
}
-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"
}
+78 -6
View File
@@ -2,17 +2,89 @@ package assets
import raylib "vendor:raylib"
japanese_font: raylib.Font
// Glyphs are rasterized at this size and scaled down when drawn
FONT_BASE_SIZE :: 128
Font_Style :: enum {
REGULAR, // UI text (M PLUS Rounded 1c Medium)
BOLD, // UI headings and Japanese symbols (M PLUS Rounded 1c ExtraBold)
KOREAN, // Hangul (Jua)
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_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
// Extra non-range characters used in UI text
EXTRA_JAPANESE :: "日本語・·āō"
EXTRA_KOREAN :: "한글국어축하해요"
EXTRA_THAI :: "ไทย"
// raylib only bakes the codepoints it is given, so every glyph a font must draw is listed here.
// Tests check that every deck's characters are covered.
codepoints_for :: proc(style: Font_Style, allocator := context.allocator) -> [dynamic]rune {
codepoints := make([dynamic]rune, allocator)
for r in rune(0x20) ..= 0x7E {
// Jua has no backtick; Korean text never uses it
if style == .KOREAN && r == '`' { continue }
append(&codepoints, r) // ASCII
}
switch style {
case .REGULAR, .BOLD:
for r in rune(0x3041) ..= 0x3096 { append(&codepoints, r) } // Hiragana
for r in rune(0x30A1) ..= 0x30FC { append(&codepoints, r) } // Katakana
for r in EXTRA_JAPANESE { append(&codepoints, r) }
case .KOREAN:
for r in rune(0x3131) ..= 0x3163 { append(&codepoints, r) } // Modern Hangul compatibility jamo
for r in EXTRA_KOREAN { append(&codepoints, r) }
case .THAI:
for r in rune(0x0E01) ..= 0x0E3A { append(&codepoints, r) } // Thai (U+0E3B-0E3E are unassigned)
for r in rune(0x0E3F) ..= 0x0E5B { append(&codepoints, r) }
for r in EXTRA_THAI { append(&codepoints, r) }
}
return codepoints
}
@(private)
load_font :: proc(style: Font_Style) -> raylib.Font {
codepoints := codepoints_for(style)
defer delete(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 style %v", style)
return raylib.GetFontDefault()
}
// Mipmaps keep small UI text smooth when the large atlas is scaled down
raylib.GenTextureMipmaps(&font.texture)
raylib.SetTextureFilter(font.texture, .TRILINEAR)
return font
}
load_fonts :: proc() {
// Use raylib's default font for now
japanese_font = raylib.GetFontDefault()
for style in Font_Style {
fonts[style] = load_font(style)
}
}
unload_fonts :: proc() {
// Don't unload the default font
default_id := raylib.GetFontDefault().texture.id
for font in fonts {
if font.texture.id != default_id { raylib.UnloadFont(font) }
}
}
get_font :: proc() -> raylib.Font {
return japanese_font
get_font :: proc(style: Font_Style = .REGULAR) -> raylib.Font {
return fonts[style]
}
+135
View File
@@ -0,0 +1,135 @@
package audio
import "core:math"
import "core:math/rand"
import raylib "vendor:raylib"
// All sounds are synthesized at startup, so no audio files are needed
SAMPLE_RATE :: 44100
Sound_Effect :: enum {
FLIP,
MATCH,
MISMATCH,
WIN,
CLICK,
}
sounds: [Sound_Effect]raylib.Sound
ready: bool
@(private)
Tone :: struct {
start: f32, // seconds
freq: f32, // Hz
duration: f32, // seconds
amp: f32,
}
// Sine with a little 2nd/3rd harmonic, soft attack and exponential decay: a small bell
@(private)
add_tone :: proc(samples: []f32, tone: Tone) {
first := int(tone.start * SAMPLE_RATE)
count := int(tone.duration * SAMPLE_RATE)
attack: f32 = 0.006
for i in 0 ..< count {
idx := first + i
if idx >= len(samples) { break }
t := f32(i) / SAMPLE_RATE
env := min(t / attack, 1) * math.exp(-5 * t / tone.duration)
phase := 2 * math.PI * tone.freq * t
s := math.sin(phase) + 0.25 * math.sin(2 * phase) + 0.08 * math.sin(3 * phase)
samples[idx] += tone.amp * env * s
}
}
// Card "snap": low-passed noise with a fast decay over a short low thump
@(private)
add_flip :: proc(samples: []f32) {
lp: f32 = 0
for i in 0 ..< len(samples) {
t := f32(i) / SAMPLE_RATE
noise := rand.float32_range(-1, 1)
lp += 0.35 * (noise - lp)
swish := lp * math.exp(-t * 55) * min(t / 0.004, 1)
thump := math.sin(2 * math.PI * 150 * t) * math.exp(-t * 70)
samples[i] += 0.55 * swish + 0.35 * thump
}
}
@(private)
make_sound :: proc(samples: []f32) -> raylib.Sound {
pcm := make([]i16, len(samples))
defer delete(pcm)
for s, i in samples {
pcm[i] = i16(math.clamp(s, -1, 1) * 32000)
}
wave := raylib.Wave{
frameCount = u32(len(pcm)),
sampleRate = SAMPLE_RATE,
sampleSize = 16,
channels = 1,
data = raw_data(pcm),
}
// LoadSoundFromWave copies the samples into its own buffer
return raylib.LoadSoundFromWave(wave)
}
@(private)
build :: proc(duration: f32, tones: []Tone, flip := false) -> raylib.Sound {
samples := make([]f32, int(duration * SAMPLE_RATE))
defer delete(samples)
if flip { add_flip(samples) }
for tone in tones { add_tone(samples, tone) }
return make_sound(samples)
}
// Call after raylib.InitAudioDevice
load :: proc() {
if !raylib.IsAudioDeviceReady() { return }
E5 :: 659.25
A5 :: 880.00
B5 :: 987.77
C5 :: 523.25
G5 :: 783.99
C6 :: 1046.50
E6 :: 1318.51
sounds[.FLIP] = build(0.12, {}, flip = true)
sounds[.MATCH] = build(0.6, {
{start = 0.00, freq = E5, duration = 0.35, amp = 0.30},
{start = 0.09, freq = B5, duration = 0.50, amp = 0.28},
})
sounds[.MISMATCH] = build(0.45, {
{start = 0.00, freq = 330, duration = 0.20, amp = 0.20},
{start = 0.11, freq = 262, duration = 0.30, amp = 0.20},
})
sounds[.WIN] = build(1.3, {
{start = 0.00, freq = C5, duration = 0.40, amp = 0.18},
{start = 0.11, freq = E5, duration = 0.40, amp = 0.18},
{start = 0.22, freq = G5, duration = 0.40, amp = 0.18},
{start = 0.33, freq = C6, duration = 0.90, amp = 0.20},
{start = 0.33, freq = E6, duration = 0.90, amp = 0.07},
})
sounds[.CLICK] = build(0.08, {{start = 0, freq = A5, duration = 0.06, amp = 0.15}})
raylib.SetSoundVolume(sounds[.FLIP], 0.7)
ready = true
}
unload :: proc() {
if !ready { return }
for s in sounds { raylib.UnloadSound(s) }
ready = false
}
play :: proc(effect: Sound_Effect) {
if !ready { return }
if effect == .FLIP {
// Slight pitch variation so repeated flips don't sound identical
raylib.SetSoundPitch(sounds[.FLIP], rand.float32_range(0.9, 1.12))
}
raylib.PlaySound(sounds[effect])
}
+281 -62
View File
@@ -1,81 +1,300 @@
package card
import "core:math"
import raylib "vendor:raylib"
import themes "../themes"
import assets "../assets"
import themes "../themes"
import ui "../ui"
// Card art is rendered once into textures at 2x size and scaled down when drawn.
// This lets the flip animation squash the whole card (text included) horizontally.
ART_SCALE :: 2
ART_WIDTH :: themes.CARD_WIDTH * ART_SCALE
ART_HEIGHT :: themes.CARD_HEIGHT * ART_SCALE
ROUNDNESS :: 0.16
SEGMENTS :: 16
FLIP_SPEED :: 2.6 // flips per second
DEAL_SPEED :: 3.5 // how fast a dealt card settles
DEAL_STAGGER :: 0.12 // delay between consecutive cards, in deal_t units
PULSE_DURATION :: 0.35 // seconds
HOVER_LIFT :: 4
Card :: struct {
x: i32,
y: i32,
character: cstring,
is_face_up: bool,
is_matched: bool,
pair_id: i32,
flip_progress: f32, // 0 = face down, 1 = face up
is_flipping: bool,
flip_direction: bool, // true = to face up, false = to face down
rect: raylib.Rectangle, // resting position on screen
text: cstring,
hint: cstring, // small second line on reading cards
is_symbol: bool, // script symbol (vs. its reading)
pair_id: int,
face_up: bool, // logical state; the animation follows it
matched: bool,
flip_t: f32, // 0 = back showing, 1 = face showing
pulse_t: f32, // counts down after a match
deal_t: f32, // > 0 while being dealt onto the table (0 = resting)
face_art: raylib.RenderTexture2D,
}
draw_card :: proc(card: ^Card, mouse_x, mouse_y: i32, theme_color: raylib.Color) {
draw_color := theme_color
if card.is_matched {
draw_color = themes.LIGHT_GRAY
}
@(private)
back_art: [themes.Flag]raylib.RenderTexture2D
is_hovered := mouse_x >= card.x && mouse_x <= card.x + themes.CARD_WIDTH &&
mouse_y >= card.y && mouse_y <= card.y + themes.CARD_HEIGHT
if is_hovered && !card.is_face_up && !card.is_matched && !card.is_flipping {
draw_color = raylib.Color{
byte(f32(theme_color.r) * 0.8),
byte(f32(theme_color.g) * 0.8),
byte(f32(theme_color.b) * 0.8),
theme_color.a,
}
}
// ---------------------------------------------------------------------------
// Art generation
// ---------------------------------------------------------------------------
// Draw card background with rounded corners
raylib.DrawRectangleRounded(raylib.Rectangle{f32(card.x), f32(card.y), f32(themes.CARD_WIDTH), f32(themes.CARD_HEIGHT)}, 0.2, 10, draw_color)
raylib.DrawRectangleRoundedLines(raylib.Rectangle{f32(card.x), f32(card.y), f32(themes.CARD_WIDTH), f32(themes.CARD_HEIGHT)}, 0.2, 10, themes.BLACK)
@(private)
art_rect :: proc(inset: f32 = 0) -> raylib.Rectangle {
return {inset, inset, ART_WIDTH - 2 * inset, ART_HEIGHT - 2 * inset}
}
// Handle flip animation
if card.is_flipping {
// Draw the flip animation
flip_angle := card.flip_progress * 180.0
if !card.flip_direction {
flip_angle = 180.0 - flip_angle
}
@(private)
new_art_texture :: proc(edge_color: raylib.Color) -> raylib.RenderTexture2D {
rt := raylib.LoadRenderTexture(ART_WIDTH, ART_HEIGHT)
raylib.SetTextureFilter(rt.texture, .BILINEAR)
raylib.BeginTextureMode(rt)
// Transparent, but with the edge's RGB so filtering doesn't create dark fringes
raylib.ClearBackground({edge_color.r, edge_color.g, edge_color.b, 0})
raylib.EndTextureMode()
return rt
}
// Draw the card as a rectangle that's rotating
center_x := f32(card.x + themes.CARD_WIDTH / 2)
center_y := f32(card.y + themes.CARD_HEIGHT / 2)
@(private)
draw_frame :: proc(fill, border: raylib.Color) {
raylib.DrawRectangleRounded(art_rect(), ROUNDNESS, SEGMENTS, fill)
raylib.DrawRectangleRoundedLinesEx(art_rect(3), ROUNDNESS, SEGMENTS, 6, border)
}
// For simplicity, just show the character when flip_progress > 0.5
show_face := card.flip_progress > 0.5
if card.flip_direction {
show_face = card.flip_progress >= 0.5
// Japan: Hinomaru, a crimson sun disc on white, with a double crimson border
@(private)
draw_japan_back :: proc() {
draw_frame(themes.WHITE, themes.HINOMARU_RED)
raylib.DrawRectangleRoundedLinesEx(art_rect(16), ROUNDNESS, SEGMENTS, 2, themes.HINOMARU_RED)
center := raylib.Vector2{ART_WIDTH / 2, ART_HEIGHT / 2}
raylib.DrawCircleV(center, ART_WIDTH * 0.3, themes.HINOMARU_RED)
}
// One Taegukgi trigram: three bars, each either solid or broken in the middle
@(private)
draw_trigram :: proc(center: raylib.Vector2, angle_deg: f32, solid: [3]bool, scale: f32) {
length := 46 * scale
thick := 8 * scale
gap := 6 * scale
rad := angle_deg * math.RAD_PER_DEG
along := raylib.Vector2{math.cos(rad), math.sin(rad)} // bar direction
across := raylib.Vector2{-along.y, along.x} // stacking direction
for i in 0 ..< 3 {
bar_center := center + across * (f32(i - 1) * (thick + gap))
if solid[i] {
raylib.DrawRectanglePro({bar_center.x, bar_center.y, length, thick}, {length / 2, thick / 2}, angle_deg, themes.BLACK)
} else {
show_face = card.flip_progress < 0.5
half := (length - gap) / 2
for side in ([2]f32{-1, 1}) {
c := bar_center + along * (side * (half + gap) / 2)
raylib.DrawRectanglePro({c.x, c.y, half, thick}, {half / 2, thick / 2}, angle_deg, themes.BLACK)
}
}
if show_face || card.is_face_up || card.is_matched {
font := assets.get_font()
text_w := raylib.MeasureText(card.character, 40)
text_x := card.x + (themes.CARD_WIDTH - text_w) / 2
text_y := card.y + (themes.CARD_HEIGHT - 40) / 2 - 8
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x + 1), f32(text_y + 1)}, 40.0, 0, themes.BLACK)
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x), f32(text_y)}, 40.0, 0, themes.WHITE)
}
} else if card.is_face_up || card.is_matched {
font := assets.get_font()
text_w := raylib.MeasureText(card.character, 40)
text_x := card.x + (themes.CARD_WIDTH - text_w) / 2
text_y := card.y + (themes.CARD_HEIGHT - 40) / 2 - 8
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x + 1), f32(text_y + 1)}, 40.0, 0, themes.BLACK)
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x), f32(text_y)}, 40.0, 0, themes.WHITE)
}
}
is_card_clicked :: proc(card: ^Card, mouse_x, mouse_y: i32) -> bool {
return mouse_x >= card.x && mouse_x <= card.x + themes.CARD_WIDTH &&
mouse_y >= card.y && mouse_y <= card.y + themes.CARD_HEIGHT
// Korea: Taegukgi, a red/blue taeguk circle with the four black trigrams in the corners
@(private)
draw_korea_back :: proc() {
draw_frame(themes.WHITE, themes.TAEGUK_BLUE)
center := raylib.Vector2{ART_WIDTH / 2, ART_HEIGHT / 2}
radius: f32 = ART_WIDTH * 0.26
// The flag's taeguk is tilted along the diagonal of a 3:2 rectangle
tilt := math.atan2(f32(2), 3) * math.DEG_PER_RAD
raylib.DrawCircleSector(center, radius, 180 + tilt, 360 + tilt, 48, themes.TAEGUK_RED)
raylib.DrawCircleSector(center, radius, tilt, 180 + tilt, 48, themes.TAEGUK_BLUE)
offset := raylib.Vector2{math.cos(tilt * math.RAD_PER_DEG), math.sin(tilt * math.RAD_PER_DEG)} * (radius / 2)
// Red head toward the upper-left (geon ☰), blue head toward the lower-right (gon ☷)
raylib.DrawCircleV(center - offset, radius / 2, themes.TAEGUK_RED)
raylib.DrawCircleV(center + offset, radius / 2, themes.TAEGUK_BLUE)
scale: f32 = 0.8
dx: f32 = ART_WIDTH * 0.24
dy: f32 = ART_HEIGHT * 0.26
draw_trigram(center + {-dx, -dy}, -45, {true, true, true}, scale) // geon ☰
draw_trigram(center + {dx, -dy}, 45, {false, true, false}, scale) // gam ☵
draw_trigram(center + {-dx, dy}, 45, {true, false, true}, scale) // ri ☲
draw_trigram(center + {dx, dy}, -45, {false, false, false}, scale) // gon ☷
}
// Thailand: Thong Trairong, red-white-blue-white-red stripes (blue twice as wide)
@(private)
draw_thailand_back :: proc() {
draw_frame(themes.WHITE, themes.THAI_BLUE)
panel := art_rect(18)
unit := panel.height / 6
stripes := [5]struct{ color: raylib.Color, units: f32 }{
{themes.THAI_RED, 1}, {themes.WHITE, 1}, {themes.THAI_BLUE, 2}, {themes.WHITE, 1}, {themes.THAI_RED, 1},
}
y := panel.y
for stripe in stripes {
raylib.DrawRectangleRec({panel.x, y, panel.width, unit * stripe.units}, stripe.color)
y += unit * stripe.units
}
raylib.DrawRectangleLinesEx(panel, 2, themes.THAI_BLUE)
}
// Builds the card back textures. Call once after the window is created.
load_art :: proc() {
edges := [themes.Flag]raylib.Color{.JAPAN = themes.HINOMARU_RED, .KOREA = themes.TAEGUK_BLUE, .THAILAND = themes.THAI_BLUE}
for flag in themes.Flag {
back_art[flag] = new_art_texture(edges[flag])
raylib.BeginTextureMode(back_art[flag])
switch flag {
case .JAPAN: draw_japan_back()
case .KOREA: draw_korea_back()
case .THAILAND: draw_thailand_back()
}
raylib.EndTextureMode()
}
}
unload_art :: proc() {
for rt in back_art { raylib.UnloadRenderTexture(rt) }
}
get_back_art :: proc(flag: themes.Flag) -> raylib.Texture2D {
return back_art[flag].texture
}
// Font size that fits text inside the card width
@(private)
fit_size :: proc(text: cstring, size: f32, style: assets.Font_Style) -> f32 {
max_width: f32 = ART_WIDTH * 0.78
width := ui.measure_text(text, size, style).x
return width > max_width ? size * max_width / width : size
}
// Renders the card's face into its own texture. Symbols use their script's font.
build_face :: proc(card: ^Card, theme: themes.Theme, symbol_style: assets.Font_Style) {
unload_face(card)
card.face_art = new_art_texture(theme.primary)
raylib.BeginTextureMode(card.face_art)
draw_frame(theme.card_face, theme.primary)
raylib.DrawRectangleRoundedLinesEx(art_rect(16), ROUNDNESS, SEGMENTS, 2, raylib.Fade(theme.primary, 0.35))
if card.is_symbol {
ui.draw_text_in_rect(card.text, art_rect(), fit_size(card.text, 132, symbol_style), theme.ink, symbol_style)
} else if card.hint == nil {
ui.draw_text_in_rect(card.text, art_rect(), fit_size(card.text, 96, .BOLD), theme.primary, .BOLD)
} else {
// Reading on top, hint underneath
main_rect := art_rect()
main_rect.height -= 56
ui.draw_text_in_rect(card.text, main_rect, fit_size(card.text, 88, .BOLD), theme.primary, .BOLD)
hint_rect := raylib.Rectangle{0, ART_HEIGHT * 0.62, ART_WIDTH, 60}
ui.draw_text_in_rect(card.hint, hint_rect, fit_size(card.hint, 34, .REGULAR), theme.ink, .REGULAR)
}
raylib.EndTextureMode()
}
unload_face :: proc(card: ^Card) {
if card.face_art.id != 0 {
raylib.UnloadRenderTexture(card.face_art)
card.face_art = {}
}
}
// ---------------------------------------------------------------------------
// Behaviour
// ---------------------------------------------------------------------------
is_animating :: proc(card: ^Card) -> bool {
target: f32 = card.face_up ? 1 : 0
return card.flip_t != target
}
update :: proc(card: ^Card, dt: f32) {
target: f32 = card.face_up ? 1 : 0
step := dt * FLIP_SPEED
if card.flip_t < target {
card.flip_t = min(card.flip_t + step, target)
} else if card.flip_t > target {
card.flip_t = max(card.flip_t - step, target)
}
if card.pulse_t > 0 {
card.pulse_t = max(card.pulse_t - dt, 0)
}
if card.deal_t > 0 {
card.deal_t = max(card.deal_t - dt * DEAL_SPEED, 0)
}
}
// Starts the deal-in animation; later cards in the deal arrive later
start_deal :: proc(card: ^Card, order: int) {
card.deal_t = 1 + f32(order) * DEAL_STAGGER
}
start_pulse :: proc(card: ^Card) {
card.pulse_t = PULSE_DURATION
}
is_hovered :: proc(card: ^Card) -> bool {
return raylib.CheckCollisionPointRec(raylib.GetMousePosition(), card.rect)
}
@(private)
smoothstep :: proc(t: f32) -> f32 {
return t * t * (3 - 2 * t)
}
draw :: proc(card: ^Card, theme: themes.Theme, clickable: bool, focused := false) {
eased := smoothstep(card.flip_t)
squash := abs(math.cos(eased * math.PI)) // 1 → 0 → 1 across the flip
showing_face := eased >= 0.5
// Cards lift as they turn, and a little on hover
lift := math.sin(eased * math.PI) * 6
mouse_over := clickable && is_hovered(card)
if mouse_over { ui.set_hovered() }
hovered := mouse_over || (clickable && focused)
if hovered { lift += HOVER_LIFT }
// Dealing: slide up from below and grow in
dealt := smoothstep(1 - clamp(card.deal_t, 0, 1))
if dealt == 0 { return }
deal_offset := (1 - dealt) * 60
scale: f32 = 0.85 + 0.15 * dealt
if card.pulse_t > 0 {
progress := 1 - card.pulse_t / PULSE_DURATION
scale += 0.09 * math.sin(progress * math.PI)
}
width := card.rect.width * scale
height := card.rect.height * scale
center := raylib.Vector2{card.rect.x + card.rect.width / 2, card.rect.y + card.rect.height / 2 - lift + deal_offset}
// Shadow stays on the table and spreads as the card lifts
shadow := raylib.Rectangle{
center.x - width * squash / 2 + 2,
card.rect.y + card.rect.height * (1 - scale) / 2 + 4 + lift * 0.15 + deal_offset,
max(width * squash, 2),
height,
}
raylib.DrawRectangleRounded(shadow, ROUNDNESS, SEGMENTS, raylib.Fade(themes.BLACK, (0.12 - lift * 0.004) * dealt))
// Darken the card while it is edge-on, to fake lighting
shade := u8(255 - (1 - squash) * 70)
tint := raylib.Color{shade, shade, shade, u8(255 * dealt)}
if hovered && !showing_face {
tint = raylib.ColorBrightness(tint, 0.08)
}
texture := showing_face ? card.face_art.texture : back_art[theme.flag].texture
source := raylib.Rectangle{0, 0, f32(texture.width), -f32(texture.height)} // render textures are upside down
dest := raylib.Rectangle{center.x, center.y, width * squash, height}
raylib.DrawTexturePro(texture, source, dest, {dest.width / 2, dest.height / 2}, 0, tint)
if focused {
ring := raylib.Rectangle{center.x - width * squash / 2 - 6, center.y - height / 2 - 6, width * squash + 12, height + 12}
raylib.DrawRectangleRoundedLinesEx(ring, ROUNDNESS, SEGMENTS, 3, raylib.Fade(theme.ink, 0.75))
}
if card.matched && card.flip_t == 1 {
outline := raylib.Rectangle{center.x - width / 2 - 3, center.y - height / 2 - 3, width + 6, height + 6}
raylib.DrawRectangleRoundedLinesEx(outline, ROUNDNESS, SEGMENTS, 3, theme.accent)
}
}
+141
View File
@@ -0,0 +1,141 @@
package decks
import assets "../assets"
import themes "../themes"
Language :: enum {
JAPANESE,
KOREAN,
THAI,
}
Deck_Id :: enum {
HIRAGANA,
KATAKANA,
HANGUL,
THAI_CONSONANTS,
}
// One card pair: the script symbol and how it reads
Entry :: struct {
symbol: cstring,
reading: cstring, // romanization, shown large
hint: cstring, // optional small line under the reading
}
Deck_Info :: struct {
name: cstring, // English name, used on buttons
native: cstring, // name in its own script
language: Language,
}
Language_Info :: struct {
theme: themes.Theme,
native: cstring, // language name in its own script
symbol_style: assets.Font_Style, // font for the script's symbols
congrats: cstring, // win message
congrats_style: assets.Font_Style,
}
@(rodata)
DECKS := [Deck_Id]Deck_Info{
.HIRAGANA = {name = "Hiragana", native = "ひらがな", language = .JAPANESE},
.KATAKANA = {name = "Katakana", native = "カタカナ", language = .JAPANESE},
.HANGUL = {name = "Hangul", native = "한글", language = .KOREAN},
.THAI_CONSONANTS = {name = "Consonants", native = "ก ข ค", language = .THAI},
}
@(rodata)
LANGUAGES := [Language]Language_Info{
.JAPANESE = {theme = themes.JAPAN, native = "日本語", symbol_style = .BOLD, congrats = "おめでとう!", congrats_style = .BOLD},
.KOREAN = {theme = themes.KOREA, native = "한국어", symbol_style = .KOREAN, congrats = "축하해요!", congrats_style = .KOREAN},
// Thai words with vowel/tone marks need text shaping, which raylib doesn't do
.THAI = {theme = themes.THAI, native = "ไทย", symbol_style = .THAI, congrats = "Well done!", congrats_style = .BOLD},
}
@(private, rodata)
japanese_decks := [?]Deck_Id{.HIRAGANA, .KATAKANA}
@(private, rodata)
korean_decks := [?]Deck_Id{.HANGUL}
@(private, rodata)
thai_decks := [?]Deck_Id{.THAI_CONSONANTS}
decks_for :: proc(language: Language) -> []Deck_Id {
switch language {
case .JAPANESE: return japanese_decks[:]
case .KOREAN: return korean_decks[:]
case .THAI: return thai_decks[:]
}
return nil
}
language_of :: proc(deck: Deck_Id) -> Language_Info {
return LANGUAGES[DECKS[deck].language]
}
entries :: proc(deck: Deck_Id) -> []Entry {
switch deck {
case .HIRAGANA: return hiragana[:]
case .KATAKANA: return katakana[:]
case .HANGUL: return hangul[:]
case .THAI_CONSONANTS: return thai_consonants[:]
}
return nil
}
// The 46 basic hiragana (gojūon)
@(private, rodata)
hiragana := [?]Entry{
{"あ", "a", nil}, {"い", "i", nil}, {"う", "u", nil}, {"え", "e", nil}, {"お", "o", nil},
{"か", "ka", nil}, {"き", "ki", nil}, {"く", "ku", nil}, {"け", "ke", nil}, {"こ", "ko", nil},
{"さ", "sa", nil}, {"し", "shi", nil}, {"す", "su", nil}, {"せ", "se", nil}, {"そ", "so", nil},
{"た", "ta", nil}, {"ち", "chi", nil}, {"つ", "tsu", nil}, {"て", "te", nil}, {"と", "to", nil},
{"な", "na", nil}, {"に", "ni", nil}, {"ぬ", "nu", nil}, {"ね", "ne", nil}, {"の", "no", nil},
{"は", "ha", nil}, {"ひ", "hi", nil}, {"ふ", "fu", nil}, {"へ", "he", nil}, {"ほ", "ho", nil},
{"ま", "ma", nil}, {"み", "mi", nil}, {"む", "mu", nil}, {"め", "me", nil}, {"も", "mo", nil},
{"や", "ya", nil}, {"ゆ", "yu", nil}, {"よ", "yo", nil},
{"ら", "ra", nil}, {"り", "ri", nil}, {"る", "ru", nil}, {"れ", "re", nil}, {"ろ", "ro", nil},
{"わ", "wa", nil}, {"を", "wo", nil}, {"ん", "n", nil},
}
// The 46 basic katakana
@(private, rodata)
katakana := [?]Entry{
{"ア", "a", nil}, {"イ", "i", nil}, {"ウ", "u", nil}, {"エ", "e", nil}, {"オ", "o", nil},
{"カ", "ka", nil}, {"キ", "ki", nil}, {"ク", "ku", nil}, {"ケ", "ke", nil}, {"コ", "ko", nil},
{"サ", "sa", nil}, {"シ", "shi", nil}, {"ス", "su", nil}, {"セ", "se", nil}, {"ソ", "so", nil},
{"タ", "ta", nil}, {"チ", "chi", nil}, {"ツ", "tsu", nil}, {"テ", "te", nil}, {"ト", "to", nil},
{"ナ", "na", nil}, {"ニ", "ni", nil}, {"ヌ", "nu", nil}, {"ネ", "ne", nil}, {"ノ", "no", nil},
{"ハ", "ha", nil}, {"ヒ", "hi", nil}, {"フ", "fu", nil}, {"ヘ", "he", nil}, {"ホ", "ho", nil},
{"マ", "ma", nil}, {"ミ", "mi", nil}, {"ム", "mu", nil}, {"メ", "me", nil}, {"モ", "mo", nil},
{"ヤ", "ya", nil}, {"ユ", "yu", nil}, {"ヨ", "yo", nil},
{"ラ", "ra", nil}, {"リ", "ri", nil}, {"ル", "ru", nil}, {"レ", "re", nil}, {"ロ", "ro", nil},
{"ワ", "wa", nil}, {"ヲ", "wo", nil}, {"ン", "n", nil},
}
// The 14 basic consonants and 10 basic vowels, Revised Romanization (initial position)
@(private, rodata)
hangul := [?]Entry{
{"ㄱ", "g", nil}, {"ㄴ", "n", nil}, {"ㄷ", "d", nil}, {"ㄹ", "r", nil}, {"ㅁ", "m", nil},
{"ㅂ", "b", nil}, {"ㅅ", "s", nil}, {"ㅇ", "ng", "silent first"}, {"ㅈ", "j", nil}, {"ㅊ", "ch", nil},
{"ㅋ", "k", nil}, {"ㅌ", "t", nil}, {"ㅍ", "p", nil}, {"ㅎ", "h", nil},
{"ㅏ", "a", nil}, {"ㅑ", "ya", nil}, {"ㅓ", "eo", nil}, {"ㅕ", "yeo", nil}, {"ㅗ", "o", nil},
{"ㅛ", "yo", nil}, {"ㅜ", "u", nil}, {"ㅠ", "yu", nil}, {"ㅡ", "eu", nil}, {"ㅣ", "i", nil},
}
// The 42 Thai consonants in use (ฃ and ฅ are obsolete). Many share a sound, so the
// hint is each letter's acrophonic name word (RTGS), which is unique.
@(private, rodata)
thai_consonants := [?]Entry{
{"ก", "k", "kai"}, {"ข", "kh", "khai"}, {"ค", "kh", "khwai"}, {"ฆ", "kh", "rakhang"},
{"ง", "ng", "ngu"}, {"จ", "ch", "chan"}, {"ฉ", "ch", "ching"}, {"ช", "ch", "chang"},
{"ซ", "s", "so"}, {"ฌ", "ch", "choe"}, {"ญ", "y", "ying"}, {"ฎ", "d", "chada"},
{"ฏ", "t", "patak"}, {"ฐ", "th", "than"}, {"ฑ", "th", "montho"}, {"ฒ", "th", "phuthao"},
{"ณ", "n", "nen"}, {"ด", "d", "dek"}, {"ต", "t", "tao"}, {"ถ", "th", "thung"},
{"ท", "th", "thahan"}, {"ธ", "th", "thong"}, {"น", "n", "nu"}, {"บ", "b", "baimai"},
{"ป", "p", "pla"}, {"ผ", "ph", "phueng"}, {"ฝ", "f", "fa"}, {"พ", "ph", "phan"},
{"ฟ", "f", "fan"}, {"ภ", "ph", "samphao"}, {"ม", "m", "ma"}, {"ย", "y", "yak"},
{"ร", "r", "ruea"}, {"ล", "l", "ling"}, {"ว", "w", "waen"}, {"ศ", "s", "sala"},
{"ษ", "s", "ruesi"}, {"ส", "s", "suea"}, {"ห", "h", "hip"}, {"ฬ", "l", "chula"},
{"อ", "o", "ang"}, {"ฮ", "h", "nokhuk"},
}
+292 -184
View File
@@ -1,218 +1,326 @@
package memory
import "core:fmt"
import "core:math/rand"
import "core:strings"
import raylib "vendor:raylib"
import themes "../themes"
import audio "../audio"
import card "../card"
import assets "../assets"
import decks "../decks"
import profile "../profile"
import themes "../themes"
import ui "../ui"
MemoryState :: enum {
WAITING,
FIRST,
SECOND,
MAX_CARDS :: 32
MATCH_DELAY :: 0.35 // seconds both cards stay up before a match is confirmed
MISMATCH_DELAY :: 0.9 // seconds before a wrong pair flips back
Difficulty :: enum {
EASY,
NORMAL,
HARD,
}
Layout :: struct {
rows, cols: int,
}
@(rodata)
LAYOUTS := [Difficulty]Layout{
.EASY = {3, 4},
.NORMAL = {4, 6},
.HARD = {4, 8},
}
@(rodata)
DIFFICULTY_NAMES := [Difficulty]cstring{.EASY = "Easy", .NORMAL = "Normal", .HARD = "Hard"}
State :: enum {
WAITING, // no card selected
FIRST, // one card selected
RESOLVING, // two cards up, waiting before match/mismatch
OVER,
}
memory_cards: [24]card.Card
memory_state: MemoryState
memory_first: i32
memory_second: i32
memory_matches: i32
memory_moves: i32
memory_time: f32
memory_flip: f32
cards: [MAX_CARDS]card.Card
card_count: int
pair_count: int
deck: decks.Deck_Id
difficulty: Difficulty
theme: themes.Theme
layout: Layout
state: State
first: int = -1
second: int = -1
matches: int
moves: int
elapsed: f32
resolve_timer: f32
win_t: f32 // fades in the win panel
restart_requested: bool
last_result: profile.Result // filled in when a game is won
focus: int // card selected with the keyboard
keyboard_active: bool // show the focus ring (hidden again when the mouse moves)
init_memory_game :: proc() {
hiragana_chars: [12]cstring = {"あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ", "さ", "し"}
romanji_chars: [12]cstring = {"A", "I", "U", "E", "O", "KA", "KI", "KU", "KE", "KO", "SA", "SHI"}
for i := 0; i < 12; i += 1 {
memory_cards[i*2] = card.Card{
x = 0, y = 0,
character = romanji_chars[i],
is_face_up = false,
is_matched = false,
pair_id = i32(i),
flip_progress = 0.0,
is_flipping = false,
flip_direction = false,
}
memory_cards[i*2+1] = card.Card{
x = 0, y = 0,
character = romanji_chars[i],
is_face_up = false,
is_matched = false,
pair_id = i32(i),
flip_progress = 0.0,
is_flipping = false,
flip_direction = false,
}
}
for i := 0; i < 100; i += 1 {
a := i % 24
b := (i * 7) % 24
temp := memory_cards[a]
memory_cards[a] = memory_cards[b]
memory_cards[b] = temp
}
total_w := themes.GRID_COLS * (themes.CARD_WIDTH + themes.CARD_SPACING) - themes.CARD_SPACING
total_h := themes.GRID_ROWS * (themes.CARD_HEIGHT + themes.CARD_SPACING) - themes.CARD_SPACING
start_x := (themes.SCREEN_WIDTH - total_w) / 2
start_y := (themes.SCREEN_HEIGHT - total_h) / 2
for i := 0; i < 24; i += 1 {
row := i32(i) / themes.GRID_COLS
col := i32(i) % themes.GRID_COLS
memory_cards[i].x = start_x + col * (themes.CARD_WIDTH + themes.CARD_SPACING)
memory_cards[i].y = start_y + row * (themes.CARD_HEIGHT + themes.CARD_SPACING)
}
memory_state = MemoryState.WAITING
memory_first = -1
memory_second = -1
memory_matches = 0
memory_moves = 0
memory_time = 0.0
memory_flip = 0.0
// Key for this deck and difficulty in the saved profile
score_key :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) -> string {
return fmt.tprintf("%v/%v", deck_id, game_difficulty)
}
update_memory_game :: proc(delta_time: f32, mouse_x, mouse_y: i32) {
memory_time += delta_time
// Deals a new game and renders the card faces. Needs the window to exist.
start :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) {
deal(deck_id, game_difficulty)
style := decks.language_of(deck).symbol_style
for &c in active_cards() { card.build_face(&c, theme, style) }
}
if memory_state == MemoryState.SECOND {
memory_flip += delta_time
if memory_flip >= themes.FLIP_DELAY {
if memory_first >= 0 && memory_second >= 0 {
if memory_cards[memory_first].pair_id == memory_cards[memory_second].pair_id {
memory_cards[memory_first].is_matched = true
memory_cards[memory_second].is_matched = true
memory_matches += 1
} else {
memory_cards[memory_first].is_flipping = true
memory_cards[memory_first].flip_direction = false
memory_cards[memory_first].flip_progress = 0.0
memory_cards[memory_second].is_flipping = true
memory_cards[memory_second].flip_direction = false
memory_cards[memory_second].flip_progress = 0.0
}
}
memory_state = MemoryState.WAITING
memory_flip = 0.0
memory_first = -1
memory_second = -1
// Shuffles, lays out and resets the game state without touching the GPU
deal :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) {
unload()
deck = deck_id
difficulty = game_difficulty
theme = decks.language_of(deck).theme
layout = LAYOUTS[difficulty]
card_count = layout.rows * layout.cols
pair_count = card_count / 2
// Pick random entries from the deck
entries := decks.entries(deck)
order := make([]int, len(entries), context.temp_allocator)
for &o, i in order { o = i }
rand.shuffle(order)
for pair in 0 ..< pair_count {
entry := entries[order[pair]]
cards[pair * 2] = card.Card{text = entry.symbol, is_symbol = true, pair_id = pair}
cards[pair * 2 + 1] = card.Card{text = entry.reading, hint = entry.hint, pair_id = pair}
}
rand.shuffle(cards[:card_count])
// Center the grid horizontally and in the space below the header
step_x: f32 = themes.CARD_WIDTH + themes.CARD_SPACING
step_y: f32 = themes.CARD_HEIGHT + themes.CARD_SPACING
total_w := f32(layout.cols) * step_x - themes.CARD_SPACING
total_h := f32(layout.rows) * step_y - themes.CARD_SPACING
start_x := (themes.SCREEN_WIDTH - total_w) / 2
start_y := themes.GRID_TOP + (themes.SCREEN_HEIGHT - 16 - themes.GRID_TOP - total_h) / 2
for &c, i in active_cards() {
card.start_deal(&c, i)
c.rect = {
start_x + f32(i % layout.cols) * step_x,
start_y + f32(i / layout.cols) * step_y,
themes.CARD_WIDTH,
themes.CARD_HEIGHT,
}
}
// Update flip animations
for i := 0; i < 24; i += 1 {
if memory_cards[i].is_flipping {
memory_cards[i].flip_progress += delta_time * 3.0 // Flip speed
if memory_cards[i].flip_progress >= 1.0 {
memory_cards[i].flip_progress = 1.0
memory_cards[i].is_flipping = false
memory_cards[i].is_face_up = memory_cards[i].flip_direction
}
state = .WAITING
first = -1
second = -1
matches = 0
moves = 0
elapsed = 0
resolve_timer = 0
win_t = 0
restart_requested = false
focus = 0
}
active_cards :: proc() -> []card.Card {
return cards[:card_count]
}
unload :: proc() {
for &c in cards { card.unload_face(&c) }
}
// Whether the player can click or press on card i right now (it must have finished being dealt)
can_interact :: proc(i: int) -> bool {
return can_pick(i) && cards[i].deal_t == 0
}
can_pick :: proc(i: int) -> bool {
if i < 0 || i >= card_count { return false }
c := &cards[i]
return (state == .WAITING || state == .FIRST) && !c.face_up && !c.matched
}
// 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 {
first = i
state = .FIRST
} else {
second = i
moves += 1
is_match := cards[first].pair_id == cards[second].pair_id
resolve_timer = is_match ? MATCH_DELAY : MISMATCH_DELAY
state = .RESOLVING
}
return true
}
// Moves the keyboard focus within the grid, clamped at the edges
move_focus :: proc(dx, dy: int) {
col := clamp(focus % layout.cols + dx, 0, layout.cols - 1)
row := clamp(focus / layout.cols + dy, 0, layout.rows - 1)
focus = row * layout.cols + col
}
@(private)
key_pressed :: proc(keys: ..raylib.KeyboardKey) -> bool {
for key in keys {
if raylib.IsKeyPressed(key) || raylib.IsKeyPressedRepeat(key) { return true }
}
return false
}
// Arrows, WASD or vim keys move the focus; Enter or Space flips the focused card
@(private)
handle_keyboard :: proc() {
if raylib.GetMouseDelta() != {} { keyboard_active = false }
dx, dy := 0, 0
if key_pressed(.LEFT, .A, .H) { dx -= 1 }
if key_pressed(.RIGHT, .D, .L) { dx += 1 }
if key_pressed(.UP, .W, .K) { dy -= 1 }
if key_pressed(.DOWN, .S, .J) { dy += 1 }
if dx != 0 || dy != 0 {
// The first key press only reveals the focus ring
if keyboard_active { move_focus(dx, dy) }
keyboard_active = true
}
if raylib.IsKeyPressed(.ENTER) || raylib.IsKeyPressed(.SPACE) {
keyboard_active = true
if can_interact(focus) { select_card(focus) }
}
}
@(private)
resolve :: proc() {
a, b := &cards[first], &cards[second]
if a.pair_id == b.pair_id {
a.matched = true
b.matched = true
card.start_pulse(a)
card.start_pulse(b)
matches += 1
if matches == pair_count {
state = .OVER
last_result = profile.record_game(score_key(deck, difficulty), moves, elapsed)
audio.play(.WIN)
} else {
audio.play(.MATCH)
state = .WAITING
}
} else {
a.face_up = false
b.face_up = false
audio.play(.MISMATCH)
audio.play(.FLIP)
state = .WAITING
}
first = -1
second = -1
}
update :: proc(dt: f32) {
if restart_requested {
start(deck, difficulty)
return
}
if memory_state == MemoryState.WAITING || memory_state == MemoryState.FIRST {
for i := 0; i < 24; i += 1 {
if !memory_cards[i].is_matched && !memory_cards[i].is_flipping {
if card.is_card_clicked(&memory_cards[i], mouse_x, mouse_y) &&
raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT) {
memory_cards[i].is_flipping = true
memory_cards[i].flip_direction = true
memory_cards[i].flip_progress = 0.0
memory_moves += 1
if memory_first == -1 {
memory_first = i32(i)
memory_state = MemoryState.FIRST
} else if i32(i) != memory_first {
memory_second = i32(i)
memory_state = MemoryState.SECOND
}
for &c in active_cards() { card.update(&c, dt) }
if state != .OVER { handle_keyboard() }
switch state {
case .WAITING, .FIRST:
elapsed += dt
if raylib.IsMouseButtonPressed(.LEFT) {
for &c, i in active_cards() {
if card.is_hovered(&c) && can_interact(i) && select_card(i) {
break
}
}
}
}
if memory_matches >= 12 {
memory_state = MemoryState.OVER
case .RESOLVING:
elapsed += dt
// Wait for the second card to finish turning before starting the delay
if !card.is_animating(&cards[second]) {
resolve_timer -= dt
if resolve_timer <= 0 { resolve() }
}
case .OVER:
win_t = min(win_t + dt * 2.5, 1)
}
}
draw_memory_game :: proc(mouse_x, mouse_y: i32) -> i32 {
raylib.ClearBackground(themes.JAPAN_BACKGROUND)
// Draws the game and returns the screen to show next frame
draw :: proc() -> themes.Screen {
next := themes.Screen.MEMORY
raylib.ClearBackground(theme.background)
font := assets.get_font()
title: cstring = "Memory Game - Japanese"
title_w := raylib.MeasureText(title, themes.TITLE_FONT_SIZE)
title_x := (themes.SCREEN_WIDTH - title_w) / 2
raylib.DrawTextEx(font, title, raylib.Vector2{f32(title_x + 4), 54}, f32(themes.TITLE_FONT_SIZE), 0, themes.BLACK)
raylib.DrawTextEx(font, title, raylib.Vector2{f32(title_x), 50}, f32(themes.TITLE_FONT_SIZE), 0, themes.JAPAN_PRIMARY)
info := decks.DECKS[deck]
ui.draw_title(raylib.TextFormat("%s · %s", theme.name, info.name), 24, theme.primary, 48)
minutes := int(elapsed) / 60
seconds := int(elapsed) % 60
stats := raylib.TextFormat("%s · Moves %d · Pairs %d/%d · %d:%02d",
DIFFICULTY_NAMES[difficulty], moves, matches, pair_count, minutes, seconds)
ui.draw_text_centered(stats, themes.SCREEN_WIDTH / 2, 88, 24, theme.ink)
info_text := raylib.TextFormat("Moves: %d | Time: %.1fs", memory_moves, memory_time)
info_w := raylib.MeasureText(info_text, themes.SUBTITLE_FONT_SIZE)
info_x := (themes.SCREEN_WIDTH - info_w) / 2
raylib.DrawTextEx(font, info_text, raylib.Vector2{f32(info_x), 150}, f32(themes.SUBTITLE_FONT_SIZE), 0, themes.DARK_GRAY)
for i := 0; i < 24; i += 1 {
card.draw_card(&memory_cards[i], mouse_x, mouse_y, themes.JAPAN_PRIMARY)
if state != .OVER && ui.button({30, 28, 120, 48}, "Back", theme.primary, font_size = 22) {
next = .LANGUAGE_MENU
}
back_w := themes.BUTTON_WIDTH
back_h := themes.BUTTON_HEIGHT
back_x := (themes.SCREEN_WIDTH - back_w) / 2
back_y := themes.SCREEN_HEIGHT - 100
back_hovered := mouse_x >= back_x && mouse_x <= back_x + back_w &&
mouse_y >= back_y && mouse_y <= back_y + back_h
back_color := themes.JAPAN_PRIMARY
if back_hovered {
back_color = themes.JAPAN_ACCENT
}
raylib.DrawRectangle(back_x, back_y, back_w, back_h, back_color)
raylib.DrawRectangleLines(back_x, back_y, back_w, back_h, themes.BLACK)
back_text: cstring = "Back"
back_text_w := raylib.MeasureText(back_text, themes.BUTTON_FONT_SIZE)
back_text_x := back_x + (back_w - back_text_w) / 2
back_text_y := back_y + (back_h - themes.BUTTON_FONT_SIZE) / 2
raylib.DrawTextEx(font, back_text, raylib.Vector2{f32(back_text_x), f32(back_text_y)}, f32(themes.BUTTON_FONT_SIZE), 0, themes.WHITE)
if back_hovered && raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT) {
return themes.JAPANESE_SCREEN
for &c, i in active_cards() {
card.draw(&c, theme, can_interact(i), focused = keyboard_active && i == focus && state != .OVER && c.deal_t == 0)
}
if memory_state == MemoryState.OVER {
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, raylib.Color{0, 0, 0, 128})
win_text: cstring = "Congratulations! You Win!"
win_w := raylib.MeasureText(win_text, 48)
win_x := (themes.SCREEN_WIDTH - win_w) / 2
raylib.DrawTextEx(font, win_text, raylib.Vector2{f32(win_x), f32(themes.SCREEN_HEIGHT / 2 - 60)}, 48, 0, themes.WHITE)
stats_text := raylib.TextFormat("Moves: %d | Time: %.1fs", memory_moves, memory_time)
stats_w := raylib.MeasureText(stats_text, 32)
stats_x := (themes.SCREEN_WIDTH - stats_w) / 2
raylib.DrawTextEx(font, stats_text, raylib.Vector2{f32(stats_x), f32(themes.SCREEN_HEIGHT / 2)}, 32, 0, themes.WHITE)
again_text: cstring = "Play Again"
again_w := raylib.MeasureText(again_text, themes.BUTTON_FONT_SIZE)
again_x := (themes.SCREEN_WIDTH - again_w) / 2
again_y := themes.SCREEN_HEIGHT / 2 + 60
again_hovered := mouse_x >= again_x && mouse_x <= again_x + again_w &&
mouse_y >= again_y && mouse_y <= again_y + themes.BUTTON_FONT_SIZE
again_color := themes.JAPAN_PRIMARY
if again_hovered {
again_color = themes.JAPAN_ACCENT
}
raylib.DrawTextEx(font, again_text, raylib.Vector2{f32(again_x), f32(again_y)}, f32(themes.BUTTON_FONT_SIZE), 0, again_color)
if again_hovered && raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT) {
init_memory_game()
if state == .OVER {
if action := draw_win_panel(); action != nil {
next = action.?
}
}
return themes.JAPANESE_MEMORY
return next
}
@(private)
draw_win_panel :: proc() -> Maybe(themes.Screen) {
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, raylib.Fade(themes.BLACK, 0.45 * win_t))
panel_w: f32 = 560
panel_h: f32 = 340
slide := (1 - win_t) * 40
panel := raylib.Rectangle{(themes.SCREEN_WIDTH - panel_w) / 2, (themes.SCREEN_HEIGHT - panel_h) / 2 + slide, panel_w, panel_h}
raylib.DrawRectangleRounded({panel.x, panel.y + 8, panel.width, panel.height}, 0.12, 16, raylib.Fade(themes.BLACK, 0.25 * win_t))
raylib.DrawRectangleRounded(panel, 0.12, 16, raylib.Fade(theme.card_face, win_t))
raylib.DrawRectangleRoundedLinesEx(panel, 0.12, 16, 4, raylib.Fade(theme.primary, win_t))
language := decks.language_of(deck)
cx := panel.x + panel_w / 2
ui.draw_text_centered(language.congrats, cx, panel.y + 34, 56, raylib.Fade(theme.primary, win_t), language.congrats_style)
ui.draw_text_centered("All pairs found!", cx, panel.y + 112, 26, raylib.Fade(theme.ink, win_t))
stats := strings.clone_to_cstring(fmt.tprintf("%d moves · %s", moves, profile.format_time(elapsed)), context.temp_allocator)
ui.draw_text_centered(stats, cx, panel.y + 156, 26, raylib.Fade(theme.ink, win_t), .BOLD)
best := last_result.record
best_text := strings.clone_to_cstring(fmt.tprintf("Best %d moves · %s", best.best_moves, profile.format_time(best.best_seconds)), context.temp_allocator)
if last_result.new_best_moves || last_result.new_best_time {
best_text = strings.clone_to_cstring(fmt.tprintf("New best! · %d games played", best.games), context.temp_allocator)
}
ui.draw_text_centered(best_text, cx, panel.y + 198, 22, raylib.Fade(theme.primary, win_t), .BOLD)
if win_t < 1 { return nil }
button_w: f32 = 200
button_y := panel.y + panel_h - 100
if ui.button({cx - button_w - 10, button_y, button_w, 60}, "Play Again", theme.primary, hotkey = .ENTER) {
restart_requested = true
}
if ui.button({cx + 10, button_y, button_w, 60}, "Menu", theme.ink) {
return .LANGUAGE_MENU
}
return nil
}
+115
View File
@@ -0,0 +1,115 @@
package profile
import "base:runtime"
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:strings"
// Best results and settings, saved as JSON in the user's data folder
// (e.g. ~/Library/Application Support/HanjaKanaMun on macOS,
// ~/.local/share/HanjaKanaMun on Linux, %APPDATA%/HanjaKanaMun on Windows).
FILE_NAME :: "profile.json"
Record :: struct {
best_moves: int, // fewest moves to clear the board
best_seconds: f32, // fastest time to clear the board
games: int,
}
Data :: struct {
difficulty: string, // last chosen difficulty, by name
records: map[string]Record, // keyed by "<deck>/<difficulty>"
}
Result :: struct {
record: Record,
new_best_moves: bool,
new_best_time: bool,
}
data: Data
path: string // empty until init: nothing is read or written
// The profile lives for the whole program, so it always allocates from the heap
// rather than whatever allocator the caller's context has.
@(private)
persistent :: proc() -> runtime.Allocator {
return runtime.heap_allocator()
}
// Loads the profile from `file_path`, or the default location when empty.
// Returns false if it existed but could not be read (the game then starts fresh).
init :: proc(file_path := "") -> bool {
context.allocator = persistent()
path = file_path
if path == "" {
dir, err := os.user_data_dir(context.allocator)
if err != nil { return false }
dir = strings.concatenate({dir, "/HanjaKanaMun"})
os.make_directory_all(dir)
path = strings.concatenate({dir, "/" + FILE_NAME})
}
if !os.exists(path) { return true }
bytes, read_err := os.read_entire_file(path, context.allocator)
if read_err != nil { return false }
defer delete(bytes)
loaded: Data
if json.unmarshal(bytes, &loaded) != nil { return false }
data = loaded
return true
}
save :: proc() -> bool {
if path == "" { return true }
bytes, err := json.marshal(data, {pretty = true, use_spaces = true, spaces = 2})
if err != nil { return false }
defer delete(bytes)
return os.write_entire_file(path, bytes) == nil
}
get :: proc(key: string) -> (Record, bool) {
record, ok := data.records[key]
return record, ok
}
// Records a finished game and saves. Reports which bests were beaten.
record_game :: proc(key: string, moves: int, seconds: f32) -> Result {
context.allocator = persistent()
result: Result
old, existed := data.records[key]
new := old
new.games += 1
if !existed || moves < old.best_moves {
new.best_moves = moves
result.new_best_moves = existed
}
if !existed || seconds < old.best_seconds {
new.best_seconds = seconds
result.new_best_time = existed
}
if existed {
data.records[key] = new
} else {
data.records[strings.clone(key)] = new
}
result.record = new
save()
return result
}
set_difficulty :: proc(difficulty: string) {
if data.difficulty == difficulty { return }
context.allocator = persistent()
delete(data.difficulty)
data.difficulty = strings.clone(difficulty)
save()
}
// Formats seconds as m:ss
format_time :: proc(seconds: f32) -> string {
total := int(seconds)
return fmt.tprintf("%d:%02d", total / 60, total % 60)
}
+122 -92
View File
@@ -1,113 +1,143 @@
package screens
import "core:fmt"
import "core:math"
import "core:strings"
import raylib "vendor:raylib"
import card "../card"
import decks "../decks"
import memory "../memory"
import profile "../profile"
import themes "../themes"
import assets "../assets"
import ui "../ui"
draw_button :: proc(x, y, w, h: i32, text: cstring, base_color, hover_color: raylib.Color, mouse_x, mouse_y: i32) -> bool {
is_hovered := mouse_x >= x && mouse_x <= x + w && mouse_y >= y && mouse_y <= y + h
color := base_color
if is_hovered {
color = hover_color
}
raylib.DrawRectangle(x, y, w, h, color)
raylib.DrawRectangleLines(x, y, w, h, themes.BLACK)
font := assets.get_font()
text_w := raylib.MeasureText(text, themes.BUTTON_FONT_SIZE)
text_x := x + (w - text_w) / 2
text_y := y + (h - themes.BUTTON_FONT_SIZE) / 2
raylib.DrawTextEx(font, text, raylib.Vector2{f32(text_x), f32(text_y)}, f32(themes.BUTTON_FONT_SIZE), 0, themes.WHITE)
return is_hovered && raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT)
// Draws a card back like a playing card lying on the table
@(private)
draw_card_art :: proc(flag: themes.Flag, rect: raylib.Rectangle, rotation: f32 = 0) {
texture := card.get_back_art(flag)
source := raylib.Rectangle{0, 0, f32(texture.width), -f32(texture.height)}
dest := raylib.Rectangle{rect.x + rect.width / 2, rect.y + rect.height / 2, rect.width, rect.height}
origin := raylib.Vector2{rect.width / 2, rect.height / 2}
shadow := dest
shadow.x += 3
shadow.y += 6
raylib.DrawRectanglePro(shadow, origin, rotation, raylib.Fade(themes.BLACK, 0.15))
raylib.DrawTexturePro(texture, source, dest, origin, rotation, themes.WHITE)
}
draw_title :: proc(text: cstring, y: i32, color: raylib.Color) {
font := assets.get_font()
text_w := raylib.MeasureText(text, themes.TITLE_FONT_SIZE)
x := (themes.SCREEN_WIDTH - text_w) / 2
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x + 4), f32(y + 4)}, f32(themes.TITLE_FONT_SIZE), 0, themes.BLACK)
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x), f32(y)}, f32(themes.TITLE_FONT_SIZE), 0, color)
@(private)
NUMBER_KEYS :: [?]raylib.KeyboardKey{.ONE, .TWO, .THREE, .FOUR, .FIVE}
// Returns the language picked this frame, if any
main_menu :: proc() -> Maybe(decks.Language) {
raylib.ClearBackground(themes.MENU_BACKGROUND)
languages := decks.LANGUAGES
keys := NUMBER_KEYS
// Thin stripes along the top in each language's primary color
stripe_w: i32 = themes.SCREEN_WIDTH / len(decks.Language)
for lang, i in languages {
raylib.DrawRectangle(i32(i) * stripe_w, 0, stripe_w, 8, lang.theme.primary)
}
ui.draw_title("HanjaKanaMun", 70, themes.SUMI_INK, 84)
ui.draw_text_centered("Learn Japanese, Korean and Thai scripts", themes.SCREEN_WIDTH / 2, 170, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
tile_w: f32 = 150
tile_h: f32 = 200
gap: f32 = 110
total := 3 * tile_w + 2 * gap
start_x := (themes.SCREEN_WIDTH - total) / 2
// Gentle bobbing so the menu feels alive
time := f32(raylib.GetTime())
picked: Maybe(decks.Language)
for lang, language in languages {
i := int(language)
x := start_x + f32(i) * (tile_w + gap)
bob := math.sin(time * 1.6 + f32(i) * 1.1) * 4
rotation := f32(i - 1) * 4
draw_card_art(lang.theme.flag, {x, 250 + bob, tile_w, tile_h}, rotation)
button_rect := raylib.Rectangle{x + tile_w / 2 - (themes.BUTTON_WIDTH - 60) / 2, 500, themes.BUTTON_WIDTH - 60, themes.BUTTON_HEIGHT}
if ui.button(button_rect, lang.theme.name, lang.theme.primary, hotkey = keys[i]) {
picked = language
}
}
ui.draw_text_centered("1-3 pick a language · In game: arrows, WASD or hjkl to move, Enter/Space to flip · Esc back / quit",
themes.SCREEN_WIDTH / 2, 660, 18, raylib.Fade(themes.DARK_GRAY, 0.8))
return picked
}
draw_centered_text :: proc(text: cstring, y: i32, font_size: i32, color: raylib.Color) {
font := assets.get_font()
text_w := raylib.MeasureText(text, font_size)
x := (themes.SCREEN_WIDTH - text_w) / 2
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x + 4), f32(y + 4)}, f32(font_size), 0, themes.BLACK)
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x), f32(y)}, f32(font_size), 0, color)
Language_Menu_Result :: union {
decks.Deck_Id, // start a game with this deck
themes.Screen, // go to another screen
}
draw_main_menu :: proc(mouse_x, mouse_y: i32) -> i32 {
raylib.ClearBackground(themes.WHITE)
// A language's menu: pick a deck and a difficulty
language_menu :: proc(language: decks.Language, difficulty: ^memory.Difficulty) -> Language_Menu_Result {
info := decks.LANGUAGES[language]
theme := info.theme
keys := NUMBER_KEYS
raylib.ClearBackground(theme.background)
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, 8, theme.primary)
draw_title("HanjaKanaMun", 100, themes.JAPAN_PRIMARY)
draw_centered_text("Learn Japanese, Korean, and Thai", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
ui.draw_title(theme.name, 50, theme.primary)
ui.draw_text_centered(info.native, themes.SCREEN_WIDTH / 2, 140, 30, theme.ink, info.symbol_style)
button_w := themes.BUTTON_WIDTH
button_h := themes.BUTTON_HEIGHT
button_space: i32 = 40
total_w := button_w * 3 + button_space * 2
start_x := (themes.SCREEN_WIDTH - total_w) / 2
start_y := themes.SCREEN_HEIGHT / 2 + 100
if draw_button(start_x, start_y, button_w, button_h, "Japanese", themes.JAPAN_PRIMARY, themes.JAPAN_ACCENT, mouse_x, mouse_y) {
return themes.JAPANESE_SCREEN
// A small fan of card backs
for i in 0 ..< 3 {
offset := f32(i - 1)
draw_card_art(theme.flag, {themes.SCREEN_WIDTH / 2 - 50 + offset * 64, 195 + abs(offset) * 10, 100, 133}, offset * 9)
}
if draw_button(start_x + button_w + button_space, start_y, button_w, button_h, "Korean", themes.KOREA_PRIMARY, themes.KOREA_ACCENT, mouse_x, mouse_y) {
return themes.KOREAN_SCREEN
result: Language_Menu_Result
// One button per deck
deck_list := decks.decks_for(language)
button_w: f32 = 220
gap: f32 = 24
total := f32(len(deck_list)) * button_w + f32(len(deck_list) - 1) * gap
x := (themes.SCREEN_WIDTH - total) / 2
for deck, i in deck_list {
rect := raylib.Rectangle{x, 380, button_w, themes.BUTTON_HEIGHT}
if ui.button(rect, decks.DECKS[deck].name, theme.primary, hotkey = keys[i]) {
result = deck
}
ui.draw_text_centered(decks.DECKS[deck].native, x + button_w / 2, 448, 22, theme.ink, info.symbol_style)
best_text := cstring("No games yet")
if record, ok := profile.get(memory.score_key(deck, difficulty^)); ok {
best_text = strings.clone_to_cstring(fmt.tprintf("Best %d moves · %s", record.best_moves, profile.format_time(record.best_seconds)), context.temp_allocator)
}
ui.draw_text_centered(best_text, x + button_w / 2, 476, 17, raylib.Fade(theme.ink, 0.65))
x += button_w + gap
}
if draw_button(start_x + i32(button_w) + i32(button_space) + i32(button_w) + i32(button_space), start_y, button_w, button_h, "Thai", themes.THAI_PRIMARY, themes.THAI_ACCENT, mouse_x, mouse_y) {
return themes.THAI_SCREEN
// Difficulty selector
names := memory.DIFFICULTY_NAMES
layouts := memory.LAYOUTS
ui.draw_text_centered("Difficulty (Left / Right)", themes.SCREEN_WIDTH / 2, 512, 20, raylib.Fade(theme.ink, 0.7))
seg_w: f32 = 150
seg_x := (themes.SCREEN_WIDTH - 3 * seg_w - 2 * 12) / 2
for level in memory.Difficulty {
selected := difficulty^ == level
color := selected ? theme.primary : raylib.ColorLerp(theme.primary, themes.WHITE, 0.55)
label := raylib.TextFormat("%s %d", names[level], layouts[level].rows * layouts[level].cols / 2)
if ui.button({seg_x, 542, seg_w, 48}, label, color, font_size = 22, selected = selected) {
difficulty^ = level
}
seg_x += seg_w + 12
}
if raylib.IsKeyPressed(.LEFT) && difficulty^ > .EASY {
difficulty^ = memory.Difficulty(int(difficulty^) - 1)
}
if raylib.IsKeyPressed(.RIGHT) && difficulty^ < .HARD {
difficulty^ = memory.Difficulty(int(difficulty^) + 1)
}
return themes.MAIN_MENU
}
draw_japanese_screen :: proc(mouse_x, mouse_y: i32) -> i32 {
raylib.ClearBackground(themes.WHITE)
draw_title("Japanese", 100, themes.JAPAN_PRIMARY)
draw_centered_text("Select a game to play", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
button_w := themes.BUTTON_WIDTH
button_h := themes.BUTTON_HEIGHT
button_space := 40
start_y := themes.SCREEN_HEIGHT / 2
start_x := (themes.SCREEN_WIDTH - button_w) / 2
if draw_button(start_x, start_y, button_w, button_h, "Memory Game", themes.JAPAN_PRIMARY, themes.JAPAN_ACCENT, mouse_x, mouse_y) {
return themes.JAPANESE_MEMORY
if ui.button({30, 28, 120, 48}, "Back", theme.ink, font_size = 22) {
result = themes.Screen.MAIN_MENU
}
back_y := i32(start_y) + i32(button_h) + i32(button_space)
if draw_button(start_x, back_y, button_w, button_h, "Back", themes.JAPAN_PRIMARY, themes.JAPAN_ACCENT, mouse_x, mouse_y) {
return themes.MAIN_MENU
}
return themes.JAPANESE_SCREEN
}
draw_korean_screen :: proc(mouse_x, mouse_y: i32) -> i32 {
raylib.ClearBackground(themes.WHITE)
draw_title("Korean", 100, themes.KOREA_PRIMARY)
draw_centered_text("Coming Soon", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
if draw_button((themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, themes.SCREEN_HEIGHT / 2, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT, "Back", themes.KOREA_PRIMARY, themes.KOREA_ACCENT, mouse_x, mouse_y) {
return themes.MAIN_MENU
}
return themes.KOREAN_SCREEN
}
draw_thai_screen :: proc(mouse_x, mouse_y: i32) -> i32 {
raylib.ClearBackground(themes.WHITE)
draw_title("Thai", 100, themes.THAI_PRIMARY)
draw_centered_text("Coming Soon", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
if draw_button((themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, themes.SCREEN_HEIGHT / 2, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT, "Back", themes.THAI_PRIMARY, themes.THAI_ACCENT, mouse_x, mouse_y) {
return themes.MAIN_MENU
}
return themes.THAI_SCREEN
hint := len(deck_list) > 1 ? cstring("1-2 pick a deck · Left / Right difficulty · Esc back") : cstring("1 start · Left / Right difficulty · Esc back")
ui.draw_text_centered(hint, themes.SCREEN_WIDTH / 2, 660, 18, raylib.Fade(theme.ink, 0.6))
return result
}
+80 -32
View File
@@ -2,39 +2,87 @@ package themes
import raylib "vendor:raylib"
SCREEN_WIDTH: i32 = 1280
SCREEN_HEIGHT: i32 = 720
SCREEN_WIDTH :: 1280
SCREEN_HEIGHT :: 720
JAPAN_PRIMARY: raylib.Color = raylib.Color{230, 57, 70, 255}
JAPAN_ACCENT: raylib.Color = raylib.Color{253, 184, 190, 255}
JAPAN_BACKGROUND: raylib.Color = raylib.Color{250, 245, 245, 255}
KOREA_PRIMARY: raylib.Color = raylib.Color{0, 71, 160, 255}
KOREA_ACCENT: raylib.Color = raylib.Color{180, 180, 255, 255}
KOREA_BACKGROUND: raylib.Color = raylib.Color{245, 248, 255, 255}
THAI_PRIMARY: raylib.Color = raylib.Color{165, 25, 49, 255}
THAI_ACCENT: raylib.Color = raylib.Color{255, 180, 200, 255}
THAI_BACKGROUND: raylib.Color = raylib.Color{255, 245, 248, 255}
WHITE: raylib.Color = raylib.Color{255, 255, 255, 255}
BLACK: raylib.Color = raylib.Color{0, 0, 0, 255}
DARK_GRAY: raylib.Color = raylib.Color{64, 64, 64, 255}
LIGHT_GRAY: raylib.Color = raylib.Color{200, 200, 200, 255}
CARD_BACK_COLOR: raylib.Color = raylib.Color{40, 40, 50, 255}
Screen :: enum {
MAIN_MENU,
LANGUAGE_MENU,
MEMORY,
}
BUTTON_WIDTH: i32 = 240
BUTTON_HEIGHT: i32 = 60
TITLE_FONT_SIZE: i32 = 72
SUBTITLE_FONT_SIZE: i32 = 24
BUTTON_FONT_SIZE: i32 = 24
// Which national flag the card backs are modelled on
Flag :: enum {
JAPAN, // Nisshōki / Hinomaru
KOREA, // Taegukgi
THAILAND, // Thong Trairong
}
CARD_WIDTH: i32 = 100
CARD_HEIGHT: i32 = 140
CARD_SPACING: i32 = 15
GRID_ROWS: i32 = 4
GRID_COLS: i32 = 6
FLIP_DELAY: f32 = 1.0
Theme :: struct {
name: cstring,
flag: Flag,
primary: raylib.Color, // main brand color (buttons, titles)
secondary: raylib.Color, // second flag color
accent: raylib.Color, // highlights, matched outlines
background: raylib.Color,
card_face: raylib.Color,
ink: raylib.Color, // body text
}
MAIN_MENU: i32 = 0
JAPANESE_SCREEN: i32 = 1
JAPANESE_MEMORY: i32 = 2
KOREAN_SCREEN: i32 = 3
THAI_SCREEN: i32 = 4
// Flag colors
HINOMARU_RED :: raylib.Color{188, 0, 45, 255}
TAEGUK_RED :: raylib.Color{205, 46, 58, 255}
TAEGUK_BLUE :: raylib.Color{0, 71, 160, 255}
THAI_RED :: raylib.Color{165, 25, 49, 255}
THAI_BLUE :: raylib.Color{45, 42, 74, 255}
WHITE :: raylib.Color{255, 255, 255, 255}
BLACK :: raylib.Color{0, 0, 0, 255}
SUMI_INK :: raylib.Color{35, 31, 32, 255}
DARK_GRAY :: raylib.Color{80, 76, 72, 255}
LIGHT_GRAY :: raylib.Color{200, 200, 200, 255}
MENU_BACKGROUND :: raylib.Color{248, 246, 242, 255}
JAPAN :: Theme{
name = "Japanese",
flag = .JAPAN,
primary = HINOMARU_RED,
secondary = WHITE,
accent = raylib.Color{212, 160, 23, 255}, // kin (gold)
background = raylib.Color{250, 246, 238, 255}, // washi paper
card_face = raylib.Color{255, 252, 245, 255},
ink = SUMI_INK,
}
KOREA :: Theme{
name = "Korean",
flag = .KOREA,
primary = TAEGUK_BLUE,
secondary = TAEGUK_RED,
accent = raylib.Color{30, 30, 30, 255}, // trigram black
background = raylib.Color{244, 246, 251, 255},
card_face = raylib.Color{255, 255, 255, 255},
ink = raylib.Color{22, 24, 35, 255},
}
THAI :: Theme{
name = "Thai",
flag = .THAILAND,
primary = THAI_RED,
secondary = THAI_BLUE,
accent = raylib.Color{218, 165, 32, 255}, // temple gold
background = raylib.Color{251, 246, 240, 255},
card_face = raylib.Color{255, 253, 248, 255},
ink = raylib.Color{30, 28, 45, 255},
}
BUTTON_WIDTH :: 240
BUTTON_HEIGHT :: 60
TITLE_FONT_SIZE :: 72
SUBTITLE_FONT_SIZE :: 26
BUTTON_FONT_SIZE :: 26
CARD_WIDTH :: 96
CARD_HEIGHT :: 128
CARD_SPACING :: 14
GRID_TOP :: 136
+95
View File
@@ -0,0 +1,95 @@
package ui
import raylib "vendor:raylib"
import assets "../assets"
import audio "../audio"
import themes "../themes"
Font_Style :: assets.Font_Style
TEXT_SPACING :: 1
@(private)
any_hovered: bool
// Call at the start of each frame's drawing
begin_frame :: proc() {
any_hovered = false
}
// Call at the end of each frame's drawing
end_frame :: proc() {
raylib.SetMouseCursor(any_hovered ? .POINTING_HAND : .DEFAULT)
}
// Mark that something clickable is under the mouse (shows the hand cursor)
set_hovered :: proc() {
any_hovered = true
}
measure_text :: proc(text: cstring, size: f32, style: Font_Style = .REGULAR) -> raylib.Vector2 {
return raylib.MeasureTextEx(assets.get_font(style), text, size, TEXT_SPACING)
}
draw_text :: proc(text: cstring, pos: raylib.Vector2, size: f32, color: raylib.Color, style: Font_Style = .REGULAR) {
raylib.DrawTextEx(assets.get_font(style), text, pos, size, TEXT_SPACING, color)
}
// Draws text horizontally centered on center_x with its top at y
draw_text_centered :: proc(text: cstring, center_x, y, size: f32, color: raylib.Color,
style: Font_Style = .REGULAR, shadow := false) {
w := measure_text(text, size, style).x
pos := raylib.Vector2{center_x - w / 2, y}
if shadow {
draw_text(text, pos + {0, size * 0.05}, size, raylib.Fade(themes.BLACK, 0.18), style)
}
draw_text(text, pos, size, color, style)
}
// Draws text centered in a rectangle
draw_text_in_rect :: proc(text: cstring, rect: raylib.Rectangle, size: f32, color: raylib.Color,
style: Font_Style = .REGULAR) {
dims := measure_text(text, size, style)
pos := raylib.Vector2{rect.x + (rect.width - dims.x) / 2, rect.y + (rect.height - dims.y) / 2}
draw_text(text, pos, size, color, style)
}
draw_title :: proc(text: cstring, y: f32, color: raylib.Color, size: f32 = themes.TITLE_FONT_SIZE) {
draw_text_centered(text, themes.SCREEN_WIDTH / 2, y, size, color, style = .BOLD, shadow = true)
}
// A rounded button with hover, press and shadow states. Returns true when clicked
// or when its hotkey is pressed.
button :: proc(rect: raylib.Rectangle, text: cstring, color: raylib.Color,
text_color := themes.WHITE, font_size: f32 = themes.BUTTON_FONT_SIZE,
hotkey := raylib.KeyboardKey.KEY_NULL, selected := false) -> bool {
hovered := raylib.CheckCollisionPointRec(raylib.GetMousePosition(), rect)
pressed := hovered && raylib.IsMouseButtonDown(.LEFT)
if hovered { set_hovered() }
lift: f32 = 0
fill := color
if hovered {
lift = 2
fill = raylib.ColorBrightness(color, 0.12)
}
if pressed {
lift = -1
fill = raylib.ColorBrightness(color, -0.08)
}
shadow := raylib.Rectangle{rect.x, rect.y + 4, rect.width, rect.height}
raylib.DrawRectangleRounded(shadow, 0.4, 12, raylib.Fade(themes.BLACK, 0.18))
body := raylib.Rectangle{rect.x, rect.y - lift, rect.width, rect.height}
raylib.DrawRectangleRounded(body, 0.4, 12, fill)
if selected {
ring := raylib.Rectangle{body.x - 4, body.y - 4, body.width + 8, body.height + 8}
raylib.DrawRectangleRoundedLinesEx(ring, 0.4, 12, 3, color)
}
draw_text_in_rect(text, body, font_size, text_color, style = .BOLD)
clicked := hovered && raylib.IsMouseButtonPressed(.LEFT)
if hotkey != .KEY_NULL && raylib.IsKeyPressed(hotkey) { clicked = true }
if clicked { audio.play(.CLICK) }
return clicked
}
+292
View File
@@ -0,0 +1,292 @@
package tests
import "core:testing"
import assets "../src/assets"
import decks "../src/decks"
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.active_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.active_cards() {
if j != i && c.pair_id != memory.cards[i].pair_id { return j }
}
return -1
}
@(test)
deck_sizes :: proc(t: ^testing.T) {
testing.expect_value(t, len(decks.entries(.HIRAGANA)), 46)
testing.expect_value(t, len(decks.entries(.KATAKANA)), 46)
testing.expect_value(t, len(decks.entries(.HANGUL)), 24)
testing.expect_value(t, len(decks.entries(.THAI_CONSONANTS)), 42)
}
@(test)
decks_have_unique_symbols_and_readings :: proc(t: ^testing.T) {
for deck in decks.Deck_Id {
entries := decks.entries(deck)
for a, i in entries {
for b, j in entries {
if i == j { continue }
testing.expectf(t, a.symbol != b.symbol, "%v: duplicate symbol %s", deck, a.symbol)
// Readings may repeat (e.g. Thai "kh") only when the hint tells them apart
same_reading := a.reading == b.reading && a.hint == b.hint
testing.expectf(t, !same_reading, "%v: %s and %s read the same", deck, a.symbol, b.symbol)
}
}
}
}
@(private)
covers :: proc(codepoints: []rune, text: cstring) -> (missing: rune, ok: bool) {
outer: for r in string(text) {
for c in codepoints {
if c == r { continue outer }
}
return r, false
}
return 0, true
}
// raylib renders "?" for any glyph that wasn't baked into the font
@(test)
fonts_cover_every_deck_character :: proc(t: ^testing.T) {
for deck in decks.Deck_Id {
symbol_points := assets.codepoints_for(decks.language_of(deck).symbol_style)
defer delete(symbol_points)
ui_points := assets.codepoints_for(.BOLD)
defer delete(ui_points)
for entry in decks.entries(deck) {
if r, ok := covers(symbol_points[:], entry.symbol); !ok {
testing.expectf(t, false, "%v: %s needs %r in the symbol font", deck, entry.symbol, r)
}
if r, ok := covers(ui_points[:], entry.reading); !ok {
testing.expectf(t, false, "%v: %s needs %r in the UI font", deck, entry.reading, r)
}
if entry.hint == nil { continue }
if r, ok := covers(ui_points[:], entry.hint); !ok {
testing.expectf(t, false, "%v: %s needs %r in the UI font", deck, entry.hint, r)
}
}
info := decks.DECKS[deck]
if r, ok := covers(symbol_points[:], info.native); !ok {
testing.expectf(t, false, "%v: deck name %s needs %r", deck, info.native, r)
}
}
for language in decks.Language {
info := decks.LANGUAGES[language]
points := assets.codepoints_for(info.congrats_style)
defer delete(points)
if r, ok := covers(points[:], info.congrats); !ok {
testing.expectf(t, false, "%v: %s needs %r", language, info.congrats, r)
}
native_points := assets.codepoints_for(info.symbol_style)
defer delete(native_points)
if r, ok := covers(native_points[:], info.native); !ok {
testing.expectf(t, false, "%v: %s needs %r", language, info.native, r)
}
}
}
@(test)
deal_creates_symbol_reading_pairs :: proc(t: ^testing.T) {
for deck in decks.Deck_Id {
for difficulty in memory.Difficulty {
memory.deal(deck, difficulty)
layout := memory.LAYOUTS[difficulty]
testing.expect_value(t, memory.card_count, layout.rows * layout.cols)
testing.expect_value(t, memory.pair_count, memory.card_count / 2)
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 {
symbol_count, reading_count := 0, 0
symbol, reading, hint: cstring
for c in memory.active_cards() {
if c.pair_id != pair { continue }
testing.expect(t, !c.face_up && !c.matched)
if c.is_symbol {
symbol_count += 1
symbol = c.text
} else {
reading_count += 1
reading = c.text
hint = c.hint
}
}
testing.expect_value(t, symbol_count, 1)
testing.expect_value(t, reading_count, 1)
// The two halves must belong to the same deck entry
found := false
for entry in decks.entries(deck) {
if entry.symbol == symbol && entry.reading == reading && entry.hint == hint { found = true }
}
testing.expectf(t, found, "%v pair %d is %s/%s, which is not in the deck", deck, pair, symbol, reading)
}
}
}
}
@(test)
deal_lays_out_cards_on_screen_without_overlap :: proc(t: ^testing.T) {
for difficulty in memory.Difficulty {
memory.deal(.HIRAGANA, difficulty)
for a, i in memory.active_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.active_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(.HIRAGANA, .NORMAL)
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(.HIRAGANA, .NORMAL)
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(.HIRAGANA, .NORMAL)
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(.HIRAGANA, .NORMAL)
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(.HIRAGANA, .NORMAL)
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.active_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)
}
@(test)
keyboard_focus_stays_inside_grid :: proc(t: ^testing.T) {
memory.deal(.HIRAGANA, .NORMAL) // 4 rows x 6 columns
testing.expect_value(t, memory.focus, 0)
memory.move_focus(-1, -1) // already top-left
testing.expect_value(t, memory.focus, 0)
memory.move_focus(1, 0)
testing.expect_value(t, memory.focus, 1)
memory.move_focus(0, 1)
testing.expect_value(t, memory.focus, 7)
memory.move_focus(100, 100) // clamps to bottom-right
testing.expect_value(t, memory.focus, memory.card_count - 1)
memory.deal(.HIRAGANA, .HARD) // dealing resets focus
testing.expect_value(t, memory.focus, 0)
memory.move_focus(0, 3)
testing.expect_value(t, memory.focus, 24) // 4 rows x 8 columns
}
+70
View File
@@ -0,0 +1,70 @@
package tests
import "core:os"
import "core:testing"
import memory "../src/memory"
import profile "../src/profile"
@(test)
record_game_tracks_bests :: proc(t: ^testing.T) {
key := "TEST/RECORD"
first := profile.record_game(key, 20, 90)
testing.expect_value(t, first.record.games, 1)
testing.expect_value(t, first.record.best_moves, 20)
testing.expect(t, !first.new_best_moves && !first.new_best_time) // nothing to beat yet
worse := profile.record_game(key, 25, 120)
testing.expect(t, !worse.new_best_moves && !worse.new_best_time)
testing.expect_value(t, worse.record.best_moves, 20)
// Fewer moves but slower: only the moves record changes
fewer := profile.record_game(key, 15, 100)
testing.expect(t, fewer.new_best_moves && !fewer.new_best_time)
testing.expect_value(t, fewer.record.best_moves, 15)
testing.expect_value(t, fewer.record.best_seconds, 90)
testing.expect_value(t, fewer.record.games, 3)
}
@(test)
profile_round_trips_through_json :: proc(t: ^testing.T) {
path := "build/test-profile.json"
os.remove(path)
defer os.remove(path)
testing.expect(t, profile.init(path))
defer profile.path = "" // stop other tests from writing the file
profile.set_difficulty("HARD")
profile.record_game("TEST/SAVE", 12, 61.5)
// Reload from disk into a clean state
profile.data = {}
testing.expect(t, profile.init(path))
testing.expect_value(t, profile.data.difficulty, "HARD")
record, ok := profile.get("TEST/SAVE")
testing.expect(t, ok)
testing.expect_value(t, record.best_moves, 12)
testing.expect_value(t, record.best_seconds, 61.5)
}
@(test)
winning_records_a_score :: proc(t: ^testing.T) {
memory.deal(.KATAKANA, .EASY)
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)
record, ok := profile.get(memory.score_key(.KATAKANA, .EASY))
testing.expect(t, ok)
testing.expect_value(t, record.best_moves, memory.pair_count)
testing.expect_value(t, memory.last_result.record.games, record.games)
}
@(test)
time_format :: proc(t: ^testing.T) {
testing.expect_value(t, profile.format_time(0), "0:00")
testing.expect_value(t, profile.format_time(65.9), "1:05")
testing.expect_value(t, profile.format_time(600), "10:00")
}