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