diff --git a/CLAUDE.md b/CLAUDE.md index d735c39..afdc9e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. @@ -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`. diff --git a/src/app.zig b/src/app.zig index eaa13b2..a0c875e 100644 --- a/src/app.zig +++ b/src/app.zig @@ -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; @@ -22,11 +23,10 @@ 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, }; @@ -34,6 +34,7 @@ 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, @@ -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, @@ -72,6 +70,7 @@ 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), }; @@ -79,6 +78,7 @@ pub const AppState = struct { } pub fn deinit(self: *AppState) void { + self.audio.deinit(); self.game_state.deinit(); self.clearHistory(); self.history.deinit(); @@ -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(); } }; diff --git a/src/audio.zig b/src/audio.zig new file mode 100644 index 0000000..b94ac97 --- /dev/null +++ b/src/audio.zig @@ -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); + 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; +} diff --git a/src/gtk.zig b/src/gtk.zig index 86bde34..a498796 100644 --- a/src/gtk.zig +++ b/src/gtk.zig @@ -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; @@ -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 {}; @@ -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; @@ -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; @@ -121,6 +127,9 @@ 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; @@ -128,7 +137,12 @@ 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; diff --git a/src/render.zig b/src/render.zig index 6e853cf..20a9ab9 100644 --- a/src/render.zig +++ b/src/render.zig @@ -18,12 +18,17 @@ pub fn drawWheel(_: *gtk.GtkDrawingArea, cr: *gtk.cairo_t, width: gtk.gint, heig const w: f64 = @floatFromInt(width); const h: f64 = @floatFromInt(height); const cx = w * 0.5; - const cy = h * 0.58; - const radius = @min(w, h) * 0.36; + const cy = h * 0.5; + const radius = @min(w, h) * 0.46; - gtk.cairo_set_source_rgb(cr, 0.06, 0.07, 0.07); + gtk.cairo_set_source_rgb(cr, 0.07, 0.09, 0.13); gtk.cairo_paint(cr); + // Dark outer rim that frames the coloured pockets. + gtk.cairo_set_source_rgb(cr, 0.10, 0.11, 0.13); + gtk.cairo_arc(cr, cx, cy, radius * 1.06, 0, std.math.tau); + gtk.cairo_fill(cr); + const slice = wheel.sliceAngle(); for (wheel.order, 0..) |number, i| { const start = state.wheel_angle + @as(f64, @floatFromInt(i)) * slice; @@ -33,32 +38,56 @@ pub fn drawWheel(_: *gtk.GtkDrawingArea, cr: *gtk.cairo_t, width: gtk.gint, heig gtk.cairo_arc(cr, cx, cy, radius, start, end); gtk.cairo_close_path(cr); gtk.cairo_fill_preserve(cr); - gtk.cairo_set_source_rgb(cr, 0.94, 0.88, 0.72); + gtk.cairo_set_source_rgba(cr, 0.0, 0.0, 0.0, 0.45); gtk.cairo_set_line_width(cr, 1); gtk.cairo_stroke(cr); + + drawPocketNumber(cr, cx, cy, radius, start + slice * 0.5, number); } - gtk.cairo_set_source_rgb(cr, 0.12, 0.08, 0.04); - gtk.cairo_arc(cr, cx, cy, radius * 0.56, 0, std.math.tau); + // Hub: dark disc with a soft golden core, drawn over the pocket centres. + gtk.cairo_set_source_rgb(cr, 0.13, 0.14, 0.16); + gtk.cairo_arc(cr, cx, cy, radius * 0.52, 0, std.math.tau); gtk.cairo_fill(cr); gtk.cairo_set_source_rgb(cr, 0.82, 0.62, 0.26); - gtk.cairo_arc(cr, cx, cy, radius * 0.22, 0, std.math.tau); + gtk.cairo_arc(cr, cx, cy, radius * 0.16, 0, std.math.tau); gtk.cairo_fill(cr); - const ball_r = radius * 0.77; + const ball_r = radius * 0.80; const bx = cx + @cos(state.ball_angle) * ball_r; const by = cy + @sin(state.ball_angle) * ball_r; gtk.cairo_set_source_rgb(cr, 0.97, 0.97, 0.93); - gtk.cairo_arc(cr, bx, by, 8, 0, std.math.tau); + gtk.cairo_arc(cr, bx, by, 7, 0, std.math.tau); gtk.cairo_fill(cr); - if (state.last_number) |number| { - var buf: [64]u8 = undefined; - const text = std.fmt.bufPrintZ(&buf, "Resultat: {d}", .{number}) catch "Resultat"; - drawCenteredText(cr, text.ptr, cx, 32, 24); - } else { - drawCenteredText(cr, "Place tes mises", cx, 32, 22); - } + // Last result, large, in the centre of the hub (hidden while spinning). + if (state.last_number) |number| if (!state.spinning) { + var buf: [16]u8 = undefined; + const text = std.fmt.bufPrintZ(&buf, "{d}", .{number}) catch ""; + drawCenteredText(cr, text.ptr, cx, cy + radius * 0.12, radius * 0.30); + }; +} + +/// Draw `number` inside its pocket, rotated so it reads radially (top of the +/// glyph pointing outward), matching a real wheel. +fn drawPocketNumber(cr: *gtk.cairo_t, cx: f64, cy: f64, radius: f64, mid: f64, number: u8) void { + const text_r = radius * 0.87; + const tx = cx + @cos(mid) * text_r; + const ty = cy + @sin(mid) * text_r; + + var buf: [4:0]u8 = numberLabel(number); + + gtk.cairo_save(cr); + gtk.cairo_translate(cr, tx, ty); + gtk.cairo_rotate(cr, mid + std.math.pi / 2.0); + gtk.cairo_select_font_face(cr, "Sans", gtk.CAIRO_FONT_SLANT_NORMAL, gtk.CAIRO_FONT_WEIGHT_BOLD); + gtk.cairo_set_font_size(cr, @max(radius * 0.068, 8.0)); + var extents: gtk.cairo_text_extents_t = undefined; + gtk.cairo_text_extents(cr, &buf, &extents); + gtk.cairo_set_source_rgb(cr, 0.98, 0.97, 0.93); + gtk.cairo_move_to(cr, -extents.width / 2.0 - extents.x_bearing, -extents.height / 2.0 - extents.y_bearing); + gtk.cairo_show_text(cr, &buf); + gtk.cairo_restore(cr); } pub fn drawTable(_: *gtk.GtkDrawingArea, cr: *gtk.cairo_t, width: gtk.gint, height: gtk.gint, data: ?*anyopaque) callconv(.c) void { @@ -67,7 +96,7 @@ pub fn drawTable(_: *gtk.GtkDrawingArea, cr: *gtk.cairo_t, width: gtk.gint, heig const w: f64 = @floatFromInt(width); const h: f64 = @floatFromInt(height); - gtk.cairo_set_source_rgb(cr, 0.02, 0.31, 0.16); + gtk.cairo_set_source_rgb(cr, 0.20, 0.25, 0.38); gtk.cairo_paint(cr); const margin = 16.0; @@ -122,29 +151,61 @@ fn drawZone(state: *app.AppState, cr: *gtk.cairo_t, zone: app.HitZone, text: [*: const selected = if (state.selected) |kind| std.meta.eql(kind, zone.kind) else false; switch (color) { - .red => gtk.cairo_set_source_rgb(cr, 0.58, 0.04, 0.04), - .black => gtk.cairo_set_source_rgb(cr, 0.03, 0.035, 0.04), - .green => gtk.cairo_set_source_rgb(cr, 0.02, 0.42, 0.20), - .neutral => gtk.cairo_set_source_rgb(cr, 0.05, 0.36, 0.18), + .red => gtk.cairo_set_source_rgb(cr, 0.80, 0.11, 0.18), + .black => gtk.cairo_set_source_rgb(cr, 0.18, 0.22, 0.30), + .green => gtk.cairo_set_source_rgb(cr, 0.10, 0.55, 0.30), + .neutral => gtk.cairo_set_source_rgb(cr, 0.16, 0.20, 0.28), } - gtk.cairo_rectangle(cr, zone.x, zone.y, zone.w, zone.h); + gtk.cairo_rectangle(cr, zone.x + 2.0, zone.y + 2.0, zone.w - 4.0, zone.h - 4.0); gtk.cairo_fill_preserve(cr); if (selected) { gtk.cairo_set_source_rgb(cr, 1.0, 0.84, 0.22); - gtk.cairo_set_line_width(cr, 4); + gtk.cairo_set_line_width(cr, 3); } else { - gtk.cairo_set_source_rgb(cr, 0.9, 0.84, 0.68); - gtk.cairo_set_line_width(cr, 1.5); + gtk.cairo_set_source_rgba(cr, 0.0, 0.0, 0.0, 0.35); + gtk.cairo_set_line_width(cr, 1.0); } gtk.cairo_stroke(cr); drawCenteredText(cr, text, zone.x + zone.w / 2.0, zone.y + zone.h / 2.0 + 5.0, 15); + + const staked = stakedOn(state, zone.kind); + if (staked > 0) drawChip(cr, zone, staked); +} + +/// Sum of every bet currently staked on `kind` (a single table zone). +fn stakedOn(state: *app.AppState, kind: game.BetKind) i64 { + var total: i64 = 0; + for (state.game_state.bets.items) |bet| { + if (std.meta.eql(bet.kind, kind)) total += bet.amount; + } + return total; +} + +/// Draw a casino-style chip in the bottom-right corner of a zone, labelled with +/// the total amount staked there, so bets are visible directly on the table. +fn drawChip(cr: *gtk.cairo_t, zone: app.HitZone, amount: i64) void { + const r = @min(@min(zone.w, zone.h) * 0.32, 18.0); + const cx = zone.x + zone.w - r - 3.0; + const cy = zone.y + zone.h - r - 3.0; + + gtk.cairo_set_source_rgb(cr, 0.86, 0.16, 0.16); + gtk.cairo_arc(cr, cx, cy, r, 0, std.math.tau); + gtk.cairo_fill(cr); + gtk.cairo_set_source_rgb(cr, 0.97, 0.95, 0.88); + gtk.cairo_set_line_width(cr, 2); + gtk.cairo_arc(cr, cx, cy, r, 0, std.math.tau); + gtk.cairo_stroke(cr); + + var buf: [16]u8 = undefined; + const text = std.fmt.bufPrintZ(&buf, "{d}", .{amount}) catch ""; + drawCenteredText(cr, text.ptr, cx, cy + 4.0, r * 0.72); } fn setNumberColor(cr: *gtk.cairo_t, number: u8) void { switch (colorTagForNumber(number)) { - .red => gtk.cairo_set_source_rgb(cr, 0.62, 0.03, 0.03), - .black => gtk.cairo_set_source_rgb(cr, 0.025, 0.027, 0.03), - .green => gtk.cairo_set_source_rgb(cr, 0.0, 0.34, 0.16), + .red => gtk.cairo_set_source_rgb(cr, 0.80, 0.11, 0.18), + .black => gtk.cairo_set_source_rgb(cr, 0.13, 0.15, 0.18), + .green => gtk.cairo_set_source_rgb(cr, 0.10, 0.55, 0.30), .neutral => gtk.cairo_set_source_rgb(cr, 0.1, 0.1, 0.1), } } diff --git a/src/style.css b/src/style.css index b810168..8aae5d5 100644 --- a/src/style.css +++ b/src/style.css @@ -42,36 +42,42 @@ windowhandle label { .menu-copy { color: #bbb5a5; } -.roulette-sidebar { - background: #202322; - color: #f4efdf; - border-radius: 8px; - padding: 12px; +.play-area { + background: #2a3450; + padding: 16px; } -.roulette-list { - background: #151817; +.control-rail { + background: #14181c; color: #f4efdf; - border-radius: 6px; + padding: 14px; } -.roulette-sidebar label { +.control-rail label { color: #f4efdf; } -.roulette-sidebar spinbutton, -.roulette-sidebar spinbutton { +.control-rail spinbutton { color: #211f1c; } -.roulette-sidebar button { +.control-rail button { background: #303532; color: #f4efdf; border-color: #565e58; } -.roulette-sidebar button label { +.control-rail button label { color: #f4efdf; } -.roulette-sidebar button.suggested-action { - background: #2f7df6; +.spin-button { + padding-top: 12px; + padding-bottom: 12px; + font-weight: 800; +} +.control-rail button.suggested-action, +.control-rail button.spin-button { + background: #19b377; + color: #ffffff; + border-color: #19b377; +} +.control-rail button.suggested-action label { color: #ffffff; - border-color: #2f7df6; } .menu-panel button.suggested-action { background: #f7a41d; @@ -81,29 +87,33 @@ windowhandle label { .menu-panel button.suggested-action label { color: #121514; } -.roulette-sidebar button.suggested-action label { +.balance-pill { + background: #14181c; + color: #f4efdf; + border-radius: 14px; + padding: 6px 14px; + font-weight: 700; +} +.result-chip { + border-radius: 13px; + padding: 3px 9px; + font-weight: 800; color: #ffffff; } -.history-row, -.bet-row { - padding: 6px; - border-radius: 6px; +.chip-red { + background: #cc1c2e; } -.history-row { - background: #181c1a; +.chip-black { + background: #2e394d; +} +.chip-green { + background: #19a05a; } -.bet-row { - background: #151817; +.play-area button.flat { + background: transparent; + border: none; + color: #d6dcea; } .muted-label { color: #bbb5a5; } -.profit-positive { - color: #75d878; -} -.profit-negative { - color: #ff7b72; -} -.profit-neutral { - color: #d6c99b; -} diff --git a/src/ui.zig b/src/ui.zig index 4b91b21..92352bd 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -76,32 +76,94 @@ pub fn buildMenu(state: *app.AppState, game_toolbar: *gtk.GtkWidget) *gtk.GtkWid return root; } -/// Build the game view (wheel + table + sidebar) inside the given toolbar. +/// Build the game view: a slim control rail on the left, then the wheel, the +/// last-results strip and the betting table filling the rest. No bulky sidebar. fn buildGame(state: *app.AppState, toolbar: *gtk.GtkWidget) *gtk.GtkWidget { - const root = gtk.gtk_box_new(gtk.GTK_ORIENTATION_HORIZONTAL, 16); + const root = gtk.gtk_box_new(gtk.GTK_ORIENTATION_HORIZONTAL, 0); gtk.gtk_widget_add_css_class(root, "roulette-root"); - gtk.gtk_widget_set_margin_top(root, 16); - gtk.gtk_widget_set_margin_bottom(root, 16); - gtk.gtk_widget_set_margin_start(root, 16); - gtk.gtk_widget_set_margin_end(root, 16); + // --- Left control rail ------------------------------------------------ + const rail = gtk.gtk_box_new(gtk.GTK_ORIENTATION_VERTICAL, 10); + gtk.gtk_widget_add_css_class(rail, "control-rail"); + gtk.gtk_widget_set_size_request(rail, 190, -1); + gtk.gtk_box_append(@ptrCast(root), rail); + + addTitle(rail, "Mise"); + state.amount_spin = gtk.gtk_spin_button_new_with_range(1, 1000, 5); + gtk.gtk_spin_button_set_value(@ptrCast(state.amount_spin.?), 25); + _ = gtk.g_signal_connect_data(state.amount_spin.?, "value-changed", @ptrCast(&amountChanged), state, null, 0); + gtk.gtk_box_append(@ptrCast(rail), state.amount_spin.?); + + const scale_row = gtk.gtk_box_new(gtk.GTK_ORIENTATION_HORIZONTAL, 8); + const half_button = gtk.gtk_button_new_with_label("½"); + gtk.gtk_widget_set_hexpand(half_button, 1); + _ = gtk.g_signal_connect_data(half_button, "clicked", @ptrCast(&halveClicked), state, null, 0); + gtk.gtk_box_append(@ptrCast(scale_row), half_button); + const double_button = gtk.gtk_button_new_with_label("2×"); + gtk.gtk_widget_set_hexpand(double_button, 1); + _ = gtk.g_signal_connect_data(double_button, "clicked", @ptrCast(&doubleClicked), state, null, 0); + gtk.gtk_box_append(@ptrCast(scale_row), double_button); + gtk.gtk_box_append(@ptrCast(rail), scale_row); + + state.spin_button = gtk.gtk_button_new_with_label("Lancer"); + gtk.gtk_widget_add_css_class(state.spin_button.?, "suggested-action"); + gtk.gtk_widget_add_css_class(state.spin_button.?, "spin-button"); + _ = gtk.g_signal_connect_data(state.spin_button.?, "clicked", @ptrCast(&spinClicked), state, null, 0); + gtk.gtk_box_append(@ptrCast(rail), state.spin_button.?); + + // Spacer pushes the status text + reset to the bottom of the rail. + const spacer = gtk.gtk_box_new(gtk.GTK_ORIENTATION_VERTICAL, 0); + gtk.gtk_widget_set_vexpand(spacer, 1); + gtk.gtk_box_append(@ptrCast(rail), spacer); + + state.status_label = gtk.gtk_label_new("Clique une zone du tapis pour miser."); + gtk.gtk_label_set_wrap(@ptrCast(state.status_label.?), 1); + gtk.gtk_label_set_xalign(@ptrCast(state.status_label.?), 0); + gtk.gtk_widget_add_css_class(state.status_label.?, "muted-label"); + gtk.gtk_box_append(@ptrCast(rail), state.status_label.?); + + const new_session_button = gtk.gtk_button_new_with_label("Nouvelle session"); + _ = gtk.g_signal_connect_data(new_session_button, "clicked", @ptrCast(&newSessionClicked), state, null, 0); + gtk.gtk_box_append(@ptrCast(rail), new_session_button); + + // --- Main play area --------------------------------------------------- const play_box = gtk.gtk_box_new(gtk.GTK_ORIENTATION_VERTICAL, 12); + gtk.gtk_widget_add_css_class(play_box, "play-area"); gtk.gtk_widget_set_hexpand(play_box, 1); gtk.gtk_widget_set_vexpand(play_box, 1); gtk.gtk_box_append(@ptrCast(root), play_box); + const top_bar = gtk.gtk_box_new(gtk.GTK_ORIENTATION_HORIZONTAL, 12); + + const sound_button = gtk.gtk_button_new_with_label("🔊"); + gtk.gtk_widget_add_css_class(sound_button, "flat"); + _ = gtk.g_signal_connect_data(sound_button, "clicked", @ptrCast(&soundClicked), state, null, 0); + gtk.gtk_box_append(@ptrCast(top_bar), sound_button); + + state.balance_label = gtk.gtk_label_new(""); + gtk.gtk_widget_add_css_class(state.balance_label.?, "balance-pill"); + gtk.gtk_label_set_xalign(@ptrCast(state.balance_label.?), 0); + gtk.gtk_widget_set_hexpand(state.balance_label.?, 1); + gtk.gtk_widget_set_halign(state.balance_label.?, gtk.GTK_ALIGN_START); + gtk.gtk_box_append(@ptrCast(top_bar), state.balance_label.?); + + state.history_strip = gtk.gtk_box_new(gtk.GTK_ORIENTATION_HORIZONTAL, 5); + gtk.gtk_widget_set_halign(state.history_strip.?, gtk.GTK_ALIGN_END); + gtk.gtk_box_append(@ptrCast(top_bar), state.history_strip.?); + gtk.gtk_box_append(@ptrCast(play_box), top_bar); + const wheel_area = gtk.gtk_drawing_area_new(); state.wheel_area = wheel_area; - gtk.gtk_widget_set_size_request(wheel_area, 520, 360); + gtk.gtk_widget_set_size_request(wheel_area, 360, 320); gtk.gtk_widget_set_hexpand(wheel_area, 1); + gtk.gtk_widget_set_vexpand(wheel_area, 1); gtk.gtk_drawing_area_set_draw_func(@ptrCast(wheel_area), @ptrCast(&render.drawWheel), state, null); gtk.gtk_box_append(@ptrCast(play_box), wheel_area); const table = gtk.gtk_drawing_area_new(); state.table_area = table; - gtk.gtk_widget_set_size_request(table, 720, 320); + gtk.gtk_widget_set_size_request(table, 720, 300); gtk.gtk_widget_set_hexpand(table, 1); - gtk.gtk_widget_set_vexpand(table, 1); gtk.gtk_drawing_area_set_draw_func(@ptrCast(table), @ptrCast(&render.drawTable), state, null); const click = gtk.gtk_gesture_click_new(); @@ -110,77 +172,19 @@ fn buildGame(state: *app.AppState, toolbar: *gtk.GtkWidget) *gtk.GtkWidget { _ = gtk.g_signal_connect_data(click, "pressed", @ptrCast(&tablePressed), state, null, 0); gtk.gtk_box_append(@ptrCast(play_box), table); - const side = gtk.gtk_box_new(gtk.GTK_ORIENTATION_VERTICAL, 10); - gtk.gtk_widget_add_css_class(side, "roulette-sidebar"); - gtk.gtk_widget_set_size_request(side, 300, -1); - gtk.gtk_box_append(@ptrCast(root), side); - - state.balance_label = gtk.gtk_label_new(""); - gtk.gtk_label_set_xalign(@ptrCast(state.balance_label.?), 0); - addTitle(side, "Solde"); - gtk.gtk_box_append(@ptrCast(side), state.balance_label.?); - - state.result_label = gtk.gtk_label_new("Aucun tirage"); - gtk.gtk_label_set_wrap(@ptrCast(state.result_label.?), 1); - gtk.gtk_label_set_xalign(@ptrCast(state.result_label.?), 0); - addTitle(side, "Dernier resultat"); - gtk.gtk_box_append(@ptrCast(side), state.result_label.?); - - state.selected_label = gtk.gtk_label_new(""); - gtk.gtk_label_set_wrap(@ptrCast(state.selected_label.?), 1); - gtk.gtk_label_set_xalign(@ptrCast(state.selected_label.?), 0); - addTitle(side, "Selection"); - gtk.gtk_box_append(@ptrCast(side), state.selected_label.?); - - const amount_row = gtk.gtk_box_new(gtk.GTK_ORIENTATION_HORIZONTAL, 8); - const amount_label = gtk.gtk_label_new("Montant"); - state.amount_spin = gtk.gtk_spin_button_new_with_range(1, 1000, 5); - gtk.gtk_spin_button_set_value(@ptrCast(state.amount_spin.?), 25); - _ = gtk.g_signal_connect_data(state.amount_spin.?, "value-changed", @ptrCast(&amountChanged), state, null, 0); - gtk.gtk_box_append(@ptrCast(amount_row), amount_label); - gtk.gtk_box_append(@ptrCast(amount_row), state.amount_spin.?); - const max_button = gtk.gtk_button_new_with_label("Max"); - _ = gtk.g_signal_connect_data(max_button, "clicked", @ptrCast(&maxClicked), state, null, 0); - gtk.gtk_box_append(@ptrCast(amount_row), max_button); - gtk.gtk_box_append(@ptrCast(side), amount_row); - - const add_button = gtk.gtk_button_new_with_label("Ajouter la mise"); - _ = gtk.g_signal_connect_data(add_button, "clicked", @ptrCast(&addBetClicked), state, null, 0); - gtk.gtk_box_append(@ptrCast(side), add_button); - - state.spin_button = gtk.gtk_button_new_with_label("Lancer"); - gtk.gtk_widget_add_css_class(state.spin_button.?, "suggested-action"); - _ = gtk.g_signal_connect_data(state.spin_button.?, "clicked", @ptrCast(&spinClicked), state, null, 0); - gtk.gtk_box_append(@ptrCast(side), state.spin_button.?); - - const clear_button = gtk.gtk_button_new_with_label("Effacer les mises"); - _ = gtk.g_signal_connect_data(clear_button, "clicked", @ptrCast(&clearClicked), state, null, 0); - gtk.gtk_box_append(@ptrCast(side), clear_button); - - const undo_button = gtk.gtk_button_new_with_label("Annuler derniere mise"); + const bottom_bar = gtk.gtk_box_new(gtk.GTK_ORIENTATION_HORIZONTAL, 8); + const undo_button = gtk.gtk_button_new_with_label("↩ Annuler"); + gtk.gtk_widget_add_css_class(undo_button, "flat"); _ = gtk.g_signal_connect_data(undo_button, "clicked", @ptrCast(&undoClicked), state, null, 0); - gtk.gtk_box_append(@ptrCast(side), undo_button); - - const new_session_button = gtk.gtk_button_new_with_label("Nouvelle session"); - _ = gtk.g_signal_connect_data(new_session_button, "clicked", @ptrCast(&newSessionClicked), state, null, 0); - gtk.gtk_box_append(@ptrCast(side), new_session_button); - - state.status_label = gtk.gtk_label_new("Choisis un montant puis clique une zone du tapis pour ajouter la mise."); - gtk.gtk_label_set_wrap(@ptrCast(state.status_label.?), 1); - gtk.gtk_label_set_xalign(@ptrCast(state.status_label.?), 0); - gtk.gtk_box_append(@ptrCast(side), state.status_label.?); - - addTitle(side, "Mises du tour"); - state.bet_list = gtk.gtk_list_box_new(); - gtk.gtk_widget_add_css_class(state.bet_list.?, "roulette-list"); - gtk.gtk_widget_set_vexpand(state.bet_list.?, 1); - gtk.gtk_box_append(@ptrCast(side), state.bet_list.?); - - addTitle(side, "Historique"); - state.history_list = gtk.gtk_list_box_new(); - gtk.gtk_widget_add_css_class(state.history_list.?, "roulette-list"); - gtk.gtk_widget_set_vexpand(state.history_list.?, 1); - gtk.gtk_box_append(@ptrCast(side), state.history_list.?); + gtk.gtk_box_append(@ptrCast(bottom_bar), undo_button); + const bottom_spacer = gtk.gtk_box_new(gtk.GTK_ORIENTATION_HORIZONTAL, 0); + gtk.gtk_widget_set_hexpand(bottom_spacer, 1); + gtk.gtk_box_append(@ptrCast(bottom_bar), bottom_spacer); + const clear_button = gtk.gtk_button_new_with_label("Effacer ✕"); + gtk.gtk_widget_add_css_class(clear_button, "flat"); + _ = gtk.g_signal_connect_data(clear_button, "clicked", @ptrCast(&clearClicked), state, null, 0); + gtk.gtk_box_append(@ptrCast(bottom_bar), clear_button); + gtk.gtk_box_append(@ptrCast(play_box), bottom_bar); gtk.adw_toolbar_view_set_content(@ptrCast(toolbar), root); return toolbar; @@ -231,35 +235,40 @@ fn playClicked(_: *gtk.GtkButton, data: ?*anyopaque) callconv(.c) void { refreshUi(state); } -fn maxClicked(_: *gtk.GtkButton, data: ?*anyopaque) callconv(.c) void { +fn halveClicked(_: *gtk.GtkButton, data: ?*anyopaque) callconv(.c) void { const state: *app.AppState = @ptrCast(@alignCast(data.?)); if (state.spinning) return; - - const max_amount = @max(state.game_state.available(), 1); - state.amount = max_amount; - if (state.amount_spin) |spin| { - gtk.gtk_spin_button_set_value(@ptrCast(spin), @floatFromInt(max_amount)); - } - setStatus(state, "Montant regle sur le solde disponible."); - refreshUi(state); + state.audio.play(.chip); + setAmount(state, @max(@divTrunc(state.amount, 2), 1)); } -fn addBetClicked(_: *gtk.GtkButton, data: ?*anyopaque) callconv(.c) void { +fn doubleClicked(_: *gtk.GtkButton, data: ?*anyopaque) callconv(.c) void { const state: *app.AppState = @ptrCast(@alignCast(data.?)); if (state.spinning) return; + state.audio.play(.chip); + setAmount(state, @min(state.amount * 2, 1000)); +} - const kind = state.selected orelse { - setStatus(state, "Selectionne d'abord une zone du tapis."); - return; - }; +fn soundClicked(button: *gtk.GtkButton, data: ?*anyopaque) callconv(.c) void { + const state: *app.AppState = @ptrCast(@alignCast(data.?)); + const on = state.audio.toggle(); + gtk.gtk_button_set_label(button, if (on) "🔊" else "🔇"); +} - addBetForKind(state, kind); +/// Set the stake amount and keep the spin button's display in sync. +fn setAmount(state: *app.AppState, amount: i64) void { + state.amount = amount; + if (state.amount_spin) |spin| { + gtk.gtk_spin_button_set_value(@ptrCast(spin), @floatFromInt(amount)); + } + refreshUi(state); } fn clearClicked(_: *gtk.GtkButton, data: ?*anyopaque) callconv(.c) void { const state: *app.AppState = @ptrCast(@alignCast(data.?)); if (state.spinning) return; state.game_state.clearBets(); + state.audio.play(.chip); setStatus(state, "Mises effacees."); refreshUi(state); } @@ -269,6 +278,7 @@ fn undoClicked(_: *gtk.GtkButton, data: ?*anyopaque) callconv(.c) void { if (state.spinning) return; if (state.game_state.undoLastBet()) { + state.audio.play(.chip); setStatus(state, "Derniere mise annulee."); } else { setStatus(state, "Aucune mise a annuler."); @@ -316,6 +326,7 @@ fn addBetForKind(state: *app.AppState, kind: game.BetKind) void { } return; }; + state.audio.play(.chip); setStatus(state, "Mise ajoutee."); refreshUi(state); } @@ -338,6 +349,7 @@ fn spinClicked(_: *gtk.GtkButton, data: ?*anyopaque) callconv(.c) void { state.spin_end_wheel_angle = state.spin_start_wheel_angle + std.math.tau * 4.0 + state.rng.random().float(f64) * std.math.tau; state.spin_end_ball_angle = state.spin_end_wheel_angle + wheel.angleForNumber(state.spin_target) + wheel.sliceAngle() / 2.0; state.spinning = true; + state.audio.play(.spin); gtk.gtk_widget_set_sensitive(state.spin_button.?, 0); setStatus(state, "La roue tourne..."); _ = gtk.g_timeout_add(16, @ptrCast(&spinTick), state); @@ -364,6 +376,7 @@ fn spinTick(data: ?*anyopaque) callconv(.c) gtk.gboolean { const outcome = game.outcomeForNumber(state.spin_target); const settled = state.game_state.settle(outcome); + if (settled.profit > 0) state.audio.play(.win); appendHistory(state, outcome, settled) catch {}; setSpinResultStatus(state, outcome, settled); gtk.gtk_widget_set_sensitive(state.spin_button.?, 1); @@ -374,36 +387,9 @@ fn spinTick(data: ?*anyopaque) callconv(.c) gtk.gboolean { } fn appendHistory(state: *app.AppState, outcome: game.SpinOutcome, settled: game.SettleResult) !void { - var summary_buf: [64]u8 = undefined; - var details_buf: [128]u8 = undefined; - const color = if (outcome.color) |col| col.label() else "Vert"; - const sign: []const u8 = if (settled.profit >= 0) "+" else ""; - const summary = try std.fmt.bufPrint(&summary_buf, "{d} {s}", .{ - outcome.number, - color, - }); - const details = try std.fmt.bufPrint(&details_buf, "Mise {d} · Retour {d} · {s}{d}", .{ - settled.wagered, - settled.returned, - sign, - settled.profit, - }); - const summary_owned = try state.allocator.dupeZ(u8, summary); - errdefer state.allocator.free(summary_owned); - const details_owned = try state.allocator.dupeZ(u8, details); - errdefer state.allocator.free(details_owned); - - const entry: app.HistoryEntry = .{ - .summary = summary_owned, - .details = details_owned, - .profit = settled.profit, - }; - - try state.history.insert(0, entry); + try state.history.insert(0, .{ .number = outcome.number, .profit = settled.profit }); while (state.history.items.len > app.HistoryLimit) { - const old = state.history.pop().?; - state.allocator.free(old.summary); - state.allocator.free(old.details); + _ = state.history.pop(); } } @@ -413,106 +399,42 @@ fn appendHistory(state: *app.AppState, outcome: game.SpinOutcome, settled: game. pub fn refreshUi(state: *app.AppState) void { if (state.balance_label) |label| { var buf: [128]u8 = undefined; - const text = std.fmt.bufPrintZ(&buf, "{d} credits | disponible {d}", .{ + const text = std.fmt.bufPrintZ(&buf, "Solde {d} · dispo {d}", .{ state.game_state.balance, state.game_state.available(), }) catch "Erreur"; gtk.gtk_label_set_text(@ptrCast(label), text.ptr); } - if (state.selected_label) |label| { - var buf: [128]u8 = undefined; - const text = if (state.selected) |kind| kindLabelZ(&buf, kind) else "Aucune zone selectionnee"; - gtk.gtk_label_set_text(@ptrCast(label), text.ptr); - } - - if (state.result_label) |label| { - var buf: [160]u8 = undefined; - const text = resultLabelZ(&buf, state) catch "Aucun tirage"; - gtk.gtk_label_set_text(@ptrCast(label), text.ptr); - } - - rebuildBetList(state); - rebuildHistoryList(state); + rebuildHistoryStrip(state); if (state.table_area) |area| gtk.gtk_widget_queue_draw(area); if (state.wheel_area) |area| gtk.gtk_widget_queue_draw(area); } -fn rebuildBetList(state: *app.AppState) void { - const list = state.bet_list orelse return; - clearListBox(list); - - for (state.game_state.bets.items) |bet| { - appendBetRow(list, bet); - } -} - -fn rebuildHistoryList(state: *app.AppState) void { - const list = state.history_list orelse return; - clearListBox(list); - for (state.history.items) |entry| appendHistoryRow(list, entry); -} - -fn clearListBox(list: *gtk.GtkWidget) void { - while (true) { - const child = gtk.gtk_widget_get_first_child(list) orelse break; - gtk.gtk_list_box_remove(@ptrCast(list), child); +/// Rebuild the row of coloured chips showing the most recent drawn numbers. +fn rebuildHistoryStrip(state: *app.AppState) void { + const strip = state.history_strip orelse return; + while (gtk.gtk_widget_get_first_child(strip)) |child| { + gtk.gtk_box_remove(@ptrCast(strip), child); } + for (state.history.items) |entry| appendHistoryChip(strip, entry); } -fn appendBetRow(list: *gtk.GtkWidget, bet: game.Bet) void { - const row = gtk.gtk_box_new(gtk.GTK_ORIENTATION_HORIZONTAL, 8); - gtk.gtk_widget_add_css_class(row, "bet-row"); - gtk.gtk_widget_set_margin_top(row, 3); - gtk.gtk_widget_set_margin_bottom(row, 3); - gtk.gtk_widget_set_margin_start(row, 3); - gtk.gtk_widget_set_margin_end(row, 3); - - var kind_buf: [96]u8 = undefined; - const kind_text = kindLabelZ(&kind_buf, bet.kind); - const kind_label = gtk.gtk_label_new(kind_text.ptr); - gtk.gtk_label_set_xalign(@ptrCast(kind_label), 0); - gtk.gtk_widget_set_hexpand(kind_label, 1); - gtk.gtk_widget_set_halign(kind_label, gtk.GTK_ALIGN_START); - gtk.gtk_box_append(@ptrCast(row), kind_label); - - var amount_buf: [32]u8 = undefined; - const amount_text = std.fmt.bufPrintZ(&amount_buf, "{d}", .{bet.amount}) catch "0"; - const amount_label = gtk.gtk_label_new(amount_text.ptr); - gtk.gtk_widget_add_css_class(amount_label, "muted-label"); - gtk.gtk_label_set_xalign(@ptrCast(amount_label), 1); - gtk.gtk_widget_set_halign(amount_label, gtk.GTK_ALIGN_END); - gtk.gtk_box_append(@ptrCast(row), amount_label); - - gtk.gtk_list_box_append(@ptrCast(list), row); -} - -fn appendHistoryRow(list: *gtk.GtkWidget, entry: app.HistoryEntry) void { - const row = gtk.gtk_box_new(gtk.GTK_ORIENTATION_VERTICAL, 3); - gtk.gtk_widget_add_css_class(row, "history-row"); - gtk.gtk_widget_set_margin_top(row, 3); - gtk.gtk_widget_set_margin_bottom(row, 3); - gtk.gtk_widget_set_margin_start(row, 3); - gtk.gtk_widget_set_margin_end(row, 3); - - const summary = gtk.gtk_label_new(entry.summary.ptr); - gtk.gtk_label_set_xalign(@ptrCast(summary), 0); - gtk.gtk_widget_add_css_class(summary, profitCssClass(entry.profit)); - gtk.gtk_box_append(@ptrCast(row), summary); - - const details = gtk.gtk_label_new(entry.details.ptr); - gtk.gtk_label_set_xalign(@ptrCast(details), 0); - gtk.gtk_label_set_wrap(@ptrCast(details), 1); - gtk.gtk_widget_add_css_class(details, "muted-label"); - gtk.gtk_box_append(@ptrCast(row), details); - - gtk.gtk_list_box_append(@ptrCast(list), row); +fn appendHistoryChip(strip: *gtk.GtkWidget, entry: app.HistoryEntry) void { + var buf: [4]u8 = undefined; + const text = std.fmt.bufPrintZ(&buf, "{d}", .{entry.number}) catch "?"; + const chip = gtk.gtk_label_new(text.ptr); + gtk.gtk_widget_add_css_class(chip, "result-chip"); + gtk.gtk_widget_add_css_class(chip, chipColorClass(entry.number)); + gtk.gtk_box_append(@ptrCast(strip), chip); } -fn profitCssClass(profit: i64) [*:0]const u8 { - if (profit > 0) return "profit-positive"; - if (profit < 0) return "profit-negative"; - return "profit-neutral"; +fn chipColorClass(number: u8) [*:0]const u8 { + if (number == 0) return "chip-green"; + return switch (game.colorForNumber(number).?) { + .red => "chip-red", + .black => "chip-black", + }; } // --- Status & label formatting -------------------------------------------- @@ -528,21 +450,3 @@ fn setSpinResultStatus(state: *app.AppState, outcome: game.SpinOutcome, settled: const text = std.fmt.bufPrintZ(&buf, "Resultat {d} {s} | {s}{d}", .{ outcome.number, color, sign, settled.profit }) catch "Resultat calcule."; setStatus(state, text.ptr); } - -fn kindLabelZ(buf: []u8, kind: game.BetKind) [:0]const u8 { - return switch (kind) { - .straight => |n| std.fmt.bufPrintZ(buf, "Numero plein {d}", .{n}) catch "", - .color => |color| std.fmt.bufPrintZ(buf, "{s}", .{color.label()}) catch "", - .parity => |parity| std.fmt.bufPrintZ(buf, "{s}", .{parity.label()}) catch "", - .range => |range| std.fmt.bufPrintZ(buf, "{s}", .{range.label()}) catch "", - .dozen => |dozen| std.fmt.bufPrintZ(buf, "Douzaine {s}", .{dozen.label()}) catch "", - .column => |column| std.fmt.bufPrintZ(buf, "{s}", .{column.label()}) catch "", - }; -} - -fn resultLabelZ(buf: []u8, state: *app.AppState) ![:0]const u8 { - const number = state.last_number orelse return std.fmt.bufPrintZ(buf, "Aucun tirage", .{}); - const outcome = game.outcomeForNumber(number); - const color = if (outcome.color) |col| col.label() else "Vert"; - return std.fmt.bufPrintZ(buf, "{d} {s}", .{ number, color }); -}