Compare commits

...
8 Commits
Author SHA1 Message Date
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
10 changed files with 17849 additions and 2321 deletions
-1
View File
@@ -74,7 +74,6 @@ else
echo "Building debug ($SRC_DIR -> $OUT_DIR/$OUT_NAME)..."
odin build "$SRC_DIR" \
-out:"$OUT_DIR/$OUT_NAME" \
-debug \
-define:DEBUG_MODE=true
fi
+17418 -2234
View File
File diff suppressed because one or more lines are too long
+148
View File
@@ -0,0 +1,148 @@
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.5
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.0
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
}
+3 -1
View File
@@ -77,7 +77,9 @@ check_ball_paddle_collision :: proc(ball: Entity, paddle: Entity) -> bool {
width = paddle.size.x,
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(Event{ type = .Ball_Collision })
return collided
}
increment_score :: proc(score_side: ^int, max_score: int, player: bool) {
+45
View File
@@ -2,12 +2,19 @@ package game
// all possible game events
Event_Type :: enum {
// game state
Game_Start,
Game_Over,
Game_Quit,
Game_Pause,
Game_Resume,
Game_Main_Menu,
Game_Settings,
// ui events
UI_Select,
// gameplay
Score_Change,
Ball_Collision,
}
@@ -28,3 +35,41 @@ emit_event :: proc(event: Event) {
flush_events :: proc() {
clear(&GLOBAL_EVENTS)
}
process_events :: proc(game: ^Game) {
for event in GLOBAL_EVENTS {
#partial switch event.type {
case .Game_Start:
game.overlay_scene = nil
game.scene = make_gameplay_scene()
audio_fade_in(&game.audio, duration = 1.5)
case .Game_Pause:
game.overlay_scene = make_pause_overlay()
case .Game_Resume:
game.overlay_scene = nil
case .Game_Main_Menu:
game.overlay_scene = nil
game.scene = make_main_menu()
audio_fade_out(&game.audio, duration = 1.0)
case .Game_Settings:
game.overlay_scene = nil
game.overlay_scene = make_settings_menu(&game.audio)
case .Game_Over:
game.overlay_scene = make_game_over_overlay(event.winner_is_player)
case .Game_Quit:
game.should_close = true
case .UI_Select:
audio_play_sfx(&game.audio, .UI_Select)
case .Ball_Collision:
audio_play_sfx(&game.audio, .Ball_Collision)
}
}
}
+11
View File
@@ -13,6 +13,7 @@ Game :: struct {
scene: Scene,
overlay_scene: Maybe(Scene),
should_close: bool,
audio: Audio_Manager,
}
// ------------------------------------------------------------------------------
@@ -22,6 +23,9 @@ game_init :: proc(game: ^Game) {
rl.InitWindow(WINDOW_W, WINDOW_H, WINDOW_TITLE)
rl.SetTargetFPS(60)
// init any global managers
audio_init(&game.audio)
// allocate some event capacity to stop early dynamic reallocations
GLOBAL_EVENTS = make([dynamic]Event, 0, 16)
@@ -43,6 +47,9 @@ game_update :: proc(game: ^Game) {
scene_update(&game.scene, game, input, dt)
}
// background music buffer streaming
audio_update(&game.audio, dt)
// flush any events triggered in this loop
flush_events()
}
@@ -72,6 +79,10 @@ game_render :: proc(game: ^Game) {
game_quit :: proc(game: ^Game) {
delete(GLOBAL_EVENTS)
rl.UnloadRenderTexture(game.viewport)
// cleanup global managers
audio_manager_quit(&game.audio)
rl.CloseWindow()
}
+4
View File
@@ -14,6 +14,8 @@ Input_Event :: struct {
// single frame actions UI and triggers
up_pressed: bool,
down_pressed: bool,
left_pressed: bool,
right_pressed: bool,
confirm_pressed: bool,
pause_pressed: bool,
}
@@ -38,6 +40,8 @@ poll_input :: proc() -> Input_Event {
// --------------------------------------------------------------------------
input_event.up_pressed = rl.IsKeyPressed(.W) || rl.IsKeyPressed(.UP)
input_event.down_pressed = rl.IsKeyPressed(.S) || rl.IsKeyPressed(.DOWN)
input_event.left_pressed = rl.IsKeyPressed(.A) || rl.IsKeyPressed(.LEFT)
input_event.right_pressed = rl.IsKeyPressed(.D) || rl.IsKeyPressed(.RIGHT)
input_event.confirm_pressed = rl.IsKeyPressed(.ENTER) || rl.IsKeyPressed(.SPACE)
input_event.pause_pressed = rl.IsKeyPressed(.P)
+2 -1
View File
@@ -22,7 +22,8 @@ Level :: struct {
}
init_level :: proc() -> Level {
// positions work off viewport resolution, independent of target display size
// positions work off viewport resolution,
// independent of target display size
// 10% padding around the field
pad_x := f32(VIEWPORT_W) * 0.10
pad_y := f32(VIEWPORT_H) * 0.10
-1
View File
@@ -17,7 +17,6 @@ main :: proc() {
defer mem.tracking_allocator_destroy(&track)
context.allocator = mem.tracking_allocator(&track)
}
// ------------------------------------------------------------------------------
// ------------------------------------------------------------------------------
// Game Loop
+215 -80
View File
@@ -9,6 +9,7 @@ Scene_Type :: enum {
Main_Menu,
Gameplay,
Pause_Menu,
Settings_Menu,
Game_Over,
}
@@ -19,6 +20,55 @@ Scene :: struct {
winner_is_player: bool,
}
// ------------------------------------------------------------------------------
// Unified Scene Dispatchers
// ------------------------------------------------------------------------------
scene_render :: proc(scene: ^Scene) {
if level, ok := scene.level.?; ok {
render_level(level)
render_hud(level)
}
#partial switch scene.type {
case .Pause_Menu:
render_pause_menu(scene^)
case .Settings_Menu:
render_settings_menu(scene^)
case .Game_Over:
render_game_over(scene^)
case .Main_Menu:
render_main_menu(scene^)
}
}
scene_update :: proc(scene: ^Scene, game: ^Game, input: Input_Event, dt: f32) {
#partial switch scene.type {
case .Main_Menu:
update_main_menu(scene, input)
case .Pause_Menu:
update_pause_menu(scene, input)
case .Settings_Menu:
update_settings_menu(scene, input)
case .Gameplay:
level, level_ok := &scene.level.?
assert(level_ok, "scene_update: .Gameplay scene is missing a Level component")
update_level(level, input, dt)
if input.pause_pressed {
emit_event(Event{ type = .Game_Pause })
}
case .Game_Over:
update_game_over(scene, input)
}
// Now handle any events triggered
process_events(game)
}
// ------------------------------------------------------------------------------
// UI Definitions
// ------------------------------------------------------------------------------
@@ -30,8 +80,23 @@ Canvas :: struct {
menu: Maybe(UI_Menu),
}
UI_Button :: struct {
label: cstring,
}
UI_Slider :: struct {
label: cstring,
value: ^f32,
step: f32,
}
UI_Item :: union {
UI_Button,
UI_Slider,
}
UI_Menu :: struct {
items: [8]cstring,
items: [8]UI_Item,
count: int,
selected_index: int,
}
@@ -53,16 +118,35 @@ list_menu_items :: proc(canvas: Canvas, menu: UI_Menu) {
item := menu.items[i]
item_rec := get_canvas_item_rec(canvas, i, top_margin = 150.0)
text_width := rl.MeasureText(item, 20)
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)) - 10
is_selected := (i == menu.selected_index)
border_color := is_selected ? rl.ORANGE : rl.DARKGRAY
text_color := is_selected ? rl.ORANGE : rl.WHITE
rl.DrawRectangleLinesEx(item_rec, 3, border_color)
rl.DrawText(item, text_x, text_y, 20, text_color)
#partial switch v in item {
case UI_Button:
text_width := rl.MeasureText(v.label, 20)
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)) - 10
rl.DrawText(v.label, text_x, text_y, 20, text_color)
case UI_Slider:
slider_rec := item_rec
text_width := rl.MeasureText(v.label, 20)
slider_rec.x = slider_rec.x + f32(text_width)
slider_rec.width = (slider_rec.width - f32(text_width))
rl.GuiSlider(
bounds = slider_rec,
textLeft = v.label,
textRight = nil,
value = v.value,
minValue = 0.0,
maxValue = 1.0,
)
}
}
}
@@ -70,6 +154,12 @@ list_menu_items :: proc(canvas: Canvas, menu: UI_Menu) {
// 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,
canvas = Canvas{
@@ -78,7 +168,7 @@ make_main_menu :: proc() -> Scene {
item_height = 40,
item_spaceing = 15,
menu = UI_Menu{
items = {0 = "Start Game", 1 = "Options", 2 = "Quit"},
items = ui_items,
count = 3,
selected_index = 0,
},
@@ -112,9 +202,11 @@ update_main_menu :: proc(scene: ^Scene, input: Input_Event) {
if input.up_pressed {
menu.selected_index = (menu.selected_index - 1 + menu.count) % menu.count
emit_event(Event{ type = .UI_Select })
}
if input.down_pressed {
menu.selected_index = (menu.selected_index + 1) % menu.count
emit_event(Event{ type = .UI_Select })
}
if input.confirm_pressed {
@@ -122,7 +214,7 @@ update_main_menu :: proc(scene: ^Scene, input: Input_Event) {
case 0:
emit_event(Event{ type = .Game_Start })
case 1:
// @todo open options
emit_event(Event { type = .Game_Settings })
case 2:
emit_event(Event{ type = .Game_Quit })
}
@@ -133,6 +225,12 @@ update_main_menu :: proc(scene: ^Scene, input: Input_Event) {
// 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,
canvas = Canvas{
@@ -141,7 +239,7 @@ make_pause_overlay :: proc() -> Scene {
item_height = 40,
item_spaceing = 15,
menu = UI_Menu{
items = {0 = "Resume Game", 1 = "Options", 2 = "Main Menu"},
items = ui_items,
count = 3,
selected_index = 0,
},
@@ -175,9 +273,11 @@ update_pause_menu :: proc(scene: ^Scene, input: Input_Event) {
if input.up_pressed {
menu.selected_index = (menu.selected_index - 1 + menu.count) % menu.count
emit_event(Event{ type = .UI_Select })
}
if input.down_pressed {
menu.selected_index = (menu.selected_index + 1) % menu.count
emit_event(Event{ type = .UI_Select })
}
if input.confirm_pressed {
@@ -185,7 +285,7 @@ update_pause_menu :: proc(scene: ^Scene, input: Input_Event) {
case 0:
emit_event(Event{ type = .Game_Resume })
case 1:
// @todo open options
emit_event(Event { type = .Game_Settings })
case 2:
emit_event(Event{ type = .Game_Main_Menu })
}
@@ -196,10 +296,108 @@ update_pause_menu :: proc(scene: ^Scene, input: Input_Event) {
}
}
// ------------------------------------------------------------------------------
// Settings Menu
// ------------------------------------------------------------------------------
make_settings_menu :: proc(audio: ^Audio_Manager) -> Scene {
ui_items:[8]UI_Item = {
0 = UI_Button{"Back"},
1 = UI_Button{"Resolution"},
2 = UI_Slider{
label = "Music Volume",
value = &audio.user_music_vol,
step = 0.1,
},
3 = UI_Slider{
label = "SFX Volume",
value = &audio.sfx_vol,
step = 0.1,
},
}
return Scene{
type = .Settings_Menu,
canvas = Canvas{
bounds = rl.Rectangle{0, 0, VIEWPORT_W, VIEWPORT_H},
padding = 20,
item_height = 40,
item_spaceing = 15,
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")
rl.DrawRectangleRec(canvas.bounds, rl.Color{0, 0, 0, 255})
t_text: cstring = "SETTINGS"
t_width := rl.MeasureText(t_text, 40)
t_x := i32(canvas.bounds.width * 0.5) - (t_width / 2)
rl.DrawText(t_text, t_x, i32(canvas.padding), 40, 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(Event{ type = .UI_Select })
}
if input.down_pressed {
menu.selected_index = (menu.selected_index + 1) % menu.count
emit_event(Event{ type = .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(Event{ type = .Game_Main_Menu })
}
}
case UI_Slider:
if input.left_pressed {
// Decrement value by step and keep within normalized 0.0 - 1.0 bounds
v.value^ = clamp(v.value^ - v.step, 0.0, 1.0)
emit_event(Event{ type = .UI_Select })
}
if input.right_pressed {
// Increment value by step and keep within normalized 0.0 - 1.0 bounds
v.value^ = clamp(v.value^ + v.step, 0.0, 1.0)
emit_event(Event{ type = .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,
winner_is_player = winner_is_player,
@@ -209,7 +407,7 @@ make_game_over_overlay :: proc(winner_is_player: bool) -> Scene {
item_height = 40,
item_spaceing = 15,
menu = UI_Menu{
items = {0 = "Restart Game", 1 = "Main Menu"},
items = ui_items,
count = 2,
selected_index = 0,
},
@@ -219,10 +417,10 @@ make_game_over_overlay :: proc(winner_is_player: bool) -> Scene {
render_game_over :: proc(scene: Scene) {
canvas, canvas_ok := scene.canvas.?
assert(canvas_ok, "render_pause_menu: Scene is missing a Canvas component")
assert(canvas_ok, "update_game_over: Scene is missing a Canvas component")
menu, menu_ok := canvas.menu.?
assert(menu_ok, "render_pause_menu: Canvas is missing a UI_Menu component")
assert(menu_ok, "update_game_over: Canvas is missing a UI_Menu component")
rl.DrawRectangleRec(canvas.bounds, rl.Color{0, 0, 0, 255})
@@ -237,16 +435,18 @@ render_game_over :: proc(scene: Scene) {
update_game_over :: proc(scene: ^Scene, input: Input_Event) {
canvas, canvas_ok := &scene.canvas.?
assert(canvas_ok, "update_pause_menu: Scene is missing a Canvas component")
assert(canvas_ok, "update_game_over: Scene is missing a Canvas component")
menu, menu_ok := &canvas.menu.?
assert(menu_ok, "update_pause_menu: Canvas is missing a UI_Menu component")
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(Event{ type = .UI_Select })
}
if input.down_pressed {
menu.selected_index = (menu.selected_index + 1) % menu.count
emit_event(Event{ type = .UI_Select })
}
if input.confirm_pressed {
@@ -268,68 +468,3 @@ make_gameplay_scene :: proc() -> Scene {
level = init_level(),
}
}
// ------------------------------------------------------------------------------
// Unified Scene Dispatchers
// ------------------------------------------------------------------------------
scene_render :: proc(scene: ^Scene) {
if level, ok := scene.level.?; ok {
render_level(level)
render_hud(level)
}
#partial switch scene.type {
case .Pause_Menu:
render_pause_menu(scene^)
case .Game_Over:
render_game_over(scene^)
case .Main_Menu:
render_main_menu(scene^)
}
}
scene_update :: proc(scene: ^Scene, game: ^Game, input: Input_Event, dt: f32) {
#partial switch scene.type {
case .Main_Menu:
update_main_menu(scene, input)
case .Pause_Menu:
update_pause_menu(scene, input)
case .Gameplay:
level, level_ok := &scene.level.?
assert(level_ok, "scene_update: .Gameplay scene is missing a Level component")
update_level(level, input, dt)
if input.pause_pressed {
emit_event(Event{ type = .Game_Pause })
}
case .Game_Over:
update_game_over(scene, input)
}
for event in GLOBAL_EVENTS {
#partial switch event.type {
case .Game_Start:
game.overlay_scene = nil
game.scene = make_gameplay_scene()
case .Game_Pause:
game.overlay_scene = make_pause_overlay()
case .Game_Resume:
game.overlay_scene = nil
case .Game_Main_Menu:
game.overlay_scene = nil
game.scene = make_main_menu()
case .Game_Over:
game.overlay_scene = make_game_over_overlay(event.winner_is_player)
case .Game_Quit:
game.should_close = true
}
}
}