Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59d8c16b29 |
No files matched your search
@@ -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"
|
||||
@@ -1,69 +0,0 @@
|
||||
#
|
||||
# .gitea/workflows/gitea-build.yaml
|
||||
#
|
||||
# Checks, tests and builds (Linux, plus a Windows cross-build) on every push and
|
||||
# pull request to main and develop.
|
||||
#
|
||||
|
||||
name: "Build and Test"
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Toolchain
|
||||
uses: ./.gitea/actions/setup
|
||||
|
||||
- name: Check
|
||||
run: |
|
||||
odin version
|
||||
just check
|
||||
|
||||
- name: Test
|
||||
run: just test
|
||||
|
||||
- name: Build
|
||||
run: just release
|
||||
|
||||
# Cross-compiles for Windows so link breakages show up before a release.
|
||||
# Runs after `build`: both jobs clone the same cached actions/checkout and
|
||||
# actions/cache repos into one shared directory on the runner, and starting
|
||||
# them at the same time can race on that clone/fetch - one job then gets a
|
||||
# missing or corrupted action and fails immediately (seen on run 136, not
|
||||
# related to the odin test fix above). Sequencing avoids the race.
|
||||
windows:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Toolchain
|
||||
uses: ./.gitea/actions/setup
|
||||
with:
|
||||
packages: lld zip
|
||||
|
||||
- name: Cache Windows SDK
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
build/xwin
|
||||
build/xwin-sdk
|
||||
build/raylib-windows
|
||||
key: windows-sdk-${{ hashFiles('justfile') }}
|
||||
|
||||
- name: Windows Build
|
||||
run: |
|
||||
# Debian/Ubuntu may only ship a versioned lld-link
|
||||
LLD_LINK="$(command -v lld-link || ls /usr/lib/llvm-*/bin/lld-link | sort -V | tail -1)"
|
||||
export LLD_LINK
|
||||
just windows-deps
|
||||
just windows
|
||||
@@ -1,64 +0,0 @@
|
||||
#
|
||||
# .gitea/workflows/gitea-release.yaml
|
||||
#
|
||||
# On a version tag (e.g. v1.0): tests, builds Linux and Windows packages,
|
||||
# uploads them to the Gitea generic package registry and creates a Gitea release.
|
||||
#
|
||||
|
||||
name: "Release"
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
PROJECT_NAME: HanjaKanaMun
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Toolchain
|
||||
uses: ./.gitea/actions/setup
|
||||
with:
|
||||
packages: lld zip
|
||||
|
||||
- name: Set Version
|
||||
run: echo "GAME_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Test
|
||||
run: just test
|
||||
|
||||
- name: Linux Build
|
||||
run: just package "$GAME_VERSION"
|
||||
|
||||
- name: Windows Build
|
||||
run: |
|
||||
# Debian/Ubuntu may only ship a versioned lld-link
|
||||
LLD_LINK="$(command -v lld-link || ls /usr/lib/llvm-*/bin/lld-link | sort -V | tail -1)"
|
||||
export LLD_LINK
|
||||
just windows-deps
|
||||
just package-windows "$GAME_VERSION"
|
||||
|
||||
- name: Upload Artifacts
|
||||
run: |
|
||||
ls -la build
|
||||
for FILE in build/${PROJECT_NAME}_${GAME_VERSION}_*; do
|
||||
NAME="$(basename "$FILE")"
|
||||
echo "Uploading $NAME"
|
||||
curl --fail --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \
|
||||
--upload-file "$FILE" \
|
||||
https://gitea.sanplex.xyz/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$NAME
|
||||
done
|
||||
|
||||
- name: Create Release
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
env:
|
||||
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
|
||||
with:
|
||||
files: |-
|
||||
build/${{ env.PROJECT_NAME }}_*.tar.gz
|
||||
build/${{ env.PROJECT_NAME }}_*.zip
|
||||
@@ -16,6 +16,3 @@ HanjaKanaMun
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Build output
|
||||
build/
|
||||
@@ -3,76 +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, loaded via `LoadFontEx` with explicit codepoints
|
||||
- [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 `LoadFontEx`. Any new non-ASCII text needs its characters added in `assets.codepoints_for` (or the `EXTRA_*` strings), otherwise it renders as `?`. The `fonts_cover_every_deck_character` test checks deck text.
|
||||
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. The game changes directory to the executable's folder at startup so `assets/` is found from anywhere.
|
||||
6. **Windows builds are cross-compiled**: Odin can't link Windows binaries on Linux, so `just windows` builds objects with `-build-mode:obj` and links with `lld-link` against an xwin SDK. The VC runtime is linked statically; only Windows system DLLs are needed. New Odin core usage can need new import libs (e.g. `ole32.lib` for `os.user_data_dir`); the CI `windows` job catches this. Not yet tested on a real Windows machine.
|
||||
7. **Odin constant arrays can't be indexed at runtime**: lookup tables (`DECKS`, `LAYOUTS`, …) are `@(rodata)` globals instead of `::` constants.
|
||||
8. **Profile memory**: `profile` always allocates from the heap allocator, because the test runner frees each test's allocator afterwards.
|
||||
9. **`odin test` segfaults if the stack rlimit is "unlimited"**: the CI runner's container sets `ulimit -s unlimited`, which makes `getrlimit(RLIMIT_STACK).rlim_cur` come back as `RLIM_INFINITY` (a huge unsigned value). Odin's `core:thread` passes that straight to `pthread_attr_setstacksize`, `pthread_create` then fails, and `thread.create()` returns `nil` - which `core:testing`'s `pool_init` dereferences unconditionally, segfaulting the whole test binary (exit 139) before it prints anything. `just test` works around it with `ulimit -s 8192` before running `odin test`. Upstream Odin bug (core:thread/core:testing don't handle a failed thread creation); not our code.
|
||||
### 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,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.apple.xcode.dsym.HanjaKanaMun</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>dSYM</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,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>.tar.gz
|
||||
just windows-deps # Linux only: fetch Windows SDK/CRT (xwin) and raylib.lib
|
||||
just package-windows 1.0 # cross-compiled Windows build -> build/HanjaKanaMun_1.0_windows_x86_64.zip
|
||||
```
|
||||
|
||||
The Windows build needs `lld-link`. On macOS, `brew install lld` and set `LLD_LINK`, and point `XWIN_SDK` and
|
||||
`RAYLIB_WINDOWS` at an xwin splat and Odin's Windows `vendor/raylib/windows` folder (the Linux/macOS Odin releases
|
||||
don't include `raylib.lib`).
|
||||
|
||||
### Neovim
|
||||
|
||||
`ols.json` and `odinfmt.json` configure the [ols](https://github.com/DanielGavin/ols) language server and formatter.
|
||||
Install ols (e.g. `:MasonInstall ols`) and enable it with nvim-lspconfig: `vim.lsp.enable('ols')`.
|
||||
Formatting goes through ols (`vim.lsp.buf.format()`), or `just fmt` if `odinfmt` is on your PATH.
|
||||
|
||||
## CI
|
||||
|
||||
- `.gitea/workflows/gitea-build.yaml`: check, test and build on pushes and PRs to `main` and `develop`, plus a
|
||||
Windows cross-build so link problems show up before a release.
|
||||
- `.gitea/workflows/gitea-release.yaml`: on a version tag like `v1.0`, builds Linux and Windows packages, uploads them
|
||||
to the Gitea generic package registry (`HanjaKanaMun/1.0`) and creates a Gitea release with both attached.
|
||||
Needs a `TOKEN` secret with package write access.
|
||||
- `.gitea/actions/setup`: shared toolchain setup (apt packages, Odin, just).
|
||||
|
||||
Release: `git tag v1.0 && git push origin v1.0`
|
||||
|
||||
## Credits
|
||||
|
||||
Fonts, all under the SIL Open Font License (license files in `assets/`):
|
||||
M PLUS Rounded 1c (UI and kana), Jua (Hangul), Mali (Thai).
|
||||
Odin + raylib multilingual application (Thai, Korean, Japanese)
|
||||
@@ -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.
@@ -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.
|
||||
@@ -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.
@@ -1,105 +0,0 @@
|
||||
name := "HanjaKanaMun"
|
||||
build_dir := "build"
|
||||
odin_version := "dev-2026-09"
|
||||
xwin_version := "0.10.0"
|
||||
|
||||
# Windows cross-compilation inputs (see `windows-deps`)
|
||||
xwin := env("XWIN", build_dir / "xwin")
|
||||
xwin_sdk := env("XWIN_SDK", build_dir / "xwin-sdk")
|
||||
raylib_windows := env("RAYLIB_WINDOWS", build_dir / "raylib-windows")
|
||||
lld_link := env("LLD_LINK", "lld-link")
|
||||
|
||||
# List available recipes
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Debug build
|
||||
build:
|
||||
odin build . -vet -debug -out:{{name}}
|
||||
|
||||
# Build and run (debug)
|
||||
run:
|
||||
odin run . -vet -debug -out:{{name}}
|
||||
|
||||
# Type-check with vet, no binary
|
||||
check:
|
||||
odin check . -vet
|
||||
|
||||
# Run the test suite (single-threaded: the tests share the memory game's global state)
|
||||
test:
|
||||
mkdir -p {{build_dir}}
|
||||
# If the stack rlimit is "unlimited" (RLIM_INFINITY, as some CI/container hosts set it),
|
||||
# Odin's core:thread passes that straight to pthread_attr_setstacksize, pthread_create
|
||||
# then fails, and thread.create() returns nil - which core:testing's pool_init
|
||||
# dereferences unconditionally, segfaulting the whole test binary. Clamp it.
|
||||
# (One line: just runs each recipe line in its own shell, so a separate
|
||||
# `ulimit` line wouldn't carry over to the `odin test` line.)
|
||||
ulimit -s 8192 && odin test tests -vet -define:ODIN_TEST_THREADS=1 -out:{{build_dir}}/tests
|
||||
|
||||
# Optimized native build into build/<os>/, with the assets it needs at runtime
|
||||
release:
|
||||
mkdir -p {{build_dir}}/{{os()}}/{{name}}
|
||||
odin build . -vet -o:speed -out:{{build_dir}}/{{os()}}/{{name}}/{{name}}
|
||||
cp -R assets {{build_dir}}/{{os()}}/{{name}}/
|
||||
|
||||
# Native release build packed as build/<name>_<version>_<os>_<arch>.tar.gz
|
||||
package version="dev": release
|
||||
tar -czf {{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}}.tar.gz -C {{build_dir}}/{{os()}} {{name}}
|
||||
@echo "{{build_dir}}/{{name}}_{{version}}_{{os()}}_{{arch()}}.tar.gz"
|
||||
|
||||
# Download the Windows SDK + CRT (via xwin, accepting Microsoft's license) and raylib.lib. Linux x86_64 only.
|
||||
windows-deps:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
mkdir -p {{build_dir}}
|
||||
if [ ! -x "{{xwin}}" ]; then
|
||||
curl -fsSL https://github.com/Jake-Shadle/xwin/releases/download/{{xwin_version}}/xwin-{{xwin_version}}-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz -C {{build_dir}} --strip-components=1 xwin-{{xwin_version}}-x86_64-unknown-linux-musl/xwin
|
||||
fi
|
||||
if [ ! -d "{{xwin_sdk}}/crt" ]; then
|
||||
"{{xwin}}" --accept-license --arch x86_64 --cache-dir {{build_dir}}/xwin-cache splat --output "{{xwin_sdk}}"
|
||||
fi
|
||||
# Odin's Linux/macOS releases don't ship raylib's Windows library, so take it from the Windows release
|
||||
if [ ! -f "{{raylib_windows}}/raylib.lib" ]; then
|
||||
mkdir -p "{{raylib_windows}}"
|
||||
curl -fsSL -o {{build_dir}}/odin-windows.zip \
|
||||
https://github.com/odin-lang/Odin/releases/download/{{odin_version}}/odin-windows-amd64-{{odin_version}}.zip
|
||||
unzip -o -j {{build_dir}}/odin-windows.zip 'dist/vendor/raylib/windows/raylib.lib' -d "{{raylib_windows}}"
|
||||
fi
|
||||
|
||||
# Cross-compile a Windows x86_64 build into build/windows/ (run `just windows-deps` first)
|
||||
windows:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
obj_dir={{build_dir}}/obj-windows
|
||||
out_dir={{build_dir}}/windows/{{name}}
|
||||
mkdir -p "$obj_dir" "$out_dir"
|
||||
rm -f "$obj_dir"/*.obj
|
||||
odin build . -vet -o:speed -target:windows_amd64 -build-mode:obj -out:"$obj_dir/{{name}}.obj"
|
||||
# Odin can't link for Windows from other platforms, so link with lld-link.
|
||||
# The VC runtime is linked statically (no VCRUNTIME140.dll needed); the
|
||||
# Universal CRT is used from Windows itself (built into Windows 10+).
|
||||
{{lld_link}} /nologo /machine:x64 /subsystem:windows /entry:mainCRTStartup \
|
||||
/out:"$out_dir/{{name}}.exe" "$obj_dir"/*.obj "{{raylib_windows}}/raylib.lib" \
|
||||
/libpath:"{{xwin_sdk}}/crt/lib/x86_64" \
|
||||
/libpath:"{{xwin_sdk}}/sdk/lib/um/x86_64" \
|
||||
/libpath:"{{xwin_sdk}}/sdk/lib/ucrt/x86_64" \
|
||||
/nodefaultlib:msvcrt /nodefaultlib:vcruntime /nodefaultlib:libucrt \
|
||||
libcmt.lib libvcruntime.lib ucrt.lib \
|
||||
kernel32.lib user32.lib gdi32.lib winmm.lib shell32.lib opengl32.lib \
|
||||
advapi32.lib bcrypt.lib ntdll.lib synchronization.lib ole32.lib
|
||||
cp -R assets "$out_dir/"
|
||||
|
||||
# Windows build packed as build/<name>_<version>_windows_x86_64.zip
|
||||
package-windows version="dev": windows
|
||||
cd {{build_dir}}/windows && zip -qr ../{{name}}_{{version}}_windows_x86_64.zip {{name}}
|
||||
@echo "{{build_dir}}/{{name}}_{{version}}_windows_x86_64.zip"
|
||||
|
||||
# Format all Odin files with odinfmt (settings in odinfmt.json)
|
||||
fmt:
|
||||
odinfmt -w .
|
||||
|
||||
# Remove build output
|
||||
clean:
|
||||
if [ -d {{build_dir}} ]; then rm -r -- {{build_dir}}; fi
|
||||
rm -f -- {{name}}
|
||||
@@ -1,114 +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)
|
||||
// Assets are loaded relative to the executable, so it can be started from anywhere
|
||||
raylib.ChangeDirectory(raylib.GetApplicationDirectory())
|
||||
raylib.SetTargetFPS(60)
|
||||
|
||||
raylib.InitAudioDevice()
|
||||
defer raylib.CloseAudioDevice()
|
||||
audio.load()
|
||||
defer audio.unload()
|
||||
|
||||
assets.load_fonts()
|
||||
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()
|
||||
|
||||
for !raylib.WindowShouldClose() {
|
||||
mouse_x = raylib.GetMouseX()
|
||||
mouse_y = raylib.GetMouseY()
|
||||
|
||||
if raylib.IsKeyPressed(raylib.KeyboardKey.ESCAPE) {
|
||||
current_screen = themes.MAIN_MENU
|
||||
}
|
||||
|
||||
screen := themes.Screen.MAIN_MENU
|
||||
language := decks.Language.JAPANESE
|
||||
difficulty := memory.Difficulty.NORMAL
|
||||
if saved, ok := reflect.enum_from_name(memory.Difficulty, profile.data.difficulty); ok {
|
||||
difficulty = saved
|
||||
}
|
||||
transition_t: f32 = 1 // fades the screen in from the background color
|
||||
running := true
|
||||
for running && !raylib.WindowShouldClose() {
|
||||
dt := raylib.GetFrameTime()
|
||||
|
||||
if raylib.IsKeyPressed(.ESCAPE) {
|
||||
if parent, ok := parent_screen(screen).?; ok {
|
||||
screen = parent
|
||||
transition_t = 1
|
||||
} else {
|
||||
running = false
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
if next != screen { transition_t = 1 }
|
||||
screen = next
|
||||
} 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)
|
||||
}
|
||||
|
||||
raylib.EndDrawing()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "HanjaKanaMun",
|
||||
"path": ".",
|
||||
"source_dir": ".",
|
||||
"include_dirs": ["vendor", "src"],
|
||||
"rays": [
|
||||
{
|
||||
"name": "raylib",
|
||||
"path": "vendor:raylib"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/DanielGavin/ols/master/misc/ols.schema.json",
|
||||
"enable_semantic_tokens": true,
|
||||
"enable_document_symbols": true,
|
||||
"enable_hover": true,
|
||||
"enable_snippets": true,
|
||||
"enable_references": true,
|
||||
"enable_format": true,
|
||||
"enable_inlay_hints_params": true,
|
||||
"enable_inlay_hints_default_params": true,
|
||||
"enable_unused_imports_reporting": true,
|
||||
"checker_args": "-vet"
|
||||
}
|
||||
+6
-74
@@ -2,85 +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)
|
||||
}
|
||||
|
||||
@(rodata)
|
||||
FONT_PATHS := [Font_Style]cstring{
|
||||
.REGULAR = "assets/MPLUSRounded1c-Medium.ttf",
|
||||
.BOLD = "assets/MPLUSRounded1c-ExtraBold.ttf",
|
||||
.KOREAN = "assets/Jua-Regular.ttf",
|
||||
.THAI = "assets/Mali-SemiBold.ttf",
|
||||
}
|
||||
|
||||
fonts: [Font_Style]raylib.Font
|
||||
|
||||
// Extra non-range characters used in UI text
|
||||
EXTRA_JAPANESE :: "日本語・·āō"
|
||||
EXTRA_KOREAN :: "한글국어축하해요"
|
||||
EXTRA_THAI :: "ไทย"
|
||||
|
||||
// raylib only bakes the codepoints it is given, so every glyph a font must draw is listed here.
|
||||
// Tests check that every deck's characters are covered.
|
||||
codepoints_for :: proc(style: Font_Style, allocator := context.allocator) -> [dynamic]rune {
|
||||
codepoints := make([dynamic]rune, allocator)
|
||||
for r in rune(0x20) ..= 0x7E {
|
||||
// Jua has no backtick; Korean text never uses it
|
||||
if style == .KOREAN && r == '`' { continue }
|
||||
append(&codepoints, r) // ASCII
|
||||
}
|
||||
switch style {
|
||||
case .REGULAR, .BOLD:
|
||||
for r in rune(0x3041) ..= 0x3096 { append(&codepoints, r) } // Hiragana
|
||||
for r in rune(0x30A1) ..= 0x30FC { append(&codepoints, r) } // Katakana
|
||||
for r in EXTRA_JAPANESE { append(&codepoints, r) }
|
||||
case .KOREAN:
|
||||
for r in rune(0x3131) ..= 0x3163 { append(&codepoints, r) } // Modern Hangul compatibility jamo
|
||||
for r in EXTRA_KOREAN { append(&codepoints, r) }
|
||||
case .THAI:
|
||||
for r in rune(0x0E01) ..= 0x0E3A { append(&codepoints, r) } // Thai (U+0E3B-0E3E are unassigned)
|
||||
for r in rune(0x0E3F) ..= 0x0E5B { append(&codepoints, r) }
|
||||
for r in EXTRA_THAI { append(&codepoints, r) }
|
||||
}
|
||||
return codepoints
|
||||
}
|
||||
|
||||
@(private)
|
||||
load_font :: proc(style: Font_Style) -> raylib.Font {
|
||||
codepoints := codepoints_for(style)
|
||||
defer delete(codepoints)
|
||||
path := FONT_PATHS[style]
|
||||
font := raylib.LoadFontEx(path, FONT_BASE_SIZE, raw_data(codepoints), i32(len(codepoints)))
|
||||
if !raylib.IsFontValid(font) || font.texture.id == 0 {
|
||||
raylib.TraceLog(.WARNING, "ASSETS: Falling back to default font, could not load %s", path)
|
||||
return raylib.GetFontDefault()
|
||||
}
|
||||
// Mipmaps keep small UI text smooth when the large atlas is scaled down
|
||||
raylib.GenTextureMipmaps(&font.texture)
|
||||
raylib.SetTextureFilter(font.texture, .TRILINEAR)
|
||||
return font
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
+59
-278
@@ -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
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Art generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(private)
|
||||
art_rect :: proc(inset: f32 = 0) -> raylib.Rectangle {
|
||||
return {inset, inset, ART_WIDTH - 2 * inset, ART_HEIGHT - 2 * inset}
|
||||
draw_card :: proc(card: ^Card, mouse_x, mouse_y: i32, theme_color: raylib.Color) {
|
||||
draw_color := theme_color
|
||||
if card.is_matched {
|
||||
draw_color = themes.LIGHT_GRAY
|
||||
}
|
||||
|
||||
@(private)
|
||||
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
|
||||
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)
|
||||
draw_frame :: proc(fill, border: raylib.Color) {
|
||||
raylib.DrawRectangleRounded(art_rect(), ROUNDNESS, SEGMENTS, fill)
|
||||
raylib.DrawRectangleRoundedLinesEx(art_rect(3), ROUNDNESS, SEGMENTS, 6, border)
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if show_face || card.is_face_up || card.is_matched {
|
||||
font := assets.get_font()
|
||||
text_w := raylib.MeasureText(card.character, 40)
|
||||
text_x := card.x + (themes.CARD_WIDTH - text_w) / 2
|
||||
text_y := card.y + (themes.CARD_HEIGHT - 40) / 2 - 8
|
||||
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x + 1), f32(text_y + 1)}, 40.0, 0, themes.BLACK)
|
||||
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x), f32(text_y)}, 40.0, 0, themes.WHITE)
|
||||
}
|
||||
} else if card.is_face_up || card.is_matched {
|
||||
font := assets.get_font()
|
||||
text_w := raylib.MeasureText(card.character, 40)
|
||||
text_x := card.x + (themes.CARD_WIDTH - text_w) / 2
|
||||
text_y := card.y + (themes.CARD_HEIGHT - 40) / 2 - 8
|
||||
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x + 1), f32(text_y + 1)}, 40.0, 0, themes.BLACK)
|
||||
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x), f32(text_y)}, 40.0, 0, themes.WHITE)
|
||||
}
|
||||
}
|
||||
|
||||
// Korea: Taegukgi, a red/blue taeguk circle with the four black trigrams in the corners
|
||||
@(private)
|
||||
draw_korea_back :: proc() {
|
||||
draw_frame(themes.WHITE, themes.TAEGUK_BLUE)
|
||||
center := raylib.Vector2{ART_WIDTH / 2, ART_HEIGHT / 2}
|
||||
radius: f32 = ART_WIDTH * 0.26
|
||||
// The flag's taeguk is tilted along the diagonal of a 3:2 rectangle
|
||||
tilt := math.atan2(f32(2), 3) * math.DEG_PER_RAD
|
||||
raylib.DrawCircleSector(center, radius, 180 + tilt, 360 + tilt, 48, themes.TAEGUK_RED)
|
||||
raylib.DrawCircleSector(center, radius, tilt, 180 + tilt, 48, themes.TAEGUK_BLUE)
|
||||
offset := raylib.Vector2{math.cos(tilt * math.RAD_PER_DEG), math.sin(tilt * math.RAD_PER_DEG)} * (radius / 2)
|
||||
// Red head toward the upper-left (geon ☰), blue head toward the lower-right (gon ☷)
|
||||
raylib.DrawCircleV(center - offset, radius / 2, themes.TAEGUK_RED)
|
||||
raylib.DrawCircleV(center + offset, radius / 2, themes.TAEGUK_BLUE)
|
||||
|
||||
scale: f32 = 0.8
|
||||
dx: f32 = ART_WIDTH * 0.24
|
||||
dy: f32 = ART_HEIGHT * 0.26
|
||||
draw_trigram(center + {-dx, -dy}, -45, {true, true, true}, scale) // geon ☰
|
||||
draw_trigram(center + {dx, -dy}, 45, {false, true, false}, scale) // gam ☵
|
||||
draw_trigram(center + {-dx, dy}, 45, {true, false, true}, scale) // ri ☲
|
||||
draw_trigram(center + {dx, dy}, -45, {false, false, false}, scale) // gon ☷
|
||||
}
|
||||
|
||||
// Thailand: Thong Trairong, red-white-blue-white-red stripes (blue twice as wide)
|
||||
@(private)
|
||||
draw_thailand_back :: proc() {
|
||||
draw_frame(themes.WHITE, themes.THAI_BLUE)
|
||||
panel := art_rect(18)
|
||||
unit := panel.height / 6
|
||||
stripes := [5]struct{ color: raylib.Color, units: f32 }{
|
||||
{themes.THAI_RED, 1}, {themes.WHITE, 1}, {themes.THAI_BLUE, 2}, {themes.WHITE, 1}, {themes.THAI_RED, 1},
|
||||
}
|
||||
y := panel.y
|
||||
for stripe in stripes {
|
||||
raylib.DrawRectangleRec({panel.x, y, panel.width, unit * stripe.units}, stripe.color)
|
||||
y += unit * stripe.units
|
||||
}
|
||||
raylib.DrawRectangleLinesEx(panel, 2, themes.THAI_BLUE)
|
||||
}
|
||||
|
||||
// Builds the card back textures. Call once after the window is created.
|
||||
load_art :: proc() {
|
||||
edges := [themes.Flag]raylib.Color{.JAPAN = themes.HINOMARU_RED, .KOREA = themes.TAEGUK_BLUE, .THAILAND = themes.THAI_BLUE}
|
||||
for flag in themes.Flag {
|
||||
back_art[flag] = new_art_texture(edges[flag])
|
||||
raylib.BeginTextureMode(back_art[flag])
|
||||
switch flag {
|
||||
case .JAPAN: draw_japan_back()
|
||||
case .KOREA: draw_korea_back()
|
||||
case .THAILAND: draw_thailand_back()
|
||||
}
|
||||
raylib.EndTextureMode()
|
||||
}
|
||||
}
|
||||
|
||||
unload_art :: proc() {
|
||||
for rt in back_art { raylib.UnloadRenderTexture(rt) }
|
||||
}
|
||||
|
||||
get_back_art :: proc(flag: themes.Flag) -> raylib.Texture2D {
|
||||
return back_art[flag].texture
|
||||
}
|
||||
|
||||
// Font size that fits text inside the card width
|
||||
@(private)
|
||||
fit_size :: proc(text: cstring, size: f32, style: assets.Font_Style) -> f32 {
|
||||
max_width: f32 = ART_WIDTH * 0.78
|
||||
width := ui.measure_text(text, size, style).x
|
||||
return width > max_width ? size * max_width / width : size
|
||||
}
|
||||
|
||||
// Renders the card's face into its own texture. Symbols use their script's font.
|
||||
build_face :: proc(card: ^Card, theme: themes.Theme, symbol_style: assets.Font_Style) {
|
||||
unload_face(card)
|
||||
card.face_art = new_art_texture(theme.primary)
|
||||
raylib.BeginTextureMode(card.face_art)
|
||||
draw_frame(theme.card_face, theme.primary)
|
||||
raylib.DrawRectangleRoundedLinesEx(art_rect(16), ROUNDNESS, SEGMENTS, 2, raylib.Fade(theme.primary, 0.35))
|
||||
|
||||
if card.is_symbol {
|
||||
ui.draw_text_in_rect(card.text, art_rect(), fit_size(card.text, 132, symbol_style), theme.ink, symbol_style)
|
||||
} else if card.hint == nil {
|
||||
ui.draw_text_in_rect(card.text, art_rect(), fit_size(card.text, 96, .BOLD), theme.primary, .BOLD)
|
||||
} else {
|
||||
// Reading on top, hint underneath
|
||||
main_rect := art_rect()
|
||||
main_rect.height -= 56
|
||||
ui.draw_text_in_rect(card.text, main_rect, fit_size(card.text, 88, .BOLD), theme.primary, .BOLD)
|
||||
hint_rect := raylib.Rectangle{0, ART_HEIGHT * 0.62, ART_WIDTH, 60}
|
||||
ui.draw_text_in_rect(card.hint, hint_rect, fit_size(card.hint, 34, .REGULAR), theme.ink, .REGULAR)
|
||||
}
|
||||
raylib.EndTextureMode()
|
||||
}
|
||||
|
||||
unload_face :: proc(card: ^Card) {
|
||||
if card.face_art.id != 0 {
|
||||
raylib.UnloadRenderTexture(card.face_art)
|
||||
card.face_art = {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behaviour
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
is_animating :: proc(card: ^Card) -> bool {
|
||||
target: f32 = card.face_up ? 1 : 0
|
||||
return card.flip_t != target
|
||||
}
|
||||
|
||||
update :: proc(card: ^Card, dt: f32) {
|
||||
target: f32 = card.face_up ? 1 : 0
|
||||
step := dt * FLIP_SPEED
|
||||
if card.flip_t < target {
|
||||
card.flip_t = min(card.flip_t + step, target)
|
||||
} else if card.flip_t > target {
|
||||
card.flip_t = max(card.flip_t - step, target)
|
||||
}
|
||||
if card.pulse_t > 0 {
|
||||
card.pulse_t = max(card.pulse_t - dt, 0)
|
||||
}
|
||||
if card.deal_t > 0 {
|
||||
card.deal_t = max(card.deal_t - dt * DEAL_SPEED, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Starts the deal-in animation; later cards in the deal arrive later
|
||||
start_deal :: proc(card: ^Card, order: int) {
|
||||
card.deal_t = 1 + f32(order) * DEAL_STAGGER
|
||||
}
|
||||
|
||||
start_pulse :: proc(card: ^Card) {
|
||||
card.pulse_t = PULSE_DURATION
|
||||
}
|
||||
|
||||
is_hovered :: proc(card: ^Card) -> bool {
|
||||
return raylib.CheckCollisionPointRec(raylib.GetMousePosition(), card.rect)
|
||||
}
|
||||
|
||||
@(private)
|
||||
smoothstep :: proc(t: f32) -> f32 {
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
|
||||
draw :: proc(card: ^Card, theme: themes.Theme, clickable: bool, focused := false) {
|
||||
eased := smoothstep(card.flip_t)
|
||||
squash := abs(math.cos(eased * math.PI)) // 1 → 0 → 1 across the flip
|
||||
showing_face := eased >= 0.5
|
||||
|
||||
// Cards lift as they turn, and a little on hover
|
||||
lift := math.sin(eased * math.PI) * 6
|
||||
mouse_over := clickable && is_hovered(card)
|
||||
if mouse_over { ui.set_hovered() }
|
||||
hovered := mouse_over || (clickable && focused)
|
||||
if hovered { lift += HOVER_LIFT }
|
||||
|
||||
// Dealing: slide up from below and grow in
|
||||
dealt := smoothstep(1 - clamp(card.deal_t, 0, 1))
|
||||
if dealt == 0 { return }
|
||||
deal_offset := (1 - dealt) * 60
|
||||
|
||||
scale: f32 = 0.85 + 0.15 * dealt
|
||||
if card.pulse_t > 0 {
|
||||
progress := 1 - card.pulse_t / PULSE_DURATION
|
||||
scale += 0.09 * math.sin(progress * math.PI)
|
||||
}
|
||||
|
||||
width := card.rect.width * scale
|
||||
height := card.rect.height * scale
|
||||
center := raylib.Vector2{card.rect.x + card.rect.width / 2, card.rect.y + card.rect.height / 2 - lift + deal_offset}
|
||||
|
||||
// Shadow stays on the table and spreads as the card lifts
|
||||
shadow := raylib.Rectangle{
|
||||
center.x - width * squash / 2 + 2,
|
||||
card.rect.y + card.rect.height * (1 - scale) / 2 + 4 + lift * 0.15 + deal_offset,
|
||||
max(width * squash, 2),
|
||||
height,
|
||||
}
|
||||
raylib.DrawRectangleRounded(shadow, ROUNDNESS, SEGMENTS, raylib.Fade(themes.BLACK, (0.12 - lift * 0.004) * dealt))
|
||||
|
||||
// Darken the card while it is edge-on, to fake lighting
|
||||
shade := u8(255 - (1 - squash) * 70)
|
||||
tint := raylib.Color{shade, shade, shade, u8(255 * dealt)}
|
||||
if hovered && !showing_face {
|
||||
tint = raylib.ColorBrightness(tint, 0.08)
|
||||
}
|
||||
|
||||
texture := showing_face ? card.face_art.texture : back_art[theme.flag].texture
|
||||
source := raylib.Rectangle{0, 0, f32(texture.width), -f32(texture.height)} // render textures are upside down
|
||||
dest := raylib.Rectangle{center.x, center.y, width * squash, height}
|
||||
raylib.DrawTexturePro(texture, source, dest, {dest.width / 2, dest.height / 2}, 0, tint)
|
||||
|
||||
if focused {
|
||||
ring := raylib.Rectangle{center.x - width * squash / 2 - 6, center.y - height / 2 - 6, width * squash + 12, height + 12}
|
||||
raylib.DrawRectangleRoundedLinesEx(ring, ROUNDNESS, SEGMENTS, 3, raylib.Fade(theme.ink, 0.75))
|
||||
}
|
||||
|
||||
if card.matched && card.flip_t == 1 {
|
||||
outline := raylib.Rectangle{center.x - width / 2 - 3, center.y - height / 2 - 3, width + 6, height + 6}
|
||||
raylib.DrawRectangleRoundedLinesEx(outline, ROUNDNESS, SEGMENTS, 3, theme.accent)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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"},
|
||||
}
|
||||
+167
-275
@@ -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"}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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) }
|
||||
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
|
||||
}
|
||||
|
||||
// Shuffles, lays out and resets the game state without touching the GPU
|
||||
deal :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) {
|
||||
unload()
|
||||
deck = deck_id
|
||||
difficulty = game_difficulty
|
||||
theme = decks.language_of(deck).theme
|
||||
layout = LAYOUTS[difficulty]
|
||||
card_count = layout.rows * layout.cols
|
||||
pair_count = card_count / 2
|
||||
|
||||
// Pick random entries from the deck
|
||||
entries := decks.entries(deck)
|
||||
order := make([]int, len(entries), context.temp_allocator)
|
||||
for &o, i in order { o = i }
|
||||
rand.shuffle(order)
|
||||
|
||||
for pair in 0 ..< pair_count {
|
||||
entry := entries[order[pair]]
|
||||
cards[pair * 2] = card.Card{text = entry.symbol, is_symbol = true, pair_id = pair}
|
||||
cards[pair * 2 + 1] = card.Card{text = entry.reading, hint = entry.hint, pair_id = pair}
|
||||
}
|
||||
rand.shuffle(cards[:card_count])
|
||||
|
||||
// Center the grid horizontally and in the space below the header
|
||||
step_x: f32 = themes.CARD_WIDTH + themes.CARD_SPACING
|
||||
step_y: f32 = themes.CARD_HEIGHT + themes.CARD_SPACING
|
||||
total_w := f32(layout.cols) * step_x - themes.CARD_SPACING
|
||||
total_h := f32(layout.rows) * step_y - themes.CARD_SPACING
|
||||
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)
|
||||
}
|
||||
|
||||
state = .WAITING
|
||||
first = -1
|
||||
second = -1
|
||||
matches = 0
|
||||
moves = 0
|
||||
elapsed = 0
|
||||
resolve_timer = 0
|
||||
win_t = 0
|
||||
restart_requested = false
|
||||
focus = 0
|
||||
memory_state = MemoryState.WAITING
|
||||
memory_first = -1
|
||||
memory_second = -1
|
||||
memory_matches = 0
|
||||
memory_moves = 0
|
||||
memory_time = 0.0
|
||||
memory_flip = 0.0
|
||||
}
|
||||
|
||||
active_cards :: proc() -> []card.Card {
|
||||
return cards[:card_count]
|
||||
}
|
||||
update_memory_game :: proc(delta_time: f32, mouse_x, mouse_y: i32) {
|
||||
memory_time += delta_time
|
||||
|
||||
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
|
||||
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 {
|
||||
second = i
|
||||
moves += 1
|
||||
is_match := cards[first].pair_id == cards[second].pair_id
|
||||
resolve_timer = is_match ? MATCH_DELAY : MISMATCH_DELAY
|
||||
state = .RESOLVING
|
||||
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
|
||||
}
|
||||
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) }
|
||||
memory_state = MemoryState.WAITING
|
||||
memory_flip = 0.0
|
||||
memory_first = -1
|
||||
memory_second = -1
|
||||
}
|
||||
}
|
||||
|
||||
@(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
|
||||
if memory_state == MemoryState.WAITING || memory_state == MemoryState.FIRST {
|
||||
for i := 0; i < 24; i += 1 {
|
||||
if !memory_cards[i].is_matched && !memory_cards[i].is_flipping {
|
||||
if card.is_card_clicked(&memory_cards[i], mouse_x, mouse_y) &&
|
||||
raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT) {
|
||||
memory_cards[i].is_flipping = true
|
||||
memory_cards[i].flip_direction = true
|
||||
memory_cards[i].flip_progress = 0.0
|
||||
memory_moves += 1
|
||||
if memory_first == -1 {
|
||||
memory_first = i32(i)
|
||||
memory_state = MemoryState.FIRST
|
||||
} else if i32(i) != memory_first {
|
||||
memory_second = i32(i)
|
||||
memory_state = MemoryState.SECOND
|
||||
}
|
||||
|
||||
for &c in active_cards() { card.update(&c, dt) }
|
||||
if state != .OVER { handle_keyboard() }
|
||||
|
||||
switch state {
|
||||
case .WAITING, .FIRST:
|
||||
elapsed += dt
|
||||
if raylib.IsMouseButtonPressed(.LEFT) {
|
||||
for &c, i in active_cards() {
|
||||
if card.is_hovered(&c) && can_interact(i) && select_card(i) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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.?
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
if memory_state == MemoryState.OVER {
|
||||
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, raylib.Color{0, 0, 0, 128})
|
||||
|
||||
@(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))
|
||||
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)
|
||||
|
||||
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))
|
||||
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)
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
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
|
||||
again_color := themes.JAPAN_PRIMARY
|
||||
if again_hovered {
|
||||
again_color = themes.JAPAN_ACCENT
|
||||
}
|
||||
if ui.button({cx + 10, button_y, button_w, 60}, "Menu", theme.ink) {
|
||||
return .LANGUAGE_MENU
|
||||
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 nil
|
||||
}
|
||||
return themes.JAPANESE_MEMORY
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+90
-120
@@ -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)
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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_main_menu :: proc(mouse_x, mouse_y: i32) -> i32 {
|
||||
raylib.ClearBackground(themes.WHITE)
|
||||
|
||||
draw_title("HanjaKanaMun", 100, themes.JAPAN_PRIMARY)
|
||||
draw_centered_text("Learn Japanese, Korean, and Thai", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Language_Menu_Result :: union {
|
||||
decks.Deck_Id, // start a game with this deck
|
||||
themes.Screen, // go to another screen
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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)
|
||||
|
||||
// A small fan of card backs
|
||||
for i in 0 ..< 3 {
|
||||
offset := f32(i - 1)
|
||||
draw_card_art(theme.flag, {themes.SCREEN_WIDTH / 2 - 50 + offset * 64, 195 + abs(offset) * 10, 100, 133}, offset * 9)
|
||||
if draw_button(start_x + 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
|
||||
}
|
||||
|
||||
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
|
||||
return themes.MAIN_MENU
|
||||
}
|
||||
|
||||
// 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)
|
||||
draw_japanese_screen :: proc(mouse_x, mouse_y: i32) -> i32 {
|
||||
raylib.ClearBackground(themes.WHITE)
|
||||
|
||||
draw_title("Japanese", 100, themes.JAPAN_PRIMARY)
|
||||
draw_centered_text("Select a game to play", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
|
||||
|
||||
button_w := themes.BUTTON_WIDTH
|
||||
button_h := themes.BUTTON_HEIGHT
|
||||
button_space := 40
|
||||
start_y := themes.SCREEN_HEIGHT / 2
|
||||
start_x := (themes.SCREEN_WIDTH - button_w) / 2
|
||||
|
||||
if draw_button(start_x, start_y, button_w, button_h, "Memory Game", themes.JAPAN_PRIMARY, themes.JAPAN_ACCENT, mouse_x, mouse_y) {
|
||||
return themes.JAPANESE_MEMORY
|
||||
}
|
||||
|
||||
if ui.button({30, 28, 120, 48}, "Back", theme.ink, font_size = 22) {
|
||||
result = themes.Screen.MAIN_MENU
|
||||
back_y := i32(start_y) + i32(button_h) + i32(button_space)
|
||||
if draw_button(start_x, back_y, button_w, button_h, "Back", themes.JAPAN_PRIMARY, themes.JAPAN_ACCENT, mouse_x, mouse_y) {
|
||||
return themes.MAIN_MENU
|
||||
}
|
||||
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
|
||||
|
||||
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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user