Compare commits

...
63 Commits
Author SHA1 Message Date
jasonhilder 5038bc5673 Setup game controller status on game state. 2026-10-21 07:28:49 +02:00
jasonhilder 7fbdb9f5cf Updated readme for credits. 2026-10-19 07:19:10 +02:00
jasonhilder b1bab7ece5 Screenshots v1.0 2026-10-15 06:59:13 +02:00
jasonhilder ba410861e6 Initial detailed Readme V1.0 2026-10-15 06:57:14 +02:00
jasonhilder 1ee4ff920b Make screen shake only happen on score. 2026-10-15 06:56:53 +02:00
jasonhilder f9987788bf Cleanups and consitency. 2026-10-15 06:56:44 +02:00
jasonhilder ec2efe79f0 Fixed funny resolution changing. 2026-10-15 05:36:11 +02:00
jasonhilder 73b04fc80c Fixed random bug, spacebar should not be confirm. 2026-10-15 05:35:38 +02:00
jasonhilder 9b9c777515 Initial assets, sounds and shader 2026-10-13 07:13:53 +02:00
jasonhilder f1ef1075e7 ignore file for fzf 2026-10-13 07:13:40 +02:00
jasonhilder 72eb4d5447 White space/formatting clean up and better audio defaults. 2026-10-13 07:12:14 +02:00
jasonhilder d5b3588cb3 Small tweaks for theme consistency. 2026-10-13 07:11:50 +02:00
jasonhilder a7ada07466 Initial workings for simple transitions between scenes. 2026-10-13 07:11:21 +02:00
jasonhilder 79d9ba147c removed ctags. 2026-10-13 07:10:15 +02:00
jasonhilder bb35f185b8 Added compile time vars for consistency. 2026-10-08 07:48:54 +02:00
jasonhilder dda388868c Todo to add difficulty levels 2026-10-08 07:45:08 +02:00
jasonhilder fb7bfd6bf3 Event manager now handles the new parent field. 2026-10-08 07:44:49 +02:00
jasonhilder 5395225cc2 Scene rendering is better now with parent setup. 2026-10-08 07:44:31 +02:00
jasonhilder 200e05f2dc Improved scene manager history.
Added a parent field essentially making a linked list.
Allows for better menu traversal.
2026-10-08 07:43:58 +02:00
jasonhilder 404c320973 reset ball now clears trail effect. 2026-10-08 07:43:21 +02:00
jasonhilder d6c52cecda Setup serve direction after scoring 2026-10-08 07:43:00 +02:00
jasonhilder 25bd7a85a1 Better clean up funcs for managers. 2026-10-08 07:41:44 +02:00
jasonhilder 0f393af931 Moved over to vis with ctags. 2026-10-08 07:40:22 +02:00
jasonhilder cd1d9ef0e4 Updated sublime file. 2026-09-29 07:54:30 +02:00
jasonhilder 5f38503b49 Updated build file. 2026-09-29 07:54:23 +02:00
jasonhilder 29c88a67ad Setup 2D Camera for shake effect. 2026-09-29 07:54:09 +02:00
jasonhilder f345b0a5f4 Add a CRT shader effect over the game. 2026-09-29 07:53:43 +02:00
jasonhilder ce142a2d02 Setup constants and conditions for effects. 2026-09-29 07:48:52 +02:00
jasonhilder 4a1df958e4 Updated collision event for screenshake. 2026-09-29 07:48:20 +02:00
jasonhilder 2fc3bd07a2 Big additions.
Updated speeds etc for smaller resolution.
Added a squish animation on collision of paddles.
Updated the paddles from here to indicate a hit.
Make use of simple particle system for on hit effects.
2026-09-28 08:05:54 +02:00
jasonhilder d4d67cbd6c Trying out defaults. 2026-09-28 08:04:39 +02:00
jasonhilder a185a1e777 Better structure and size constants for pixel art resolution. 2026-09-28 08:04:30 +02:00
jasonhilder 23301801e7 Very simple particle system. 2026-09-28 08:04:09 +02:00
jasonhilder 3691f33654 Updates for smaller internal resoultion and style change. 2026-09-28 08:03:57 +02:00
jasonhilder 7e78a0e219 Changing to a pixel art style with crt bg. 2026-09-28 08:03:31 +02:00
jasonhilder cdaddeee44 Small tweaks for testing and better design. 2026-09-28 08:02:46 +02:00
jasonhilder 5ec8c918b3 New fields for fat struct. 2026-09-28 08:02:27 +02:00
jasonhilder c50f9cb2b7 Added controller support.
Added SDL3 controller mappings and loaded with raylib.
Updated input manager to get active controller, check for disconnect etc
and then use that within the poll input proc.
2026-09-23 07:52:42 +02:00
jasonhilder d40395fae4 Sublime updates. 2026-09-23 07:51:05 +02:00
jasonhilder 72c564b24a Updated scene manager.
Scene manager now uses multiple event types.
UI updates, todo must move to its own file at some point.
2026-09-21 07:32:28 +02:00
jasonhilder 4260b11fdb Setup multiple resolutions. 2026-09-21 07:31:31 +02:00
jasonhilder 1ce0992cf3 Refactor for easier management.
Refactored to have events be a union so we can pack data in cases.
And in others just emit a simple notifying event.
2026-09-21 07:31:06 +02:00
jasonhilder df18e0c8b7 Updated game to use new scene stack. 2026-09-21 07:30:07 +02:00
jasonhilder 5791f14db8 Updated scene manager into a stack 2026-09-21 07:29:24 +02:00
jasonhilder 179a3fa91b Added a new field to ent struct. 2026-09-21 07:27:53 +02:00
jasonhilder 4a45ba3328 Updated ball to have deflection and max speed.
Updated the ball to increment its speed on hit.
Also to calc a better angle on hit to serve back.
Also set a max speed.
2026-09-21 07:27:45 +02:00
jasonhilder 34a52f2076 sublime workspace update. 2026-09-18 07:25:33 +02:00
jasonhilder b66442f55f Cleanup build script. 2026-09-18 07:25:26 +02:00
jasonhilder 777e9c511f Major refactor.
Moved dispatchers to the top, to separate ui and scene code.
Added a settigns menu.
Updated UI code to have multi element types.
2026-09-18 07:25:15 +02:00
jasonhilder d78117441c Ball entity now emits event.s 2026-09-18 07:23:59 +02:00
jasonhilder 3fb194c57d New events and consolidated event manager procs. 2026-09-18 07:23:46 +02:00
jasonhilder 6b76ad6874 Basic audio manager setup. 2026-09-18 07:22:53 +02:00
jasonhilder f6f8e99e6b Additional input events added. 2026-09-18 07:22:42 +02:00
jasonhilder fa21fd60b8 Small cleanup. 2026-09-18 07:21:44 +02:00
jasonhilder 37dc2ab2fc Setup all menus to use canvas. 2026-09-16 07:54:25 +02:00
jasonhilder 43b46eec1e Added additional events. 2026-09-16 07:54:08 +02:00
jasonhilder 54f0e4dcc6 Small cleanup, spacing, comments. 2026-09-16 07:53:55 +02:00
jasonhilder 133c328c32 Big rework for menus. 2026-09-15 07:59:52 +02:00
jasonhilder 86c792e39c Passed game for should close flag. 2026-09-15 07:59:31 +02:00
jasonhilder efcb9325fc New events for event manager. 2026-09-15 07:59:00 +02:00
jasonhilder e0ff1a51fd Integrated events and should close flag. 2026-09-15 07:58:51 +02:00
jasonhilder 676a24e883 Small clean up and win score set. 2026-09-15 07:58:28 +02:00
jasonhilder c82c51aabf Cleaned/improved input manager.
Added single frame actions for menus etc
Cleaned up continuous actions and set both to be captured.
2026-09-15 07:58:19 +02:00
25 changed files with 3895 additions and 3745 deletions
+3
View File
@@ -0,0 +1,3 @@
out/
assets/
gamecontrollerdb.txt
+85 -2
View File
@@ -1,3 +1,86 @@
# pong_raylib # PONG 2026
Classic pong implemented in Odin and Raylib A deliberately over engineered Pong clone in [Odin](https://odin-lang.org) with [raylib](https://www.raylib.com).
Retro line-based graphics with a CRT post-processing shader.
## Screenshots
![Main menu](screenshots/main_menu.png)
![Settings menu](screenshots/settings_menu.png)
![Gameplay](screenshots/gameplay.png)
## Why Over-Engineered?
While the game could have been done in a single file, the goal was to structure the project like a small production game instead:
- **Manager-per-subsystem**: audio, input, event, level, particle, and scene managers, each in its own file
- **Event system**: decoupled communication between systems (`event_manager.odin`) instead of direct calls
- **Scene management**: state/scene stack for menus, gameplay, etc. (`scene_manager.odin`)
- **Entity layer**: shared base entity with ball, player, and opponent specializations
- **Input abstraction**: keyboard and gamepad behind one interface
- **Data-driven assets**: audio and shaders loaded from `assets/`, not embedded
The idea behind breaking it into systems is so that it can help build my foundations into more complex games.
## Features
- Single-player vs AI opponent
- CRT fragment shader (`assets/shaders/crt.fs`)
- Particle effects, sound effects, music
- Gamepad support via SDL controller mappings
## Controls
| Action | Keyboard | Gamepad |
|--------|----------|---------|
| Move | W(up) S(down) | Left Joystick |
| Select | Enter | A button |
| Pause | Spacebar | Start button |
## Requirements
Please note, I have not built this on MacOs or Windows, only linux.
While in theory if you have the odin compiler it should just work as there is no external libs besides those bundled with Odin.
- Odin compiler (version dev-2026-10:e87445a58)
- raylib (bundled with Odin as `vendor:raylib`)
- GPU/driver with OpenGL 3.3 support (shader uses `#version 330`)
## Build & Run
./build.sh # see build.sh for flags and options
./out/pong
## Project Layout
assets/
audio/ music, SFX, UI sounds
shaders/ crt.fs
screenshots/ README images
src/
main.odin entry point
game.odin game state and loop
*_manager.odin audio, event, input, level, particle, scene
entity*.odin base entity, ball, player, opponent
out/ build output
build.sh build script
## Credits
Sourced from the web, not mine.
| File | Title | Author | Source | License |
|------|-------|--------|--------|---------|
| `UI, gameplay effects` | Impact Sounds 1.0 + Interface Sounds 1.0 | Kenney | https://www.kenney.nl | [CC0 1.0](http://creativecommons.org/publicdomain/zero/1.0/) |
| `Background Music` | 5 Action Chiptunes By Juhani Junkala | Juhani Junkala | <download page URL> | CC0 1.0 |
### Third-Party
- `gamecontrollerdb.txt`: [SDL_GameControllerDB](https://github.com/mdqinc/SDL_GameControllerDB) (zlib)
- [raylib](https://github.com/raysan5/raylib) by Ramon Santamaria (@raysan5), zlib
- [Odin](https://github.com/odin-lang/Odin), BSD-3-Clause
## License
MIT, see [LICENSE](LICENSE).
Any third-party assets retain their original licenses, listed above.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
#version 330
in vec2 fragTexCoord;
in vec4 fragColor;
out vec4 finalColor;
uniform sampler2D texture0;
uniform vec4 colDiffuse;
// Adjust these values to tweak the aesthetic:
const float SCANLINE_INTENSITY = 0.25; // Lower value = softer lines (0.08 to 0.20)
const float BLOOM_AMOUNT = 0.10; // Slight phosphor bleed/glow
const float CRT_CURVATURE = 0.03; // Subtle screen bulge (0.0 = flat)
void main() {
// 1. Subtle screen curvature distortion
vec2 uv = fragTexCoord;
vec2 dc = abs(0.5 - uv);
uv.x += uv.x * (dc.y * dc.y) * CRT_CURVATURE;
uv.y += uv.y * (dc.x * dc.x) * CRT_CURVATURE;
// Discard pixels outside the curved screen bounds
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
finalColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
// 2. Sample pixel with slight horizontal blur (Phosphor bleed)
vec4 col = texture(texture0, uv);
vec4 col_left = texture(texture0, uv - vec2(0.0008, 0.0));
vec4 col_right = texture(texture0, uv + vec2(0.0008, 0.0));
col.rgb = mix(col.rgb, (col_left.rgb + col_right.rgb) * 0.5, BLOOM_AMOUNT);
// 3. Gaussian scanline profile matching 360 vertical lines
float scanline = sin(uv.y * 360.0 * 3.14159);
// Smooth power curve prevents harsh pitch-black cutoffs
float scanline_factor = pow(0.5 + 0.5 * scanline, 1.8);
// Apply scanlines smoothly
col.rgb *= mix(1.0 - SCANLINE_INTENSITY, 1.0, scanline_factor);
// 4. Subtle corner vignette
float vig = (16.0 * uv.x * uv.y * (1.0 - uv.x) * (1.0 - uv.y));
col.rgb *= pow(vig, 0.05);
finalColor = col * colDiffuse * fragColor;
}
Regular → Executable
+114 -60
View File
@@ -1,107 +1,161 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e set -eo pipefail
# Find the odin binary # Find the odin binary
export PATH="$HOME/.odin:$PATH" export PATH="$HOME/.odin:$PATH"
SRC_DIR="src" SRC_DIR="src"
OUT_DIR="out" OUT_DIR="out"
OUT_NAME="pong" OUT_NAME="pong"
# flags for every odin build; unix-style errors (file:line:col: msg) are what the vis error picker parses
ODIN_FLAGS=(-error-pos-style:unix)
# ---- ctags ------------------------------------------------------------
# tags = project tags (-t, vis runs it on every .odin save)
# + stdlib tags (-T, once, or after an Odin update), shared by all projects
TAGS_FILE="tags"
STD_TAGS="${XDG_CACHE_HOME:-$HOME/.cache}/odin-tags/std.tags"
# --languages=Odin keeps vendor's bundled C/JS out of the index;
# --fields=+n adds the line:N field the vis pickers jump to
CTAGS_OPTS=(--languages=Odin --fields=+n)
# -----------------------------------------------------------------------
BUILD_ONLY=0 BUILD_ONLY=0
OPTIMIZED=0 OPTIMIZED=0
WEB=0 WEB=0
CLEAN=0 CLEAN=0
DEBUGGER=0 DEBUGGER=0
VALGRIND=0 VALGRIND=0
TAGS=""
usage() { usage() {
echo "Usage: $0 [-b] [-o] [-w] [-d] [-v] [-c] [-h]" echo "Usage: $0 [-b] [-o] [-w] [-d] [-v] [-c] [-t] [-T] [-h]"
echo " -b build only, don't run" echo " -b build only, don't run"
echo " -o optimized release build (-o:speed, no debug info)" echo " -o optimized release build (-o:speed, no debug info)"
echo " -w web/WASM build (js_wasm32 target)" echo " -w web/WASM build (js_wasm32 target)"
echo " -d build then launch in debugger instead of running" echo " -d build then launch in debugger instead of running"
echo " -v build then run under Valgrind" echo " -v build then run under Valgrind"
echo " -c clean build artifacts" echo " -c clean build artifacts"
echo " -h show this help" echo " -t re-index $SRC_DIR and merge it with the stdlib tags"
exit 1 echo " -T index Odin core + vendor (once, or after an Odin update)"
echo " -h show this help"
exit 1
} }
while getopts "bowdvch" opt; do # stdlib tags, header lines stripped so the merged file keeps only one set
case $opt in generate_std_tags() {
b) BUILD_ONLY=1 ;; local root
o) OPTIMIZED=1 ;; root="$(odin root 2>/dev/null || echo "$HOME/.odin")"
w) WEB=1 ;; root="${root%/}"
d) DEBUGGER=1 ;;
v) VALGRIND=1 ;; mkdir -p "$(dirname "$STD_TAGS")"
c) CLEAN=1 ;; ctags -R -f - "${CTAGS_OPTS[@]}" "$root/core" "$root/vendor" | grep -v '^!_TAG_' > "$STD_TAGS.tmp"
h) usage ;; mv -f "$STD_TAGS.tmp" "$STD_TAGS"
*) usage ;; echo "Stdlib tags written -> $STD_TAGS"
esac }
# project tags merged with the stdlib tags; both inputs are byte-sorted
generate_tags() {
if [ ! -f "$STD_TAGS" ]; then
echo "No stdlib tags; run $0 -T first" >&2
exit 1
fi
# $$ keeps two overlapping on-save runs from sharing temp files
local src_tags="$TAGS_FILE.src.$$"
local merged="$TAGS_FILE.tmp.$$"
ctags -R -f "$src_tags" "${CTAGS_OPTS[@]}" "$SRC_DIR"
LC_ALL=C sort -m -u "$src_tags" "$STD_TAGS" > "$merged"
mv -f "$merged" "$TAGS_FILE"
rm -f "$src_tags"
}
while getopts "bowdvctTh" opt; do
case $opt in
b) BUILD_ONLY=1 ;;
o) OPTIMIZED=1 ;;
w) WEB=1 ;;
d) DEBUGGER=1 ;;
v) VALGRIND=1 ;;
c) CLEAN=1 ;;
t) TAGS=src ;;
T) TAGS=std ;;
h) usage ;;
*) usage ;;
esac
done done
case "$TAGS" in
src) generate_tags; exit 0 ;;
std) generate_std_tags; exit 0 ;;
esac
mkdir -p "$OUT_DIR" mkdir -p "$OUT_DIR"
if [ "$CLEAN" -eq 1 ]; then if [ "$CLEAN" -eq 1 ]; then
echo "Cleaning..." echo "Cleaning..."
rm -rf "${OUT_DIR:?}"/* rm -rf "${OUT_DIR:?}"/*
exit 0 exit 0
fi fi
if [ "$WEB" -eq 1 ]; then if [ "$WEB" -eq 1 ]; then
echo "Building web (WASM)..." echo "Building web (WASM)..."
odin build "$SRC_DIR" \ odin build "$SRC_DIR" "${ODIN_FLAGS[@]}" \
-target:js_wasm32 \ -target:js_wasm32 \
-out:"$OUT_DIR/$OUT_NAME.wasm" \ -out:"$OUT_DIR/$OUT_NAME.wasm" \
$( [ "$OPTIMIZED" -eq 1 ] && echo "-o:speed" || echo "-debug" ) $( [ "$OPTIMIZED" -eq 1 ] && echo "-o:speed" || echo "-debug" )
# Copy the HTML shell if one exists # Copy the HTML shell if one exists
if [ -f "web/index_template.html" ]; then if [ -f "web/index_template.html" ]; then
cp web/index_template.html "$OUT_DIR/index.html" cp web/index_template.html "$OUT_DIR/index.html"
fi fi
echo "Web build complete -> $OUT_DIR/$OUT_NAME.wasm" echo "Web build complete -> $OUT_DIR/$OUT_NAME.wasm"
exit 0 exit 0
fi fi
if [ "$OPTIMIZED" -eq 1 ]; then if [ "$OPTIMIZED" -eq 1 ]; then
echo "Building optimized ($SRC_DIR -> $OUT_DIR/$OUT_NAME)..." echo "Building optimized ($SRC_DIR -> $OUT_DIR/$OUT_NAME)..."
odin build "$SRC_DIR" \ odin build "$SRC_DIR" "${ODIN_FLAGS[@]}" \
-out:"$OUT_DIR/$OUT_NAME" \ -out:"$OUT_DIR/$OUT_NAME" \
-o:speed \ -o:speed \
-no-bounds-check -no-bounds-check
else else
echo "Building debug ($SRC_DIR -> $OUT_DIR/$OUT_NAME)..." echo "Building debug ($SRC_DIR -> $OUT_DIR/$OUT_NAME)..."
odin build "$SRC_DIR" \ odin build "$SRC_DIR" "${ODIN_FLAGS[@]}" \
-out:"$OUT_DIR/$OUT_NAME" \ -out:"$OUT_DIR/$OUT_NAME" \
-debug \ -debug \
-define:DEBUG_MODE=true -define:DEBUG_MODE=true
fi fi
echo "Build complete." echo "Build complete."
if [ "$BUILD_ONLY" -eq 0 ]; then if [ "$BUILD_ONLY" -eq 0 ]; then
echo "----------------------------------------" echo "----------------------------------------"
if [ "$DEBUGGER" -eq 1 ]; then if [ "$DEBUGGER" -eq 1 ]; then
lldb "$OUT_DIR/$OUT_NAME" lldb "$OUT_DIR/$OUT_NAME"
elif [ "$VALGRIND" -eq 1 ]; then elif [ "$VALGRIND" -eq 1 ]; then
# --show-leak-kinds=definite,possible reduces tons of noise VG_STATUS=0
# still shows the leaks you actually need to fix. valgrind \
# to audit whole process, temporarily switch back to --show-leak-kinds=all
valgrind \
--leak-check=full \ --leak-check=full \
--show-leak-kinds=definite,possible \ --show-leak-kinds=definite,possible \
--track-origins=yes \ --track-origins=yes \
--errors-for-leak-kinds=definite,possible \ --errors-for-leak-kinds=definite,possible \
--error-exitcode=1 \ --error-exitcode=1 \
--log-file="$OUT_DIR/valgrind.log" \ --log-file="$OUT_DIR/valgrind.log" \
"./$OUT_DIR/$OUT_NAME" "./$OUT_DIR/$OUT_NAME" || VG_STATUS=$?
echo echo
echo "Valgrind report written to $OUT_DIR/valgrind.log" echo "Valgrind report written to $OUT_DIR/valgrind.log"
else [ "$VG_STATUS" -ne 0 ] && echo "Valgrind reported errors (exit $VG_STATUS)"
"./$OUT_DIR/$OUT_NAME" exit "$VG_STATUS"
fi else
"./$OUT_DIR/$OUT_NAME"
fi
fi fi
+2259
View File
File diff suppressed because it is too large Load Diff
-44
View File
@@ -1,44 +0,0 @@
{
"folders":
[
{
"path": "."
}
],
"build_systems":
[
{
"name": "Run Build Script",
"cmd": ["/bin/bash", "build.sh"],
"working_dir": "$project_path",
"file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
"variants":
[
{
"name": "Run with Arguments...",
"cmd": ["/bin/bash", "build.sh", "$select_args"]
},
{
"name": "Build Only (-b)",
"cmd": ["/bin/bash", "build.sh", "-b"]
},
{
"name": "Optimized (-o)",
"cmd": ["/bin/bash", "build.sh", "-o"]
},
{
"name": "Debug in LLDB (-d)",
"cmd": ["/bin/bash", "build.sh", "-d"]
},
{
"name": "Valgrind (-v)",
"cmd": ["/bin/bash", "build.sh", "-v"]
},
{
"name": "Clean (-c)",
"cmd": ["/bin/bash", "build.sh", "-c"]
}
]
}
]
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

+149
View File
@@ -0,0 +1,149 @@
package game
import rl "vendor:raylib"
Fade_State :: enum {
None,
In,
Out,
}
// setup sound IDs so gameplay code never
// touches raw Raylib sound handles
Sound_ID :: enum {
Ball_Collision,
Score_Goal,
UI_Select,
UI_Confirm,
}
Audio_Manager :: struct {
sounds: map[Sound_ID]rl.Sound,
music: rl.Music,
music_loaded: bool, //
sfx_vol: f32, // User SFX volume setting
user_music_vol: f32, // User Music volume setting
current_music_vol: f32, // Live interpolated output volume
// Runtime Fade State
fade_state: Fade_State,
fade_elapsed_sec: f32,
fade_duration_sec: f32,
}
// ------------------------------------------------------------------------------
// Lifecycle procs
// ------------------------------------------------------------------------------
audio_init :: proc(audio: ^Audio_Manager) {
rl.InitAudioDevice()
audio.sounds = make(map[Sound_ID]rl.Sound)
// Load SFX
audio.sfx_vol = 0.4
audio.sounds[.UI_Select] = rl.LoadSound("assets/audio/ui/select.ogg")
audio.sounds[.Ball_Collision] = rl.LoadSound("assets/audio/gameplay/ballhit.ogg")
// Load Music Stream
audio.user_music_vol = 0.4
audio.current_music_vol = 0.4
audio.fade_state = .None
audio.music = rl.LoadMusicStream("assets/audio/gameplay/music/track_1.wav")
audio.music_loaded = true
rl.SetMusicVolume(audio.music, audio.current_music_vol)
}
audio_update :: proc(audio: ^Audio_Manager, dt: f32) {
if !audio.music_loaded do return
// Process Fade if the audio state machine requires
if audio.fade_state != .None {
audio.fade_elapsed_sec += dt
// convert elapsed seconds over target seconds into a 0.0 -> 1.0 percentage
progress_pct := clamp(audio.fade_elapsed_sec / audio.fade_duration_sec, 0.0, 1.0)
#partial switch audio.fade_state {
case .In:
audio.current_music_vol = rl.Lerp(0.0, audio.user_music_vol, progress_pct)
// progress is 100% or more stop fading in music
if progress_pct >= 1.0 do audio.fade_state = .None
case .Out:
audio.current_music_vol = rl.Lerp(audio.user_music_vol, 0.0, progress_pct)
// progress is 100% or more stop fading out music and stop the stream
if progress_pct >= 1.0 {
audio.fade_state = .None
rl.StopMusicStream(audio.music)
}
}
} else {
// not fading, output matches the user setting directly
audio.current_music_vol = audio.user_music_vol
}
rl.SetMusicVolume(audio.music, audio.current_music_vol)
// keep audio stream buffer fed
if rl.IsMusicStreamPlaying(audio.music) {
rl.UpdateMusicStream(audio.music)
}
}
audio_manager_quit :: proc(audio: ^Audio_Manager) {
for _, sound in audio.sounds {
rl.UnloadSound(sound)
}
delete(audio.sounds)
rl.UnloadMusicStream(audio.music)
rl.CloseAudioDevice()
}
// ------------------------------------------------------------------------------
// Utilities
// ------------------------------------------------------------------------------
audio_play_music :: proc(audio: ^Audio_Manager) {
if audio.music_loaded {
// Only trigger start if it isn't playing already
if !rl.IsMusicStreamPlaying(audio.music) {
rl.PlayMusicStream(audio.music)
}
}
}
audio_stop_music :: proc(audio: ^Audio_Manager) {
if audio.music_loaded && rl.IsMusicStreamPlaying(audio.music) {
rl.StopMusicStream(audio.music)
}
}
audio_play_sfx :: proc(audio: ^Audio_Manager, id: Sound_ID) {
if sound, ok := audio.sounds[id]; ok {
rl.SetSoundVolume(sound, audio.sfx_vol)
rl.PlaySound(sound)
}
}
// ------------------------------------------------------------------------------
// Audio Fading
// ------------------------------------------------------------------------------
audio_fade_in :: proc(audio: ^Audio_Manager, duration: f32 = 1.0) {
if !audio.music_loaded do return
if !rl.IsMusicStreamPlaying(audio.music) {
rl.PlayMusicStream(audio.music)
}
audio.fade_state = .In
audio.fade_duration_sec = max(0.001, duration)
audio.fade_elapsed_sec = 0.0
audio.current_music_vol = 0.0
rl.SetMusicVolume(audio.music, 0.0)
}
audio_fade_out :: proc(audio: ^Audio_Manager, duration: f32 = 1.0) {
if !audio.music_loaded || !rl.IsMusicStreamPlaying(audio.music) do return
audio.fade_state = .Out
audio.fade_duration_sec = max(0.001, duration)
audio.fade_elapsed_sec = 0.0
}
+16 -5
View File
@@ -6,6 +6,7 @@ import rl "vendor:raylib"
// Generic base structure shared by all world objects. // Generic base structure shared by all world objects.
// Fields are populated selectively based on entity type. // Fields are populated selectively based on entity type.
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
MAX_TRAILS :: 8
Entity_Type :: enum { Entity_Type :: enum {
Player, Player,
@@ -23,16 +24,26 @@ Entity :: struct {
active: bool, active: bool,
pos: rl.Vector2, pos: rl.Vector2,
shape: Shape_Type, shape: Shape_Type,
color: rl.Color,
// width / height or diameter / diameter // width / height or diameter / diameter
size: rl.Vector2, size: rl.Vector2,
// Fixed input speed (paddles) // Fixed input speed (paddles)
speed: f32, speed: f32,
max_speed: f32,
// 2D momentum & direction (ball / physics) // 2D momentum & direction (ball / physics)
vel: rl.Vector2, vel: rl.Vector2,
target_y: f32, target_y: f32,
reaction_timer: f32 reaction_timer: f32,
}
// ------------------------------------------------------------------------------ // animation options
// @todo: entity dispatchers tween_duration: f32,
// ------------------------------------------------------------------------------ tween_elapsed: f32,
// trail effect
trail_positions: [MAX_TRAILS]rl.Vector2,
trail_count: int,
trail_timer: f32,
}
+192 -21
View File
@@ -1,23 +1,35 @@
package game package game
import "core:math"
import "core:math/rand" import "core:math/rand"
import rl "vendor:raylib" import rl "vendor:raylib"
SPEED_INC :: 30
EFFECTS_SPEED :: 300
INITIAL_SPEED :: 200
MAX_SPEED :: 550
init_ball :: proc() -> Entity { init_ball :: proc() -> Entity {
return Entity{ return Entity{
type = .Ball, type = .Ball,
active = true, active = true,
pos = rl.Vector2{(VIEWPORT_W / 2), (VIEWPORT_H / 2)}, pos = rl.Vector2{(VIEWPORT_W / 2), (VIEWPORT_H / 2)},
speed = 500, speed = INITIAL_SPEED,
max_speed = MAX_SPEED,
vel = rl.Vector2{0,0}, vel = rl.Vector2{0,0},
shape = .Circle, shape = .Circle,
size = rl.Vector2{15, 15} size = rl.Vector2{8, 0},
} tween_duration = 0.65,
}
} }
serve_ball :: proc(ball: ^Entity) { serve_ball :: proc(ball: ^Entity, serve_dir: u8) {
dir_x: f32 = 0.0
// Randomize initial direction: -1.0 or 1.0 // Randomize initial direction: -1.0 or 1.0
dir_x := f32(rand.float32() > 0.5 ? 1 : -1) if serve_dir == 0 do dir_x = f32(rand.float32() > 0.5 ? 1 : -1)
if serve_dir == 1 do dir_x = 1.0
if serve_dir == 2 do dir_x = -1.0
dir_y := rand.choice([]f32{-0.1, 0.1}) // Slightly angled launch dir_y := rand.choice([]f32{-0.1, 0.1}) // Slightly angled launch
ball.vel = rl.Vector2Normalize({dir_x, dir_y}) * ball.speed ball.vel = rl.Vector2Normalize({dir_x, dir_y}) * ball.speed
} }
@@ -25,30 +37,53 @@ serve_ball :: proc(ball: ^Entity) {
reset_ball :: proc(ball: ^Entity) { reset_ball :: proc(ball: ^Entity) {
ball.pos = rl.Vector2{(VIEWPORT_W / 2), (VIEWPORT_H / 2)} ball.pos = rl.Vector2{(VIEWPORT_W / 2), (VIEWPORT_H / 2)}
ball.vel = rl.Vector2{0,0} ball.vel = rl.Vector2{0,0}
ball.speed = INITIAL_SPEED
ball.trail_positions = {}
ball.trail_count = 0
} }
update_ball :: proc(ball: ^Entity, level: ^Level, dt: f32) { update_ball :: proc(ball: ^Entity, level: ^Level, dt: f32) {
// squish animation check
if ball.tween_elapsed < ball.tween_duration {
ball.tween_elapsed += dt
}
// first check the level timer are we waiting to serve? // first check the level timer are we waiting to serve?
if level.serve_timer > 0 { if level.serve_timer > 0 {
level.serve_timer -= dt level.serve_timer -= dt
if level.serve_timer <= 0 { if level.serve_timer <= 0 {
serve_ball(ball) serve_ball(ball, level.serve_dir)
} }
return return
} }
ball.pos += ball.vel * dt // Update trail history buffer
update_ball_trail(ball, dt)
radius := ball.size.x radius := ball.size.x
field_bounds := level.field_bounds field_bounds := level.field_bounds
// paddle collisions detection (player then enemy) // paddle collisions detection (player then enemy)
if check_ball_paddle_collision(ball^, level.player) { if check_ball_paddle_collision(ball, level.player) {
ball.vel.x = abs(ball.vel.x) level.player.color = rl.Color{255,255,255,255}
// stp stickiness ball.tween_elapsed = 0.0
ball.pos.x = level.player.pos.x + level.player.size.x + radius resolve_collision(ball, level.player, true)
} else if check_ball_paddle_collision(ball^, level.opponent) { if ball.speed >= EFFECTS_SPEED {
ball.vel.x = -abs(ball.vel.x) spawn_max_speed_sparks(&level.particles, level.player, ball.pos.y, true)
ball.pos.x = level.opponent.pos.x - radius }
} else if check_ball_paddle_collision(ball, level.opponent) {
level.opponent.color = rl.Color{255,255,255,255}
ball.tween_elapsed = 0.0
resolve_collision(ball, level.opponent, false)
if ball.speed >= EFFECTS_SPEED {
spawn_max_speed_sparks(&level.particles, level.opponent, ball.pos.y, false)
}
} else {
level.opponent.color = rl.Color{255,255,255,0}
level.player.color = rl.Color{255,255,255,0}
} }
// bounds collision detection // bounds collision detection
@@ -56,34 +91,170 @@ update_ball :: proc(ball: ^Entity, level: ^Level, dt: f32) {
if ball.pos.y <= field_bounds.y + ball.size.x { ball.vel.y = -ball.vel.y } if ball.pos.y <= field_bounds.y + ball.size.x { ball.vel.y = -ball.vel.y }
if ball.pos.x >= (field_bounds.x + field_bounds.width) - ball.size.x { if ball.pos.x >= (field_bounds.x + field_bounds.width) - ball.size.x {
increment_score(&level.score_player, level.score_win, true) increment_score(&level.score_player, level.score_win, true, ball)
reset_ball(ball) reset_ball(ball)
level.serve_timer = level.wait_time level.serve_timer = level.wait_time
level.serve_dir = 1
} }
if ball.pos.x <= field_bounds.x + ball.size.x { if ball.pos.x <= field_bounds.x + ball.size.x {
increment_score(&level.score_enemy, level.score_win, false) increment_score(&level.score_enemy, level.score_win, false, ball)
reset_ball(ball) reset_ball(ball)
level.serve_timer = level.wait_time level.serve_timer = level.wait_time
level.serve_dir = 2
} }
ball.pos += ball.vel * dt
} }
check_ball_paddle_collision :: proc(ball: Entity, paddle: Entity) -> bool { check_ball_paddle_collision :: proc(ball: ^Entity, paddle: Entity) -> bool {
paddle_rec := rl.Rectangle{ paddle_rec := rl.Rectangle{
x = paddle.pos.x, x = paddle.pos.x,
y = paddle.pos.y, y = paddle.pos.y,
width = paddle.size.x, width = paddle.size.x,
height = paddle.size.y, height = paddle.size.y,
} }
return rl.CheckCollisionCircleRec(ball.pos, ball.size.x, paddle_rec) collided := rl.CheckCollisionCircleRec(ball.pos, ball.size.x, paddle_rec)
if collided do emit_event(Ball_Collision_Data{speed = ball.speed})
return collided
} }
increment_score :: proc(score_side: ^int, max_score: int, player: bool) { increment_score :: proc(score_side: ^int, max_score: int, player: bool, ball: ^Entity) {
emit_event(.Score_Change)
score_side^ += 1 score_side^ += 1
if score_side^ == max_score {
emit_event(Game_Over_Data{player})
}
}
if score_side^ == max_score do emit_event(Event{ .Game_Over, player }) increment_speed :: proc(ball: ^Entity) {
if ball.speed <= ball.max_speed {
ball.speed += SPEED_INC
ball.vel = rl.Vector2Normalize({ball.vel.x, ball.vel.y}) * ball.speed
}
}
resolve_collision :: proc(ball: ^Entity, paddle: Entity, is_player: bool) {
ball_center_y := ball.pos.y
paddle_center_y := paddle.pos.y + (paddle.size.y / 2.0)
local_y := ball_center_y - paddle_center_y
half_height := paddle.size.y / 2.0
// check if we are in top or bottom half of paddle
hit_t := clamp(local_y / half_height, -1.0, 1.0)
// direction is dependant of if its the player
dir_x: f32 = is_player ? 1.0 : -1.0
// steer the angle by a value using its hit t value
dir_y := hit_t * 1.3
new_dir := rl.Vector2Normalize({dir_x, dir_y})
ball.vel = new_dir * ball.speed
increment_speed(ball)
// Resolve overlap
if is_player {
ball.pos.x = paddle.pos.x + paddle.size.x + ball.size.x
} else {
ball.pos.x = paddle.pos.x - ball.size.x
}
}
update_ball_trail :: proc(ball: ^Entity, dt: f32) {
if ball.speed < EFFECTS_SPEED {
ball.trail_count = 0
return
}
ball.trail_timer += dt
// get pos every 20ms
sample_rate := f32(0.02)
if ball.trail_timer >= sample_rate {
ball.trail_timer = 0.0
// shift old pos back
for i := MAX_TRAILS - 1; i > 0; i -= 1 {
ball.trail_positions[i] = ball.trail_positions[i - 1]
}
// store new pos
ball.trail_positions[0] = ball.pos
if ball.trail_count < MAX_TRAILS {
ball.trail_count += 1
}
}
}
spawn_max_speed_sparks :: proc(particles: ^[dynamic]Particle, paddle: Entity, ball_y: f32, is_player: bool) {
count := 12
impact_x := is_player ? (paddle.pos.x + paddle.size.x) : paddle.pos.x
impact_y := clamp(ball_y, paddle.pos.y, paddle.pos.y + paddle.size.y)
impact_pos := rl.Vector2{impact_x, impact_y}
for _ in 0 ..< count {
// Direct sparks outward from the paddle face
base_angle := is_player ? f32(0.0) : rl.PI // 0 rad = right, PI rad = left
spread := (f32(rand.float32()) - 0.5) * 1.2
angle := base_angle + spread
speed := f32(rand.float32() * 180.0 + 100.0)
append(particles, Particle{
pos = impact_pos,
vel = {math.cos(angle) * speed, math.sin(angle) * speed},
color = rl.SKYBLUE,
life = 0.25,
max_life = 0.25,
size = f32(rand.float32() * 2.0 + 1.5),
})
}
}
render_ball_trail :: proc(ball: Entity) {
if ball.trail_count == 0 do return
base_radius := ball.size.x
for i in 0 ..< ball.trail_count {
pos := ball.trail_positions[i]
// Farther back in history = smaller and fainter
progress := f32(i + 1) / f32(ball.trail_count) // 0.0 -> 1.0
radius := base_radius * (1.0 - (progress * 0.6)) // Shrinks slightly
alpha := u8(180.0 * (1.0 - progress)) // Fades out
trail_color := rl.Color{rl.MAROON.r, rl.MAROON.g, rl.MAROON.b, alpha}
rl.DrawCircleV(pos, radius, trail_color)
}
} }
render_ball :: proc(ball: Entity, field_bounds: rl.Rectangle) { render_ball :: proc(ball: Entity, field_bounds: rl.Rectangle) {
rl.DrawCircleV(ball.pos, ball.size.x, rl.MAROON) render_ball_trail(ball)
radius_x := ball.size.x
radius_y := ball.size.x
// if animation active
if ball.tween_elapsed < ball.tween_duration {
radius_x = rl.EaseBackOut(
ball.tween_elapsed,
5.0,
ball.size.x - 5.0,
ball.tween_duration
)
radius_y = rl.EaseBackOut(
ball.tween_elapsed,
12.0,
ball.size.x - 12.0,
ball.tween_duration
)
}
rl.DrawEllipse(i32(ball.pos.x), i32(ball.pos.y), radius_x, radius_y, rl.MAROON)
} }
+12 -4
View File
@@ -9,9 +9,9 @@ init_opponent :: proc() -> Entity {
type = .Opponent, type = .Opponent,
active = true, active = true,
pos = rl.Vector2{0,0}, pos = rl.Vector2{0,0},
speed = 800, speed = 250,
shape = .Rectangle, shape = .Rectangle,
size = rl.Vector2{30, 150} size = rl.Vector2{16, 64}
} }
} }
@@ -27,7 +27,8 @@ update_opponent :: proc(self: ^Entity, ball: Entity, bounds: rl.Rectangle, dt: f
// center of ball - center of paddle gives the target // center of ball - center of paddle gives the target
self.target_y = (ball.pos.y + (ball.size.y / 2)) - (self.size.y / 2) + error_offset self.target_y = (ball.pos.y + (ball.size.y / 2)) - (self.size.y / 2) + error_offset
self.reaction_timer = 0.20 // TODO: make this and the deadzone change with difficulty
self.reaction_timer = 0.15
} }
} else { } else {
// ball going away get back to middle of screen // ball going away get back to middle of screen
@@ -49,5 +50,12 @@ update_opponent :: proc(self: ^Entity, ball: Entity, bounds: rl.Rectangle, dt: f
} }
render_opponent :: proc(self: Entity, field_bounds: rl.Rectangle) { render_opponent :: proc(self: Entity, field_bounds: rl.Rectangle) {
rl.DrawRectangleV(self.pos, self.size, rl.BLACK) opp_rec := rl.Rectangle{
x = self.pos.x - 5,
y = self.pos.y,
width = self.size.x,
height = self.size.y,
}
rl.DrawRectangleRounded(opp_rec, 2.0, 120, self.color)
rl.DrawRectangleRoundedLinesEx(opp_rec, 2.0, 120, 1.0, rl.WHITE)
} }
+5 -4
View File
@@ -8,13 +8,12 @@ init_player :: proc() -> Entity {
type = .Player, type = .Player,
active = true, active = true,
pos = rl.Vector2{0,0}, pos = rl.Vector2{0,0},
speed = 800, speed = 250,
shape = .Rectangle, shape = .Rectangle,
size = rl.Vector2{30, 150} size = rl.Vector2{16, 64}
} }
} }
update_player :: proc(player: ^Entity, input: Input_Event, bounds: rl.Rectangle, dt: f32,) { update_player :: proc(player: ^Entity, input: Input_Event, bounds: rl.Rectangle, dt: f32,) {
// move direction is captured and calculated + normalized from input manager // move direction is captured and calculated + normalized from input manager
// Apply Movement (Frame-rate independent) // Apply Movement (Frame-rate independent)
@@ -28,5 +27,7 @@ update_player :: proc(player: ^Entity, input: Input_Event, bounds: rl.Rectangle,
} }
render_player :: proc(player: Entity, field_bounds: rl.Rectangle) { render_player :: proc(player: Entity, field_bounds: rl.Rectangle) {
rl.DrawRectangleV(player.pos, player.size, rl.BLACK) player_rec := rl.Rectangle{player.pos.x + 5, player.pos.y, player.size.x, player.size.y}
rl.DrawRectangleRounded(player_rec, 2.0, 120, player.color)
rl.DrawRectangleRoundedLinesEx(player_rec, 2.0, 120, 1.0, rl.WHITE)
} }
+106 -8
View File
@@ -1,16 +1,27 @@
package game package game
// all possible game events Change_Resolution_Data :: struct { res_dir: int }
Event_Type :: enum { Ball_Collision_Data :: struct { speed: f32, }
Game_Over, Game_Over_Data :: struct { winner_is_player: bool }
Simple_Event :: enum {
Game_Start,
Game_Pause,
Game_Resume,
Game_Back,
Game_Main_Menu,
Game_Settings,
Game_Over_Generic,
Game_Quit,
UI_Select,
Score_Change, Score_Change,
Ball_Collision,
} }
// optional details attached to an event Event :: union {
Event :: struct { Simple_Event,
type: Event_Type, Change_Resolution_Data,
winner_is_player: bool, Game_Over_Data,
Ball_Collision_Data,
} }
// global event storage // global event storage
@@ -23,3 +34,90 @@ emit_event :: proc(event: Event) {
flush_events :: proc() { flush_events :: proc() {
clear(&GLOBAL_EVENTS) clear(&GLOBAL_EVENTS)
} }
process_events :: proc(game: ^Game) {
for event in GLOBAL_EVENTS {
switch e in event {
case Change_Resolution_Data:
new_index := (game.res_index + e.res_dir + len(RESOLUTIONS)) % len(RESOLUTIONS)
game_set_resolution(game, new_index)
// Dynamically update the active UI_Selector label on screen
if active_scene := get_active_scene(game); active_scene != nil {
if canvas, ok := &active_scene.canvas.?; ok {
if menu, menu_ok := &canvas.menu.?; menu_ok {
if selector, sel_ok := &menu.items[1].(UI_Selector); sel_ok {
selector.value_label = RESOLUTIONS[game.res_index].label
}
}
}
}
audio_play_sfx(&game.audio, .UI_Select)
case Game_Over_Data:
push_scene(game, make_game_over_overlay(e.winner_is_player))
case Ball_Collision_Data:
audio_play_sfx(&game.audio, .Ball_Collision)
case Simple_Event:
#partial switch e {
case .Game_Start:
set_root_scene(game, make_gameplay_scene())
game.transition = {true, 0, 0.2}
audio_fade_in(&game.audio, duration = 1.5)
case .Game_Pause:
push_scene(game, make_pause_overlay())
case .Game_Resume:
pop_scene(game)
case .Score_Change:
game_trigger_shake(game, 10, duration = 0.20)
case .Game_Settings:
parent: Maybe(Scene_Type) = nil
if active := get_active_scene(game); active != nil {
parent = active.type
}
pop_scene(game)
push_scene(game, make_settings_menu(parent, &game.audio, RESOLUTIONS[game.res_index].label))
case .Game_Main_Menu:
set_root_scene(game, make_main_menu())
game.transition = {true, 0, 0.3}
audio_fade_out(&game.audio, duration = 1.0)
case .Game_Back:
// Read parent from active scene
if active := get_active_scene(game); active != nil {
if parent, ok := active.parent.?; ok {
pop_scene(game)
#partial switch parent {
case .Pause_Menu: push_scene(game, make_pause_overlay())
case .Main_Menu: push_scene(game, make_main_menu())
}
break
}
}
// still pop when no parent linked ie resuming Gameplay
pop_scene(game)
case .Game_Quit:
game.should_close = true
case .UI_Select:
audio_play_sfx(&game.audio, .UI_Select)
}
}
}
}
event_manager_quit :: proc(game: ^Game) {
delete(GLOBAL_EVENTS)
}
+216 -38
View File
@@ -1,44 +1,163 @@
package game package game
import "core:strings"
import "core:math/rand"
import rl "vendor:raylib" import rl "vendor:raylib"
VIEWPORT_W :: 1280 VIEWPORT_W :: 640
VIEWPORT_H :: 720 VIEWPORT_H :: 360
WINDOW_W :: 2560 WINDOW_TITLE :: "PONG 2026"
WINDOW_H :: 1440
WINDOW_TITLE :: "Pong" Resolution :: struct {
width: i32,
height: i32,
label: cstring,
}
// Preset window dimensions
RESOLUTIONS := [?]Resolution{
{0, 0, "Fullscreen"},
{1024, 768, "1024x768"},
{1280, 720, "1280x720"},
{1366, 768, "1366x768"},
{1600, 900, "1600x900"},
{1920, 1080, "1920x1080"},
{2560, 1440, "2560x1440"},
{3840, 2160, "3840x2160"},
}
Transition :: struct {
active: bool,
elapsed: f32,
duration: f32,
}
Game :: struct { Game :: struct {
viewport: rl.RenderTexture2D, viewport: rl.RenderTexture2D,
scene: Scene, bg_texture: rl.Texture2D,
overlay_scene: Maybe(Scene), crt_shader: rl.Shader,
transition: Transition,
scene_stack: [dynamic]Scene,
should_close: bool,
audio: Audio_Manager,
// Camera state for screen effects / screen shake
camera: rl.Camera2D,
shake_intensity: f32,
shake_timer: f32,
// resolution state
window_w: i32,
window_h: i32,
res_index: int,
// Controller state.
controller_gid: i32,
controller_connected: bool,
} }
// AO: lets me load this at compile time!
gamepad_compat := #load("../gamecontrollerdb.txt", string)
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
// Game Lifecycle // Game Lifecycle
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
game_init :: proc(game: ^Game) { game_init :: proc(game: ^Game) {
rl.InitWindow(WINDOW_W, WINDOW_H, WINDOW_TITLE) rl.SetConfigFlags({.WINDOW_ALWAYS_RUN, .MSAA_4X_HINT, .WINDOW_RESIZABLE})
game.controller_gid = -1
game.res_index = 0
game.window_w = RESOLUTIONS[game.res_index].width
game.window_h = RESOLUTIONS[game.res_index].height
rl.InitWindow(game.window_w, game.window_h, WINDOW_TITLE)
rl.SetTargetFPS(60) rl.SetTargetFPS(60)
rl.SetExitKey(.KEY_NULL)
rl.HideCursor()
game_set_resolution(game, game.res_index)
gamepad_cstr := strings.clone_to_cstring(gamepad_compat)
defer delete(gamepad_cstr)
rl.SetGamepadMappings(gamepad_cstr)
// init any global managers
audio_init(&game.audio)
// allocate some event capacity to stop early dynamic reallocations
GLOBAL_EVENTS = make([dynamic]Event, 0, 16)
game.scene_stack = make([dynamic]Scene, 0, 8)
game.viewport = rl.LoadRenderTexture(VIEWPORT_W, VIEWPORT_H) game.viewport = rl.LoadRenderTexture(VIEWPORT_W, VIEWPORT_H)
game.scene = make_gameplay_scene()
// game.scene = make_main_menu()
debug_init() // Look into texture filters
rl.SetTextureFilter(game.viewport.texture, .BILINEAR)
// Generate soft CRT background radial texture once on startup
bg_center := rl.Color{22, 28, 36, 255}
bg_corner := rl.Color{6, 8, 12, 255}
bg_img := rl.GenImageGradientRadial(VIEWPORT_W, VIEWPORT_H, 0.3, bg_center, bg_corner)
game.bg_texture = rl.LoadTextureFromImage(bg_img)
rl.UnloadImage(bg_img) // Free CPU memory
game.crt_shader = rl.LoadShader(nil, "assets/shaders/crt.fs")
// some ray gui color updates for now
rl.GuiSetStyle(.SLIDER, i32(rl.GuiControlProperty.BASE_COLOR_NORMAL), 0x00000000)
rl.GuiSetStyle(.SLIDER, i32(rl.GuiControlProperty.BORDER_WIDTH), 0x00000000)
rl.GuiSetStyle(.SLIDER, i32(rl.GuiControlProperty.BASE_COLOR_PRESSED), 0x66BFFFFF)
rl.GuiSetStyle(.SLIDER, i32(rl.GuiControlProperty.TEXT_COLOR_FOCUSED), 0x66BFFFFF)
// Setup 2D camera matching the 640x360 viewport center
game.camera = rl.Camera2D{
target = rl.Vector2{VIEWPORT_W / 2.0, VIEWPORT_H / 2.0},
offset = rl.Vector2{VIEWPORT_W / 2.0, VIEWPORT_H / 2.0},
rotation = 0.0,
zoom = 1.0,
}
game.transition = {true, 0, 0.2}
game.should_close = false
set_root_scene(game, make_main_menu())
} }
game_update :: proc(game: ^Game) { game_update :: proc(game: ^Game) {
dt := rl.GetFrameTime() dt := rl.GetFrameTime()
input := poll_input() input := poll_input(game)
// Pass direct pointers (^Scene) so modifications persist // -------------------------------------------------------------------------
if overlay, ok := &game.overlay_scene.?; ok { // Update Camera Shake
scene_update(overlay, game, input, dt) // -------------------------------------------------------------------------
base_center := rl.Vector2{VIEWPORT_W / 2.0, VIEWPORT_H / 2.0}
if game.shake_timer > 0 {
game.shake_timer -= dt
// Generate random offset within current intensity bounds
offset_x := (f32(rand.float32()) * 2.0 - 1.0) * game.shake_intensity
offset_y := (f32(rand.float32()) * 2.0 - 1.0) * game.shake_intensity
game.camera.target = base_center + rl.Vector2{offset_x, offset_y}
if game.shake_timer <= 0 {
game.shake_intensity = 0.0
game.camera.target = base_center
}
} else { } else {
scene_update(&game.scene, game, input, dt) game.camera.target = base_center
} }
active_scene := get_active_scene(game)
if game.transition.active {
game.transition.elapsed += dt
if game.transition.elapsed >= game.transition.duration do game.transition.active = false
} else if active_scene != nil {
scene_update(active_scene, game, input, dt)
}
// background music buffer streaming
audio_update(&game.audio, dt)
// flush any events triggered in this loop // flush any events triggered in this loop
flush_events() flush_events()
} }
@@ -48,43 +167,102 @@ game_render :: proc(game: ^Game) {
rl.ClearBackground(rl.BLACK) rl.ClearBackground(rl.BLACK)
rl.BeginTextureMode(game.viewport) rl.BeginTextureMode(game.viewport)
rl.ClearBackground(rl.BLACK)
// Render base scene rl.DrawTexture(game.bg_texture, 0, 0, rl.WHITE)
scene_render(&game.scene)
// World/Scene objects rendered through 2D camera space
rl.BeginMode2D(game.camera)
// Render all active items on the stack from bottom to top
for &scene in game.scene_stack {
scene_render(&scene)
}
// when game transition active increment its elapsed time
// render will check and display accordingly
if game.transition.active {
a := u8(255 * (1 - clamp(game.transition.elapsed / game.transition.duration, 0, 1)))
rl.DrawRectangle(0, 0, VIEWPORT_W, VIEWPORT_H, {0, 0, 0, a})
}
rl.EndMode2D()
// Render active overlay on top if present
if overlay, ok := &game.overlay_scene.?; ok {
scene_render(overlay)
}
rl.EndTextureMode() rl.EndTextureMode()
render_game_to_window(game.viewport.texture) render_game_to_window(game)
// when DEBUG do rl.DrawFPS(10, rl.GetScreenHeight() - 20)
rl.EndDrawing() rl.EndDrawing()
} }
game_quit :: proc(game: ^Game) { game_quit :: proc(game: ^Game) {
audio_manager_quit(&game.audio)
scene_manager_quit(game)
event_manager_quit(game)
rl.UnloadShader(game.crt_shader)
rl.UnloadTexture(game.bg_texture)
rl.UnloadRenderTexture(game.viewport) rl.UnloadRenderTexture(game.viewport)
rl.CloseWindow() rl.CloseWindow()
} }
game_should_run :: proc() -> bool { game_should_run :: proc(game: Game) -> bool {
return !rl.WindowShouldClose() return !game.should_close && !rl.WindowShouldClose()
}
game_trigger_shake :: proc(game: ^Game, intensity: f32, duration: f32 = 0.15) {
game.shake_intensity = max(game.shake_intensity, intensity)
game.shake_timer = duration
} }
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
// Window Upscale Render // Window Upscale Render
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
render_game_to_window :: proc(texture: rl.Texture) { game_set_resolution :: proc(game: ^Game, res_index: int) {
rl.DrawTexturePro( game.res_index = clamp(res_index, 0, len(RESOLUTIONS) - 1)
texture,
// src: inverted y coordinate flips opengl texture buffer correctly borderless := rl.IsWindowState({.BORDERLESS_WINDOWED_MODE})
rl.Rectangle{0, 0, f32(texture.width), -f32(texture.height)}, if game.res_index == 0 {
// dst: scales viewport canvas to fill window resolution if !borderless do rl.ToggleBorderlessWindowed()
rl.Rectangle{0, 0, f32(WINDOW_W), f32(WINDOW_H)}, return
rl.Vector2{}, }
0,
rl.WHITE, if borderless {
) rl.ToggleBorderlessWindowed()
rl.RestoreWindow()
}
target_w := RESOLUTIONS[game.res_index].width
target_h := RESOLUTIONS[game.res_index].height
monitor := rl.GetCurrentMonitor()
screen_w := rl.GetMonitorWidth(monitor)
screen_h := rl.GetMonitorHeight(monitor)
// Clamp window size so it doesn't exceed physical screen limits
game.window_w = min(target_w, screen_w)
game.window_h = min(target_h, screen_h)
rl.SetWindowSize(game.window_w, game.window_h)
}
render_game_to_window :: proc(game: ^Game) {
texture := game.viewport.texture
w, h := f32(rl.GetScreenWidth()), f32(rl.GetScreenHeight())
s := min(w / VIEWPORT_W, h / VIEWPORT_H)
dst := rl.Rectangle{(w - VIEWPORT_W*s)/2, (h - VIEWPORT_H*s)/2, VIEWPORT_W*s, VIEWPORT_H*s}
rl.BeginShaderMode(game.crt_shader)
rl.DrawTexturePro(
texture,
// src: inverted y coordinate flips opengl texture buffer correctly
rl.Rectangle{0, 0, f32(texture.width), -f32(texture.height)},
// dst: scales viewport canvas to fill window resolution
// rl.Rectangle{0, 0, f32(game.window_w), f32(game.window_h)},
dst,
rl.Vector2{},
0,
rl.WHITE,
)
rl.EndShaderMode()
} }
+87 -23
View File
@@ -1,38 +1,102 @@
package game package game
import "core:strings"
import rl "vendor:raylib" import rl "vendor:raylib"
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
// Input Manager, captures input events and stores them into a struct. // Input Manager, captures input events and stores them into a struct.
// Passing the struct down allows for components to use inputs independantly // Passing the struct down allows for components to use inputs independently
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
Input_Event :: struct { Input_Event :: struct {
// Normalized and merged from keyboard + controller // Continuous movement normalized
move_dir: rl.Vector2, move_dir: rl.Vector2,
pause_pressed: bool,
// single frame actions UI and triggers
up_pressed: bool,
down_pressed: bool,
left_pressed: bool,
right_pressed: bool,
confirm_pressed: bool,
pause_pressed: bool,
} }
poll_input :: proc() -> Input_Event { // Currently active controller
input_event := Input_Event{} is_real_pad :: proc(i: i32) -> bool {
input_dir := rl.Vector2{0,0} return rl.IsGamepadAvailable(i) &&
!strings.contains(string(rl.GetGamepadName(i)), "Mouse") &&
!strings.contains(string(rl.GetGamepadName(i)), "Keyboard")
}
// ------------------------------------------------------------------------------------------------------ update_active_gamepad :: proc(game: ^Game) {
// Keyboard // is there currently a controller, if so check if its still connected.
// ------------------------------------------------------------------------------------------------------ if game.controller_gid != -1 {
if rl.IsKeyDown(.W) || rl.IsKeyDown(.UP) { input_dir.y -= 1 } if rl.IsGamepadAvailable(game.controller_gid) {
if rl.IsKeyDown(.S) || rl.IsKeyDown(.DOWN) { input_dir.y += 1 } // do nothing still connected
return
// single-fire == IsKeyPressed, held == IsKeyDown }
input_event.pause_pressed = rl.IsKeyPressed(.P) // else we lost connection
game.controller_gid = -1
// ------------------------------------------------------------------------------------------------------ game.controller_connected = false
// Movement/direction needs update
// ------------------------------------------------------------------------------------------------------
if input_dir.x != 0 || input_dir.y != 0 {
input_dir = rl.Vector2Normalize(input_dir)
} }
input_event.move_dir = input_dir
return input_event for i: i32 = 0; i < 4; i += 1 {
if rl.IsGamepadAvailable(i) && is_real_pad(i) {
game.controller_connected = true
game.controller_gid = i
return
}
}
}
poll_input :: proc(game: ^Game) -> Input_Event {
input_event := Input_Event{}
input_dir := rl.Vector2{0, 0}
update_active_gamepad(game)
gamepad_active := game.controller_connected
gid := game.controller_gid
// --------------------------------------------------------------------------
// Continuous Input (keyboard + gamepad d-pad)
// --------------------------------------------------------------------------
if rl.IsKeyDown(.W) || rl.IsKeyDown(.UP) || (gamepad_active && rl.IsGamepadButtonDown(gid, .LEFT_FACE_UP)) { input_dir.y -= 1 }
if rl.IsKeyDown(.S) || rl.IsKeyDown(.DOWN) || (gamepad_active && rl.IsGamepadButtonDown(gid, .LEFT_FACE_DOWN)) { input_dir.y += 1 }
// --------------------------------------------------------------------------
// Analog stick input gamepads
// --------------------------------------------------------------------------
if gamepad_active {
DEADZONE :: 0.25
axis_y := rl.GetGamepadAxisMovement(gid, .LEFT_Y)
// apply deadzone and combine with input if moved
if abs(axis_y) > DEADZONE { input_dir.y = axis_y }
}
// Normalize so diagonal movement isn't faster than cardinal directions
if input_dir.x != 0 || input_dir.y != 0 do input_event.move_dir = rl.Vector2Normalize(input_dir)
// --------------------------------------------------------------------------
// actions single press ie menu navigation etc
// --------------------------------------------------------------------------
input_event.up_pressed = rl.IsKeyPressed(.W) || rl.IsKeyPressed(.UP) ||
(gamepad_active && rl.IsGamepadButtonPressed(gid, .LEFT_FACE_UP))
input_event.down_pressed = rl.IsKeyPressed(.S) || rl.IsKeyPressed(.DOWN) ||
(gamepad_active && rl.IsGamepadButtonPressed(gid, .LEFT_FACE_DOWN))
input_event.left_pressed = rl.IsKeyPressed(.A) || rl.IsKeyPressed(.LEFT) ||
(gamepad_active && rl.IsGamepadButtonPressed(gid, .LEFT_FACE_LEFT))
input_event.right_pressed = rl.IsKeyPressed(.D) || rl.IsKeyPressed(.RIGHT) ||
(gamepad_active && rl.IsGamepadButtonPressed(gid, .LEFT_FACE_RIGHT))
input_event.confirm_pressed = rl.IsKeyPressed(.ENTER) ||
(gamepad_active && rl.IsGamepadButtonPressed(gid, .RIGHT_FACE_DOWN))
input_event.pause_pressed = rl.IsKeyPressed(.SPACE) ||
(gamepad_active && rl.IsGamepadButtonPressed(gid, .MIDDLE_RIGHT))
return input_event
} }
+60 -32
View File
@@ -4,33 +4,40 @@ import "core:fmt"
import rl "vendor:raylib" import rl "vendor:raylib"
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
// LEVEL specifics // LEVEL Specifics
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
FIELD_BORDER :: 2
SERVE_WAIT_TIME :: 1.0
// ------------------------------------------------------------------------------
// HUD Styling Constants
// ------------------------------------------------------------------------------
HUD_FONT_SIZE :: 10.0
HUD_FONT_SPACING :: 1.0
HUD_PADDING :: 4.0
Level :: struct { Level :: struct {
field_bounds: rl.Rectangle, field_bounds: rl.Rectangle,
player: Entity, player: Entity,
opponent: Entity, opponent: Entity,
ball: Entity, ball: Entity,
serve_dir: u8,
serve_timer: f32, serve_timer: f32,
wait_time: f32, wait_time: f32,
score_player: int, score_player: int,
score_enemy: int, score_enemy: int,
score_win: int, score_win: int,
particles: [dynamic]Particle,
} }
FIELD_BORDER :: 3
SERVE_WAIT_TIME :: 1.0
init_level :: proc() -> Level { init_level :: proc() -> Level {
// positions work off viewport resolution, independent of target display size pad_x := f32(VIEWPORT_W) * 0.10
// 10% padding around the field
pad_x := f32(VIEWPORT_W) * 0.10
pad_y := f32(VIEWPORT_H) * 0.10 pad_y := f32(VIEWPORT_H) * 0.10
bounds := rl.Rectangle{ bounds := rl.Rectangle{
x = pad_x, x = pad_x,
y = pad_y, y = pad_y,
width = f32(VIEWPORT_W) - (pad_x * 2), width = f32(VIEWPORT_W) - (pad_x * 2),
height = f32(VIEWPORT_H) - (pad_y * 2), height = f32(VIEWPORT_H) - (pad_y * 2),
} }
@@ -49,21 +56,31 @@ init_level :: proc() -> Level {
ball = init_ball(), ball = init_ball(),
serve_timer = SERVE_WAIT_TIME, serve_timer = SERVE_WAIT_TIME,
wait_time = SERVE_WAIT_TIME, wait_time = SERVE_WAIT_TIME,
score_win = 3,
score_player = 0, score_player = 0,
score_enemy = 0, score_enemy = 0,
score_win = 2, particles = make([dynamic]Particle),
} }
} }
// Free memory allocated by dynamic slice fields in the level struct
cleanup_level :: proc(level: ^Level) {
delete(level.particles)
}
update_level :: proc(level: ^Level, input: Input_Event, dt: f32) { update_level :: proc(level: ^Level, input: Input_Event, dt: f32) {
update_ball(&level.ball, level, dt) update_ball(&level.ball, level, dt)
update_player(&level.player, input, level.field_bounds, dt) update_player(&level.player, input, level.field_bounds, dt)
update_opponent(&level.opponent, level.ball, level.field_bounds, dt) update_opponent(&level.opponent, level.ball, level.field_bounds, dt)
update_particles(&level.particles, dt)
} }
render_level :: proc(level: Level) { render_level :: proc(level: Level) {
rl.DrawRectangleRec(level.field_bounds, rl.DARKGRAY) rl.BeginBlendMode(.ADDITIVE)
rl.DrawRectangleLinesEx(level.field_bounds, FIELD_BORDER, rl.RAYWHITE) // Draw outward field glow
render_field_outward_glow(level.field_bounds, FIELD_BORDER, rl.WHITE)
render_particles(level.particles)
rl.EndBlendMode()
center_x := f32(VIEWPORT_W) / 2.0 center_x := f32(VIEWPORT_W) / 2.0
start_pos := rl.Vector2{center_x, level.field_bounds.y + FIELD_BORDER} start_pos := rl.Vector2{center_x, level.field_bounds.y + FIELD_BORDER}
@@ -75,31 +92,42 @@ render_level :: proc(level: Level) {
render_ball(level.ball, level.field_bounds) render_ball(level.ball, level.field_bounds)
} }
render_field_outward_glow :: proc(bounds: rl.Rectangle, line_thickness: f32, glow_color: rl.Color) {
rl.DrawRectangleLinesEx(bounds, line_thickness, glow_color)
glow_layers := 24
glow_step := f32(1.0)
for i in 1 ..= glow_layers {
offset := f32(i) * glow_step
outer_rec := rl.Rectangle{
x = bounds.x - offset,
y = bounds.y - offset,
width = bounds.width + (offset * 2.0),
height = bounds.height + (offset * 2.0),
}
alpha := u8(155.0 / f32(i * i))
layer_color := rl.Color{glow_color.r, glow_color.g, glow_color.b, alpha}
rl.DrawRectangleLinesEx(outer_rec, line_thickness, layer_color)
}
}
render_hud :: proc(level: Level) { render_hud :: proc(level: Level) {
// 2 spacing, 20 font size, 5 padding
font := rl.GetFontDefault() font := rl.GetFontDefault()
// player one id p1_size := rl.MeasureTextEx(font, "PLAYER 1", HUD_FONT_SIZE, HUD_FONT_SPACING)
p1_size := rl.MeasureTextEx(font, "PLAYER 1", 20, 2) p1_pos := rl.Vector2{HUD_PADDING, HUD_PADDING}
p1_box := rl.Rectangle{5, 5, p1_size.x, p1_size.y} rl.DrawTextEx(font, "PLAYER", p1_pos, HUD_FONT_SIZE, HUD_FONT_SPACING, rl.WHITE)
p1_pos := rl.Vector2{p1_box.x, p1_box.y}
rl.DrawTextEx(font, "PLAYER 1", p1_pos, 20, 2, rl.WHITE)
// rl.DrawRectangleLinesEx(p1_box, 1.0, rl.RED)
// opponent id opp_size := rl.MeasureTextEx(font, "COMPUTER", HUD_FONT_SIZE, HUD_FONT_SPACING)
opp_size := rl.MeasureTextEx(font, "COMPUTER", 20, 2) opp_pos := rl.Vector2{f32(VIEWPORT_W) - HUD_PADDING - opp_size.x, HUD_PADDING}
opp_box := rl.Rectangle{(VIEWPORT_W - 5 - opp_size.x), 5, (opp_size.x), (opp_size.y)} rl.DrawTextEx(font, "COMPUTER", opp_pos, HUD_FONT_SIZE, HUD_FONT_SPACING, rl.WHITE)
opp_pos := rl.Vector2{opp_box.x, opp_box.y}
rl.DrawTextEx(font, "COMPUTER", opp_pos, 20, 2, rl.WHITE)
// score box
// Format the score dynamically into a C-string allocated on the temp allocator
score_text := fmt.ctprintf("%d : %d", level.score_player, level.score_enemy) score_text := fmt.ctprintf("%d : %d", level.score_player, level.score_enemy)
score_size := rl.MeasureTextEx(font, score_text, 20, 2) score_size := rl.MeasureTextEx(font, score_text, HUD_FONT_SIZE, HUD_FONT_SPACING)
score_box := rl.Rectangle{(VIEWPORT_W / 2 - score_size.x / 2), 5, (score_size.x), (score_size.y)} score_pos := rl.Vector2{(f32(VIEWPORT_W) * 0.5) - (score_size.x * 0.5), HUD_PADDING}
score_pos := rl.Vector2{score_box.x, score_box.y} rl.DrawTextEx(font, score_text, score_pos, HUD_FONT_SIZE, HUD_FONT_SPACING, rl.WHITE)
rl.DrawTextEx(font, score_text, score_pos, 20, 2, rl.WHITE)
// game name
// rl.DrawRectangleLines(VIEWPORT_W / 2 - 50, VIEWPORT_H - 50, 100, 50, rl.RED)
} }
+1 -2
View File
@@ -17,7 +17,6 @@ main :: proc() {
defer mem.tracking_allocator_destroy(&track) defer mem.tracking_allocator_destroy(&track)
context.allocator = mem.tracking_allocator(&track) context.allocator = mem.tracking_allocator(&track)
} }
// ------------------------------------------------------------------------------
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
// Game Loop // Game Loop
@@ -26,7 +25,7 @@ main :: proc() {
game_init(&game) game_init(&game)
// Game loop // Game loop
for game_should_run() { for game_should_run(game) {
game_update(&game) game_update(&game)
game_render(&game) game_render(&game)
} }
+40
View File
@@ -0,0 +1,40 @@
package game
import rl "vendor:raylib"
Particle :: struct {
pos: rl.Vector2,
vel: rl.Vector2,
color: rl.Color,
life: f32,
max_life: f32,
size: f32
}
update_particles :: proc(particles: ^[dynamic]Particle, dt: f32) {
#reverse for &p, i in particles {
p.life -= dt
if p.life <= 0 {
unordered_remove(particles, i)
continue
}
// update live particles
p.pos += p.vel * dt
// times the velocitt so we can slow it down over time
p.vel *= 0.92
}
}
render_particles :: proc(particles: [dynamic]Particle) {
rl.BeginBlendMode(.ADDITIVE)
for p in particles {
alpha := u8(255 * (p.life / p.max_life))
col := rl.Color{p.color.r, p.color.g, p.color.b, alpha}
rl.DrawRectangleV(p.pos, {p.size, p.size}, col)
}
rl.EndBlendMode()
}
+495 -78
View File
@@ -1,5 +1,6 @@
package game package game
import "core:fmt"
import rl "vendor:raylib" import rl "vendor:raylib"
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
@@ -9,129 +10,545 @@ Scene_Type :: enum {
Main_Menu, Main_Menu,
Gameplay, Gameplay,
Pause_Menu, Pause_Menu,
Settings_Menu,
Game_Over, Game_Over,
} }
Scene :: struct { Scene :: struct {
type: Scene_Type, type: Scene_Type,
parent: Maybe(Scene_Type),
level: Maybe(Level), level: Maybe(Level),
canvas: Maybe(UI_Canvas), canvas: Maybe(Canvas),
winner_is_player: bool, // Store win state for rendering winner_is_player: bool,
} }
// ------------------------------------------------------------------------------ push_scene :: proc(game: ^Game, scene: Scene) {
// UI Definitions append(&game.scene_stack, scene)
// ------------------------------------------------------------------------------
UI_Canvas :: struct {
bounds: rl.Rectangle,
padding: f32,
item_height: f32,
item_spaceing: f32,
} }
// ------------------------------------------------------------------------------ pop_scene :: proc(game: ^Game) {
// Scene Constructors if len(game.scene_stack) > 0 {
// ------------------------------------------------------------------------------ top_index := len(game.scene_stack) - 1
make_gameplay_scene :: proc() -> Scene { destroy_scene(&game.scene_stack[top_index])
return Scene{ pop(&game.scene_stack)
type = .Gameplay,
level = init_level(),
} }
} }
make_pause_overlay :: proc() -> Scene { destroy_scene :: proc(scene: ^Scene) {
return Scene{ if level, ok := &scene.level.?; ok {
type = .Pause_Menu, cleanup_level(level)
canvas = UI_Canvas{},
} }
} }
make_main_menu :: proc() -> Scene { set_root_scene :: proc(game: ^Game, root_scene: Scene) {
return Scene{ for &scene in game.scene_stack {
type = .Main_Menu, destroy_scene(&scene)
canvas = UI_Canvas{},
} }
clear(&game.scene_stack)
append(&game.scene_stack, root_scene)
} }
make_game_over_overlay :: proc(winner_is_player: bool) -> Scene { get_active_scene :: proc(game: ^Game) -> ^Scene {
return Scene{ if len(game.scene_stack) == 0 do return nil
type = .Game_Over, return &game.scene_stack[len(game.scene_stack) - 1]
canvas = UI_Canvas{},
winner_is_player = winner_is_player,
}
} }
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
// Unified Scene Dispatchers // Unified Scene Dispatchers
// ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------
scene_update :: proc(scene: ^Scene, game: ^Game, input: Input_Event, dt: f32) { scene_update :: proc(scene: ^Scene, game: ^Game, input: Input_Event, dt: f32) {
// mutate level state through pointer if level exists
if level, ok := &scene.level.?; ok {
update_level(level, input, dt)
}
// dispatch scene behavior
#partial switch scene.type { #partial switch scene.type {
case .Gameplay: case .Main_Menu:
if input.pause_pressed { update_main_menu(scene, input)
game.overlay_scene = make_pause_overlay()
}
case .Pause_Menu: case .Pause_Menu:
if input.pause_pressed { update_pause_menu(scene, input)
game.overlay_scene = nil
} case .Settings_Menu:
update_settings_menu(scene, input)
case .Game_Over: case .Game_Over:
if input.pause_pressed { // Press P or your start key to restart update_game_over(scene, input)
game.overlay_scene = nil
game.scene = make_gameplay_scene() // Reset the base level
}
case .Main_Menu: case .Gameplay:
// @todo: handle menu selection / start game level, level_ok := &scene.level.?
} assert(level_ok, "scene_update: .Gameplay scene is missing a Level component")
update_level(level, input, dt)
// Loop over global frame events if input.pause_pressed {
for event in GLOBAL_EVENTS { emit_event(.Game_Pause)
#partial switch event.type {
case .Game_Over:
game.overlay_scene = make_game_over_overlay(event.winner_is_player)
} }
} }
// Process events triggered during scene updates
process_events(game)
} }
scene_render :: proc(scene: ^Scene) { scene_render :: proc(scene: ^Scene) {
// render gameplay field & hud if present
if level, ok := scene.level.?; ok { if level, ok := scene.level.?; ok {
render_level(level) render_level(level)
render_hud(level) render_hud(level)
} }
// render scene/overlay ui elements
#partial switch scene.type { #partial switch scene.type {
case .Pause_Menu: case .Pause_Menu:
rl.DrawRectangleRec( render_pause_menu(scene^)
rl.Rectangle{0, 0, f32(VIEWPORT_W), f32(VIEWPORT_H)}, case .Settings_Menu:
rl.Color{0, 0, 0, 140}, render_settings_menu(scene^)
)
rl.DrawText("PAUSED", VIEWPORT_W / 2 - 80, VIEWPORT_H / 2 - 40, 40, rl.WHITE)
rl.DrawText("Press P to Resume", VIEWPORT_W / 2 - 90, VIEWPORT_H / 2 + 10, 20, rl.LIGHTGRAY)
case .Game_Over: case .Game_Over:
rl.DrawRectangleRec( render_game_over(scene^)
rl.Rectangle{0, 0, f32(VIEWPORT_W), f32(VIEWPORT_H)},
rl.Color{0, 0, 0, 200},
)
msg :cstring = scene.winner_is_player ? "YOU WIN!" : "GAME OVER"
text_color := scene.winner_is_player ? rl.GREEN : rl.RED
rl.DrawText(msg, VIEWPORT_W / 2 - 100, VIEWPORT_H / 2 - 40, 40, text_color)
rl.DrawText("Press P to Play Again", VIEWPORT_W / 2 - 110, VIEWPORT_H / 2 + 10, 20, rl.LIGHTGRAY)
case .Main_Menu: case .Main_Menu:
rl.DrawText("PONG", VIEWPORT_W / 2 - 50, VIEWPORT_H / 3, 40, rl.WHITE) render_main_menu(scene^)
rl.DrawText("Press ENTER to Start", VIEWPORT_W / 2 - 110, VIEWPORT_H / 2, 20, rl.LIGHTGRAY) }
}
scene_manager_quit :: proc(game: ^Game) {
for &scene in game.scene_stack {
destroy_scene(&scene)
}
delete(game.scene_stack)
}
// ------------------------------------------------------------------------------
// UI Layout & Styling Constants
// ------------------------------------------------------------------------------
UI_TITLE_FONT_SIZE :: 20
UI_LABEL_FONT_SIZE :: 10
UI_TITLE_PADDING :: 10.0
UI_ITEM_HEIGHT :: 20.0
UI_ITEM_SPACING :: 8.0
UI_ITEM_BORDER_THICK :: 2.0
UI_TOP_MARGIN_DEFAULT :: 60.0
UI_TOP_MARGIN_LIST :: 75.0
// ------------------------------------------------------------------------------
// UI Definitions
// ------------------------------------------------------------------------------
Canvas :: struct {
bounds: rl.Rectangle,
padding: f32,
item_height: f32,
item_spaceing: f32,
menu: Maybe(UI_Menu),
}
UI_Button :: struct {
label: cstring,
}
UI_Slider :: struct {
label: cstring,
value: ^f32,
step: f32,
}
UI_Selector :: struct {
label: cstring,
value_label: cstring,
}
UI_Item :: union {
UI_Button,
UI_Slider,
UI_Selector,
}
UI_Menu :: struct {
items: [8]UI_Item,
count: int,
selected_index: int,
}
get_canvas_item_rec :: proc(canvas: Canvas, index: int, top_margin: f32 = UI_TOP_MARGIN_DEFAULT) -> rl.Rectangle {
y_pos := (canvas.bounds.y + top_margin) + (f32(index) * (canvas.item_height + canvas.item_spaceing))
width_value := (canvas.bounds.width * 0.5)
return rl.Rectangle{
x = (canvas.bounds.x) + width_value * 0.5,
y = y_pos,
width = width_value,
height = canvas.item_height,
}
}
list_menu_items :: proc(canvas: Canvas, menu: UI_Menu) {
for i in 0 ..< menu.count {
item := menu.items[i]
item_rec := get_canvas_item_rec(canvas, i, top_margin = UI_TOP_MARGIN_LIST)
is_selected := (i == menu.selected_index)
border_color := is_selected ? rl.SKYBLUE : rl.DARKGRAY
text_color := is_selected ? rl.SKYBLUE : rl.WHITE
rl.DrawRectangleLinesEx(item_rec, UI_ITEM_BORDER_THICK, border_color)
#partial switch v in item {
case UI_Button:
text_width := rl.MeasureText(v.label, UI_LABEL_FONT_SIZE)
text_x := i32(item_rec.x + item_rec.width * 0.5) - (text_width / 2)
text_y := i32(item_rec.y + (item_rec.height * 0.5)) - (UI_LABEL_FONT_SIZE / 2)
rl.DrawText(v.label, text_x, text_y, UI_LABEL_FONT_SIZE, text_color)
case UI_Slider:
slider_rec := item_rec
text_width := 31
slider_rec.x = slider_rec.x + f32(text_width * 2)
slider_rec.width = (slider_rec.width - f32(text_width * 2))
rl.GuiSlider(
bounds = slider_rec,
textLeft = v.label,
textRight = nil,
value = v.value,
minValue = 0.0,
maxValue = 1.0,
)
case UI_Selector:
lbl_y := i32(item_rec.y + (item_rec.height * 0.5)) - (UI_LABEL_FONT_SIZE / 2)
rl.DrawText(v.label, i32(item_rec.x + 8), lbl_y, UI_LABEL_FONT_SIZE, text_color)
display_str := fmt.ctprintf("< %s >", v.value_label)
str_w := rl.MeasureText(display_str, UI_LABEL_FONT_SIZE)
val_x := i32(item_rec.x + item_rec.width) - str_w - 8
rl.DrawText(display_str, val_x, lbl_y, UI_LABEL_FONT_SIZE, text_color)
}
}
}
// ------------------------------------------------------------------------------
// Main Menu
// ------------------------------------------------------------------------------
make_main_menu :: proc() -> Scene {
ui_items:[8]UI_Item = {
0 = UI_Button{"Start Game"},
1 = UI_Button{"Options"},
2 = UI_Button{"Quit"},
}
return Scene{
type = .Main_Menu,
parent = nil,
canvas = Canvas{
bounds = rl.Rectangle{0, 0, VIEWPORT_W, VIEWPORT_H},
padding = UI_TITLE_PADDING,
item_height = UI_ITEM_HEIGHT,
item_spaceing = UI_ITEM_SPACING,
menu = UI_Menu{
items = ui_items,
count = 3,
selected_index = 0,
},
},
}
}
render_main_menu :: proc(scene: Scene) {
canvas, canvas_ok := scene.canvas.?
assert(canvas_ok, "render_main_menu: Scene is missing a Canvas component")
menu, menu_ok := canvas.menu.?
assert(menu_ok, "render_main_menu: Canvas is missing a UI_Menu component")
t_text: cstring = WINDOW_TITLE
t_width := rl.MeasureText(t_text, UI_TITLE_FONT_SIZE)
t_x := i32(canvas.bounds.width * 0.5) - (t_width / 2)
rl.DrawText(t_text, t_x, i32(canvas.padding), UI_TITLE_FONT_SIZE, rl.WHITE)
list_menu_items(canvas, menu)
}
update_main_menu :: proc(scene: ^Scene, input: Input_Event) {
canvas, canvas_ok := &scene.canvas.?
assert(canvas_ok, "update_main_menu: Scene is missing a Canvas component")
menu, menu_ok := &canvas.menu.?
assert(menu_ok, "update_main_menu: Canvas is missing a UI_Menu component")
if input.up_pressed {
menu.selected_index = (menu.selected_index - 1 + menu.count) % menu.count
emit_event(.UI_Select)
}
if input.down_pressed {
menu.selected_index = (menu.selected_index + 1) % menu.count
emit_event(.UI_Select)
}
if input.confirm_pressed {
switch menu.selected_index {
case 0:
emit_event(.Game_Start)
case 1:
emit_event(.Game_Settings)
case 2:
emit_event(.Game_Quit)
}
}
}
// ------------------------------------------------------------------------------
// Pause Menu
// ------------------------------------------------------------------------------
make_pause_overlay :: proc() -> Scene {
ui_items:[8]UI_Item = {
0 = UI_Button{"Resume Game"},
1 = UI_Button{"Options"},
2 = UI_Button{"Main Menu"},
}
return Scene{
type = .Pause_Menu,
parent = nil,
canvas = Canvas{
bounds = rl.Rectangle{0, 0, VIEWPORT_W, VIEWPORT_H},
padding = UI_TITLE_PADDING,
item_height = UI_ITEM_HEIGHT,
item_spaceing = UI_ITEM_SPACING,
menu = UI_Menu{
items = ui_items,
count = 3,
selected_index = 0,
},
},
}
}
render_pause_menu :: proc(scene: Scene) {
canvas, canvas_ok := scene.canvas.?
assert(canvas_ok, "render_pause_menu: Scene is missing a Canvas component")
menu, menu_ok := canvas.menu.?
assert(menu_ok, "render_pause_menu: Canvas is missing a UI_Menu component")
// Semi-transparent overlay so gameplay stays visible behind menu
rl.DrawRectangleRec(canvas.bounds, rl.Color{0, 0, 0, 200})
t_text: cstring = "PAUSE MENU"
t_width := rl.MeasureText(t_text, UI_TITLE_FONT_SIZE)
t_x := i32(canvas.bounds.width * 0.5) - (t_width / 2)
rl.DrawText(t_text, t_x, i32(canvas.padding), UI_TITLE_FONT_SIZE, rl.WHITE)
list_menu_items(canvas, menu)
}
update_pause_menu :: proc(scene: ^Scene, input: Input_Event) {
canvas, canvas_ok := &scene.canvas.?
assert(canvas_ok, "update_pause_menu: Scene is missing a Canvas component")
menu, menu_ok := &canvas.menu.?
assert(menu_ok, "update_pause_menu: Canvas is missing a UI_Menu component")
if input.up_pressed {
menu.selected_index = (menu.selected_index - 1 + menu.count) % menu.count
emit_event(.UI_Select)
}
if input.down_pressed {
menu.selected_index = (menu.selected_index + 1) % menu.count
emit_event(.UI_Select)
}
if input.confirm_pressed {
switch menu.selected_index {
case 0:
emit_event(.Game_Back)
case 1:
emit_event(.Game_Settings)
case 2:
emit_event(.Game_Main_Menu)
}
}
if input.pause_pressed {
emit_event(.Game_Back)
}
}
// ------------------------------------------------------------------------------
// Settings Menu
// ------------------------------------------------------------------------------
make_settings_menu :: proc(parent: Maybe(Scene_Type), audio: ^Audio_Manager, current_res_label: cstring) -> Scene {
ui_items:[8]UI_Item = {
0 = UI_Button{"Back"},
1 = UI_Selector{
label = "Resolution",
value_label = current_res_label,
},
2 = UI_Slider{
label = "Music ",
value = &audio.user_music_vol,
step = 0.1,
},
3 = UI_Slider{
label = "SFX ",
value = &audio.sfx_vol,
step = 0.1,
},
}
return Scene{
type = .Settings_Menu,
parent = parent,
canvas = Canvas{
bounds = rl.Rectangle{0, 0, VIEWPORT_W, VIEWPORT_H},
padding = UI_TITLE_PADDING,
item_height = UI_ITEM_HEIGHT,
item_spaceing = UI_ITEM_SPACING,
menu = UI_Menu{
items = ui_items,
count = 4,
selected_index = 0,
},
},
}
}
render_settings_menu :: proc(scene: Scene) {
canvas, canvas_ok := scene.canvas.?
assert(canvas_ok, "render_settings_menu: Scene is missing a Canvas component")
menu, menu_ok := canvas.menu.?
assert(menu_ok, "render_settings_menu: Canvas is missing a UI_Menu component")
if scene.parent != .Main_Menu {
// Draw dark overlay so gameplay/background stays visible behind Settings
rl.DrawRectangleRec(canvas.bounds, rl.Color{0, 0, 0, 200})
}
t_text: cstring = "SETTINGS"
t_width := rl.MeasureText(t_text, UI_TITLE_FONT_SIZE)
t_x := i32(canvas.bounds.width * 0.5) - (t_width / 2)
rl.DrawText(t_text, t_x, i32(canvas.padding), UI_TITLE_FONT_SIZE, rl.WHITE)
list_menu_items(canvas, menu)
}
update_settings_menu :: proc(scene: ^Scene, input: Input_Event) {
canvas, canvas_ok := &scene.canvas.?
assert(canvas_ok, "update_settings_menu: Scene is missing a Canvas component")
menu, menu_ok := &canvas.menu.?
assert(menu_ok, "update_settings_menu: Canvas is missing a UI_Menu component")
if input.up_pressed {
menu.selected_index = (menu.selected_index - 1 + menu.count) % menu.count
emit_event(.UI_Select)
}
if input.down_pressed {
menu.selected_index = (menu.selected_index + 1) % menu.count
emit_event(.UI_Select)
}
current_item := &menu.items[menu.selected_index]
#partial switch v in current_item {
case UI_Button:
if input.confirm_pressed {
switch menu.selected_index {
case 0:
emit_event(.Game_Back)
}
}
case UI_Selector:
if input.left_pressed {
emit_event(Change_Resolution_Data{-1})
}
if input.right_pressed {
emit_event(Change_Resolution_Data{1})
}
case UI_Slider:
if input.left_pressed {
v.value^ = clamp(v.value^ - v.step, 0.0, 1.0)
emit_event(.UI_Select)
}
if input.right_pressed {
v.value^ = clamp(v.value^ + v.step, 0.0, 1.0)
emit_event(.UI_Select)
}
}
}
// ------------------------------------------------------------------------------
// Game Over Menu
// ------------------------------------------------------------------------------
make_game_over_overlay :: proc(winner_is_player: bool) -> Scene {
ui_items:[8]UI_Item = {
0 = UI_Button{"Restart Game"},
1 = UI_Button{"Main Menu"},
}
return Scene{
type = .Game_Over,
parent = nil,
winner_is_player = winner_is_player,
canvas = Canvas{
bounds = rl.Rectangle{0, 0, VIEWPORT_W, VIEWPORT_H},
padding = UI_TITLE_PADDING,
item_height = UI_ITEM_HEIGHT,
item_spaceing = UI_ITEM_SPACING,
menu = UI_Menu{
items = ui_items,
count = 2,
selected_index = 0,
},
},
}
}
render_game_over :: proc(scene: Scene) {
canvas, canvas_ok := scene.canvas.?
assert(canvas_ok, "render_game_over: Scene is missing a Canvas component")
menu, menu_ok := canvas.menu.?
assert(menu_ok, "render_game_over: Canvas is missing a UI_Menu component")
rl.DrawRectangleRec(canvas.bounds, rl.Color{0, 0, 0, 220})
t_text: cstring = scene.winner_is_player ? "YOU WIN!" : "GAME OVER"
t_width := rl.MeasureText(t_text, UI_TITLE_FONT_SIZE)
t_x := i32(canvas.bounds.width * 0.5) - (t_width / 2)
text_color := scene.winner_is_player ? rl.GREEN : rl.RED
rl.DrawText(t_text, t_x, i32(canvas.padding), UI_TITLE_FONT_SIZE, text_color)
list_menu_items(canvas, menu)
}
update_game_over :: proc(scene: ^Scene, input: Input_Event) {
canvas, canvas_ok := &scene.canvas.?
assert(canvas_ok, "update_game_over: Scene is missing a Canvas component")
menu, menu_ok := &canvas.menu.?
assert(menu_ok, "update_game_over: Canvas is missing a UI_Menu component")
if input.up_pressed {
menu.selected_index = (menu.selected_index - 1 + menu.count) % menu.count
emit_event(.UI_Select)
}
if input.down_pressed {
menu.selected_index = (menu.selected_index + 1) % menu.count
emit_event(.UI_Select)
}
if input.confirm_pressed {
switch menu.selected_index {
case 0:
emit_event(.Game_Start)
case 1:
emit_event(.Game_Main_Menu)
}
}
}
// ------------------------------------------------------------------------------
// Gameplay Scene
// ------------------------------------------------------------------------------
make_gameplay_scene :: proc() -> Scene {
return Scene{
type = .Gameplay,
parent = nil,
level = init_level(),
} }
} }