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
29 changed files with 473 additions and 2485 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
- name: Cache Windows SDK
uses: actions/cache@v4
with:
path: |
build/xwin
build/xwin-sdk
build/raylib-windows
key: windows-sdk-${{ hashFiles('justfile') }}
- name: Windows Build
run: |
# Debian/Ubuntu may only ship a versioned lld-link
LLD_LINK="$(command -v lld-link || ls /usr/lib/llvm-*/bin/lld-link | sort -V | tail -1)"
export LLD_LINK
just windows-deps
just windows
-63
View File
@@ -1,63 +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
- name: Set Version
run: echo "GAME_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
- name: Test
run: just test
- name: Linux Build
run: just package "$GAME_VERSION"
- name: Windows Build
run: |
# Debian/Ubuntu may only ship a versioned lld-link
LLD_LINK="$(command -v lld-link || ls /usr/lib/llvm-*/bin/lld-link | sort -V | tail -1)"
export LLD_LINK
just windows-deps
just package-windows "$GAME_VERSION"
- name: Upload Artifacts
run: |
ls -la build
for FILE in build/${PROJECT_NAME}_${GAME_VERSION}_*; do
NAME="$(basename "$FILE")"
echo "Uploading $NAME"
curl --fail --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \
--upload-file "$FILE" \
https://gitea.sanplex.xyz/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$NAME
done
- name: Create Release
uses: akkuman/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
with:
files: |-
build/${{ env.PROJECT_NAME }}_*
-4
View File
@@ -16,7 +16,3 @@ HanjaKanaMun
.vscode/
*.swp
*.swo
# Build output
build/
*.dSYM/
+55 -57
View File
@@ -3,77 +3,75 @@
## Overview
Odin + raylib multilingual memory card game for learning Japanese (Hiragana/Katakana), Korean (Hangul), and Thai scripts.
Commands (see `justfile`): `just run`, `just check`, `just test`, `just package <version>`, `just package-windows <version>`
## Current Status
### Completed
- [x] Code split into modules under `src/` (themes, assets, audio, ui, card, decks, memory, profile, screens)
- [x] `.odin` files tracked in git (`.gitignore` used to ignore `*.odin`)
- [x] Fonts: M PLUS Rounded 1c (UI + kana), Jua (Hangul), Mali (Thai), all OFL, embedded via `#load` and loaded with `LoadFontFromMemory` + explicit codepoints - release builds are a single self-contained executable
- [x] Per-language themes (`themes.Theme`) with flag-based card backs: Japan (Hinomaru), Korea (Taegukgi), Thailand (Trairong)
- [x] Card flip animation (art rendered to textures and squashed), lift, shadow, lighting; matched cards pulse; cards are dealt in at game start
- [x] Sound effects synthesized in code (flip, match, mismatch, win, button click), no audio files needed
- [x] Decks: Hiragana (46), Katakana (46), Hangul basic jamo (24), Thai consonants (42); symbol ↔ reading pairs
- [x] Difficulty levels: Easy 3x4 (6 pairs), Normal 4x6 (12), Hard 4x8 (16)
- [x] Keyboard play: arrows / WASD / hjkl move focus, Enter/Space flips; number keys pick menu entries; Esc goes back (quits from main menu)
- [x] High scores: best moves, best time and games played per deck + difficulty, plus last difficulty, saved as JSON in the user data folder
- [x] Screen fade-in transitions
- [x] Win panel with best score / "New best!", Play Again (Enter) / Menu
- [x] Tooling: justfile, ols/odinfmt config for neovim, tests, Gitea CI (build/test, Windows cross-build, tagged releases)
- [x] Created AGENTS.md project plan
- [x] Created src/ directory structure with subdirectories (themes, card, memory, screens, assets)
- [x] Separated themes into themes/themes.odin (colors, constants)
- [x] Separated card logic into card/card.odin (Card struct, rendering, click detection)
- [x] Separated memory game into memory/memory.odin (game state, logic)
- [x] Separated screens into screens/screens.odin (UI screens, buttons)
- [x] Added font loading in assets/assets.odin
- [x] Fixed card positioning (removed -40 offset that pushed cards off screen)
- [x] Fixed card selection (now allows selection in both WAITING and FIRST states)
- [x] Added flip animations for cards (visual feedback when flipping)
- [x] Improved visual styling (rounded corners, theme colors)
### Ideas / Next
- [ ] Hangul syllable blocks deck (e.g. 가 ga, 나 na), Thai vowels (needs care: combining marks)
- [ ] Hiragana ↔ katakana pairing mode, dakuten (が, ぱ…) deck
- [ ] Settings: sound on/off, volume
- [ ] Test the Windows build on a real machine
- [ ] macOS release build (would need a macOS runner or osxcross)
### In Progress
- [ ] Fix Japanese font rendering (TTF loading fails in raylib with "Failed to process TTF font data")
## Known Issues / Gotchas
1. **Font glyphs must be listed**: raylib only bakes the codepoints passed to `LoadFontFromMemory`. Any new non-ASCII text needs its characters added in `assets.codepoints_for` (or the `EXTRA_*` strings), otherwise it renders as `?`. The `fonts_cover_every_deck_character` test checks deck text.
2. **Old font failure root cause**: `DroidSansJapanese.ttf` was a GitHub HTML page saved from a blob URL, not a font. Always download fonts from raw URLs and check them with `file`.
3. **Thai shaping**: raylib does no text shaping, so Thai combining vowels/tone marks would be misplaced. Thai cards use standalone consonants; Thai UI text avoids marks (win message is English).
4. Tests share the memory game's global state, so they must run single-threaded (`just test` sets `ODIN_TEST_THREADS=1`).
5. **Windows builds are cross-compiled**: Odin can't link Windows binaries on Linux, so `just windows` builds objects with `-build-mode:obj` and links with `lld-link` against an xwin SDK. The VC runtime is linked statically; only Windows system DLLs are needed. New Odin core usage can need new import libs (e.g. `ole32.lib` for `os.user_data_dir`); the CI `windows` job catches this. Not yet tested on a real Windows machine.
6. **Odin constant arrays can't be indexed at runtime**: lookup tables (`DECKS`, `LAYOUTS`, …) are `@(rodata)` globals instead of `::` constants.
7. **Profile memory**: `profile` always allocates from the heap allocator, because the test runner frees each test's allocator afterwards.
8. **`odin test` segfaults if the stack rlimit is "unlimited"**: the CI runner's container sets `ulimit -s unlimited`, which makes `getrlimit(RLIMIT_STACK).rlim_cur` come back as `RLIM_INFINITY` (a huge unsigned value). Odin's `core:thread` passes that straight to `pthread_attr_setstacksize`, `pthread_create` then fails, and `thread.create()` returns `nil` - which `core:testing`'s `pool_init` dereferences unconditionally, segfaulting the whole test binary (exit 139) before it prints anything. `just test` works around it with `ulimit -s 8192` on the same line as `odin test` (`just` runs each recipe line in its own shell, so a separate line wouldn't stick). Upstream Odin bug (core:thread/core:testing don't handle a failed thread creation); not our code.
9. **CI jobs share one self-hosted runner**: `build` and `windows` in `gitea-build.yaml` both clone `actions/checkout`/`actions/cache` into the same cache directory on the runner. Starting them at the same time can race on that clone and fail one job outright, so `windows` runs with `needs: build`.
10. **Release artifacts are bare executables**, not archives: fonts are embedded in the binary (see gotcha 1), so there's nothing else to ship alongside it.
### Pending
- [ ] Add keyboard support for card selection (1-9, A-L, etc.)
- [ ] Add proper Japanese font that works with raylib
- [ ] Add Korean and Thai language support
- [ ] Add more animations (win screen, transitions)
- [ ] Add sound effects
- [ ] Add score tracking and high scores
- [ ] Add difficulty levels
- [ ] Test all changes work together
## Known Issues
1. **Font rendering**: Japanese characters show as "?" - raylib's TTF loader fails with CJK fonts. Current workaround: using default font with romanji characters (A, I, U, etc.)
2. **Keyboard support**: Not yet implemented
## File Structure
```
HanjaKanaMun/
├── AGENTS.md # Project plan (this file)
├── README.md
├── justfile # build/run/check/test/package/windows recipes
├── ols.json # ols language server settings (neovim)
├── odinfmt.json # odinfmt formatter settings (4 spaces, LF)
├── .gitea/
│ ├── actions/setup/ # shared CI toolchain setup (apt, Odin, just)
│ └── workflows/
│ ├── gitea-build.yaml # check + test + build, and Windows cross-build, on main/develop
│ └── gitea-release.yaml # on tags v*: Linux + Windows packages, package registry upload, Gitea release
├── main.odin # Window/audio setup, screen state machine, transitions
├── README.md # Project overview
├── odin.json # Odin config
├── main.odin # Entry point only
├── src/
│ ├── themes/themes.odin # Theme struct, flag colors, Screen/Flag enums, layout constants
│ ├── assets/assets.odin # Font styles and codepoint lists, font loading
│ ├── audio/audio.odin # Synthesized sound effects, play()
│ ├── ui/ui.odin # Text helpers, buttons (with hotkeys), hover cursor
│ ├── card/card.odin # Card struct, flag card-back art, face textures, flip/deal animation
│ ├── decks/decks.odin # Languages, decks and their entries (symbol, reading, hint)
│ ├── memory/memory.odin # Memory game state, logic, keyboard focus, drawing
│ ├── profile/profile.odin # Best scores and settings, JSON in the user data folder
│ └── screens/screens.odin # Main menu and language menus
├── tests/
│ ├── memory_test.odin # Decks, font coverage, game rules, layout, keyboard focus, card animation
│ └── profile_test.odin # Score records, JSON round trip, win recording
└── assets/ # Fonts (+ OFL license files)
│ ├── themes/
│ │ └── themes.odin # Color themes, styling constants
│ ├── card/
│ │ └── card.odin # Card struct and rendering
│ ├── memory/
│ │ └── memory.odin # Memory game logic
│ ├── screens/
│ │ └── screens.odin # Screen management (main menu, language screens)
│ └── assets/
│ └── assets.odin # Font loading, resource management
└── assets/ # Font files, images (future)
└── DroidSansJapanese.ttf
```
## Next Steps
1. **Font rendering**: Try alternative approaches:
- Use raylib's LoadFontFromImage to create font from bitmap
- Use pre-converted font files
- Use system fonts via platform-specific APIs
- Use a different font format that raylib supports
2. **Keyboard support**: Add number/letter keys to select cards
3. **Testing**: Verify all current changes work together
## Notes
- NO rm -rf or starting over
- Make small, incremental changes
- Test after each change (`just check`, `just test`, then `just run`)
- Test after each change
- 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)
+1 -58
View File
@@ -1,60 +1,3 @@
# HanjaKanaMun
Odin + raylib memory card game for learning Japanese, Korean and Thai scripts.
Match each symbol with its reading: hiragana and katakana with romaji, Hangul letters with their
Revised Romanization, and Thai consonants with their sound and name word. Three difficulties
(6, 12 or 16 pairs), best scores per deck, and a card deck themed on each country's flag.
## Controls
| | |
|---|---|
| Mouse | Click cards and buttons |
| Arrows / WASD / hjkl | Move between cards |
| Enter / Space | Flip the selected card (Enter also plays again) |
| 1-3 | Pick a language or deck in menus |
| Left / Right | Change difficulty |
| Esc | Back (quits from the main menu) |
Scores are saved to `profile.json` in the user data folder (`~/Library/Application Support/HanjaKanaMun`
on macOS, `~/.local/share/HanjaKanaMun` on Linux, `%APPDATA%\HanjaKanaMun` on Windows).
## Development
Requires [Odin](https://odin-lang.org) (dev-2026-09) and [just](https://just.systems).
```sh
just run # build and play (debug)
just check # type-check with -vet
just test # run tests
just package 1.0 # native release build -> build/HanjaKanaMun_1.0_<os>_<arch> (self-contained executable)
just windows-deps # Linux only: fetch Windows SDK/CRT (xwin) and raylib.lib
just package-windows 1.0 # cross-compiled Windows build -> build/HanjaKanaMun_1.0_windows_x86_64.exe
```
The Windows build needs `lld-link`. On macOS, `brew install lld` and set `LLD_LINK`, and point `XWIN_SDK` and
`RAYLIB_WINDOWS` at an xwin splat and Odin's Windows `vendor/raylib/windows` folder (the Linux/macOS Odin releases
don't include `raylib.lib`).
### Neovim
`ols.json` and `odinfmt.json` configure the [ols](https://github.com/DanielGavin/ols) language server and formatter.
Install ols (e.g. `:MasonInstall ols`) and enable it with nvim-lspconfig: `vim.lsp.enable('ols')`.
Formatting goes through ols (`vim.lsp.buf.format()`), or `just fmt` if `odinfmt` is on your PATH.
## CI
- `.gitea/workflows/gitea-build.yaml`: check, test and build on pushes and PRs to `main` and `develop`, plus a
Windows cross-build so link problems show up before a release.
- `.gitea/workflows/gitea-release.yaml`: on a version tag like `v1.0`, builds Linux and Windows packages, uploads them
to the Gitea generic package registry (`HanjaKanaMun/1.0`) and creates a Gitea release with both attached.
Needs a `TOKEN` secret with package write access.
- `.gitea/actions/setup`: shared toolchain setup (apt packages, Odin, just).
Release: `git tag v1.0 && git push origin v1.0`
## Credits
Fonts, all under the SIL Open Font License (license files in `assets/`):
M PLUS Rounded 1c (UI and kana), Jua (Hangul), Mali (Thai).
Odin + raylib multilingual application (Thai, Korean, Japanese)
-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.
-103
View File
@@ -1,103 +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>/<name> (self-contained: fonts are embedded)
release:
mkdir -p {{build_dir}}/{{os()}}
odin build . -vet -o:speed -out:{{build_dir}}/{{os()}}/{{name}}
# Native release build as build/<name>_<version>_<os>_<arch>
package version="dev": release
cp {{build_dir}}/{{os()}}/{{name}} {{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}}
@echo "{{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}}"
# Download the Windows SDK + CRT (via xwin, accepting Microsoft's license) and raylib.lib. Linux x86_64 only.
windows-deps:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p {{build_dir}}
if [ ! -x "{{xwin}}" ]; then
curl -fsSL https://github.com/Jake-Shadle/xwin/releases/download/{{xwin_version}}/xwin-{{xwin_version}}-x86_64-unknown-linux-musl.tar.gz \
| tar -xz -C {{build_dir}} --strip-components=1 xwin-{{xwin_version}}-x86_64-unknown-linux-musl/xwin
fi
if [ ! -d "{{xwin_sdk}}/crt" ]; then
"{{xwin}}" --accept-license --arch x86_64 --cache-dir {{build_dir}}/xwin-cache splat --output "{{xwin_sdk}}"
fi
# Odin's Linux/macOS releases don't ship raylib's Windows library, so take it from the Windows release
if [ ! -f "{{raylib_windows}}/raylib.lib" ]; then
mkdir -p "{{raylib_windows}}"
curl -fsSL -o {{build_dir}}/odin-windows.zip \
https://github.com/odin-lang/Odin/releases/download/{{odin_version}}/odin-windows-amd64-{{odin_version}}.zip
unzip -o -j {{build_dir}}/odin-windows.zip 'dist/vendor/raylib/windows/raylib.lib' -d "{{raylib_windows}}"
fi
# Cross-compile a self-contained Windows x86_64 build into build/windows/ (run `just windows-deps` first)
windows:
#!/usr/bin/env bash
set -euo pipefail
obj_dir={{build_dir}}/obj-windows
out_dir={{build_dir}}/windows
mkdir -p "$obj_dir" "$out_dir"
rm -f "$obj_dir"/*.obj
odin build . -vet -o:speed -target:windows_amd64 -build-mode:obj -out:"$obj_dir/{{name}}.obj"
# Odin can't link for Windows from other platforms, so link with lld-link.
# The VC runtime is linked statically (no VCRUNTIME140.dll needed); the
# Universal CRT is used from Windows itself (built into Windows 10+).
{{lld_link}} /nologo /machine:x64 /subsystem:windows /entry:mainCRTStartup \
/out:"$out_dir/{{name}}.exe" "$obj_dir"/*.obj "{{raylib_windows}}/raylib.lib" \
/libpath:"{{xwin_sdk}}/crt/lib/x86_64" \
/libpath:"{{xwin_sdk}}/sdk/lib/um/x86_64" \
/libpath:"{{xwin_sdk}}/sdk/lib/ucrt/x86_64" \
/nodefaultlib:msvcrt /nodefaultlib:vcruntime /nodefaultlib:libucrt \
libcmt.lib libvcruntime.lib ucrt.lib \
kernel32.lib user32.lib gdi32.lib winmm.lib shell32.lib opengl32.lib \
advapi32.lib bcrypt.lib ntdll.lib synchronization.lib ole32.lib
# Windows build as build/<name>_<version>_windows_x86_64.exe
package-windows version="dev": windows
cp {{build_dir}}/windows/{{name}}.exe {{build_dir}}/{{name}}_{{version}}_windows_x86_64.exe
@echo "{{build_dir}}/{{name}}_{{version}}_windows_x86_64.exe"
# Format all Odin files with odinfmt (settings in odinfmt.json)
fmt:
odinfmt -w .
# Remove build output
clean:
if [ -d {{build_dir}} ]; then rm -r -- {{build_dir}}; fi
rm -f -- {{name}}
+32 -89
View File
@@ -1,112 +1,55 @@
package main
import "core:fmt"
import "core:reflect"
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 ui "src/ui"
import screens "src/screens"
import memory "src/memory"
import assets "src/assets"
TRANSITION_SPEED :: 4 // screen fade-ins per second
// Where Escape takes you from each screen (nil = quit)
parent_screen :: proc(screen: themes.Screen) -> Maybe(themes.Screen) {
switch screen {
case .MAIN_MENU: return nil
case .LANGUAGE_MENU: return .MAIN_MENU
case .MEMORY: return .LANGUAGE_MENU
}
return nil
}
current_screen: i32 = themes.MAIN_MENU
mouse_x: i32
mouse_y: i32
main :: proc() {
raylib.SetConfigFlags({.MSAA_4X_HINT, .VSYNC_HINT})
raylib.InitWindow(themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, "HanjaKanaMun")
raylib.InitWindow(themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, cstring("HanjaKanaMun"))
defer raylib.CloseWindow()
raylib.SetExitKey(.KEY_NULL)
raylib.SetTargetFPS(60)
raylib.InitAudioDevice()
defer raylib.CloseAudioDevice()
audio.load()
defer audio.unload()
assets.load_fonts()
defer assets.unload_fonts()
card.load_art()
defer card.unload_art()
defer memory.unload()
if !profile.init() {
raylib.TraceLog(.WARNING, "PROFILE: Could not read saved scores, starting fresh")
}
raylib.SetTargetFPS(60)
memory.init_memory_game()
screen := themes.Screen.MAIN_MENU
language := decks.Language.JAPANESE
difficulty := memory.Difficulty.NORMAL
if saved, ok := reflect.enum_from_name(memory.Difficulty, profile.data.difficulty); ok {
difficulty = saved
}
transition_t: f32 = 1 // fades the screen in from the background color
running := true
for running && !raylib.WindowShouldClose() {
dt := raylib.GetFrameTime()
for !raylib.WindowShouldClose() {
mouse_x = raylib.GetMouseX()
mouse_y = raylib.GetMouseY()
if raylib.IsKeyPressed(.ESCAPE) {
if parent, ok := parent_screen(screen).?; ok {
screen = parent
transition_t = 1
} else {
running = false
}
if raylib.IsKeyPressed(raylib.KeyboardKey.ESCAPE) {
current_screen = themes.MAIN_MENU
}
if screen == .MEMORY {
memory.update(dt)
if current_screen == themes.JAPANESE_MEMORY {
delta_time := raylib.GetFrameTime()
memory.update_memory_game(delta_time, mouse_x, mouse_y)
}
raylib.BeginDrawing()
ui.begin_frame()
next := screen
start_deck: Maybe(decks.Deck_Id)
switch screen {
case .MAIN_MENU:
if picked, ok := screens.main_menu().?; ok {
language = picked
next = .LANGUAGE_MENU
}
case .LANGUAGE_MENU:
switch result in screens.language_menu(language, &difficulty) {
case decks.Deck_Id:
start_deck = result
next = .MEMORY
case themes.Screen:
next = result
}
case .MEMORY:
next = memory.draw()
}
if transition_t > 0 {
alpha := transition_t * transition_t
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, raylib.Fade(themes.MENU_BACKGROUND, alpha))
transition_t = max(transition_t - dt * TRANSITION_SPEED, 0)
}
ui.end_frame()
raylib.EndDrawing()
free_all(context.temp_allocator)
profile.set_difficulty(fmt.tprintf("%v", difficulty))
// Building card faces renders into textures, so do it outside of drawing
if deck, ok := start_deck.?; ok {
memory.start(deck, difficulty)
if current_screen == themes.MAIN_MENU {
current_screen = screens.draw_main_menu(mouse_x, mouse_y)
} else if current_screen == themes.JAPANESE_SCREEN {
current_screen = screens.draw_japanese_screen(mouse_x, mouse_y)
if current_screen == themes.JAPANESE_MEMORY {
memory.init_memory_game()
}
} else if current_screen == themes.JAPANESE_MEMORY {
current_screen = memory.draw_memory_game(mouse_x, mouse_y)
} else if current_screen == themes.KOREAN_SCREEN {
current_screen = screens.draw_korean_screen(mouse_x, mouse_y)
} else if current_screen == themes.THAI_SCREEN {
current_screen = screens.draw_thai_screen(mouse_x, mouse_y)
}
if next != screen { transition_t = 1 }
screen = next
raylib.EndDrawing()
}
}
+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 -78
View File
@@ -2,89 +2,17 @@ package assets
import raylib "vendor:raylib"
// Glyphs are rasterized at this size and scaled down when drawn
FONT_BASE_SIZE :: 128
Font_Style :: enum {
REGULAR, // UI text (M PLUS Rounded 1c Medium)
BOLD, // UI headings and Japanese symbols (M PLUS Rounded 1c ExtraBold)
KOREAN, // Hangul (Jua)
THAI, // Thai script (Mali SemiBold)
}
// Fonts are baked into the executable so a release is a single self-contained
// binary: no assets/ folder has to ship (or be found) alongside it at runtime.
@(rodata)
FONT_DATA := [Font_Style][]u8{
.REGULAR = #load("../../assets/MPLUSRounded1c-Medium.ttf"),
.BOLD = #load("../../assets/MPLUSRounded1c-ExtraBold.ttf"),
.KOREAN = #load("../../assets/Jua-Regular.ttf"),
.THAI = #load("../../assets/Mali-SemiBold.ttf"),
}
fonts: [Font_Style]raylib.Font
// Extra non-range characters used in UI text
EXTRA_JAPANESE :: "日本語・·āō"
EXTRA_KOREAN :: "한글국어축하해요"
EXTRA_THAI :: "ไทย"
// raylib only bakes the codepoints it is given, so every glyph a font must draw is listed here.
// Tests check that every deck's characters are covered.
codepoints_for :: proc(style: Font_Style, allocator := context.allocator) -> [dynamic]rune {
codepoints := make([dynamic]rune, allocator)
for r in rune(0x20) ..= 0x7E {
// Jua has no backtick; Korean text never uses it
if style == .KOREAN && r == '`' { continue }
append(&codepoints, r) // ASCII
}
switch style {
case .REGULAR, .BOLD:
for r in rune(0x3041) ..= 0x3096 { append(&codepoints, r) } // Hiragana
for r in rune(0x30A1) ..= 0x30FC { append(&codepoints, r) } // Katakana
for r in EXTRA_JAPANESE { append(&codepoints, r) }
case .KOREAN:
for r in rune(0x3131) ..= 0x3163 { append(&codepoints, r) } // Modern Hangul compatibility jamo
for r in EXTRA_KOREAN { append(&codepoints, r) }
case .THAI:
for r in rune(0x0E01) ..= 0x0E3A { append(&codepoints, r) } // Thai (U+0E3B-0E3E are unassigned)
for r in rune(0x0E3F) ..= 0x0E5B { append(&codepoints, r) }
for r in EXTRA_THAI { append(&codepoints, r) }
}
return codepoints
}
@(private)
load_font :: proc(style: Font_Style) -> raylib.Font {
codepoints := codepoints_for(style)
defer delete(codepoints)
data := FONT_DATA[style]
font := raylib.LoadFontFromMemory(
".ttf", raw_data(data), i32(len(data)), FONT_BASE_SIZE, raw_data(codepoints), i32(len(codepoints)),
)
if !raylib.IsFontValid(font) || font.texture.id == 0 {
raylib.TraceLog(.WARNING, "ASSETS: Falling back to default font, could not load style %v", style)
return raylib.GetFontDefault()
}
// Mipmaps keep small UI text smooth when the large atlas is scaled down
raylib.GenTextureMipmaps(&font.texture)
raylib.SetTextureFilter(font.texture, .TRILINEAR)
return font
}
japanese_font: raylib.Font
load_fonts :: proc() {
for style in Font_Style {
fonts[style] = load_font(style)
}
// Use raylib's default font for now
japanese_font = raylib.GetFontDefault()
}
unload_fonts :: proc() {
default_id := raylib.GetFontDefault().texture.id
for font in fonts {
if font.texture.id != default_id { raylib.UnloadFont(font) }
}
// Don't unload the default font
}
get_font :: proc(style: Font_Style = .REGULAR) -> raylib.Font {
return fonts[style]
get_font :: proc() -> raylib.Font {
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])
}
+60 -279
View File
@@ -1,300 +1,81 @@
package card
import "core:math"
import raylib "vendor:raylib"
import assets "../assets"
import themes "../themes"
import ui "../ui"
// Card art is rendered once into textures at 2x size and scaled down when drawn.
// This lets the flip animation squash the whole card (text included) horizontally.
ART_SCALE :: 2
ART_WIDTH :: themes.CARD_WIDTH * ART_SCALE
ART_HEIGHT :: themes.CARD_HEIGHT * ART_SCALE
ROUNDNESS :: 0.16
SEGMENTS :: 16
FLIP_SPEED :: 2.6 // flips per second
DEAL_SPEED :: 3.5 // how fast a dealt card settles
DEAL_STAGGER :: 0.12 // delay between consecutive cards, in deal_t units
PULSE_DURATION :: 0.35 // seconds
HOVER_LIFT :: 4
import assets "../assets"
Card :: struct {
rect: raylib.Rectangle, // resting position on screen
text: cstring,
hint: cstring, // small second line on reading cards
is_symbol: bool, // script symbol (vs. its reading)
pair_id: int,
face_up: bool, // logical state; the animation follows it
matched: bool,
flip_t: f32, // 0 = back showing, 1 = face showing
pulse_t: f32, // counts down after a match
deal_t: f32, // > 0 while being dealt onto the table (0 = resting)
face_art: raylib.RenderTexture2D,
x: i32,
y: i32,
character: cstring,
is_face_up: bool,
is_matched: bool,
pair_id: i32,
flip_progress: f32, // 0 = face down, 1 = face up
is_flipping: bool,
flip_direction: bool, // true = to face up, false = to face down
}
@(private)
back_art: [themes.Flag]raylib.RenderTexture2D
draw_card :: proc(card: ^Card, mouse_x, mouse_y: i32, theme_color: raylib.Color) {
draw_color := theme_color
if card.is_matched {
draw_color = themes.LIGHT_GRAY
}
// ---------------------------------------------------------------------------
// Art generation
// ---------------------------------------------------------------------------
is_hovered := mouse_x >= card.x && mouse_x <= card.x + themes.CARD_WIDTH &&
mouse_y >= card.y && mouse_y <= card.y + themes.CARD_HEIGHT
if is_hovered && !card.is_face_up && !card.is_matched && !card.is_flipping {
draw_color = raylib.Color{
byte(f32(theme_color.r) * 0.8),
byte(f32(theme_color.g) * 0.8),
byte(f32(theme_color.b) * 0.8),
theme_color.a,
}
}
@(private)
art_rect :: proc(inset: f32 = 0) -> raylib.Rectangle {
return {inset, inset, ART_WIDTH - 2 * inset, ART_HEIGHT - 2 * inset}
}
// Draw card background with rounded corners
raylib.DrawRectangleRounded(raylib.Rectangle{f32(card.x), f32(card.y), f32(themes.CARD_WIDTH), f32(themes.CARD_HEIGHT)}, 0.2, 10, draw_color)
raylib.DrawRectangleRoundedLines(raylib.Rectangle{f32(card.x), f32(card.y), f32(themes.CARD_WIDTH), f32(themes.CARD_HEIGHT)}, 0.2, 10, themes.BLACK)
@(private)
new_art_texture :: proc(edge_color: raylib.Color) -> raylib.RenderTexture2D {
rt := raylib.LoadRenderTexture(ART_WIDTH, ART_HEIGHT)
raylib.SetTextureFilter(rt.texture, .BILINEAR)
raylib.BeginTextureMode(rt)
// Transparent, but with the edge's RGB so filtering doesn't create dark fringes
raylib.ClearBackground({edge_color.r, edge_color.g, edge_color.b, 0})
raylib.EndTextureMode()
return rt
}
// Handle flip animation
if card.is_flipping {
// Draw the flip animation
flip_angle := card.flip_progress * 180.0
if !card.flip_direction {
flip_angle = 180.0 - flip_angle
}
@(private)
draw_frame :: proc(fill, border: raylib.Color) {
raylib.DrawRectangleRounded(art_rect(), ROUNDNESS, SEGMENTS, fill)
raylib.DrawRectangleRoundedLinesEx(art_rect(3), ROUNDNESS, SEGMENTS, 6, border)
}
// Draw the card as a rectangle that's rotating
center_x := f32(card.x + themes.CARD_WIDTH / 2)
center_y := f32(card.y + themes.CARD_HEIGHT / 2)
// Japan: Hinomaru, a crimson sun disc on white, with a double crimson border
@(private)
draw_japan_back :: proc() {
draw_frame(themes.WHITE, themes.HINOMARU_RED)
raylib.DrawRectangleRoundedLinesEx(art_rect(16), ROUNDNESS, SEGMENTS, 2, themes.HINOMARU_RED)
center := raylib.Vector2{ART_WIDTH / 2, ART_HEIGHT / 2}
raylib.DrawCircleV(center, ART_WIDTH * 0.3, themes.HINOMARU_RED)
}
// One Taegukgi trigram: three bars, each either solid or broken in the middle
@(private)
draw_trigram :: proc(center: raylib.Vector2, angle_deg: f32, solid: [3]bool, scale: f32) {
length := 46 * scale
thick := 8 * scale
gap := 6 * scale
rad := angle_deg * math.RAD_PER_DEG
along := raylib.Vector2{math.cos(rad), math.sin(rad)} // bar direction
across := raylib.Vector2{-along.y, along.x} // stacking direction
for i in 0 ..< 3 {
bar_center := center + across * (f32(i - 1) * (thick + gap))
if solid[i] {
raylib.DrawRectanglePro({bar_center.x, bar_center.y, length, thick}, {length / 2, thick / 2}, angle_deg, themes.BLACK)
// For simplicity, just show the character when flip_progress > 0.5
show_face := card.flip_progress > 0.5
if card.flip_direction {
show_face = card.flip_progress >= 0.5
} else {
half := (length - gap) / 2
for side in ([2]f32{-1, 1}) {
c := bar_center + along * (side * (half + gap) / 2)
raylib.DrawRectanglePro({c.x, c.y, half, thick}, {half / 2, thick / 2}, angle_deg, themes.BLACK)
}
show_face = card.flip_progress < 0.5
}
}
}
// Korea: Taegukgi, a red/blue taeguk circle with the four black trigrams in the corners
@(private)
draw_korea_back :: proc() {
draw_frame(themes.WHITE, themes.TAEGUK_BLUE)
center := raylib.Vector2{ART_WIDTH / 2, ART_HEIGHT / 2}
radius: f32 = ART_WIDTH * 0.26
// The flag's taeguk is tilted along the diagonal of a 3:2 rectangle
tilt := math.atan2(f32(2), 3) * math.DEG_PER_RAD
raylib.DrawCircleSector(center, radius, 180 + tilt, 360 + tilt, 48, themes.TAEGUK_RED)
raylib.DrawCircleSector(center, radius, tilt, 180 + tilt, 48, themes.TAEGUK_BLUE)
offset := raylib.Vector2{math.cos(tilt * math.RAD_PER_DEG), math.sin(tilt * math.RAD_PER_DEG)} * (radius / 2)
// Red head toward the upper-left (geon ☰), blue head toward the lower-right (gon ☷)
raylib.DrawCircleV(center - offset, radius / 2, themes.TAEGUK_RED)
raylib.DrawCircleV(center + offset, radius / 2, themes.TAEGUK_BLUE)
scale: f32 = 0.8
dx: f32 = ART_WIDTH * 0.24
dy: f32 = ART_HEIGHT * 0.26
draw_trigram(center + {-dx, -dy}, -45, {true, true, true}, scale) // geon ☰
draw_trigram(center + {dx, -dy}, 45, {false, true, false}, scale) // gam ☵
draw_trigram(center + {-dx, dy}, 45, {true, false, true}, scale) // ri ☲
draw_trigram(center + {dx, dy}, -45, {false, false, false}, scale) // gon ☷
}
// Thailand: Thong Trairong, red-white-blue-white-red stripes (blue twice as wide)
@(private)
draw_thailand_back :: proc() {
draw_frame(themes.WHITE, themes.THAI_BLUE)
panel := art_rect(18)
unit := panel.height / 6
stripes := [5]struct{ color: raylib.Color, units: f32 }{
{themes.THAI_RED, 1}, {themes.WHITE, 1}, {themes.THAI_BLUE, 2}, {themes.WHITE, 1}, {themes.THAI_RED, 1},
}
y := panel.y
for stripe in stripes {
raylib.DrawRectangleRec({panel.x, y, panel.width, unit * stripe.units}, stripe.color)
y += unit * stripe.units
}
raylib.DrawRectangleLinesEx(panel, 2, themes.THAI_BLUE)
}
// Builds the card back textures. Call once after the window is created.
load_art :: proc() {
edges := [themes.Flag]raylib.Color{.JAPAN = themes.HINOMARU_RED, .KOREA = themes.TAEGUK_BLUE, .THAILAND = themes.THAI_BLUE}
for flag in themes.Flag {
back_art[flag] = new_art_texture(edges[flag])
raylib.BeginTextureMode(back_art[flag])
switch flag {
case .JAPAN: draw_japan_back()
case .KOREA: draw_korea_back()
case .THAILAND: draw_thailand_back()
if show_face || card.is_face_up || card.is_matched {
font := assets.get_font()
text_w := raylib.MeasureText(card.character, 40)
text_x := card.x + (themes.CARD_WIDTH - text_w) / 2
text_y := card.y + (themes.CARD_HEIGHT - 40) / 2 - 8
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x + 1), f32(text_y + 1)}, 40.0, 0, themes.BLACK)
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x), f32(text_y)}, 40.0, 0, themes.WHITE)
}
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() {
for rt in back_art { raylib.UnloadRenderTexture(rt) }
}
get_back_art :: proc(flag: themes.Flag) -> raylib.Texture2D {
return back_art[flag].texture
}
// Font size that fits text inside the card width
@(private)
fit_size :: proc(text: cstring, size: f32, style: assets.Font_Style) -> f32 {
max_width: f32 = ART_WIDTH * 0.78
width := ui.measure_text(text, size, style).x
return width > max_width ? size * max_width / width : size
}
// Renders the card's face into its own texture. Symbols use their script's font.
build_face :: proc(card: ^Card, theme: themes.Theme, symbol_style: assets.Font_Style) {
unload_face(card)
card.face_art = new_art_texture(theme.primary)
raylib.BeginTextureMode(card.face_art)
draw_frame(theme.card_face, theme.primary)
raylib.DrawRectangleRoundedLinesEx(art_rect(16), ROUNDNESS, SEGMENTS, 2, raylib.Fade(theme.primary, 0.35))
if card.is_symbol {
ui.draw_text_in_rect(card.text, art_rect(), fit_size(card.text, 132, symbol_style), theme.ink, symbol_style)
} else if card.hint == nil {
ui.draw_text_in_rect(card.text, art_rect(), fit_size(card.text, 96, .BOLD), theme.primary, .BOLD)
} else {
// Reading on top, hint underneath
main_rect := art_rect()
main_rect.height -= 56
ui.draw_text_in_rect(card.text, main_rect, fit_size(card.text, 88, .BOLD), theme.primary, .BOLD)
hint_rect := raylib.Rectangle{0, ART_HEIGHT * 0.62, ART_WIDTH, 60}
ui.draw_text_in_rect(card.hint, hint_rect, fit_size(card.hint, 34, .REGULAR), theme.ink, .REGULAR)
}
raylib.EndTextureMode()
}
unload_face :: proc(card: ^Card) {
if card.face_art.id != 0 {
raylib.UnloadRenderTexture(card.face_art)
card.face_art = {}
}
}
// ---------------------------------------------------------------------------
// Behaviour
// ---------------------------------------------------------------------------
is_animating :: proc(card: ^Card) -> bool {
target: f32 = card.face_up ? 1 : 0
return card.flip_t != target
}
update :: proc(card: ^Card, dt: f32) {
target: f32 = card.face_up ? 1 : 0
step := dt * FLIP_SPEED
if card.flip_t < target {
card.flip_t = min(card.flip_t + step, target)
} else if card.flip_t > target {
card.flip_t = max(card.flip_t - step, target)
}
if card.pulse_t > 0 {
card.pulse_t = max(card.pulse_t - dt, 0)
}
if card.deal_t > 0 {
card.deal_t = max(card.deal_t - dt * DEAL_SPEED, 0)
}
}
// Starts the deal-in animation; later cards in the deal arrive later
start_deal :: proc(card: ^Card, order: int) {
card.deal_t = 1 + f32(order) * DEAL_STAGGER
}
start_pulse :: proc(card: ^Card) {
card.pulse_t = PULSE_DURATION
}
is_hovered :: proc(card: ^Card) -> bool {
return raylib.CheckCollisionPointRec(raylib.GetMousePosition(), card.rect)
}
@(private)
smoothstep :: proc(t: f32) -> f32 {
return t * t * (3 - 2 * t)
}
draw :: proc(card: ^Card, theme: themes.Theme, clickable: bool, focused := false) {
eased := smoothstep(card.flip_t)
squash := abs(math.cos(eased * math.PI)) // 1 → 0 → 1 across the flip
showing_face := eased >= 0.5
// Cards lift as they turn, and a little on hover
lift := math.sin(eased * math.PI) * 6
mouse_over := clickable && is_hovered(card)
if mouse_over { ui.set_hovered() }
hovered := mouse_over || (clickable && focused)
if hovered { lift += HOVER_LIFT }
// Dealing: slide up from below and grow in
dealt := smoothstep(1 - clamp(card.deal_t, 0, 1))
if dealt == 0 { return }
deal_offset := (1 - dealt) * 60
scale: f32 = 0.85 + 0.15 * dealt
if card.pulse_t > 0 {
progress := 1 - card.pulse_t / PULSE_DURATION
scale += 0.09 * math.sin(progress * math.PI)
}
width := card.rect.width * scale
height := card.rect.height * scale
center := raylib.Vector2{card.rect.x + card.rect.width / 2, card.rect.y + card.rect.height / 2 - lift + deal_offset}
// Shadow stays on the table and spreads as the card lifts
shadow := raylib.Rectangle{
center.x - width * squash / 2 + 2,
card.rect.y + card.rect.height * (1 - scale) / 2 + 4 + lift * 0.15 + deal_offset,
max(width * squash, 2),
height,
}
raylib.DrawRectangleRounded(shadow, ROUNDNESS, SEGMENTS, raylib.Fade(themes.BLACK, (0.12 - lift * 0.004) * dealt))
// Darken the card while it is edge-on, to fake lighting
shade := u8(255 - (1 - squash) * 70)
tint := raylib.Color{shade, shade, shade, u8(255 * dealt)}
if hovered && !showing_face {
tint = raylib.ColorBrightness(tint, 0.08)
}
texture := showing_face ? card.face_art.texture : back_art[theme.flag].texture
source := raylib.Rectangle{0, 0, f32(texture.width), -f32(texture.height)} // render textures are upside down
dest := raylib.Rectangle{center.x, center.y, width * squash, height}
raylib.DrawTexturePro(texture, source, dest, {dest.width / 2, dest.height / 2}, 0, tint)
if focused {
ring := raylib.Rectangle{center.x - width * squash / 2 - 6, center.y - height / 2 - 6, width * squash + 12, height + 12}
raylib.DrawRectangleRoundedLinesEx(ring, ROUNDNESS, SEGMENTS, 3, raylib.Fade(theme.ink, 0.75))
}
if card.matched && card.flip_t == 1 {
outline := raylib.Rectangle{center.x - width / 2 - 3, center.y - height / 2 - 3, width + 6, height + 6}
raylib.DrawRectangleRoundedLinesEx(outline, ROUNDNESS, SEGMENTS, 3, theme.accent)
}
is_card_clicked :: proc(card: ^Card, mouse_x, mouse_y: i32) -> bool {
return mouse_x >= card.x && mouse_x <= card.x + themes.CARD_WIDTH &&
mouse_y >= card.y && mouse_y <= card.y + themes.CARD_HEIGHT
}
-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"},
}
+183 -291
View File
@@ -1,326 +1,218 @@
package memory
import "core:fmt"
import "core:math/rand"
import "core:strings"
import raylib "vendor:raylib"
import audio "../audio"
import card "../card"
import decks "../decks"
import profile "../profile"
import themes "../themes"
import ui "../ui"
import card "../card"
import assets "../assets"
MAX_CARDS :: 32
MATCH_DELAY :: 0.35 // seconds both cards stay up before a match is confirmed
MISMATCH_DELAY :: 0.9 // seconds before a wrong pair flips back
Difficulty :: enum {
EASY,
NORMAL,
HARD,
}
Layout :: struct {
rows, cols: int,
}
@(rodata)
LAYOUTS := [Difficulty]Layout{
.EASY = {3, 4},
.NORMAL = {4, 6},
.HARD = {4, 8},
}
@(rodata)
DIFFICULTY_NAMES := [Difficulty]cstring{.EASY = "Easy", .NORMAL = "Normal", .HARD = "Hard"}
State :: enum {
WAITING, // no card selected
FIRST, // one card selected
RESOLVING, // two cards up, waiting before match/mismatch
MemoryState :: enum {
WAITING,
FIRST,
SECOND,
OVER,
}
cards: [MAX_CARDS]card.Card
card_count: int
pair_count: int
deck: decks.Deck_Id
difficulty: Difficulty
theme: themes.Theme
layout: Layout
state: State
first: int = -1
second: int = -1
matches: int
moves: int
elapsed: f32
resolve_timer: f32
win_t: f32 // fades in the win panel
restart_requested: bool
last_result: profile.Result // filled in when a game is won
focus: int // card selected with the keyboard
keyboard_active: bool // show the focus ring (hidden again when the mouse moves)
memory_cards: [24]card.Card
memory_state: MemoryState
memory_first: i32
memory_second: i32
memory_matches: i32
memory_moves: i32
memory_time: f32
memory_flip: f32
// Key for this deck and difficulty in the saved profile
score_key :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) -> string {
return fmt.tprintf("%v/%v", deck_id, game_difficulty)
}
init_memory_game :: proc() {
hiragana_chars: [12]cstring = {"あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ", "さ", "し"}
romanji_chars: [12]cstring = {"A", "I", "U", "E", "O", "KA", "KI", "KU", "KE", "KO", "SA", "SHI"}
// Deals a new game and renders the card faces. Needs the window to exist.
start :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) {
deal(deck_id, game_difficulty)
style := decks.language_of(deck).symbol_style
for &c in active_cards() { card.build_face(&c, theme, style) }
}
// Shuffles, lays out and resets the game state without touching the GPU
deal :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) {
unload()
deck = deck_id
difficulty = game_difficulty
theme = decks.language_of(deck).theme
layout = LAYOUTS[difficulty]
card_count = layout.rows * layout.cols
pair_count = card_count / 2
// Pick random entries from the deck
entries := decks.entries(deck)
order := make([]int, len(entries), context.temp_allocator)
for &o, i in order { o = i }
rand.shuffle(order)
for pair in 0 ..< pair_count {
entry := entries[order[pair]]
cards[pair * 2] = card.Card{text = entry.symbol, is_symbol = true, pair_id = pair}
cards[pair * 2 + 1] = card.Card{text = entry.reading, hint = entry.hint, pair_id = pair}
for i := 0; i < 12; i += 1 {
memory_cards[i*2] = card.Card{
x = 0, y = 0,
character = romanji_chars[i],
is_face_up = false,
is_matched = false,
pair_id = i32(i),
flip_progress = 0.0,
is_flipping = false,
flip_direction = false,
}
memory_cards[i*2+1] = card.Card{
x = 0, y = 0,
character = romanji_chars[i],
is_face_up = false,
is_matched = false,
pair_id = i32(i),
flip_progress = 0.0,
is_flipping = false,
flip_direction = false,
}
}
rand.shuffle(cards[:card_count])
// Center the grid horizontally and in the space below the header
step_x: f32 = themes.CARD_WIDTH + themes.CARD_SPACING
step_y: f32 = themes.CARD_HEIGHT + themes.CARD_SPACING
total_w := f32(layout.cols) * step_x - themes.CARD_SPACING
total_h := f32(layout.rows) * step_y - themes.CARD_SPACING
for i := 0; i < 100; i += 1 {
a := i % 24
b := (i * 7) % 24
temp := memory_cards[a]
memory_cards[a] = memory_cards[b]
memory_cards[b] = temp
}
total_w := themes.GRID_COLS * (themes.CARD_WIDTH + themes.CARD_SPACING) - themes.CARD_SPACING
total_h := themes.GRID_ROWS * (themes.CARD_HEIGHT + themes.CARD_SPACING) - themes.CARD_SPACING
start_x := (themes.SCREEN_WIDTH - total_w) / 2
start_y := themes.GRID_TOP + (themes.SCREEN_HEIGHT - 16 - themes.GRID_TOP - total_h) / 2
for &c, i in active_cards() {
card.start_deal(&c, i)
c.rect = {
start_x + f32(i % layout.cols) * step_x,
start_y + f32(i / layout.cols) * step_y,
themes.CARD_WIDTH,
themes.CARD_HEIGHT,
start_y := (themes.SCREEN_HEIGHT - total_h) / 2
for i := 0; i < 24; i += 1 {
row := i32(i) / themes.GRID_COLS
col := i32(i) % themes.GRID_COLS
memory_cards[i].x = start_x + col * (themes.CARD_WIDTH + themes.CARD_SPACING)
memory_cards[i].y = start_y + row * (themes.CARD_HEIGHT + themes.CARD_SPACING)
}
memory_state = MemoryState.WAITING
memory_first = -1
memory_second = -1
memory_matches = 0
memory_moves = 0
memory_time = 0.0
memory_flip = 0.0
}
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
first = -1
second = -1
matches = 0
moves = 0
elapsed = 0
resolve_timer = 0
win_t = 0
restart_requested = false
focus = 0
}
active_cards :: proc() -> []card.Card {
return cards[:card_count]
}
unload :: proc() {
for &c in cards { card.unload_face(&c) }
}
// Whether the player can click or press on card i right now (it must have finished being dealt)
can_interact :: proc(i: int) -> bool {
return can_pick(i) && cards[i].deal_t == 0
}
can_pick :: proc(i: int) -> bool {
if i < 0 || i >= card_count { return false }
c := &cards[i]
return (state == .WAITING || state == .FIRST) && !c.face_up && !c.matched
}
// Turns card i face up if it can be picked. Returns false when the pick is not allowed.
select_card :: proc(i: int) -> bool {
if !can_pick(i) { return false }
cards[i].face_up = true
audio.play(.FLIP)
if state == .WAITING {
first = i
state = .FIRST
} else {
second = i
moves += 1
is_match := cards[first].pair_id == cards[second].pair_id
resolve_timer = is_match ? MATCH_DELAY : MISMATCH_DELAY
state = .RESOLVING
}
return true
}
// Moves the keyboard focus within the grid, clamped at the edges
move_focus :: proc(dx, dy: int) {
col := clamp(focus % layout.cols + dx, 0, layout.cols - 1)
row := clamp(focus / layout.cols + dy, 0, layout.rows - 1)
focus = row * layout.cols + col
}
@(private)
key_pressed :: proc(keys: ..raylib.KeyboardKey) -> bool {
for key in keys {
if raylib.IsKeyPressed(key) || raylib.IsKeyPressedRepeat(key) { return true }
}
return false
}
// Arrows, WASD or vim keys move the focus; Enter or Space flips the focused card
@(private)
handle_keyboard :: proc() {
if raylib.GetMouseDelta() != {} { keyboard_active = false }
dx, dy := 0, 0
if key_pressed(.LEFT, .A, .H) { dx -= 1 }
if key_pressed(.RIGHT, .D, .L) { dx += 1 }
if key_pressed(.UP, .W, .K) { dy -= 1 }
if key_pressed(.DOWN, .S, .J) { dy += 1 }
if dx != 0 || dy != 0 {
// The first key press only reveals the focus ring
if keyboard_active { move_focus(dx, dy) }
keyboard_active = true
}
if raylib.IsKeyPressed(.ENTER) || raylib.IsKeyPressed(.SPACE) {
keyboard_active = true
if can_interact(focus) { select_card(focus) }
}
}
@(private)
resolve :: proc() {
a, b := &cards[first], &cards[second]
if a.pair_id == b.pair_id {
a.matched = true
b.matched = true
card.start_pulse(a)
card.start_pulse(b)
matches += 1
if matches == pair_count {
state = .OVER
last_result = profile.record_game(score_key(deck, difficulty), moves, elapsed)
audio.play(.WIN)
} else {
audio.play(.MATCH)
state = .WAITING
// Update flip animations
for i := 0; i < 24; i += 1 {
if memory_cards[i].is_flipping {
memory_cards[i].flip_progress += delta_time * 3.0 // Flip speed
if memory_cards[i].flip_progress >= 1.0 {
memory_cards[i].flip_progress = 1.0
memory_cards[i].is_flipping = false
memory_cards[i].is_face_up = memory_cards[i].flip_direction
}
}
} else {
a.face_up = false
b.face_up = false
audio.play(.MISMATCH)
audio.play(.FLIP)
state = .WAITING
}
first = -1
second = -1
}
update :: proc(dt: f32) {
if restart_requested {
start(deck, difficulty)
return
}
for &c in active_cards() { card.update(&c, dt) }
if state != .OVER { handle_keyboard() }
switch state {
case .WAITING, .FIRST:
elapsed += dt
if raylib.IsMouseButtonPressed(.LEFT) {
for &c, i in active_cards() {
if card.is_hovered(&c) && can_interact(i) && select_card(i) {
if memory_state == MemoryState.WAITING || memory_state == MemoryState.FIRST {
for i := 0; i < 24; i += 1 {
if !memory_cards[i].is_matched && !memory_cards[i].is_flipping {
if card.is_card_clicked(&memory_cards[i], mouse_x, mouse_y) &&
raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT) {
memory_cards[i].is_flipping = true
memory_cards[i].flip_direction = true
memory_cards[i].flip_progress = 0.0
memory_moves += 1
if memory_first == -1 {
memory_first = i32(i)
memory_state = MemoryState.FIRST
} else if i32(i) != memory_first {
memory_second = i32(i)
memory_state = MemoryState.SECOND
}
break
}
}
}
case .RESOLVING:
elapsed += dt
// Wait for the second card to finish turning before starting the delay
if !card.is_animating(&cards[second]) {
resolve_timer -= dt
if resolve_timer <= 0 { resolve() }
}
case .OVER:
win_t = min(win_t + dt * 2.5, 1)
}
if memory_matches >= 12 {
memory_state = MemoryState.OVER
}
}
// Draws the game and returns the screen to show next frame
draw :: proc() -> themes.Screen {
next := themes.Screen.MEMORY
raylib.ClearBackground(theme.background)
draw_memory_game :: proc(mouse_x, mouse_y: i32) -> i32 {
raylib.ClearBackground(themes.JAPAN_BACKGROUND)
info := decks.DECKS[deck]
ui.draw_title(raylib.TextFormat("%s · %s", theme.name, info.name), 24, theme.primary, 48)
minutes := int(elapsed) / 60
seconds := int(elapsed) % 60
stats := raylib.TextFormat("%s · Moves %d · Pairs %d/%d · %d:%02d",
DIFFICULTY_NAMES[difficulty], moves, matches, pair_count, minutes, seconds)
ui.draw_text_centered(stats, themes.SCREEN_WIDTH / 2, 88, 24, theme.ink)
font := assets.get_font()
title: cstring = "Memory Game - Japanese"
title_w := raylib.MeasureText(title, themes.TITLE_FONT_SIZE)
title_x := (themes.SCREEN_WIDTH - title_w) / 2
raylib.DrawTextEx(font, title, raylib.Vector2{f32(title_x + 4), 54}, f32(themes.TITLE_FONT_SIZE), 0, themes.BLACK)
raylib.DrawTextEx(font, title, raylib.Vector2{f32(title_x), 50}, f32(themes.TITLE_FONT_SIZE), 0, themes.JAPAN_PRIMARY)
if state != .OVER && ui.button({30, 28, 120, 48}, "Back", theme.primary, font_size = 22) {
next = .LANGUAGE_MENU
info_text := raylib.TextFormat("Moves: %d | Time: %.1fs", memory_moves, memory_time)
info_w := raylib.MeasureText(info_text, themes.SUBTITLE_FONT_SIZE)
info_x := (themes.SCREEN_WIDTH - info_w) / 2
raylib.DrawTextEx(font, info_text, raylib.Vector2{f32(info_x), 150}, f32(themes.SUBTITLE_FONT_SIZE), 0, themes.DARK_GRAY)
for i := 0; i < 24; i += 1 {
card.draw_card(&memory_cards[i], mouse_x, mouse_y, themes.JAPAN_PRIMARY)
}
for &c, i in active_cards() {
card.draw(&c, theme, can_interact(i), focused = keyboard_active && i == focus && state != .OVER && c.deal_t == 0)
back_w := themes.BUTTON_WIDTH
back_h := themes.BUTTON_HEIGHT
back_x := (themes.SCREEN_WIDTH - back_w) / 2
back_y := themes.SCREEN_HEIGHT - 100
back_hovered := mouse_x >= back_x && mouse_x <= back_x + back_w &&
mouse_y >= back_y && mouse_y <= back_y + back_h
back_color := themes.JAPAN_PRIMARY
if back_hovered {
back_color = themes.JAPAN_ACCENT
}
raylib.DrawRectangle(back_x, back_y, back_w, back_h, back_color)
raylib.DrawRectangleLines(back_x, back_y, back_w, back_h, themes.BLACK)
back_text: cstring = "Back"
back_text_w := raylib.MeasureText(back_text, themes.BUTTON_FONT_SIZE)
back_text_x := back_x + (back_w - back_text_w) / 2
back_text_y := back_y + (back_h - themes.BUTTON_FONT_SIZE) / 2
raylib.DrawTextEx(font, back_text, raylib.Vector2{f32(back_text_x), f32(back_text_y)}, f32(themes.BUTTON_FONT_SIZE), 0, themes.WHITE)
if back_hovered && raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT) {
return themes.JAPANESE_SCREEN
}
if state == .OVER {
if action := draw_win_panel(); action != nil {
next = action.?
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
}
@(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
return themes.JAPANESE_MEMORY
}
-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)
}
+92 -122
View File
@@ -1,143 +1,113 @@
package screens
import "core:fmt"
import "core:math"
import "core:strings"
import raylib "vendor:raylib"
import card "../card"
import decks "../decks"
import memory "../memory"
import profile "../profile"
import themes "../themes"
import ui "../ui"
import assets "../assets"
// Draws a card back like a playing card lying on the table
@(private)
draw_card_art :: proc(flag: themes.Flag, rect: raylib.Rectangle, rotation: f32 = 0) {
texture := card.get_back_art(flag)
source := raylib.Rectangle{0, 0, f32(texture.width), -f32(texture.height)}
dest := raylib.Rectangle{rect.x + rect.width / 2, rect.y + rect.height / 2, rect.width, rect.height}
origin := raylib.Vector2{rect.width / 2, rect.height / 2}
shadow := dest
shadow.x += 3
shadow.y += 6
raylib.DrawRectanglePro(shadow, origin, rotation, raylib.Fade(themes.BLACK, 0.15))
raylib.DrawTexturePro(texture, source, dest, origin, rotation, themes.WHITE)
draw_button :: proc(x, y, w, h: i32, text: cstring, base_color, hover_color: raylib.Color, mouse_x, mouse_y: i32) -> bool {
is_hovered := mouse_x >= x && mouse_x <= x + w && mouse_y >= y && mouse_y <= y + h
color := base_color
if is_hovered {
color = hover_color
}
raylib.DrawRectangle(x, y, w, h, color)
raylib.DrawRectangleLines(x, y, w, h, themes.BLACK)
font := assets.get_font()
text_w := raylib.MeasureText(text, themes.BUTTON_FONT_SIZE)
text_x := x + (w - text_w) / 2
text_y := y + (h - themes.BUTTON_FONT_SIZE) / 2
raylib.DrawTextEx(font, text, raylib.Vector2{f32(text_x), f32(text_y)}, f32(themes.BUTTON_FONT_SIZE), 0, themes.WHITE)
return is_hovered && raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT)
}
@(private)
NUMBER_KEYS :: [?]raylib.KeyboardKey{.ONE, .TWO, .THREE, .FOUR, .FIVE}
// Returns the language picked this frame, if any
main_menu :: proc() -> Maybe(decks.Language) {
raylib.ClearBackground(themes.MENU_BACKGROUND)
languages := decks.LANGUAGES
keys := NUMBER_KEYS
// Thin stripes along the top in each language's primary color
stripe_w: i32 = themes.SCREEN_WIDTH / len(decks.Language)
for lang, i in languages {
raylib.DrawRectangle(i32(i) * stripe_w, 0, stripe_w, 8, lang.theme.primary)
}
ui.draw_title("HanjaKanaMun", 70, themes.SUMI_INK, 84)
ui.draw_text_centered("Learn Japanese, Korean and Thai scripts", themes.SCREEN_WIDTH / 2, 170, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
tile_w: f32 = 150
tile_h: f32 = 200
gap: f32 = 110
total := 3 * tile_w + 2 * gap
start_x := (themes.SCREEN_WIDTH - total) / 2
// Gentle bobbing so the menu feels alive
time := f32(raylib.GetTime())
picked: Maybe(decks.Language)
for lang, language in languages {
i := int(language)
x := start_x + f32(i) * (tile_w + gap)
bob := math.sin(time * 1.6 + f32(i) * 1.1) * 4
rotation := f32(i - 1) * 4
draw_card_art(lang.theme.flag, {x, 250 + bob, tile_w, tile_h}, rotation)
button_rect := raylib.Rectangle{x + tile_w / 2 - (themes.BUTTON_WIDTH - 60) / 2, 500, themes.BUTTON_WIDTH - 60, themes.BUTTON_HEIGHT}
if ui.button(button_rect, lang.theme.name, lang.theme.primary, hotkey = keys[i]) {
picked = language
}
}
ui.draw_text_centered("1-3 pick a language · In game: arrows, WASD or hjkl to move, Enter/Space to flip · Esc back / quit",
themes.SCREEN_WIDTH / 2, 660, 18, raylib.Fade(themes.DARK_GRAY, 0.8))
return picked
draw_title :: proc(text: cstring, y: i32, color: raylib.Color) {
font := assets.get_font()
text_w := raylib.MeasureText(text, themes.TITLE_FONT_SIZE)
x := (themes.SCREEN_WIDTH - text_w) / 2
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x + 4), f32(y + 4)}, f32(themes.TITLE_FONT_SIZE), 0, themes.BLACK)
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x), f32(y)}, f32(themes.TITLE_FONT_SIZE), 0, color)
}
Language_Menu_Result :: union {
decks.Deck_Id, // start a game with this deck
themes.Screen, // go to another screen
draw_centered_text :: proc(text: cstring, y: i32, font_size: i32, color: raylib.Color) {
font := assets.get_font()
text_w := raylib.MeasureText(text, font_size)
x := (themes.SCREEN_WIDTH - text_w) / 2
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x + 4), f32(y + 4)}, f32(font_size), 0, themes.BLACK)
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x), f32(y)}, f32(font_size), 0, color)
}
// A language's menu: pick a deck and a difficulty
language_menu :: proc(language: decks.Language, difficulty: ^memory.Difficulty) -> Language_Menu_Result {
info := decks.LANGUAGES[language]
theme := info.theme
keys := NUMBER_KEYS
raylib.ClearBackground(theme.background)
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, 8, theme.primary)
draw_main_menu :: proc(mouse_x, mouse_y: i32) -> i32 {
raylib.ClearBackground(themes.WHITE)
ui.draw_title(theme.name, 50, theme.primary)
ui.draw_text_centered(info.native, themes.SCREEN_WIDTH / 2, 140, 30, theme.ink, info.symbol_style)
draw_title("HanjaKanaMun", 100, themes.JAPAN_PRIMARY)
draw_centered_text("Learn Japanese, Korean, and Thai", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
// A small fan of card backs
for i in 0 ..< 3 {
offset := f32(i - 1)
draw_card_art(theme.flag, {themes.SCREEN_WIDTH / 2 - 50 + offset * 64, 195 + abs(offset) * 10, 100, 133}, offset * 9)
button_w := themes.BUTTON_WIDTH
button_h := themes.BUTTON_HEIGHT
button_space: i32 = 40
total_w := button_w * 3 + button_space * 2
start_x := (themes.SCREEN_WIDTH - total_w) / 2
start_y := themes.SCREEN_HEIGHT / 2 + 100
if draw_button(start_x, start_y, button_w, button_h, "Japanese", themes.JAPAN_PRIMARY, themes.JAPAN_ACCENT, mouse_x, mouse_y) {
return themes.JAPANESE_SCREEN
}
result: Language_Menu_Result
// One button per deck
deck_list := decks.decks_for(language)
button_w: f32 = 220
gap: f32 = 24
total := f32(len(deck_list)) * button_w + f32(len(deck_list) - 1) * gap
x := (themes.SCREEN_WIDTH - total) / 2
for deck, i in deck_list {
rect := raylib.Rectangle{x, 380, button_w, themes.BUTTON_HEIGHT}
if ui.button(rect, decks.DECKS[deck].name, theme.primary, hotkey = keys[i]) {
result = deck
}
ui.draw_text_centered(decks.DECKS[deck].native, x + button_w / 2, 448, 22, theme.ink, info.symbol_style)
best_text := cstring("No games yet")
if record, ok := profile.get(memory.score_key(deck, difficulty^)); ok {
best_text = strings.clone_to_cstring(fmt.tprintf("Best %d moves · %s", record.best_moves, profile.format_time(record.best_seconds)), context.temp_allocator)
}
ui.draw_text_centered(best_text, x + button_w / 2, 476, 17, raylib.Fade(theme.ink, 0.65))
x += button_w + gap
if draw_button(start_x + 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
}
// Difficulty selector
names := memory.DIFFICULTY_NAMES
layouts := memory.LAYOUTS
ui.draw_text_centered("Difficulty (Left / Right)", themes.SCREEN_WIDTH / 2, 512, 20, raylib.Fade(theme.ink, 0.7))
seg_w: f32 = 150
seg_x := (themes.SCREEN_WIDTH - 3 * seg_w - 2 * 12) / 2
for level in memory.Difficulty {
selected := difficulty^ == level
color := selected ? theme.primary : raylib.ColorLerp(theme.primary, themes.WHITE, 0.55)
label := raylib.TextFormat("%s %d", names[level], layouts[level].rows * layouts[level].cols / 2)
if ui.button({seg_x, 542, seg_w, 48}, label, color, font_size = 22, selected = selected) {
difficulty^ = level
}
seg_x += seg_w + 12
}
if raylib.IsKeyPressed(.LEFT) && difficulty^ > .EASY {
difficulty^ = memory.Difficulty(int(difficulty^) - 1)
}
if raylib.IsKeyPressed(.RIGHT) && difficulty^ < .HARD {
difficulty^ = memory.Difficulty(int(difficulty^) + 1)
if draw_button(start_x + i32(button_w) + i32(button_space) + i32(button_w) + i32(button_space), start_y, button_w, button_h, "Thai", themes.THAI_PRIMARY, themes.THAI_ACCENT, mouse_x, mouse_y) {
return themes.THAI_SCREEN
}
if ui.button({30, 28, 120, 48}, "Back", theme.ink, font_size = 22) {
result = themes.Screen.MAIN_MENU
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
}
hint := len(deck_list) > 1 ? cstring("1-2 pick a deck · Left / Right difficulty · Esc back") : cstring("1 start · Left / Right difficulty · Esc back")
ui.draw_text_centered(hint, themes.SCREEN_WIDTH / 2, 660, 18, raylib.Fade(theme.ink, 0.6))
return result
back_y := i32(start_y) + i32(button_h) + i32(button_space)
if draw_button(start_x, back_y, button_w, button_h, "Back", themes.JAPAN_PRIMARY, themes.JAPAN_ACCENT, mouse_x, mouse_y) {
return themes.MAIN_MENU
}
return themes.JAPANESE_SCREEN
}
draw_korean_screen :: proc(mouse_x, mouse_y: i32) -> i32 {
raylib.ClearBackground(themes.WHITE)
draw_title("Korean", 100, themes.KOREA_PRIMARY)
draw_centered_text("Coming Soon", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
if draw_button((themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, themes.SCREEN_HEIGHT / 2, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT, "Back", themes.KOREA_PRIMARY, themes.KOREA_ACCENT, mouse_x, mouse_y) {
return themes.MAIN_MENU
}
return themes.KOREAN_SCREEN
}
draw_thai_screen :: proc(mouse_x, mouse_y: i32) -> i32 {
raylib.ClearBackground(themes.WHITE)
draw_title("Thai", 100, themes.THAI_PRIMARY)
draw_centered_text("Coming Soon", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
if draw_button((themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, themes.SCREEN_HEIGHT / 2, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT, "Back", themes.THAI_PRIMARY, themes.THAI_ACCENT, mouse_x, mouse_y) {
return themes.MAIN_MENU
}
return themes.THAI_SCREEN
}
+32 -80
View File
@@ -2,87 +2,39 @@ package themes
import raylib "vendor:raylib"
SCREEN_WIDTH :: 1280
SCREEN_HEIGHT :: 720
SCREEN_WIDTH: i32 = 1280
SCREEN_HEIGHT: i32 = 720
Screen :: enum {
MAIN_MENU,
LANGUAGE_MENU,
MEMORY,
}
JAPAN_PRIMARY: raylib.Color = raylib.Color{230, 57, 70, 255}
JAPAN_ACCENT: raylib.Color = raylib.Color{253, 184, 190, 255}
JAPAN_BACKGROUND: raylib.Color = raylib.Color{250, 245, 245, 255}
KOREA_PRIMARY: raylib.Color = raylib.Color{0, 71, 160, 255}
KOREA_ACCENT: raylib.Color = raylib.Color{180, 180, 255, 255}
KOREA_BACKGROUND: raylib.Color = raylib.Color{245, 248, 255, 255}
THAI_PRIMARY: raylib.Color = raylib.Color{165, 25, 49, 255}
THAI_ACCENT: raylib.Color = raylib.Color{255, 180, 200, 255}
THAI_BACKGROUND: raylib.Color = raylib.Color{255, 245, 248, 255}
WHITE: raylib.Color = raylib.Color{255, 255, 255, 255}
BLACK: raylib.Color = raylib.Color{0, 0, 0, 255}
DARK_GRAY: raylib.Color = raylib.Color{64, 64, 64, 255}
LIGHT_GRAY: raylib.Color = raylib.Color{200, 200, 200, 255}
CARD_BACK_COLOR: raylib.Color = raylib.Color{40, 40, 50, 255}
// Which national flag the card backs are modelled on
Flag :: enum {
JAPAN, // Nisshōki / Hinomaru
KOREA, // Taegukgi
THAILAND, // Thong Trairong
}
BUTTON_WIDTH: i32 = 240
BUTTON_HEIGHT: i32 = 60
TITLE_FONT_SIZE: i32 = 72
SUBTITLE_FONT_SIZE: i32 = 24
BUTTON_FONT_SIZE: i32 = 24
Theme :: struct {
name: cstring,
flag: Flag,
primary: raylib.Color, // main brand color (buttons, titles)
secondary: raylib.Color, // second flag color
accent: raylib.Color, // highlights, matched outlines
background: raylib.Color,
card_face: raylib.Color,
ink: raylib.Color, // body text
}
CARD_WIDTH: i32 = 100
CARD_HEIGHT: i32 = 140
CARD_SPACING: i32 = 15
GRID_ROWS: i32 = 4
GRID_COLS: i32 = 6
FLIP_DELAY: f32 = 1.0
// Flag colors
HINOMARU_RED :: raylib.Color{188, 0, 45, 255}
TAEGUK_RED :: raylib.Color{205, 46, 58, 255}
TAEGUK_BLUE :: raylib.Color{0, 71, 160, 255}
THAI_RED :: raylib.Color{165, 25, 49, 255}
THAI_BLUE :: raylib.Color{45, 42, 74, 255}
WHITE :: raylib.Color{255, 255, 255, 255}
BLACK :: raylib.Color{0, 0, 0, 255}
SUMI_INK :: raylib.Color{35, 31, 32, 255}
DARK_GRAY :: raylib.Color{80, 76, 72, 255}
LIGHT_GRAY :: raylib.Color{200, 200, 200, 255}
MENU_BACKGROUND :: raylib.Color{248, 246, 242, 255}
JAPAN :: Theme{
name = "Japanese",
flag = .JAPAN,
primary = HINOMARU_RED,
secondary = WHITE,
accent = raylib.Color{212, 160, 23, 255}, // kin (gold)
background = raylib.Color{250, 246, 238, 255}, // washi paper
card_face = raylib.Color{255, 252, 245, 255},
ink = SUMI_INK,
}
KOREA :: Theme{
name = "Korean",
flag = .KOREA,
primary = TAEGUK_BLUE,
secondary = TAEGUK_RED,
accent = raylib.Color{30, 30, 30, 255}, // trigram black
background = raylib.Color{244, 246, 251, 255},
card_face = raylib.Color{255, 255, 255, 255},
ink = raylib.Color{22, 24, 35, 255},
}
THAI :: Theme{
name = "Thai",
flag = .THAILAND,
primary = THAI_RED,
secondary = THAI_BLUE,
accent = raylib.Color{218, 165, 32, 255}, // temple gold
background = raylib.Color{251, 246, 240, 255},
card_face = raylib.Color{255, 253, 248, 255},
ink = raylib.Color{30, 28, 45, 255},
}
BUTTON_WIDTH :: 240
BUTTON_HEIGHT :: 60
TITLE_FONT_SIZE :: 72
SUBTITLE_FONT_SIZE :: 26
BUTTON_FONT_SIZE :: 26
CARD_WIDTH :: 96
CARD_HEIGHT :: 128
CARD_SPACING :: 14
GRID_TOP :: 136
MAIN_MENU: i32 = 0
JAPANESE_SCREEN: i32 = 1
JAPANESE_MEMORY: i32 = 2
KOREAN_SCREEN: i32 = 3
THAI_SCREEN: i32 = 4
-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")
}