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
62/// Per-frame layout snapshot produced by [`draw`]. Single source of truth
63/// for every clickable region on screen + the play-area envelope.
64///
65/// The platform shells (`app.rs`, `wasm_app.rs`) **store this verbatim**
66/// and call [`crate::input::InputContext::from_layout`] to project it
67/// into the per-event input context. Adding a new clickable region only
68/// touches this struct + `InputContext` + the projection — never the
69/// platform code.
70#[derive(Default)]
71pub struct DrawOutput {
72    pub biscuit_rect: Rect,
73    /// Screen position of the biscuit's focal cell ("the asshole"). Each
74    /// zoom level's art has the focal at a slightly different offset
75    /// inside its bounding box (TINY: col 7 of width 16, FULL: col 31 of
76    /// width 60, etc.), so the bbox center isn't the visual center. This
77    /// drives `hands::draw`'s orbit center.
78    pub biscuit_focal: (u16, u16),
79    /// `(spawn_id, screen_rect)` for every on-screen powerup, in render
80    /// order. Click hit-test and the `g` hotkey BOTH reference instances
81    /// by `spawn_id` (not by Vec index) so a `swap_remove` on catch is
82    /// safe even when multiple events hold layout snapshots between
83    /// frames. Empty when no powerups are visible.
84    pub powerup_rects: Vec<(u64, Rect)>,
85    /// The whole left column where the biscuit + hands + particles live —
86    /// i.e. "the box that displays the ass." Used by the input router so
87    /// the scroll-wheel zoom fires anywhere in this region (including the
88    /// vast empty space around a small biscuit at low zoom), and only the
89    /// right-hand sidebar opts out of zoom.
90    pub play_area: Rect,
91    /// `(upgrade_idx, screen_row_rect)` pairs for the Upgrades panel —
92    /// populated only when the active mode renders that panel; empty
93    /// otherwise. The click router hit-tests these for `BuyUpgrade`.
94    /// First element of each tuple is also the digit-shortcut target,
95    /// kept aligned with `visible_upgrades`.
96    pub upgrade_rows: Vec<(usize, Rect)>,
97    /// `(fingerer_idx, screen_row_rect)` for the Game-mode sidebar.
98    pub fingerer_rows: Vec<(usize, Rect)>,
99    /// (action, rect) for every clickable help-bar hint at the bottom of
100    /// the play column. Mouse-first players use these to switch panels,
101    /// catch goldens, prestige-reset, and quit — all of which used to be
102    /// keyboard-only. Empty rects when the hint is non-actionable
103    /// (e.g. `[Space/Click] finger` is informational, not a click target).
104    pub help_hits: Vec<(HelpAction, Rect)>,
105    /// Click rect for the `Press [r] to reset and claim` confirm line in
106    /// the Prestige panel. Default rect when not in Prestige mode or no
107    /// prestige is available.
108    pub prestige_reset_rect: Rect,
109}
110
111fn wrapped_height(text: &str, width: u16) -> u16 {
112    if width == 0 {
113        return text.lines().count().max(1) as u16;
114    }
115    let mut total: u16 = 0;
116    for line in text.split('\n') {
117        let mut row_len: u16 = 0;
118        let mut rows: u16 = 1;
119        for word in line.split_whitespace() {
120            let wlen = word.chars().count() as u16;
121            if row_len == 0 {
122                row_len = wlen.min(width);
123            } else if row_len + 1 + wlen <= width {
124                row_len += 1 + wlen;
125            } else {
126                rows += 1;
127                row_len = wlen.min(width);
128            }
129        }
130        total = total.saturating_add(rows);
131    }
132    total.max(1)
133}
134
135fn draw_zoom_indicator(frame: &mut Frame, area: Rect, label: &str) {
136    let text = format!("zoom {}", label);
137    let w = text.chars().count() as u16;
138    if area.width < w || area.height == 0 {
139        return;
140    }
141    let col = area.x + area.width - w;
142    let row = area.y + area.height - 1;
143    let buf = frame.buffer_mut();
144    buf.set_string(
145        col,
146        row,
147        &text,
148        Style::default().fg(Color::Rgb(120, 120, 120)),
149    );
150}
151
152pub fn draw(
153    frame: &mut Frame,
154    state: &GameState,
155    mode: Mode,
156    zoom_idx: usize,
157    debug: bool,
158    mouse_pos: Option<(u16, u16)>,
159) -> DrawOutput {
160    let lang = t();
161    let area = frame.area();
162    let cols = Layout::horizontal([Constraint::Min(1), Constraint::Length(38)]).split(area);
163
164    let help_text = match mode {
165        Mode::Game => lang.help_game,
166        Mode::Stats => lang.help_stats,
167        Mode::Achievements => lang.help_ach,
168        Mode::Upgrades => lang.help_upgrades,
169        Mode::Prestige => lang.help_prestige,
170    };
171    let help_height = wrapped_height(help_text, cols[0].width).max(1);
172    let left = Layout::vertical([
173        Constraint::Length(3),
174        Constraint::Min(1),
175        Constraint::Length(help_height),
176    ])
177    .split(cols[0]);
178
179    // J5 count-up: render the smoothed `displayed_*` values rather than the
180    // raw current values. Big jumps (golden, max-buy, F4) ease in instead of
181    // snapping. Tween itself runs in `state.tick()`.
182    //
183    // Color sweep: TWO competing channels — green for cuques going UP
184    // (income, golden, F4), red for cuques going DOWN (purchase,
185    // prestige reset). Whichever channel is stronger this frame drives
186    // the lerp toward white. So a buy that lands during a still-decaying
187    // gain flash correctly flips the digits red as the spend channel
188    // overtakes the fading gain. Both lerp toward bright white at t=0,
189    // which matches the resting (no-flash) style — no hard cut.
190    let gain_t = (state.cuques_flash_ticks as f32 / HUD_FLASH_TICKS as f32).clamp(0.0, 1.0);
191    let spend_t = (state.cuques_spend_flash_ticks as f32 / HUD_FLASH_TICKS as f32).clamp(0.0, 1.0);
192    const FLASH_GAIN: (f32, f32, f32) = (80.0, 255.0, 80.0); // bright green
193    const FLASH_SPEND: (f32, f32, f32) = (255.0, 90.0, 90.0); // urgent red
194    const FLASH_REST: (f32, f32, f32) = (255.0, 255.0, 255.0);
195    let (peak, t) = if spend_t > gain_t {
196        (FLASH_SPEND, spend_t)
197    } else {
198        (FLASH_GAIN, gain_t)
199    };
200    let mix = 1.0 - t;
201    let r = peak.0 + (FLASH_REST.0 - peak.0) * mix;
202    let g = peak.1 + (FLASH_REST.1 - peak.1) * mix;
203    let b = peak.2 + (FLASH_REST.2 - peak.2) * mix;
204    let cuques_style = Style::default()
205        .fg(Color::Rgb(
206            r.clamp(0.0, 255.0) as u8,
207            g.clamp(0.0, 255.0) as u8,
208            b.clamp(0.0, 255.0) as u8,
209        ))
210        .add_modifier(Modifier::BOLD);
211    let mut hud_spans: Vec<Span> = vec![
212        Span::raw(format!("{}: ", lang.hud_cuques)),
213        Span::styled(format::big(state.displayed_cuques), cuques_style),
214        Span::raw(format!(
215            "   {}: {}",
216            lang.hud_fps,
217            format::rate(state.displayed_fps)
218        )),
219    ];
220    if state.prestige > 0 {
221        hud_spans.push(Span::styled(
222            format!(
223                "   {}: {} (+{:.0}%)",
224                lang.prestige_title.trim(),
225                state.prestige,
226                state.prestige as f64
227            ),
228            Style::default()
229                .fg(Color::Rgb(255, 215, 0))
230                .add_modifier(Modifier::BOLD),
231        ));
232    }
233    for b in &state.buffs {
234        let secs = b.ticks_remaining().div_ceil(TICK_HZ);
235        let (label, color) = match b {
236            // The legacy `mult` field is no longer the actual click
237            // multiplier (per-click yield is FPS-scaled now); just label
238            // the buff and show its remaining time. Cleaner than showing
239            // a number that doesn't reflect the real bonus.
240            Buff::ClickFrenzy { .. } => {
241                (format!("  [!! FRENZY {}s]", secs), Color::Rgb(255, 80, 80))
242            }
243        };
244        hud_spans.push(Span::styled(
245            label,
246            Style::default().fg(color).add_modifier(Modifier::BOLD),
247        ));
248    }
249    // Active timed per-fingerer modifiers — Purple Coin Buff golden today,
250    // anything else timed in the future. Phase 5 of #21 will replace this
251    // with a dedicated HUD strip; for now we mirror the legacy chip layout
252    // so UX continuity holds across phases.
253    for (id, st) in &state.fingerers_state {
254        for m in &st.modifiers {
255            let crate::game::modifier::ModifierDuration::Ticks(remaining) = m.duration else {
256                continue;
257            };
258            let secs = remaining.div_ceil(TICK_HZ);
259            let idx = crate::game::fingerer::FINGERERS
260                .iter()
261                .position(|f| f.id == id);
262            let name = idx
263                .and_then(|i| lang.fingerer_names.get(i).copied())
264                .unwrap_or("?");
265            // Pick a number to show: prefer the strongest single MulFactor
266            // effect (matches the old "x7" presentation); fall back to a
267            // count-of-effects marker for purely additive sources.
268            let mul = m.effects.iter().find_map(|e| match e {
269                crate::game::modifier::ModifierEffect::MulFactor(v) => Some(*v),
270                _ => None,
271            });
272            let label = match mul {
273                Some(v) => format!("  [++ {} x{} {}s]", name, v as u64, secs),
274                None => format!("  [++ {} {}s]", name, secs),
275            };
276            let color = match m.source {
277                crate::game::modifier::ModifierSource::PurpleCoin => Color::Rgb(220, 140, 255),
278                crate::game::modifier::ModifierSource::GreenCoin => Color::Rgb(120, 230, 140),
279            };
280            hud_spans.push(Span::styled(
281                label,
282                Style::default().fg(color).add_modifier(Modifier::BOLD),
283            ));
284        }
285    }
286    let title = hud_title();
287    border::draw_animated(frame, left[0], state, &title);
288    let hud_inner = Rect {
289        x: left[0].x + 1,
290        y: left[0].y + 1,
291        width: left[0].width.saturating_sub(2),
292        height: left[0].height.saturating_sub(2),
293    };
294    let hud = Paragraph::new(Line::from(hud_spans));
295    frame.render_widget(hud, hud_inner);
296
297    let biscuit_rect = biscuit::draw(frame, left[1], state, zoom_idx);
298    let biscuit_focal = biscuit::focal_point(zoom_idx, biscuit_rect);
299    hands::draw(frame, left[1], biscuit_rect, biscuit_focal, state);
300    effects::draw_particles(frame, biscuit_rect, &state.particles);
301    effects::draw_misclicks(frame, &state.misclick_particles);
302    draw_zoom_indicator(
303        frame,
304        left[1],
305        biscuit::level_label(zoom_idx).unwrap_or("100%"),
306    );
307
308    if debug {
309        debug_pane::draw(frame, left[1]);
310    }
311    // Render every on-screen powerup; collect (spawn_id, rect) pairs so
312    // hit-testing remains stable across catches/swap_removes. Order is
313    // render order (Vec order); the click router walks the list and
314    // routes the first match, which is fine because the dispersion
315    // helper keeps positions distinct.
316    let mut powerup_rects: Vec<(u64, Rect)> = Vec::with_capacity(state.powerups.len());
317    for p in &state.powerups {
318        let r = biscuit::draw_powerup(frame, p, biscuit_rect);
319        powerup_rects.push((p.spawn_id, r));
320    }
321
322    // J1: achievement toast overlay. Lives in `left[1]` (biscuit/main area)
323    // so it covers nothing important on the right; auto-dismisses after
324    // TOAST_TICKS via the sim. We render *after* biscuit/powerups so it
325    // always sits on top.
326    toast::draw(frame, left[1], state);
327
328    // Custom help-bar render: lay out `[X] label` tokens left-to-right,
329    // wrapping at the rect width, paint each with mode-aware styling
330    // (active mode bolded), and return per-token click rects so the
331    // mouse-first player can drive the game without ever touching a key.
332    let help_hits = draw_help(frame, left[2], help_text, mode, mouse_pos);
333
334    let mut upgrade_rows: Vec<(usize, Rect)> = Vec::new();
335    let mut fingerer_rows: Vec<(usize, Rect)> = Vec::new();
336    let mut prestige_reset_rect = Rect::default();
337    match mode {
338        Mode::Game => fingerer_rows = sidebar::draw(frame, cols[1], state, mouse_pos),
339        Mode::Stats => stats::draw(frame, cols[1], state),
340        Mode::Achievements => achievements::draw(frame, cols[1], state),
341        Mode::Upgrades => upgrade_rows = upgrades::draw(frame, cols[1], state, mouse_pos),
342        Mode::Prestige => prestige_reset_rect = prestige::draw(frame, cols[1], state, mouse_pos),
343    }
344
345    DrawOutput {
346        biscuit_rect,
347        biscuit_focal,
348        powerup_rects,
349        play_area: left[1],
350        upgrade_rows,
351        fingerer_rows,
352        help_hits,
353        prestige_reset_rect,
354    }
355}
356
357/// Custom help-bar renderer.
358///
359/// Splits the help string into "tokens" (each token is a contiguous
360/// non-whitespace run of `[X] label words`, separated from the next by
361/// a double space or a newline — the convention used in `i18n::Lang`'s
362/// help strings). Each token is laid out left-to-right with wrap at
363/// the rect's width, painted at the resolved screen position, and
364/// matched against a (mode, key) → action table. Clickable tokens get
365/// a slightly brighter color and BOLD; the token under the mouse
366/// cursor gets an additional brightness lift + bg fill so the player
367/// reads it as a button.
368fn draw_help(
369    frame: &mut Frame,
370    area: Rect,
371    text: &str,
372    mode: Mode,
373    mouse_pos: Option<(u16, u16)>,
374) -> Vec<(HelpAction, Rect)> {
375    let mut hits: Vec<(HelpAction, Rect)> = Vec::new();
376    if area.width == 0 || area.height == 0 {
377        return hits;
378    }
379    let buf = frame.buffer_mut();
380    let mut cursor_x: u16 = 0;
381    let mut cursor_y: u16 = 0;
382    for line in text.split('\n') {
383        // Tokens are separated by a literal `  ` (two spaces). Single
384        // spaces inside a token are content (e.g. "back to game").
385        for token in line.split("  ") {
386            let token = token.trim();
387            if token.is_empty() {
388                continue;
389            }
390            let w = token.chars().count() as u16;
391            // Wrap if the token wouldn't fit on the current line.
392            if cursor_x + w > area.width && cursor_x > 0 {
393                cursor_y += 1;
394                cursor_x = 0;
395            }
396            if cursor_y >= area.height {
397                break;
398            }
399            let action = map_help_token(token, mode);
400            // Hide `[q] quit` (or its localized equivalent) on platforms
401            // where the wasm/native runner has no authority to exit —
402            // see `platform::Capabilities::can_quit`. Skipping renders
403            // AND skips appending to `help_hits`, so the next token
404            // slides into the position cursor without leaving a gap.
405            if matches!(action, Some(HelpAction::Quit)) && !crate::platform::CAPABILITIES.can_quit {
406                continue;
407            }
408            let active = matches!(action, Some(HelpAction::OpenMode(m)) if m == mode);
409            let token_rect = Rect {
410                x: area.x + cursor_x,
411                y: area.y + cursor_y,
412                width: w.min(area.width.saturating_sub(cursor_x)),
413                height: 1,
414            };
415            // Style picker:
416            //  - active mode hint                : bright yellow, BOLD
417            //  - actionable (clickable)          : light gray, BOLD
418            //  - informational                   : dark gray
419            //  - hovered                         : color lifted, bg tint
420            // Hover lift fires ONLY on actionable hints — informational
421            // tokens like `[Space/Click] finger` and `[Shift] x10` are
422            // descriptive labels with no click handler, so brightening
423            // them on hover would advertise a button that doesn't exist.
424            let hovered = action.is_some()
425                && mouse_pos
426                    .map(|(mx, my)| {
427                        mx >= token_rect.x
428                            && mx < token_rect.x + token_rect.width
429                            && my == token_rect.y
430                    })
431                    .unwrap_or(false);
432            let mut style = if active {
433                Style::default()
434                    .fg(Color::Rgb(255, 220, 120))
435                    .add_modifier(Modifier::BOLD)
436            } else if action.is_some() {
437                Style::default()
438                    .fg(Color::Rgb(180, 180, 180))
439                    .add_modifier(Modifier::BOLD)
440            } else {
441                Style::default().fg(Color::DarkGray)
442            };
443            if hovered {
444                style = style
445                    .fg(Color::Rgb(255, 255, 255))
446                    .bg(Color::Rgb(40, 40, 50))
447                    .add_modifier(Modifier::BOLD);
448            }
449            buf.set_string(token_rect.x, token_rect.y, token, style);
450            if let Some(a) = action {
451                hits.push((a, token_rect));
452            }
453            cursor_x += w + 2; // double-space separator
454        }
455        cursor_y += 1;
456        cursor_x = 0;
457        if cursor_y >= area.height {
458            break;
459        }
460    }
461    hits
462}
463
464/// Match a help-bar token like `"[u] upgrades"` to a `HelpAction`,
465/// disambiguated by the current mode (so `[s] stats` opens Stats from
466/// Game but `[s/Esc] back to game` from Stats returns to Game).
467fn map_help_token(token: &str, mode: Mode) -> Option<HelpAction> {
468    // Extract the bracketed key. We accept the first `[...]` group;
469    // everything after the first `]` is descriptive label.
470    let open = token.find('[')?;
471    let close = token[open + 1..].find(']')? + open + 1;
472    let key = &token[open + 1..close];
473    // Universal hints first.
474    if key.eq_ignore_ascii_case("q") {
475        return Some(HelpAction::Quit);
476    }
477    // Back-to-game from any non-Game mode (the `[X/Esc] back ...` pattern
478    // covers stats / achievements / upgrades / prestige).
479    if mode != Mode::Game && (key.contains("Esc") || key.contains("esc")) {
480        return Some(HelpAction::OpenMode(Mode::Game));
481    }
482    // Single-letter mode openers, only meaningful from Game.
483    match (mode, key) {
484        (Mode::Game, "u") | (Mode::Game, "U") => Some(HelpAction::OpenMode(Mode::Upgrades)),
485        (Mode::Game, "p") | (Mode::Game, "P") => Some(HelpAction::OpenMode(Mode::Prestige)),
486        (Mode::Game, "s") | (Mode::Game, "S") => Some(HelpAction::OpenMode(Mode::Stats)),
487        (Mode::Game, "a") | (Mode::Game, "A") => Some(HelpAction::OpenMode(Mode::Achievements)),
488        (Mode::Game, "g") | (Mode::Game, "G") => Some(HelpAction::GrabGolden),
489        (Mode::Prestige, "r") | (Mode::Prestige, "R") => Some(HelpAction::PrestigeReset),
490        _ => None,
491    }
492}