1 Commits
Author SHA1 Message Date
SansanandClaude Opus 5.5 59d8c16b29 Track src modules; stop ignoring .odin files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:07:47 +02:00
31 changed files with 494 additions and 4241 deletions

No files matched your search

-44
View File
@@ -1,44 +0,0 @@
#
# .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
@@ -1,69 +0,0 @@
#
# .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 zip
- 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
-64
View File
@@ -1,64 +0,0 @@
#
# .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 zip
- 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 }}_*.tar.gz
build/${{ env.PROJECT_NAME }}_*.zip
-3
View File
@@ -16,6 +16,3 @@ HanjaKanaMun
.vscode/ .vscode/
*.swp *.swp
*.swo *.swo
# Build output
build/
+55 -56
View File
@@ -3,76 +3,75 @@
## Overview ## Overview
Odin + raylib multilingual memory card game for learning Japanese (Hiragana/Katakana), Korean (Hangul), and Thai scripts. 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 ## Current Status
### Completed ### Completed
- [x] Code split into modules under `src/` (themes, assets, audio, ui, card, decks, memory, profile, screens) - [x] Created AGENTS.md project plan
- [x] `.odin` files tracked in git (`.gitignore` used to ignore `*.odin`) - [x] Created src/ directory structure with subdirectories (themes, card, memory, screens, assets)
- [x] Fonts: M PLUS Rounded 1c (UI + kana), Jua (Hangul), Mali (Thai), all OFL, loaded via `LoadFontEx` with explicit codepoints - [x] Separated themes into themes/themes.odin (colors, constants)
- [x] Per-language themes (`themes.Theme`) with flag-based card backs: Japan (Hinomaru), Korea (Taegukgi), Thailand (Trairong) - [x] Separated card logic into card/card.odin (Card struct, rendering, click detection)
- [x] Card flip animation (art rendered to textures and squashed), lift, shadow, lighting; matched cards pulse; cards are dealt in at game start - [x] Separated memory game into memory/memory.odin (game state, logic)
- [x] Sound effects synthesized in code (flip, match, mismatch, win, button click), no audio files needed - [x] Separated screens into screens/screens.odin (UI screens, buttons)
- [x] Decks: Hiragana (46), Katakana (46), Hangul basic jamo (24), Thai consonants (42); symbol ↔ reading pairs - [x] Added font loading in assets/assets.odin
- [x] Difficulty levels: Easy 3x4 (6 pairs), Normal 4x6 (12), Hard 4x8 (16) - [x] Fixed card positioning (removed -40 offset that pushed cards off screen)
- [x] Keyboard play: arrows / WASD / hjkl move focus, Enter/Space flips; number keys pick menu entries; Esc goes back (quits from main menu) - [x] Fixed card selection (now allows selection in both WAITING and FIRST states)
- [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] Added flip animations for cards (visual feedback when flipping)
- [x] Screen fade-in transitions - [x] Improved visual styling (rounded corners, theme colors)
- [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)
### Ideas / Next ### In Progress
- [ ] Hangul syllable blocks deck (e.g. 가 ga, 나 na), Thai vowels (needs care: combining marks) - [ ] Fix Japanese font rendering (TTF loading fails in raylib with "Failed to process TTF font data")
- [ ] 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)
## Known Issues / Gotchas ### Pending
1. **Font glyphs must be listed**: raylib only bakes the codepoints passed to `LoadFontEx`. Any new non-ASCII text needs its characters added in `assets.codepoints_for` (or the `EXTRA_*` strings), otherwise it renders as `?`. The `fonts_cover_every_deck_character` test checks deck text. - [ ] Add keyboard support for card selection (1-9, A-L, etc.)
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`. - [ ] Add proper Japanese font that works with raylib
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). - [ ] Add Korean and Thai language support
4. Tests share the memory game's global state, so they must run single-threaded (`just test` sets `ODIN_TEST_THREADS=1`). - [ ] Add more animations (win screen, transitions)
5. The game changes directory to the executable's folder at startup so `assets/` is found from anywhere. - [ ] Add sound effects
6. **Windows builds are cross-compiled**: Odin can't link Windows binaries on Linux, so `just windows` builds objects with `-build-mode:obj` and links with `lld-link` against an xwin SDK. The VC runtime is linked statically; only Windows system DLLs are needed. New Odin core usage can need new import libs (e.g. `ole32.lib` for `os.user_data_dir`); the CI `windows` job catches this. Not yet tested on a real Windows machine. - [ ] Add score tracking and high scores
7. **Odin constant arrays can't be indexed at runtime**: lookup tables (`DECKS`, `LAYOUTS`, …) are `@(rodata)` globals instead of `::` constants. - [ ] Add difficulty levels
8. **Profile memory**: `profile` always allocates from the heap allocator, because the test runner frees each test's allocator afterwards. - [ ] Test all changes work together
9. **`odin test` segfaults if the stack rlimit is "unlimited"**: the CI runner's container sets `ulimit -s unlimited`, which makes `getrlimit(RLIMIT_STACK).rlim_cur` come back as `RLIM_INFINITY` (a huge unsigned value). Odin's `core:thread` passes that straight to `pthread_attr_setstacksize`, `pthread_create` then fails, and `thread.create()` returns `nil` - which `core:testing`'s `pool_init` dereferences unconditionally, segfaulting the whole test binary (exit 139) before it prints anything. `just test` works around it with `ulimit -s 8192` before running `odin test`. Upstream Odin bug (core:thread/core:testing don't handle a failed thread creation); not our code.
## 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
## File Structure ## File Structure
``` ```
HanjaKanaMun/ HanjaKanaMun/
├── AGENTS.md # Project plan (this file) ├── AGENTS.md # Project plan (this file)
├── README.md ├── README.md # Project overview
├── justfile # build/run/check/test/package/windows recipes ├── odin.json # Odin config
├── ols.json # ols language server settings (neovim) ├── main.odin # Entry point only
├── 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/ ├── src/
│ ├── themes/themes.odin # Theme struct, flag colors, Screen/Flag enums, layout constants │ ├── themes/
│ ├── assets/assets.odin # Font styles and codepoint lists, font loading │ │ └── themes.odin # Color themes, styling constants
│ ├── audio/audio.odin # Synthesized sound effects, play() │ ├── card/
│ ├── ui/ui.odin # Text helpers, buttons (with hotkeys), hover cursor │ │ └── card.odin # Card struct and rendering
│ ├── card/card.odin # Card struct, flag card-back art, face textures, flip/deal animation │ ├── memory/
│ ├── decks/decks.odin # Languages, decks and their entries (symbol, reading, hint) │ │ └── memory.odin # Memory game logic
│ ├── memory/memory.odin # Memory game state, logic, keyboard focus, drawing │ ├── screens/
│ ├── profile/profile.odin # Best scores and settings, JSON in the user data folder │ │ └── screens.odin # Screen management (main menu, language screens)
│ └── screens/screens.odin # Main menu and language menus │ └── assets/
├── tests/ │ └── assets.odin # Font loading, resource management
│ ├── memory_test.odin # Decks, font coverage, game rules, layout, keyboard focus, card animation └── assets/ # Font files, images (future)
│ └── profile_test.odin # Score records, JSON round trip, win recording └── DroidSansJapanese.ttf
└── 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 ## Notes
- NO rm -rf or starting over - NO rm -rf or starting over
- Make small, incremental changes - Make small, incremental changes
- Test after each change (`just check`, `just test`, then `just run`) - Test after each change
- Keep existing functionality working - Keep existing functionality working
- Commit after each working step - All code is now properly separated into modules
- Game is playable with mouse (romanji characters display correctly)
-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleIdentifier</key>
<string>com.apple.xcode.dsym.HanjaKanaMun</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>dSYM</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
File diff suppressed because it is too large. Load diff
+1 -58
View File
@@ -1,60 +1,3 @@
# HanjaKanaMun # HanjaKanaMun
Odin + raylib memory card game for learning Japanese, Korean and Thai scripts. Odin + raylib multilingual application (Thai, Korean, Japanese)
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>.tar.gz
just windows-deps # Linux only: fetch Windows SDK/CRT (xwin) and raylib.lib
just package-windows 1.0 # cross-compiled Windows build -> build/HanjaKanaMun_1.0_windows_x86_64.zip
```
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
@@ -1,93 +0,0 @@
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
@@ -1,94 +0,0 @@
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
@@ -1,93 +0,0 @@
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.
-105
View File
@@ -1,105 +0,0 @@
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>/, with the assets it needs at runtime
release:
mkdir -p {{build_dir}}/{{os()}}/{{name}}
odin build . -vet -o:speed -out:{{build_dir}}/{{os()}}/{{name}}/{{name}}
cp -R assets {{build_dir}}/{{os()}}/{{name}}/
# Native release build packed as build/<name>_<version>_<os>_<arch>.tar.gz
package version="dev": release
tar -czf {{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}}.tar.gz -C {{build_dir}}/{{os()}} {{name}}
@echo "{{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}}.tar.gz"
# 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 Windows x86_64 build into build/windows/ (run `just windows-deps` first)
windows:
#!/usr/bin/env bash
set -euo pipefail
obj_dir={{build_dir}}/obj-windows
out_dir={{build_dir}}/windows/{{name}}
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
cp -R assets "$out_dir/"
# Windows build packed as build/<name>_<version>_windows_x86_64.zip
package-windows version="dev": windows
cd {{build_dir}}/windows && zip -qr ../{{name}}_{{version}}_windows_x86_64.zip {{name}}
@echo "{{build_dir}}/{{name}}_{{version}}_windows_x86_64.zip"
# 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}}
+37 -96
View File
@@ -1,114 +1,55 @@
package main package main
import "core:fmt"
import "core:reflect"
import raylib "vendor:raylib" import raylib "vendor:raylib"
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 themes "src/themes"
import ui "src/ui" import screens "src/screens"
import memory "src/memory"
import assets "src/assets"
TRANSITION_SPEED :: 4 // screen fade-ins per second current_screen: i32 = themes.MAIN_MENU
mouse_x: i32
// Where Escape takes you from each screen (nil = quit) mouse_y: i32
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() { main :: proc() {
raylib.SetConfigFlags({.MSAA_4X_HINT, .VSYNC_HINT}) raylib.InitWindow(themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, cstring("HanjaKanaMun"))
raylib.InitWindow(themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, "HanjaKanaMun")
defer raylib.CloseWindow() defer raylib.CloseWindow()
raylib.SetExitKey(.KEY_NULL)
// Assets are loaded relative to the executable, so it can be started from anywhere
raylib.ChangeDirectory(raylib.GetApplicationDirectory())
raylib.SetTargetFPS(60)
raylib.InitAudioDevice()
defer raylib.CloseAudioDevice()
audio.load()
defer audio.unload()
assets.load_fonts() assets.load_fonts()
defer assets.unload_fonts() defer assets.unload_fonts()
card.load_art()
defer card.unload_art() raylib.SetTargetFPS(60)
defer memory.unload() memory.init_memory_game()
if !profile.init() { for !raylib.WindowShouldClose() {
raylib.TraceLog(.WARNING, "PROFILE: Could not read saved scores, starting fresh") mouse_x = raylib.GetMouseX()
} mouse_y = raylib.GetMouseY()
screen := themes.Screen.MAIN_MENU if raylib.IsKeyPressed(raylib.KeyboardKey.ESCAPE) {
language := decks.Language.JAPANESE current_screen = themes.MAIN_MENU
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(.ESCAPE) {
if parent, ok := parent_screen(screen).?; ok {
screen = parent
transition_t = 1
} else {
running = false
}
} }
if screen == .MEMORY { if current_screen == themes.JAPANESE_MEMORY {
memory.update(dt) delta_time := raylib.GetFrameTime()
memory.update_memory_game(delta_time, mouse_x, mouse_y)
} }
raylib.BeginDrawing() raylib.BeginDrawing()
ui.begin_frame()
next := screen if current_screen == themes.MAIN_MENU {
start_deck: Maybe(decks.Deck_Id) current_screen = screens.draw_main_menu(mouse_x, mouse_y)
switch screen { } else if current_screen == themes.JAPANESE_SCREEN {
case .MAIN_MENU: current_screen = screens.draw_japanese_screen(mouse_x, mouse_y)
if picked, ok := screens.main_menu().?; ok { if current_screen == themes.JAPANESE_MEMORY {
language = picked memory.init_memory_game()
next = .LANGUAGE_MENU
} }
case .LANGUAGE_MENU: } else if current_screen == themes.JAPANESE_MEMORY {
switch result in screens.language_menu(language, &difficulty) { current_screen = memory.draw_memory_game(mouse_x, mouse_y)
case decks.Deck_Id: } else if current_screen == themes.KOREAN_SCREEN {
start_deck = result current_screen = screens.draw_korean_screen(mouse_x, mouse_y)
next = .MEMORY } else if current_screen == themes.THAI_SCREEN {
case themes.Screen: current_screen = screens.draw_thai_screen(mouse_x, mouse_y)
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() 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
@@ -0,0 +1,12 @@
{
"name": "HanjaKanaMun",
"path": ".",
"source_dir": ".",
"include_dirs": ["vendor", "src"],
"rays": [
{
"name": "raylib",
"path": "vendor:raylib"
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"$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
@@ -1,13 +0,0 @@
{
"$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"
}
+6 -74
View File
@@ -2,85 +2,17 @@ package assets
import raylib "vendor:raylib" import raylib "vendor:raylib"
// Glyphs are rasterized at this size and scaled down when drawn japanese_font: raylib.Font
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)
}
@(rodata)
FONT_PATHS := [Font_Style]cstring{
.REGULAR = "assets/MPLUSRounded1c-Medium.ttf",
.BOLD = "assets/MPLUSRounded1c-ExtraBold.ttf",
.KOREAN = "assets/Jua-Regular.ttf",
.THAI = "assets/Mali-SemiBold.ttf",
}
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)
path := FONT_PATHS[style]
font := raylib.LoadFontEx(path, FONT_BASE_SIZE, raw_data(codepoints), i32(len(codepoints)))
if !raylib.IsFontValid(font) || font.texture.id == 0 {
raylib.TraceLog(.WARNING, "ASSETS: Falling back to default font, could not load %s", path)
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() { load_fonts :: proc() {
for style in Font_Style { // Use raylib's default font for now
fonts[style] = load_font(style) japanese_font = raylib.GetFontDefault()
}
} }
unload_fonts :: proc() { unload_fonts :: proc() {
default_id := raylib.GetFontDefault().texture.id // Don't unload the default font
for font in fonts {
if font.texture.id != default_id { raylib.UnloadFont(font) }
}
} }
get_font :: proc(style: Font_Style = .REGULAR) -> raylib.Font { get_font :: proc() -> raylib.Font {
return fonts[style] return japanese_font
} }
-135
View File
@@ -1,135 +0,0 @@
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])
}
+66 -285
View File
@@ -1,300 +1,81 @@
package card package card
import "core:math"
import raylib "vendor:raylib" import raylib "vendor:raylib"
import assets "../assets"
import themes "../themes" import themes "../themes"
import ui "../ui" import assets "../assets"
// 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 { Card :: struct {
rect: raylib.Rectangle, // resting position on screen x: i32,
text: cstring, y: i32,
hint: cstring, // small second line on reading cards character: cstring,
is_symbol: bool, // script symbol (vs. its reading) is_face_up: bool,
pair_id: int, is_matched: bool,
face_up: bool, // logical state; the animation follows it pair_id: i32,
matched: bool, flip_progress: f32, // 0 = face down, 1 = face up
flip_t: f32, // 0 = back showing, 1 = face showing is_flipping: bool,
pulse_t: f32, // counts down after a match flip_direction: bool, // true = to face up, false = to face down
deal_t: f32, // > 0 while being dealt onto the table (0 = resting)
face_art: raylib.RenderTexture2D,
} }
@(private) draw_card :: proc(card: ^Card, mouse_x, mouse_y: i32, theme_color: raylib.Color) {
back_art: [themes.Flag]raylib.RenderTexture2D draw_color := theme_color
if card.is_matched {
// --------------------------------------------------------------------------- draw_color = themes.LIGHT_GRAY
// Art generation }
// ---------------------------------------------------------------------------
is_hovered := mouse_x >= card.x && mouse_x <= card.x + themes.CARD_WIDTH &&
@(private) mouse_y >= card.y && mouse_y <= card.y + themes.CARD_HEIGHT
art_rect :: proc(inset: f32 = 0) -> raylib.Rectangle { if is_hovered && !card.is_face_up && !card.is_matched && !card.is_flipping {
return {inset, inset, ART_WIDTH - 2 * inset, ART_HEIGHT - 2 * inset} draw_color = raylib.Color{
} byte(f32(theme_color.r) * 0.8),
byte(f32(theme_color.g) * 0.8),
@(private) byte(f32(theme_color.b) * 0.8),
new_art_texture :: proc(edge_color: raylib.Color) -> raylib.RenderTexture2D { theme_color.a,
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 // Draw card background with rounded corners
raylib.ClearBackground({edge_color.r, edge_color.g, edge_color.b, 0}) raylib.DrawRectangleRounded(raylib.Rectangle{f32(card.x), f32(card.y), f32(themes.CARD_WIDTH), f32(themes.CARD_HEIGHT)}, 0.2, 10, draw_color)
raylib.EndTextureMode() raylib.DrawRectangleRoundedLines(raylib.Rectangle{f32(card.x), f32(card.y), f32(themes.CARD_WIDTH), f32(themes.CARD_HEIGHT)}, 0.2, 10, themes.BLACK)
return rt
} // Handle flip animation
if card.is_flipping {
@(private) // Draw the flip animation
draw_frame :: proc(fill, border: raylib.Color) { flip_angle := card.flip_progress * 180.0
raylib.DrawRectangleRounded(art_rect(), ROUNDNESS, SEGMENTS, fill) if !card.flip_direction {
raylib.DrawRectangleRoundedLinesEx(art_rect(3), ROUNDNESS, SEGMENTS, 6, border) flip_angle = 180.0 - flip_angle
} }
// Japan: Hinomaru, a crimson sun disc on white, with a double crimson border // Draw the card as a rectangle that's rotating
@(private) center_x := f32(card.x + themes.CARD_WIDTH / 2)
draw_japan_back :: proc() { center_y := f32(card.y + themes.CARD_HEIGHT / 2)
draw_frame(themes.WHITE, themes.HINOMARU_RED)
raylib.DrawRectangleRoundedLinesEx(art_rect(16), ROUNDNESS, SEGMENTS, 2, themes.HINOMARU_RED) // For simplicity, just show the character when flip_progress > 0.5
center := raylib.Vector2{ART_WIDTH / 2, ART_HEIGHT / 2} show_face := card.flip_progress > 0.5
raylib.DrawCircleV(center, ART_WIDTH * 0.3, themes.HINOMARU_RED) if card.flip_direction {
} show_face = card.flip_progress >= 0.5
// 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 { } else {
half := (length - gap) / 2 show_face = card.flip_progress < 0.5
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()
// Korea: Taegukgi, a red/blue taeguk circle with the four black trigrams in the corners text_w := raylib.MeasureText(card.character, 40)
@(private) text_x := card.x + (themes.CARD_WIDTH - text_w) / 2
draw_korea_back :: proc() { text_y := card.y + (themes.CARD_HEIGHT - 40) / 2 - 8
draw_frame(themes.WHITE, themes.TAEGUK_BLUE) raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x + 1), f32(text_y + 1)}, 40.0, 0, themes.BLACK)
center := raylib.Vector2{ART_WIDTH / 2, ART_HEIGHT / 2} raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x), f32(text_y)}, 40.0, 0, themes.WHITE)
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() } 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)
} }
} }
unload_art :: proc() { is_card_clicked :: proc(card: ^Card, mouse_x, mouse_y: i32) -> bool {
for rt in back_art { raylib.UnloadRenderTexture(rt) } return mouse_x >= card.x && mouse_x <= card.x + themes.CARD_WIDTH &&
} mouse_y >= card.y && mouse_y <= card.y + themes.CARD_HEIGHT
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
@@ -1,141 +0,0 @@
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"},
}
+191 -299
View File
@@ -1,326 +1,218 @@
package memory package memory
import "core:fmt"
import "core:math/rand"
import "core:strings"
import raylib "vendor:raylib" import raylib "vendor:raylib"
import audio "../audio"
import card "../card"
import decks "../decks"
import profile "../profile"
import themes "../themes" import themes "../themes"
import ui "../ui" import card "../card"
import assets "../assets"
MAX_CARDS :: 32 MemoryState :: enum {
WAITING,
MATCH_DELAY :: 0.35 // seconds both cards stay up before a match is confirmed FIRST,
MISMATCH_DELAY :: 0.9 // seconds before a wrong pair flips back SECOND,
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, OVER,
} }
cards: [MAX_CARDS]card.Card memory_cards: [24]card.Card
card_count: int memory_state: MemoryState
pair_count: int memory_first: i32
deck: decks.Deck_Id memory_second: i32
difficulty: Difficulty memory_matches: i32
theme: themes.Theme memory_moves: i32
layout: Layout memory_time: f32
state: State memory_flip: f32
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)
// Key for this deck and difficulty in the saved profile init_memory_game :: proc() {
score_key :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) -> string { hiragana_chars: [12]cstring = {"あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ", "さ", "し"}
return fmt.tprintf("%v/%v", deck_id, game_difficulty) romanji_chars: [12]cstring = {"A", "I", "U", "E", "O", "KA", "KI", "KU", "KE", "KO", "SA", "SHI"}
}
for i := 0; i < 12; i += 1 {
// Deals a new game and renders the card faces. Needs the window to exist. memory_cards[i*2] = card.Card{
start :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) { x = 0, y = 0,
deal(deck_id, game_difficulty) character = romanji_chars[i],
style := decks.language_of(deck).symbol_style is_face_up = false,
for &c in active_cards() { card.build_face(&c, theme, style) } is_matched = false,
} pair_id = i32(i),
flip_progress = 0.0,
// Shuffles, lays out and resets the game state without touching the GPU is_flipping = false,
deal :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) { flip_direction = false,
unload() }
deck = deck_id memory_cards[i*2+1] = card.Card{
difficulty = game_difficulty x = 0, y = 0,
theme = decks.language_of(deck).theme character = romanji_chars[i],
layout = LAYOUTS[difficulty] is_face_up = false,
card_count = layout.rows * layout.cols is_matched = false,
pair_count = card_count / 2 pair_id = i32(i),
flip_progress = 0.0,
// Pick random entries from the deck is_flipping = false,
entries := decks.entries(deck) flip_direction = false,
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])
for i := 0; i < 100; i += 1 {
// Center the grid horizontally and in the space below the header a := i % 24
step_x: f32 = themes.CARD_WIDTH + themes.CARD_SPACING b := (i * 7) % 24
step_y: f32 = themes.CARD_HEIGHT + themes.CARD_SPACING temp := memory_cards[a]
total_w := f32(layout.cols) * step_x - themes.CARD_SPACING memory_cards[a] = memory_cards[b]
total_h := f32(layout.rows) * step_y - themes.CARD_SPACING 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_x := (themes.SCREEN_WIDTH - total_w) / 2
start_y := themes.GRID_TOP + (themes.SCREEN_HEIGHT - 16 - themes.GRID_TOP - total_h) / 2 start_y := (themes.SCREEN_HEIGHT - total_h) / 2
for &c, i in active_cards() {
card.start_deal(&c, i) for i := 0; i < 24; i += 1 {
c.rect = { row := i32(i) / themes.GRID_COLS
start_x + f32(i % layout.cols) * step_x, col := i32(i) % themes.GRID_COLS
start_y + f32(i / layout.cols) * step_y, memory_cards[i].x = start_x + col * (themes.CARD_WIDTH + themes.CARD_SPACING)
themes.CARD_WIDTH, memory_cards[i].y = start_y + row * (themes.CARD_HEIGHT + themes.CARD_SPACING)
themes.CARD_HEIGHT, }
memory_state = MemoryState.WAITING
memory_first = -1
memory_second = -1
memory_matches = 0
memory_moves = 0
memory_time = 0.0
memory_flip = 0.0
}
update_memory_game :: proc(delta_time: f32, mouse_x, mouse_y: i32) {
memory_time += delta_time
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
} }
} }
state = .WAITING // Update flip animations
first = -1 for i := 0; i < 24; i += 1 {
second = -1 if memory_cards[i].is_flipping {
matches = 0 memory_cards[i].flip_progress += delta_time * 3.0 // Flip speed
moves = 0 if memory_cards[i].flip_progress >= 1.0 {
elapsed = 0 memory_cards[i].flip_progress = 1.0
resolve_timer = 0 memory_cards[i].is_flipping = false
win_t = 0 memory_cards[i].is_face_up = memory_cards[i].flip_direction
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 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 {
update :: proc(dt: f32) { if card.is_card_clicked(&memory_cards[i], mouse_x, mouse_y) &&
if restart_requested { raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT) {
start(deck, difficulty) memory_cards[i].is_flipping = true
return memory_cards[i].flip_direction = true
} memory_cards[i].flip_progress = 0.0
memory_moves += 1
for &c in active_cards() { card.update(&c, dt) } if memory_first == -1 {
if state != .OVER { handle_keyboard() } memory_first = i32(i)
memory_state = MemoryState.FIRST
switch state { } else if i32(i) != memory_first {
case .WAITING, .FIRST: memory_second = i32(i)
elapsed += dt memory_state = MemoryState.SECOND
if raylib.IsMouseButtonPressed(.LEFT) { }
for &c, i in active_cards() {
if card.is_hovered(&c) && can_interact(i) && select_card(i) {
break break
} }
} }
} }
case .RESOLVING: }
elapsed += dt
// Wait for the second card to finish turning before starting the delay if memory_matches >= 12 {
if !card.is_animating(&cards[second]) { memory_state = MemoryState.OVER
resolve_timer -= dt
if resolve_timer <= 0 { resolve() }
}
case .OVER:
win_t = min(win_t + dt * 2.5, 1)
} }
} }
// Draws the game and returns the screen to show next frame draw_memory_game :: proc(mouse_x, mouse_y: i32) -> i32 {
draw :: proc() -> themes.Screen { raylib.ClearBackground(themes.JAPAN_BACKGROUND)
next := themes.Screen.MEMORY
raylib.ClearBackground(theme.background) font := assets.get_font()
title: cstring = "Memory Game - Japanese"
info := decks.DECKS[deck] title_w := raylib.MeasureText(title, themes.TITLE_FONT_SIZE)
ui.draw_title(raylib.TextFormat("%s · %s", theme.name, info.name), 24, theme.primary, 48) title_x := (themes.SCREEN_WIDTH - title_w) / 2
minutes := int(elapsed) / 60 raylib.DrawTextEx(font, title, raylib.Vector2{f32(title_x + 4), 54}, f32(themes.TITLE_FONT_SIZE), 0, themes.BLACK)
seconds := int(elapsed) % 60 raylib.DrawTextEx(font, title, raylib.Vector2{f32(title_x), 50}, f32(themes.TITLE_FONT_SIZE), 0, themes.JAPAN_PRIMARY)
stats := raylib.TextFormat("%s · Moves %d · Pairs %d/%d · %d:%02d",
DIFFICULTY_NAMES[difficulty], moves, matches, pair_count, minutes, seconds) info_text := raylib.TextFormat("Moves: %d | Time: %.1fs", memory_moves, memory_time)
ui.draw_text_centered(stats, themes.SCREEN_WIDTH / 2, 88, 24, theme.ink) info_w := raylib.MeasureText(info_text, themes.SUBTITLE_FONT_SIZE)
info_x := (themes.SCREEN_WIDTH - info_w) / 2
if state != .OVER && ui.button({30, 28, 120, 48}, "Back", theme.primary, font_size = 22) { raylib.DrawTextEx(font, info_text, raylib.Vector2{f32(info_x), 150}, f32(themes.SUBTITLE_FONT_SIZE), 0, themes.DARK_GRAY)
next = .LANGUAGE_MENU
for i := 0; i < 24; i += 1 {
card.draw_card(&memory_cards[i], mouse_x, mouse_y, themes.JAPAN_PRIMARY)
} }
for &c, i in active_cards() { back_w := themes.BUTTON_WIDTH
card.draw(&c, theme, can_interact(i), focused = keyboard_active && i == focus && state != .OVER && c.deal_t == 0) 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)
if state == .OVER { raylib.DrawRectangleLines(back_x, back_y, back_w, back_h, themes.BLACK)
if action := draw_win_panel(); action != nil { back_text: cstring = "Back"
next = action.? 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
}
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()
} }
} }
return next return themes.JAPANESE_MEMORY
}
@(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
@@ -1,115 +0,0 @@
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)
}
+94 -124
View File
@@ -1,143 +1,113 @@
package screens package screens
import "core:fmt"
import "core:math"
import "core:strings"
import raylib "vendor:raylib" import raylib "vendor:raylib"
import card "../card"
import decks "../decks"
import memory "../memory"
import profile "../profile"
import themes "../themes" import themes "../themes"
import ui "../ui" import assets "../assets"
// Draws a card back like a playing card lying on the table draw_button :: proc(x, y, w, h: i32, text: cstring, base_color, hover_color: raylib.Color, mouse_x, mouse_y: i32) -> bool {
@(private) is_hovered := mouse_x >= x && mouse_x <= x + w && mouse_y >= y && mouse_y <= y + h
draw_card_art :: proc(flag: themes.Flag, rect: raylib.Rectangle, rotation: f32 = 0) { color := base_color
texture := card.get_back_art(flag) if is_hovered {
source := raylib.Rectangle{0, 0, f32(texture.width), -f32(texture.height)} color = hover_color
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} raylib.DrawRectangle(x, y, w, h, color)
shadow := dest raylib.DrawRectangleLines(x, y, w, h, themes.BLACK)
shadow.x += 3 font := assets.get_font()
shadow.y += 6 text_w := raylib.MeasureText(text, themes.BUTTON_FONT_SIZE)
raylib.DrawRectanglePro(shadow, origin, rotation, raylib.Fade(themes.BLACK, 0.15)) text_x := x + (w - text_w) / 2
raylib.DrawTexturePro(texture, source, dest, origin, rotation, themes.WHITE) 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)
} }
@(private) draw_title :: proc(text: cstring, y: i32, color: raylib.Color) {
NUMBER_KEYS :: [?]raylib.KeyboardKey{.ONE, .TWO, .THREE, .FOUR, .FIVE} font := assets.get_font()
text_w := raylib.MeasureText(text, themes.TITLE_FONT_SIZE)
// Returns the language picked this frame, if any x := (themes.SCREEN_WIDTH - text_w) / 2
main_menu :: proc() -> Maybe(decks.Language) { raylib.DrawTextEx(font, text, raylib.Vector2{f32(x + 4), f32(y + 4)}, f32(themes.TITLE_FONT_SIZE), 0, themes.BLACK)
raylib.ClearBackground(themes.MENU_BACKGROUND) raylib.DrawTextEx(font, text, raylib.Vector2{f32(x), f32(y)}, f32(themes.TITLE_FONT_SIZE), 0, color)
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
} }
Language_Menu_Result :: union { draw_centered_text :: proc(text: cstring, y: i32, font_size: i32, color: raylib.Color) {
decks.Deck_Id, // start a game with this deck font := assets.get_font()
themes.Screen, // go to another screen 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)
} }
// A language's menu: pick a deck and a difficulty draw_main_menu :: proc(mouse_x, mouse_y: i32) -> i32 {
language_menu :: proc(language: decks.Language, difficulty: ^memory.Difficulty) -> Language_Menu_Result { raylib.ClearBackground(themes.WHITE)
info := decks.LANGUAGES[language]
theme := info.theme draw_title("HanjaKanaMun", 100, themes.JAPAN_PRIMARY)
keys := NUMBER_KEYS draw_centered_text("Learn Japanese, Korean, and Thai", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
raylib.ClearBackground(theme.background)
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, 8, theme.primary) button_w := themes.BUTTON_WIDTH
button_h := themes.BUTTON_HEIGHT
ui.draw_title(theme.name, 50, theme.primary) button_space: i32 = 40
ui.draw_text_centered(info.native, themes.SCREEN_WIDTH / 2, 140, 30, theme.ink, info.symbol_style) total_w := button_w * 3 + button_space * 2
start_x := (themes.SCREEN_WIDTH - total_w) / 2
// A small fan of card backs start_y := themes.SCREEN_HEIGHT / 2 + 100
for i in 0 ..< 3 {
offset := f32(i - 1) if draw_button(start_x, start_y, button_w, button_h, "Japanese", themes.JAPAN_PRIMARY, themes.JAPAN_ACCENT, mouse_x, mouse_y) {
draw_card_art(theme.flag, {themes.SCREEN_WIDTH / 2 - 50 + offset * 64, 195 + abs(offset) * 10, 100, 133}, offset * 9) return themes.JAPANESE_SCREEN
} }
result: Language_Menu_Result 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
// 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
} }
// Difficulty selector 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) {
names := memory.DIFFICULTY_NAMES return themes.THAI_SCREEN
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) 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
}
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
} }
if raylib.IsKeyPressed(.RIGHT) && difficulty^ < .HARD {
difficulty^ = memory.Difficulty(int(difficulty^) + 1) 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
}
if ui.button({30, 28, 120, 48}, "Back", theme.ink, font_size = 22) { draw_thai_screen :: proc(mouse_x, mouse_y: i32) -> i32 {
result = themes.Screen.MAIN_MENU 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
} }
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 themes.THAI_SCREEN
return result
} }
+32 -80
View File
@@ -2,87 +2,39 @@ package themes
import raylib "vendor:raylib" import raylib "vendor:raylib"
SCREEN_WIDTH :: 1280 SCREEN_WIDTH: i32 = 1280
SCREEN_HEIGHT :: 720 SCREEN_HEIGHT: i32 = 720
Screen :: enum { JAPAN_PRIMARY: raylib.Color = raylib.Color{230, 57, 70, 255}
MAIN_MENU, JAPAN_ACCENT: raylib.Color = raylib.Color{253, 184, 190, 255}
LANGUAGE_MENU, JAPAN_BACKGROUND: raylib.Color = raylib.Color{250, 245, 245, 255}
MEMORY, 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}
// Which national flag the card backs are modelled on BUTTON_WIDTH: i32 = 240
Flag :: enum { BUTTON_HEIGHT: i32 = 60
JAPAN, // Nisshōki / Hinomaru TITLE_FONT_SIZE: i32 = 72
KOREA, // Taegukgi SUBTITLE_FONT_SIZE: i32 = 24
THAILAND, // Thong Trairong BUTTON_FONT_SIZE: i32 = 24
}
Theme :: struct { CARD_WIDTH: i32 = 100
name: cstring, CARD_HEIGHT: i32 = 140
flag: Flag, CARD_SPACING: i32 = 15
primary: raylib.Color, // main brand color (buttons, titles) GRID_ROWS: i32 = 4
secondary: raylib.Color, // second flag color GRID_COLS: i32 = 6
accent: raylib.Color, // highlights, matched outlines FLIP_DELAY: f32 = 1.0
background: raylib.Color,
card_face: raylib.Color,
ink: raylib.Color, // body text
}
// Flag colors MAIN_MENU: i32 = 0
HINOMARU_RED :: raylib.Color{188, 0, 45, 255} JAPANESE_SCREEN: i32 = 1
TAEGUK_RED :: raylib.Color{205, 46, 58, 255} JAPANESE_MEMORY: i32 = 2
TAEGUK_BLUE :: raylib.Color{0, 71, 160, 255} KOREAN_SCREEN: i32 = 3
THAI_RED :: raylib.Color{165, 25, 49, 255} THAI_SCREEN: i32 = 4
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
@@ -1,95 +0,0 @@
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
@@ -1,292 +0,0 @@
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
@@ -1,70 +0,0 @@
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")
}