Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ Requires Zig `0.16.0` and the GTK4 + libadwaita development packages (linked via

## Architecture

The codebase is split into focused modules so game rules are testable without a display server and the GTK frontend stays readable. The import graph is a clean DAG: `main → ui → render → app → gtk`, with `wheel` and `game` as shared leaves.
The codebase is split into focused modules so game rules are testable without a display server and the GTK frontend stays readable. The import graph is a clean DAG: `main → ui → render → app → gtk`, with `wheel`, `game`, and `audio` as shared leaves.

- **`src/game.zig`** — pure roulette logic, zero GTK dependencies. This is the **test root** declared in `build.zig`; all unit tests live here. Contains `GameState`, bet types (`BetKind` tagged union: straight/color/parity/range/dozen/column), payout math (`wins`, `payoutMultiplier`, `settle`), and validation. Keep this module GUI-free.
- **`src/gtk.zig`** — hand-written GTK4/libadwaita/Cairo/GLib `extern fn` bindings (no deps). Add new C symbols here.
- **`src/wheel.zig`** — pure wheel geometry (`order`, `sliceAngle`, `angleForNumber`) and spin easing (`lerp`, `easeOutCubic`, `normalizeAngle`). No GTK/game deps.
- **`src/app.zig`** — the shared `AppState` plus `HitZone`, `HistoryEntry`, and constants. Depends on `gtk` + `game`.
- **`src/audio.zig`** — self-contained SFX engine. Loads `libpulse-simple` at runtime via `dlopen` (no link-time dep, no headers): if absent, sound silently disables. Synthesises 16-bit PCM buffers (`chip`/`spin`/`win`) once at init and plays them on detached threads. No GTK deps.
- **`src/app.zig`** — the shared `AppState` (incl. the `Audio` instance) plus `HitZone`, `HistoryEntry`, and constants. Depends on `gtk` + `game` + `audio`.
- **`src/render.zig`** — Cairo draw funcs (`drawWheel`, `drawTable`) and the `ZoneColor` palette. Rebuilds `hit_zones` during `drawTable`.
- **`src/ui.zig`** — GTK glue: widget construction (`buildMenu`/`buildGame`), signal callbacks, spin animation, list/label refresh (`refreshUi`).
- **`src/main.zig`** — thin entry point: allocator, `AppState` init, GTK warning silencing, `activate`.
Expand All @@ -47,6 +48,6 @@ A single heap-allocated `AppState` (created in `main`, freed via `defer`) holds

### Conventions
- UI strings are **French** (`Rouge`, `Noir`, `Pair`, `Lancer`, etc.); `label()` methods on the game enums provide these.
- `main` uses `DebugAllocator` so leaks fail in debug builds; history entries are heap-allocated `[:0]` strings that must be freed (see `clearHistory`).
- `main` uses `DebugAllocator` so leaks fail in debug builds. The `audio` engine pre-allocates its PCM buffers once and frees them in `Audio.deinit` (called first in `AppState.deinit`).
- This codebase uses the newer `std.array_list.Managed` API (Zig 0.16).
- GTK warnings are intentionally silenced via `g_log_set_handler` / `g_log_set_writer_func`.
20 changes: 8 additions & 12 deletions src/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
const std = @import("std");
const gtk = @import("gtk.zig");
const game = @import("game.zig");
const audio = @import("audio.zig");

/// Maximum number of spins kept in the history side panel.
pub const HistoryLimit = 12;
Expand All @@ -22,18 +23,18 @@ pub const HitZone = struct {
kind: game.BetKind,
};

/// One settled spin shown in the history panel. The strings are heap-allocated
/// and owned by `AppState` (freed in `clearHistory`).
/// One settled spin shown as a coloured chip in the history strip. Kept as plain
/// values (no heap allocation): the colour is derived from `number`.
pub const HistoryEntry = struct {
summary: [:0]u8,
details: [:0]u8,
number: u8,
profit: i64,
};

pub const AppState = struct {
allocator: std.mem.Allocator,
game_state: game.GameState,
rng: std.Random.DefaultPrng,
audio: audio.Audio,
amount: i64 = 25,
selected: ?game.BetKind = null,
last_number: ?u8 = null,
Expand All @@ -56,10 +57,7 @@ pub const AppState = struct {
wheel_area: ?*gtk.GtkWidget = null,
table_area: ?*gtk.GtkWidget = null,
balance_label: ?*gtk.GtkWidget = null,
result_label: ?*gtk.GtkWidget = null,
selected_label: ?*gtk.GtkWidget = null,
bet_list: ?*gtk.GtkWidget = null,
history_list: ?*gtk.GtkWidget = null,
history_strip: ?*gtk.GtkWidget = null,
status_label: ?*gtk.GtkWidget = null,
amount_spin: ?*gtk.GtkWidget = null,
spin_button: ?*gtk.GtkWidget = null,
Expand All @@ -72,13 +70,15 @@ pub const AppState = struct {
.allocator = allocator,
.game_state = game.GameState.init(allocator),
.rng = std.Random.DefaultPrng.init(seed),
.audio = audio.Audio.init(allocator),
.hit_zones = std.array_list.Managed(HitZone).init(allocator),
.history = std.array_list.Managed(HistoryEntry).init(allocator),
};
return state;
}

pub fn deinit(self: *AppState) void {
self.audio.deinit();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self.game_state.deinit();
self.clearHistory();
self.history.deinit();
Expand All @@ -87,10 +87,6 @@ pub const AppState = struct {
}

pub fn clearHistory(self: *AppState) void {
for (self.history.items) |entry| {
self.allocator.free(entry.summary);
self.allocator.free(entry.details);
}
self.history.clearRetainingCapacity();
}
};
205 changes: 205 additions & 0 deletions src/audio.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//! Tiny self-contained sound-effects engine.
//!
//! PulseAudio's `libpulse-simple` is loaded at runtime via `dlopen` (no link-time
//! dependency, no headers): if it is missing, sound silently disables and the
//! game keeps working. Effects are synthesised once into 16-bit PCM buffers and
//! played on detached threads so the GTK main loop never blocks.

const std = @import("std");

const SR: usize = 44100;
const SRF: f64 = 44100.0;

// --- libpulse-simple ABI (the few bits we need) ---------------------------

const pa_simple = opaque {};

const PaSampleSpec = extern struct {
format: c_int,
rate: u32,
channels: u8,
};

const PA_SAMPLE_S16LE: c_int = 3;
const PA_STREAM_PLAYBACK: c_int = 1;

const NewFn = *const fn (?[*:0]const u8, [*:0]const u8, c_int, ?[*:0]const u8, [*:0]const u8, *const PaSampleSpec, ?*const anyopaque, ?*const anyopaque, ?*c_int) callconv(.c) ?*pa_simple;
const WriteFn = *const fn (*pa_simple, *const anyopaque, usize, ?*c_int) callconv(.c) c_int;
const DrainFn = *const fn (*pa_simple, ?*c_int) callconv(.c) c_int;
const FreeFn = *const fn (*pa_simple) callconv(.c) void;

pub const Sound = enum { chip, spin, win };

pub const Audio = struct {
allocator: std.mem.Allocator,
enabled: bool = true,
available: bool = false,
lib: ?std.DynLib = null,
new_fn: NewFn = undefined,
write_fn: WriteFn = undefined,
drain_fn: DrainFn = undefined,
free_fn: FreeFn = undefined,
chip_pcm: []i16 = &.{},
spin_pcm: []i16 = &.{},
win_pcm: []i16 = &.{},
/// Number of detached worker threads currently reading the PCM buffers.
/// `deinit` waits for this to hit zero before freeing anything.
in_flight: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),

/// Try to wire up PulseAudio and pre-render the effect buffers. Any failure
/// leaves `available = false`; callers can still call `play` (it no-ops).
pub fn init(allocator: std.mem.Allocator) Audio {
var self = Audio{ .allocator = allocator };

var lib = std.DynLib.open("libpulse-simple.so.0") catch return self;
self.new_fn = lib.lookup(NewFn, "pa_simple_new") orelse {
lib.close();
return self;
};
self.write_fn = lib.lookup(WriteFn, "pa_simple_write") orelse {
lib.close();
return self;
};
self.drain_fn = lib.lookup(DrainFn, "pa_simple_drain") orelse {
lib.close();
return self;
};
self.free_fn = lib.lookup(FreeFn, "pa_simple_free") orelse {
lib.close();
return self;
};

self.chip_pcm = genChip(allocator) catch return giveUp(&lib, &self);
self.spin_pcm = genSpin(allocator) catch return giveUp(&lib, &self);
self.win_pcm = genWin(allocator) catch return giveUp(&lib, &self);

self.lib = lib;
self.available = true;
return self;
}

fn giveUp(lib: *std.DynLib, self: *Audio) Audio {
self.allocator.free(self.chip_pcm);
self.allocator.free(self.spin_pcm);
self.allocator.free(self.win_pcm);
self.chip_pcm = &.{};
self.spin_pcm = &.{};
self.win_pcm = &.{};
lib.close();
return self.*;
}

pub fn deinit(self: *Audio) void {
// Wait for any in-flight worker to stop touching the buffers/dynlib
// before we free them, so a detached thread can't use freed resources.
const poll = std.os.linux.timespec{ .sec = 0, .nsec = 1 * std.time.ns_per_ms };
while (self.in_flight.load(.acquire) != 0) _ = std.os.linux.nanosleep(&poll, null);
Comment thread
InstaZDLL marked this conversation as resolved.
self.allocator.free(self.chip_pcm);
self.allocator.free(self.spin_pcm);
self.allocator.free(self.win_pcm);
if (self.lib) |*lib| lib.close();
}

/// Flip mute on/off; returns the new enabled state.
pub fn toggle(self: *Audio) bool {
self.enabled = !self.enabled;
return self.enabled;
}

/// Fire-and-forget: play `sound` on a detached thread. No-op if unavailable
/// or muted. The PCM buffers are read-only and outlive the thread.
pub fn play(self: *Audio, sound: Sound) void {
if (!self.available or !self.enabled) return;
const pcm = switch (sound) {
.chip => self.chip_pcm,
.spin => self.spin_pcm,
.win => self.win_pcm,
};
// Count this worker before spawning so deinit can never race ahead and
// free the buffers between the spawn and the worker starting.
_ = self.in_flight.fetchAdd(1, .acq_rel);
const thread = std.Thread.spawn(.{}, worker, .{ self, pcm }) catch {
_ = self.in_flight.fetchSub(1, .acq_rel);
return;
};
thread.detach();
}
};

fn worker(self: *Audio, pcm: []const i16) void {
defer _ = self.in_flight.fetchSub(1, .acq_rel);
var err: c_int = 0;
var ss = PaSampleSpec{ .format = PA_SAMPLE_S16LE, .rate = @intCast(SR), .channels = 1 };
const stream = self.new_fn(null, "Zig-Roulette", PA_STREAM_PLAYBACK, null, "sfx", &ss, null, null, &err) orelse return;
defer self.free_fn(stream);
_ = self.write_fn(stream, pcm.ptr, pcm.len * @sizeOf(i16), &err);
_ = self.drain_fn(stream, &err);
}

// --- Synthesis ------------------------------------------------------------

fn toSample(value: f64) i16 {
const clamped = std.math.clamp(value, -1.0, 1.0);
return @intFromFloat(clamped * 32767.0);
}

/// Short descending blip for placing/removing a chip.
fn genChip(allocator: std.mem.Allocator) ![]i16 {
const dur = 0.06;
const n: usize = @intFromFloat(dur * SRF);
const buf = try allocator.alloc(i16, n);
var phase: f64 = 0;
for (buf, 0..) |*s, i| {
const t = @as(f64, @floatFromInt(i)) / SRF;
const k = t / dur;
const freq = 800.0 * std.math.pow(f64, 300.0 / 800.0, k);
const env = 0.30 * std.math.pow(f64, 0.01 / 0.30, k);
phase += std.math.tau * freq / SRF;
s.* = toSample(env * @sin(phase));
}
return buf;
}

/// A train of clicks that slows down, like the ball settling on the wheel.
fn genSpin(allocator: std.mem.Allocator) ![]i16 {
const dur = 2.0;
const n: usize = @intFromFloat(dur * SRF);
const buf = try allocator.alloc(i16, n);
@memset(buf, 0);

const click_len: usize = @intFromFloat(0.012 * SRF);
var t: f64 = 0.0;
var interval: f64 = 0.045;
while (t < dur) : (t += interval) {
const start: usize = @intFromFloat(t * SRF);
var j: usize = 0;
while (j < click_len and start + j < n) : (j += 1) {
const tj = @as(f64, @floatFromInt(j)) / SRF;
const env = 0.22 * @exp(-tj / 0.0035);
const square: f64 = if (@sin(std.math.tau * 1500.0 * tj) >= 0) 1.0 else -1.0;
buf[start + j] = toSample(@as(f64, @floatFromInt(buf[start + j])) / 32767.0 + env * square);
}
interval *= 1.06; // decelerate
}
return buf;
}

/// Rising four-note arpeggio for a win.
fn genWin(allocator: std.mem.Allocator) ![]i16 {
const dur = 0.8;
const n: usize = @intFromFloat(dur * SRF);
const buf = try allocator.alloc(i16, n);
const notes = [_]f64{ 440.0, 554.37, 659.25, 880.0 };
var phase: f64 = 0;
for (buf, 0..) |*s, i| {
const t = @as(f64, @floatFromInt(i)) / SRF;
const idx = @min(@as(usize, @intFromFloat(t / 0.1)), notes.len - 1);
const freq = notes[idx];
const env = 0.30 * (1.0 - t / dur);
phase += std.math.tau * freq / SRF;
// Triangle wave: warmer than a sine, softer than a square.
const tri = std.math.asin(@sin(phase)) * (2.0 / std.math.pi);
s.* = toSample(env * tri);
}
return buf;
}
14 changes: 14 additions & 0 deletions src/gtk.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub const GTK_ALIGN_END: c_int = 2;
pub const GTK_ALIGN_CENTER: c_int = 3;
pub const GTK_ORIENTATION_HORIZONTAL: c_int = 0;
pub const GTK_ORIENTATION_VERTICAL: c_int = 1;
pub const GTK_POLICY_ALWAYS: c_int = 0;
pub const GTK_POLICY_AUTOMATIC: c_int = 1;
pub const GTK_POLICY_NEVER: c_int = 2;
pub const CAIRO_FONT_SLANT_NORMAL: c_int = 0;
pub const CAIRO_FONT_WEIGHT_BOLD: c_int = 1;

Expand All @@ -42,6 +45,7 @@ pub const GtkEventController = opaque {};
pub const GtkSpinButton = opaque {};
pub const GtkButton = opaque {};
pub const GtkListBox = opaque {};
pub const GtkScrolledWindow = opaque {};
pub const GtkImage = opaque {};
pub const GtkPicture = opaque {};
pub const GtkCssProvider = opaque {};
Expand Down Expand Up @@ -88,6 +92,7 @@ pub extern fn gtk_window_set_default_icon_name(name: [*:0]const u8) void;
pub extern fn gtk_window_present(window: *GtkWindow) void;
pub extern fn gtk_box_new(orientation: c_int, spacing: c_int) *GtkWidget;
pub extern fn gtk_box_append(box: *GtkBox, child: *GtkWidget) void;
pub extern fn gtk_box_remove(box: *GtkBox, child: *GtkWidget) void;
pub extern fn gtk_widget_set_margin_top(widget: *GtkWidget, margin: c_int) void;
pub extern fn gtk_widget_set_margin_bottom(widget: *GtkWidget, margin: c_int) void;
pub extern fn gtk_widget_set_margin_start(widget: *GtkWidget, margin: c_int) void;
Expand All @@ -113,6 +118,7 @@ pub extern fn gtk_spin_button_new_with_range(min: f64, max: f64, step: f64) *Gtk
pub extern fn gtk_spin_button_set_value(spin_button: *GtkSpinButton, value: f64) void;
pub extern fn gtk_spin_button_get_value(spin_button: *GtkSpinButton) f64;
pub extern fn gtk_button_new_with_label(label: [*:0]const u8) *GtkWidget;
pub extern fn gtk_button_set_label(button: *GtkButton, label: [*:0]const u8) void;
pub extern fn gtk_image_new_from_icon_name(icon_name: [*:0]const u8) *GtkWidget;
pub extern fn gtk_image_set_pixel_size(image: *GtkImage, pixel_size: c_int) void;
pub extern fn gtk_picture_new_for_paintable(paintable: *GdkPaintable) *GtkWidget;
Expand All @@ -121,14 +127,22 @@ pub extern fn gtk_picture_set_keep_aspect_ratio(self: *GtkPicture, keep_aspect_r
pub extern fn gtk_list_box_new() *GtkWidget;
pub extern fn gtk_list_box_append(box: *GtkListBox, child: *GtkWidget) void;
pub extern fn gtk_list_box_remove(box: *GtkListBox, child: *GtkWidget) void;
pub extern fn gtk_scrolled_window_new() *GtkWidget;
pub extern fn gtk_scrolled_window_set_child(scrolled_window: *GtkScrolledWindow, child: ?*GtkWidget) void;
pub extern fn gtk_scrolled_window_set_policy(scrolled_window: *GtkScrolledWindow, hscrollbar_policy: c_int, vscrollbar_policy: c_int) void;
pub extern fn gtk_css_provider_new() *GtkCssProvider;
pub extern fn gtk_css_provider_load_from_string(css_provider: *GtkCssProvider, string: [*:0]const u8) void;
pub extern fn gtk_style_context_add_provider_for_display(display: *GdkDisplay, provider: *GtkStyleProvider, priority: guint) void;
pub extern fn gdk_display_get_default() ?*GdkDisplay;
pub extern fn gdk_texture_new_from_bytes(bytes: *GBytes, error_: ?*?*GError) ?*GdkTexture;

pub extern fn cairo_set_source_rgb(cr: *cairo_t, red: f64, green: f64, blue: f64) void;
pub extern fn cairo_set_source_rgba(cr: *cairo_t, red: f64, green: f64, blue: f64, alpha: f64) void;
pub extern fn cairo_paint(cr: *cairo_t) void;
pub extern fn cairo_save(cr: *cairo_t) void;
pub extern fn cairo_restore(cr: *cairo_t) void;
pub extern fn cairo_translate(cr: *cairo_t, tx: f64, ty: f64) void;
pub extern fn cairo_rotate(cr: *cairo_t, angle: f64) void;
pub extern fn cairo_move_to(cr: *cairo_t, x: f64, y: f64) void;
pub extern fn cairo_arc(cr: *cairo_t, xc: f64, yc: f64, radius: f64, angle1: f64, angle2: f64) void;
pub extern fn cairo_close_path(cr: *cairo_t) void;
Expand Down
Loading