Compare commits

...
23 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
20 changed files with 388 additions and 6909 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;
}
+65 -8
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
set -e
set -eo pipefail
# Find the odin binary
export PATH="$HOME/.odin:$PATH"
@@ -9,26 +9,72 @@ SRC_DIR="src"
OUT_DIR="out"
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
OPTIMIZED=0
WEB=0
CLEAN=0
DEBUGGER=0
VALGRIND=0
TAGS=""
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 " -o optimized release build (-o:speed, no debug info)"
echo " -w web/WASM build (js_wasm32 target)"
echo " -d build then launch in debugger instead of running"
echo " -v build then run under Valgrind"
echo " -c clean build artifacts"
echo " -t re-index $SRC_DIR and merge it with the stdlib tags"
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
generate_std_tags() {
local root
root="$(odin root 2>/dev/null || echo "$HOME/.odin")"
root="${root%/}"
mkdir -p "$(dirname "$STD_TAGS")"
ctags -R -f - "${CTAGS_OPTS[@]}" "$root/core" "$root/vendor" | grep -v '^!_TAG_' > "$STD_TAGS.tmp"
mv -f "$STD_TAGS.tmp" "$STD_TAGS"
echo "Stdlib tags written -> $STD_TAGS"
}
# 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 ;;
@@ -36,11 +82,18 @@ while getopts "bowdvch" opt; do
d) DEBUGGER=1 ;;
v) VALGRIND=1 ;;
c) CLEAN=1 ;;
t) TAGS=src ;;
T) TAGS=std ;;
h) usage ;;
*) usage ;;
esac
done
case "$TAGS" in
src) generate_tags; exit 0 ;;
std) generate_std_tags; exit 0 ;;
esac
mkdir -p "$OUT_DIR"
if [ "$CLEAN" -eq 1 ]; then
@@ -51,7 +104,7 @@ fi
if [ "$WEB" -eq 1 ]; then
echo "Building web (WASM)..."
odin build "$SRC_DIR" \
odin build "$SRC_DIR" "${ODIN_FLAGS[@]}" \
-target:js_wasm32 \
-out:"$OUT_DIR/$OUT_NAME.wasm" \
$( [ "$OPTIMIZED" -eq 1 ] && echo "-o:speed" || echo "-debug" )
@@ -67,14 +120,15 @@ fi
if [ "$OPTIMIZED" -eq 1 ]; then
echo "Building optimized ($SRC_DIR -> $OUT_DIR/$OUT_NAME)..."
odin build "$SRC_DIR" \
odin build "$SRC_DIR" "${ODIN_FLAGS[@]}" \
-out:"$OUT_DIR/$OUT_NAME" \
-o:speed u
-o:speed \
-no-bounds-check
else
echo "Building debug ($SRC_DIR -> $OUT_DIR/$OUT_NAME)..."
odin build "$SRC_DIR" \
odin build "$SRC_DIR" "${ODIN_FLAGS[@]}" \
-out:"$OUT_DIR/$OUT_NAME" \
-debug \
-define:DEBUG_MODE=true
fi
@@ -87,6 +141,7 @@ if [ "$BUILD_ONLY" -eq 0 ]; then
lldb "$OUT_DIR/$OUT_NAME"
elif [ "$VALGRIND" -eq 1 ]; then
VG_STATUS=0
valgrind \
--leak-check=full \
--show-leak-kinds=definite,possible \
@@ -94,10 +149,12 @@ if [ "$BUILD_ONLY" -eq 0 ]; then
--errors-for-leak-kinds=definite,possible \
--error-exitcode=1 \
--log-file="$OUT_DIR/valgrind.log" \
"./$OUT_DIR/$OUT_NAME"
"./$OUT_DIR/$OUT_NAME" || VG_STATUS=$?
echo
echo "Valgrind report written to $OUT_DIR/valgrind.log"
[ "$VG_STATUS" -ne 0 ] && echo "Valgrind reported errors (exit $VG_STATUS)"
exit "$VG_STATUS"
else
"./$OUT_DIR/$OUT_NAME"
fi
-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 one or more lines are too long
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

+4 -4
View File
@@ -20,7 +20,7 @@ Sound_ID :: enum {
Audio_Manager :: struct {
sounds: map[Sound_ID]rl.Sound,
music: rl.Music,
music_loaded: bool,
music_loaded: bool, //
sfx_vol: f32, // User SFX volume setting
user_music_vol: f32, // User Music volume setting
@@ -40,13 +40,13 @@ audio_init :: proc(audio: ^Audio_Manager) {
audio.sounds = make(map[Sound_ID]rl.Sound)
// Load SFX
audio.sfx_vol = 0.3
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.1
audio.current_music_vol = 0.1
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
+17 -7
View File
@@ -6,14 +6,16 @@ import rl "vendor:raylib"
SPEED_INC :: 30
EFFECTS_SPEED :: 300
INITIAL_SPEED :: 200
MAX_SPEED :: 550
init_ball :: proc() -> Entity {
return Entity{
type = .Ball,
active = true,
pos = rl.Vector2{(VIEWPORT_W / 2), (VIEWPORT_H / 2)},
speed = 200,
max_speed = 550,
speed = INITIAL_SPEED,
max_speed = MAX_SPEED,
vel = rl.Vector2{0,0},
shape = .Circle,
size = rl.Vector2{8, 0},
@@ -21,9 +23,13 @@ init_ball :: proc() -> Entity {
}
}
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
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
ball.vel = rl.Vector2Normalize({dir_x, dir_y}) * ball.speed
}
@@ -31,7 +37,9 @@ serve_ball :: proc(ball: ^Entity) {
reset_ball :: proc(ball: ^Entity) {
ball.pos = rl.Vector2{(VIEWPORT_W / 2), (VIEWPORT_H / 2)}
ball.vel = rl.Vector2{0,0}
ball.speed = 300
ball.speed = INITIAL_SPEED
ball.trail_positions = {}
ball.trail_count = 0
}
update_ball :: proc(ball: ^Entity, level: ^Level, dt: f32) {
@@ -45,7 +53,7 @@ update_ball :: proc(ball: ^Entity, level: ^Level, dt: f32) {
level.serve_timer -= dt
if level.serve_timer <= 0 {
serve_ball(ball)
serve_ball(ball, level.serve_dir)
}
return
@@ -86,12 +94,14 @@ update_ball :: proc(ball: ^Entity, level: ^Level, dt: f32) {
increment_score(&level.score_player, level.score_win, true, ball)
reset_ball(ball)
level.serve_timer = level.wait_time
level.serve_dir = 1
}
if ball.pos.x <= field_bounds.x + ball.size.x {
increment_score(&level.score_enemy, level.score_win, false, ball)
reset_ball(ball)
level.serve_timer = level.wait_time
level.serve_dir = 2
}
ball.pos += ball.vel * dt
@@ -110,10 +120,10 @@ check_ball_paddle_collision :: proc(ball: ^Entity, paddle: Entity) -> bool {
}
increment_score :: proc(score_side: ^int, max_score: int, player: bool, ball: ^Entity) {
emit_event(.Score_Change)
score_side^ += 1
if score_side^ == max_score {
emit_event(Game_Over_Data{player})
ball.trail_count = 0
}
}
+1
View File
@@ -27,6 +27,7 @@ update_opponent :: proc(self: ^Entity, ball: Entity, bounds: rl.Rectangle, dt: f
// 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
// TODO: make this and the deadzone change with difficulty
self.reaction_timer = 0.15
}
} else {
+34 -11
View File
@@ -58,36 +58,55 @@ process_events :: proc(game: ^Game) {
push_scene(game, make_game_over_overlay(e.winner_is_player))
case Ball_Collision_Data:
// normalize the speed between 0.0 -> 1.0
// map that value to the camera displacement
min_speed: f32 = 200.0
max_speed: f32 = 550.0
speed_factor := clamp((e.speed - min_speed) / (max_speed - min_speed), 0, 1.0)
intensity := 0.5 + (speed_factor * 4.5)
game_trigger_shake(game, intensity, duration = 0.15)
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, .Game_Back:
case .Game_Resume:
pop_scene(game)
case .Score_Change:
game_trigger_shake(game, 10, duration = 0.20)
case .Game_Settings:
push_scene(game, make_settings_menu(&game.audio, RESOLUTIONS[game.res_index].label))
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
@@ -98,3 +117,7 @@ process_events :: proc(game: ^Game) {
}
}
}
event_manager_quit :: proc(game: ^Game) {
delete(GLOBAL_EVENTS)
}
+68 -17
View File
@@ -6,7 +6,7 @@ import rl "vendor:raylib"
VIEWPORT_W :: 640
VIEWPORT_H :: 360
WINDOW_TITLE :: "Pong"
WINDOW_TITLE :: "PONG 2026"
Resolution :: struct {
width: i32,
@@ -16,16 +16,27 @@ Resolution :: struct {
// 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 {
viewport: rl.RenderTexture2D,
bg_texture: rl.Texture2D,
crt_shader: rl.Shader,
transition: Transition,
scene_stack: [dynamic]Scene,
should_close: bool,
audio: Audio_Manager,
@@ -38,7 +49,11 @@ Game :: struct {
// resolution state
window_w: i32,
window_h: i32,
res_index: int
res_index: int,
// Controller state.
controller_gid: i32,
controller_connected: bool,
}
// AO: lets me load this at compile time!
@@ -48,14 +63,18 @@ gamepad_compat := #load("../gamecontrollerdb.txt", string)
// Game Lifecycle
// ------------------------------------------------------------------------------
game_init :: proc(game: ^Game) {
rl.SetConfigFlags({.WINDOW_ALWAYS_RUN, .MSAA_4X_HINT})
game.res_index = 2
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.SetExitKey(.KEY_NULL)
rl.HideCursor()
game_set_resolution(game, game.res_index)
gamepad_cstr := strings.clone_to_cstring(gamepad_compat)
defer delete(gamepad_cstr)
@@ -79,9 +98,14 @@ game_init :: proc(game: ^Game) {
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},
@@ -90,13 +114,14 @@ game_init :: proc(game: ^Game) {
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) {
dt := rl.GetFrameTime()
input := poll_input()
input := poll_input(game)
// -------------------------------------------------------------------------
// Update Camera Shake
@@ -121,10 +146,15 @@ game_update :: proc(game: ^Game) {
}
active_scene := get_active_scene(game)
if active_scene != nil {
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)
@@ -137,36 +167,41 @@ game_render :: proc(game: ^Game) {
rl.ClearBackground(rl.BLACK)
rl.BeginTextureMode(game.viewport)
rl.DrawTexture(game.bg_texture, 0, 0, rl.WHITE)
// World/Scene objects rendered through 2D camera space
rl.BeginMode2D(game.camera)
// @note: fine for now but probably not a good idea...
// Render all scenes in the stack from bottom to top
for i in 0 ..< len(game.scene_stack) {
scene_render(&game.scene_stack[i])
// 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()
rl.EndTextureMode()
render_game_to_window(game)
rl.DrawFPS(10, game.window_h - 20)
// when DEBUG do rl.DrawFPS(10, rl.GetScreenHeight() - 20)
rl.EndDrawing()
}
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)
delete(GLOBAL_EVENTS)
delete(game.scene_stack)
rl.CloseWindow()
}
@@ -185,6 +220,17 @@ game_trigger_shake :: proc(game: ^Game, intensity: f32, duration: f32 = 0.15) {
game_set_resolution :: proc(game: ^Game, res_index: int) {
game.res_index = clamp(res_index, 0, len(RESOLUTIONS) - 1)
borderless := rl.IsWindowState({.BORDERLESS_WINDOWED_MODE})
if game.res_index == 0 {
if !borderless do rl.ToggleBorderlessWindowed()
return
}
if borderless {
rl.ToggleBorderlessWindowed()
rl.RestoreWindow()
}
target_w := RESOLUTIONS[game.res_index].width
target_h := RESOLUTIONS[game.res_index].height
@@ -202,13 +248,18 @@ game_set_resolution :: proc(game: ^Game, res_index: int) {
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)},
// rl.Rectangle{0, 0, f32(game.window_w), f32(game.window_h)},
dst,
rl.Vector2{},
0,
rl.WHITE,
+19 -13
View File
@@ -1,5 +1,6 @@
package game
import "core:strings"
import rl "vendor:raylib"
// ------------------------------------------------------------------------------
@@ -21,35 +22,40 @@ Input_Event :: struct {
}
// Currently active controller
active_gamepad_id: i32 = -1
is_real_pad :: proc(i: i32) -> bool {
return rl.IsGamepadAvailable(i) &&
!strings.contains(string(rl.GetGamepadName(i)), "Mouse") &&
!strings.contains(string(rl.GetGamepadName(i)), "Keyboard")
}
update_active_gamepad :: proc() {
update_active_gamepad :: proc(game: ^Game) {
// is there currently a controller, if so check if its still connected.
if active_gamepad_id != -1 {
if rl.IsGamepadAvailable(active_gamepad_id) {
if game.controller_gid != -1 {
if rl.IsGamepadAvailable(game.controller_gid) {
// do nothing still connected
return
}
// else we lost connection
active_gamepad_id = -1
rl.TraceLog(.WARNING, "Controller disconnected!!")
game.controller_gid = -1
game.controller_connected = false
}
for i: i32 = 0; i < 4; i += 1 {
if rl.IsGamepadAvailable(i) {
active_gamepad_id = i
if rl.IsGamepadAvailable(i) && is_real_pad(i) {
game.controller_connected = true
game.controller_gid = i
return
}
}
}
poll_input :: proc() -> Input_Event {
poll_input :: proc(game: ^Game) -> Input_Event {
input_event := Input_Event{}
input_dir := rl.Vector2{0, 0}
update_active_gamepad()
gamepad_active := active_gamepad_id != -1
gid := active_gamepad_id
update_active_gamepad(game)
gamepad_active := game.controller_connected
gid := game.controller_gid
// --------------------------------------------------------------------------
// Continuous Input (keyboard + gamepad d-pad)
@@ -86,7 +92,7 @@ poll_input :: proc() -> Input_Event {
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) || rl.IsKeyPressed(.SPACE) ||
input_event.confirm_pressed = rl.IsKeyPressed(.ENTER) ||
(gamepad_active && rl.IsGamepadButtonPressed(gid, .RIGHT_FACE_DOWN))
input_event.pause_pressed = rl.IsKeyPressed(.SPACE) ||
+3 -2
View File
@@ -21,6 +21,7 @@ Level :: struct {
player: Entity,
opponent: Entity,
ball: Entity,
serve_dir: u8,
serve_timer: f32,
wait_time: f32,
score_player: int,
@@ -55,7 +56,7 @@ init_level :: proc() -> Level {
ball = init_ball(),
serve_timer = SERVE_WAIT_TIME,
wait_time = SERVE_WAIT_TIME,
score_win = 1,
score_win = 3,
score_player = 0,
score_enemy = 0,
particles = make([dynamic]Particle),
@@ -119,7 +120,7 @@ render_hud :: proc(level: Level) {
p1_size := rl.MeasureTextEx(font, "PLAYER 1", HUD_FONT_SIZE, HUD_FONT_SPACING)
p1_pos := rl.Vector2{HUD_PADDING, HUD_PADDING}
rl.DrawTextEx(font, "PLAYER 1", p1_pos, HUD_FONT_SIZE, HUD_FONT_SPACING, rl.WHITE)
rl.DrawTextEx(font, "PLAYER", p1_pos, HUD_FONT_SIZE, HUD_FONT_SPACING, rl.WHITE)
opp_size := rl.MeasureTextEx(font, "COMPUTER", HUD_FONT_SIZE, HUD_FONT_SPACING)
opp_pos := rl.Vector2{f32(VIEWPORT_W) - HUD_PADDING - opp_size.x, HUD_PADDING}
+25 -6
View File
@@ -16,6 +16,7 @@ Scene_Type :: enum {
Scene :: struct {
type: Scene_Type,
parent: Maybe(Scene_Type),
level: Maybe(Level),
canvas: Maybe(Canvas),
winner_is_player: bool,
@@ -26,7 +27,7 @@ push_scene :: proc(game: ^Game, scene: Scene) {
}
pop_scene :: proc(game: ^Game) {
if len(game.scene_stack) > 1 {
if len(game.scene_stack) > 0 {
top_index := len(game.scene_stack) - 1
destroy_scene(&game.scene_stack[top_index])
pop(&game.scene_stack)
@@ -101,6 +102,13 @@ scene_render :: proc(scene: ^Scene) {
}
}
scene_manager_quit :: proc(game: ^Game) {
for &scene in game.scene_stack {
destroy_scene(&scene)
}
delete(game.scene_stack)
}
// ------------------------------------------------------------------------------
// UI Layout & Styling Constants
// ------------------------------------------------------------------------------
@@ -169,8 +177,8 @@ list_menu_items :: proc(canvas: Canvas, menu: UI_Menu) {
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.ORANGE : rl.DARKGRAY
text_color := is_selected ? rl.ORANGE : rl.WHITE
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)
@@ -183,7 +191,7 @@ list_menu_items :: proc(canvas: Canvas, menu: UI_Menu) {
case UI_Slider:
slider_rec := item_rec
text_width := rl.MeasureText(v.label, UI_LABEL_FONT_SIZE)
text_width := 31
slider_rec.x = slider_rec.x + f32(text_width * 2)
slider_rec.width = (slider_rec.width - f32(text_width * 2))
@@ -220,6 +228,7 @@ make_main_menu :: proc() -> Scene {
return Scene{
type = .Main_Menu,
parent = nil,
canvas = Canvas{
bounds = rl.Rectangle{0, 0, VIEWPORT_W, VIEWPORT_H},
padding = UI_TITLE_PADDING,
@@ -241,7 +250,7 @@ render_main_menu :: proc(scene: Scene) {
menu, menu_ok := canvas.menu.?
assert(menu_ok, "render_main_menu: Canvas is missing a UI_Menu component")
t_text: cstring = "PONG"
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)
@@ -289,6 +298,7 @@ make_pause_overlay :: proc() -> Scene {
return Scene{
type = .Pause_Menu,
parent = nil,
canvas = Canvas{
bounds = rl.Rectangle{0, 0, VIEWPORT_W, VIEWPORT_H},
padding = UI_TITLE_PADDING,
@@ -356,7 +366,7 @@ update_pause_menu :: proc(scene: ^Scene, input: Input_Event) {
// ------------------------------------------------------------------------------
// Settings Menu
// ------------------------------------------------------------------------------
make_settings_menu :: proc(audio: ^Audio_Manager, current_res_label: cstring) -> Scene {
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{
@@ -377,6 +387,7 @@ make_settings_menu :: proc(audio: ^Audio_Manager, current_res_label: cstring) ->
return Scene{
type = .Settings_Menu,
parent = parent,
canvas = Canvas{
bounds = rl.Rectangle{0, 0, VIEWPORT_W, VIEWPORT_H},
padding = UI_TITLE_PADDING,
@@ -398,6 +409,12 @@ render_settings_menu :: proc(scene: Scene) {
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)
@@ -465,6 +482,7 @@ make_game_over_overlay :: proc(winner_is_player: bool) -> Scene {
return Scene{
type = .Game_Over,
parent = nil,
winner_is_player = winner_is_player,
canvas = Canvas{
bounds = rl.Rectangle{0, 0, VIEWPORT_W, VIEWPORT_H},
@@ -530,6 +548,7 @@ update_game_over :: proc(scene: ^Scene, input: Input_Event) {
make_gameplay_scene :: proc() -> Scene {
return Scene{
type = .Gameplay,
parent = nil,
level = init_level(),
}
}