Skip to main content

cuqueclicker_lib/ui/
mod.rs

1pub mod achievements;
2pub mod biscuit;
3pub mod border;
4pub mod debug_pane;
5pub mod effects;
6pub mod hands;
7pub mod prestige;
8pub mod sidebar;
9pub mod stats;
10pub mod toast;
11pub mod upgrades;
12
13use ratatui::{prelude::*, widgets::*};
14
15use crate::format;
16use crate::game::state::{Buff, GameState, HUD_FLASH_TICKS, TICK_HZ};
17use crate::i18n::t;
18
19// Hardcoded as "0.0.0" in source; release.yml patches Cargo.toml before
20// building so CARGO_PKG_VERSION reflects the real version in shipped
21// binaries. A 0.0.0 build advertises itself as "(dev)" in the HUD.
22const VERSION: &str = env!("CARGO_PKG_VERSION");
23
24fn hud_title() -> String {
25    if VERSION == "0.0.0" {
26        // Dev builds include the git branch (or short SHA on detached HEAD)
27        // so two instances built from different branches can be told apart
28        // at a glance — useful for side-by-side comparison.
29        match crate::build_info::GIT_BRANCH {
30            Some(branch) => format!(" CuqueClicker v0.0.0 (dev, {branch}) "),
31            None => " CuqueClicker v0.0.0 (dev) ".into(),
32        }
33    } else {
34        format!(" CuqueClicker v{VERSION} ")
35    }
36}
37
38#[derive(Copy, Clone, PartialEq, Eq, Debug)]
39pub enum Mode {
40    Game,
41    Stats,
42    Achievements,
43    Upgrades,
44    Prestige,
45}
46
47/// Click target for a help-bar hint or for the prestige-reset confirm
48/// line. Mirrors the keyboard shortcuts so the mouse-first player has
49/// equivalent reach to every action a key would fire.
50#[derive(Clone, Copy, PartialEq, Eq, Debug)]
51pub enum HelpAction {
52    /// Open the named mode (or close it back to Game if already there).
53    OpenMode(Mode),
54    /// Catch whatever golden is on screen.
55    GrabGolden,
56    /// Confirm-and-claim prestige reset (only when prestige_available > 0).
57    PrestigeReset,
58    /// Quit the program.
59    Quit,
60}
61
62pub struct DrawOutput {
63    pub biscuit_rect: Rect,
64    pub golden_rect: Rect,
65    /// The whole left column where the biscuit + hands + particles live —
66    /// i.e. "the box that displays the ass." Used by the input router so
67    /// the scroll-wheel zoom fires anywhere in this region (including the
68    /// vast empty space around a small biscuit at low zoom), and only the
69    /// right-hand sidebar opts out of zoom.
70    pub play_area: Rect,
71    /// `(upgrade_idx, screen_row_rect)` pairs for the Upgrades panel —
72    /// populated only when the active mode renders that panel; empty
73    /// otherwise. The click router hit-tests these for `BuyUpgrade`.
74    /// First element of each tuple is also the digit-shortcut target,
75    /// kept aligned with `visible_upgrades`.
76    pub upgrade_rows: Vec<(usize, Rect)>,
77    /// `(fingerer_idx, screen_row_rect)` for the Game-mode sidebar.
78    pub fingerer_rows: Vec<(usize, Rect)>,
79    /// (action, rect) for every clickable help-bar hint at the bottom of
80    /// the play column. Mouse-first players use these to switch panels,
81    /// catch goldens, prestige-reset, and quit — all of which used to be
82    /// keyboard-only. Empty rects when the hint is non-actionable
83    /// (e.g. `[Space/Click] finger` is informational, not a click target).
84    pub help_hits: Vec<(HelpAction, Rect)>,
85    /// Click rect for the `Press [r] to reset and claim` confirm line in
86    /// the Prestige panel. Default rect when not in Prestige mode or no
87    /// prestige is available.
88    pub prestige_reset_rect: Rect,
89}
90
91fn wrapped_height(text: &str, width: u16) -> u16 {
92    if width == 0 {
93        return text.lines().count().max(1) as u16;
94    }
95    let mut total: u16 = 0;
96    for line in text.split('\n') {
97        let mut row_len: u16 = 0;
98        let mut rows: u16 = 1;
99        for word in line.split_whitespace() {
100            let wlen = word.chars().count() as u16;
101            if row_len == 0 {
102                row_len = wlen.min(width);
103            } else if row_len + 1 + wlen <= width {
104                row_len += 1 + wlen;
105            } else {
106                rows += 1;
107                row_len = wlen.min(width);
108            }
109        }
110        total = total.saturating_add(rows);
111    }
112    total.max(1)
113}
114
115fn draw_zoom_indicator(frame: &mut Frame, area: Rect, label: &str) {
116    let text = format!("zoom {}", label);
117    let w = text.chars().count() as u16;
118    if area.width < w || area.height == 0 {
119        return;
120    }
121    let col = area.x + area.width - w;
122    let row = area.y + area.height - 1;
123    let buf = frame.buffer_mut();
124    buf.set_string(
125        col,
126        row,
127        &text,
128        Style::default().fg(Color::Rgb(120, 120, 120)),
129    );
130}
131
132pub fn draw(
133    frame: &mut Frame,
134    state: &GameState,
135    mode: Mode,
136    zoom_idx: usize,
137    debug: bool,
138    mouse_pos: Option<(u16, u16)>,
139) -> DrawOutput {
140    let lang = t();
141    let area = frame.area();
142    let cols = Layout::horizontal([Constraint::Min(1), Constraint::Length(38)]).split(area);
143
144    let help_text = match mode {
145        Mode::Game => lang.help_game,
146        Mode::Stats => lang.help_stats,
147        Mode::Achievements => lang.help_ach,
148        Mode::Upgrades => lang.help_upgrades,
149        Mode::Prestige => lang.help_prestige,
150    };
151    let help_height = wrapped_height(help_text, cols[0].width).max(1);
152    let left = Layout::vertical([
153        Constraint::Length(3),
154        Constraint::Min(1),
155        Constraint::Length(help_height),
156    ])
157    .split(cols[0]);
158
159    // J5 count-up: render the smoothed `displayed_*` values rather than the
160    // raw current values. Big jumps (golden, max-buy, F4) ease in instead of
161    // snapping. Tween itself runs in `state.tick()`.
162    //
163    // Color sweep: TWO competing channels — green for cuques going UP
164    // (income, golden, F4), red for cuques going DOWN (purchase,
165    // prestige reset). Whichever channel is stronger this frame drives
166    // the lerp toward white. So a buy that lands during a still-decaying
167    // gain flash correctly flips the digits red as the spend channel
168    // overtakes the fading gain. Both lerp toward bright white at t=0,
169    // which matches the resting (no-flash) style — no hard cut.
170    let gain_t = (state.cuques_flash_ticks as f32 / HUD_FLASH_TICKS as f32).clamp(0.0, 1.0);
171    let spend_t = (state.cuques_spend_flash_ticks as f32 / HUD_FLASH_TICKS as f32).clamp(0.0, 1.0);
172    const FLASH_GAIN: (f32, f32, f32) = (80.0, 255.0, 80.0); // bright green
173    const FLASH_SPEND: (f32, f32, f32) = (255.0, 90.0, 90.0); // urgent red
174    const FLASH_REST: (f32, f32, f32) = (255.0, 255.0, 255.0);
175    let (peak, t) = if spend_t > gain_t {
176        (FLASH_SPEND, spend_t)
177    } else {
178        (FLASH_GAIN, gain_t)
179    };
180    let mix = 1.0 - t;
181    let r = peak.0 + (FLASH_REST.0 - peak.0) * mix;
182    let g = peak.1 + (FLASH_REST.1 - peak.1) * mix;
183    let b = peak.2 + (FLASH_REST.2 - peak.2) * mix;
184    let cuques_style = Style::default()
185        .fg(Color::Rgb(
186            r.clamp(0.0, 255.0) as u8,
187            g.clamp(0.0, 255.0) as u8,
188            b.clamp(0.0, 255.0) as u8,
189        ))
190        .add_modifier(Modifier::BOLD);
191    let mut hud_spans: Vec<Span> = vec![
192        Span::raw(format!("{}: ", lang.hud_cuques)),
193        Span::styled(format::big(state.displayed_cuques), cuques_style),
194        Span::raw(format!(
195            "   {}: {}",
196            lang.hud_fps,
197            format::rate(state.displayed_fps)
198        )),
199    ];
200    if state.prestige > 0 {
201        hud_spans.push(Span::styled(
202            format!(
203                "   {}: {} (+{:.0}%)",
204                lang.prestige_title.trim(),
205                state.prestige,
206                state.prestige as f64
207            ),
208            Style::default()
209                .fg(Color::Rgb(255, 215, 0))
210                .add_modifier(Modifier::BOLD),
211        ));
212    }
213    for b in &state.buffs {
214        let secs = b.ticks_remaining().div_ceil(TICK_HZ);
215        let (label, color) = match b {
216            Buff::ClickFrenzy { mult, .. } => (
217                format!("  [!! FRENZY x{} {}s]", *mult as u64, secs),
218                Color::Rgb(255, 80, 80),
219            ),
220            Buff::FingererBoost {
221                fingerer_id, mult, ..
222            } => {
223                let idx = crate::game::fingerer::FINGERERS
224                    .iter()
225                    .position(|f| f.id == fingerer_id);
226                let name = idx
227                    .and_then(|i| lang.fingerer_names.get(i).copied())
228                    .unwrap_or("?");
229                (
230                    format!("  [++ {} x{} {}s]", name, *mult as u64, secs),
231                    Color::Rgb(220, 140, 255),
232                )
233            }
234        };
235        hud_spans.push(Span::styled(
236            label,
237            Style::default().fg(color).add_modifier(Modifier::BOLD),
238        ));
239    }
240    let title = hud_title();
241    border::draw_animated(frame, left[0], state, &title);
242    let hud_inner = Rect {
243        x: left[0].x + 1,
244        y: left[0].y + 1,
245        width: left[0].width.saturating_sub(2),
246        height: left[0].height.saturating_sub(2),
247    };
248    let hud = Paragraph::new(Line::from(hud_spans));
249    frame.render_widget(hud, hud_inner);
250
251    let biscuit_rect = biscuit::draw(frame, left[1], state, zoom_idx);
252    hands::draw(frame, left[1], biscuit_rect, state);
253    effects::draw_particles(frame, biscuit_rect, &state.particles);
254    effects::draw_misclicks(frame, &state.misclick_particles);
255    draw_zoom_indicator(
256        frame,
257        left[1],
258        biscuit::level_label(zoom_idx).unwrap_or("100%"),
259    );
260
261    if debug {
262        debug_pane::draw(frame, left[1]);
263    }
264    let golden_rect = match &state.golden {
265        Some(g) => biscuit::draw_golden(frame, g, biscuit_rect),
266        None => Rect::default(),
267    };
268
269    // J1: achievement toast overlay. Lives in `left[1]` (biscuit/main area)
270    // so it covers nothing important on the right; auto-dismisses after
271    // TOAST_TICKS via the sim. We render *after* biscuit/golden so it
272    // always sits on top.
273    toast::draw(frame, left[1], state);
274
275    // Custom help-bar render: lay out `[X] label` tokens left-to-right,
276    // wrapping at the rect width, paint each with mode-aware styling
277    // (active mode bolded), and return per-token click rects so the
278    // mouse-first player can drive the game without ever touching a key.
279    let help_hits = draw_help(frame, left[2], help_text, mode, mouse_pos);
280
281    let mut upgrade_rows: Vec<(usize, Rect)> = Vec::new();
282    let mut fingerer_rows: Vec<(usize, Rect)> = Vec::new();
283    let mut prestige_reset_rect = Rect::default();
284    match mode {
285        Mode::Game => fingerer_rows = sidebar::draw(frame, cols[1], state, mouse_pos),
286        Mode::Stats => stats::draw(frame, cols[1], state),
287        Mode::Achievements => achievements::draw(frame, cols[1], state),
288        Mode::Upgrades => upgrade_rows = upgrades::draw(frame, cols[1], state, mouse_pos),
289        Mode::Prestige => prestige_reset_rect = prestige::draw(frame, cols[1], state, mouse_pos),
290    }
291
292    DrawOutput {
293        biscuit_rect,
294        golden_rect,
295        play_area: left[1],
296        upgrade_rows,
297        fingerer_rows,
298        help_hits,
299        prestige_reset_rect,
300    }
301}
302
303/// Custom help-bar renderer.
304///
305/// Splits the help string into "tokens" (each token is a contiguous
306/// non-whitespace run of `[X] label words`, separated from the next by
307/// a double space or a newline — the convention used in `i18n::Lang`'s
308/// help strings). Each token is laid out left-to-right with wrap at
309/// the rect's width, painted at the resolved screen position, and
310/// matched against a (mode, key) → action table. Clickable tokens get
311/// a slightly brighter color and BOLD; the token under the mouse
312/// cursor gets an additional brightness lift + bg fill so the player
313/// reads it as a button.
314fn draw_help(
315    frame: &mut Frame,
316    area: Rect,
317    text: &str,
318    mode: Mode,
319    mouse_pos: Option<(u16, u16)>,
320) -> Vec<(HelpAction, Rect)> {
321    let mut hits: Vec<(HelpAction, Rect)> = Vec::new();
322    if area.width == 0 || area.height == 0 {
323        return hits;
324    }
325    let buf = frame.buffer_mut();
326    let mut cursor_x: u16 = 0;
327    let mut cursor_y: u16 = 0;
328    for line in text.split('\n') {
329        // Tokens are separated by a literal `  ` (two spaces). Single
330        // spaces inside a token are content (e.g. "back to game").
331        for token in line.split("  ") {
332            let token = token.trim();
333            if token.is_empty() {
334                continue;
335            }
336            let w = token.chars().count() as u16;
337            // Wrap if the token wouldn't fit on the current line.
338            if cursor_x + w > area.width && cursor_x > 0 {
339                cursor_y += 1;
340                cursor_x = 0;
341            }
342            if cursor_y >= area.height {
343                break;
344            }
345            let action = map_help_token(token, mode);
346            // Hide `[q] quit` (or its localized equivalent) on platforms
347            // where the wasm/native runner has no authority to exit —
348            // see `platform::Capabilities::can_quit`. Skipping renders
349            // AND skips appending to `help_hits`, so the next token
350            // slides into the position cursor without leaving a gap.
351            if matches!(action, Some(HelpAction::Quit)) && !crate::platform::CAPABILITIES.can_quit {
352                continue;
353            }
354            let active = matches!(action, Some(HelpAction::OpenMode(m)) if m == mode);
355            let token_rect = Rect {
356                x: area.x + cursor_x,
357                y: area.y + cursor_y,
358                width: w.min(area.width.saturating_sub(cursor_x)),
359                height: 1,
360            };
361            // Style picker:
362            //  - active mode hint                : bright yellow, BOLD
363            //  - actionable (clickable)          : light gray, BOLD
364            //  - informational                   : dark gray
365            //  - hovered                         : color lifted, bg tint
366            // Hover lift fires ONLY on actionable hints — informational
367            // tokens like `[Space/Click] finger` and `[Shift] x10` are
368            // descriptive labels with no click handler, so brightening
369            // them on hover would advertise a button that doesn't exist.
370            let hovered = action.is_some()
371                && mouse_pos
372                    .map(|(mx, my)| {
373                        mx >= token_rect.x
374                            && mx < token_rect.x + token_rect.width
375                            && my == token_rect.y
376                    })
377                    .unwrap_or(false);
378            let mut style = if active {
379                Style::default()
380                    .fg(Color::Rgb(255, 220, 120))
381                    .add_modifier(Modifier::BOLD)
382            } else if action.is_some() {
383                Style::default()
384                    .fg(Color::Rgb(180, 180, 180))
385                    .add_modifier(Modifier::BOLD)
386            } else {
387                Style::default().fg(Color::DarkGray)
388            };
389            if hovered {
390                style = style
391                    .fg(Color::Rgb(255, 255, 255))
392                    .bg(Color::Rgb(40, 40, 50))
393                    .add_modifier(Modifier::BOLD);
394            }
395            buf.set_string(token_rect.x, token_rect.y, token, style);
396            if let Some(a) = action {
397                hits.push((a, token_rect));
398            }
399            cursor_x += w + 2; // double-space separator
400        }
401        cursor_y += 1;
402        cursor_x = 0;
403        if cursor_y >= area.height {
404            break;
405        }
406    }
407    hits
408}
409
410/// Match a help-bar token like `"[u] upgrades"` to a `HelpAction`,
411/// disambiguated by the current mode (so `[s] stats` opens Stats from
412/// Game but `[s/Esc] back to game` from Stats returns to Game).
413fn map_help_token(token: &str, mode: Mode) -> Option<HelpAction> {
414    // Extract the bracketed key. We accept the first `[...]` group;
415    // everything after the first `]` is descriptive label.
416    let open = token.find('[')?;
417    let close = token[open + 1..].find(']')? + open + 1;
418    let key = &token[open + 1..close];
419    // Universal hints first.
420    if key.eq_ignore_ascii_case("q") {
421        return Some(HelpAction::Quit);
422    }
423    // Back-to-game from any non-Game mode (the `[X/Esc] back ...` pattern
424    // covers stats / achievements / upgrades / prestige).
425    if mode != Mode::Game && (key.contains("Esc") || key.contains("esc")) {
426        return Some(HelpAction::OpenMode(Mode::Game));
427    }
428    // Single-letter mode openers, only meaningful from Game.
429    match (mode, key) {
430        (Mode::Game, "u") | (Mode::Game, "U") => Some(HelpAction::OpenMode(Mode::Upgrades)),
431        (Mode::Game, "p") | (Mode::Game, "P") => Some(HelpAction::OpenMode(Mode::Prestige)),
432        (Mode::Game, "s") | (Mode::Game, "S") => Some(HelpAction::OpenMode(Mode::Stats)),
433        (Mode::Game, "a") | (Mode::Game, "A") => Some(HelpAction::OpenMode(Mode::Achievements)),
434        (Mode::Game, "g") | (Mode::Game, "G") => Some(HelpAction::GrabGolden),
435        (Mode::Prestige, "r") | (Mode::Prestige, "R") => Some(HelpAction::PrestigeReset),
436        _ => None,
437    }
438}