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
19const VERSION: &str = env!("CARGO_PKG_VERSION");
23
24fn hud_title() -> String {
25 if VERSION == "0.0.0" {
26 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
51pub enum HelpAction {
52 OpenMode(Mode),
54 GrabGolden,
56 PrestigeReset,
58 Quit,
60}
61
62#[derive(Default)]
73pub struct DrawOutput {
74 pub biscuit_rect: Rect,
75 pub biscuit_focal: (u16, u16),
81 pub golden_rects: [Rect; 3],
86 pub green_coin_rect: Rect,
89 pub play_area: Rect,
95 pub upgrade_rows: Vec<(usize, Rect)>,
101 pub fingerer_rows: Vec<(usize, Rect)>,
103 pub help_hits: Vec<(HelpAction, Rect)>,
109 pub prestige_reset_rect: Rect,
113}
114
115fn wrapped_height(text: &str, width: u16) -> u16 {
116 if width == 0 {
117 return text.lines().count().max(1) as u16;
118 }
119 let mut total: u16 = 0;
120 for line in text.split('\n') {
121 let mut row_len: u16 = 0;
122 let mut rows: u16 = 1;
123 for word in line.split_whitespace() {
124 let wlen = word.chars().count() as u16;
125 if row_len == 0 {
126 row_len = wlen.min(width);
127 } else if row_len + 1 + wlen <= width {
128 row_len += 1 + wlen;
129 } else {
130 rows += 1;
131 row_len = wlen.min(width);
132 }
133 }
134 total = total.saturating_add(rows);
135 }
136 total.max(1)
137}
138
139fn draw_zoom_indicator(frame: &mut Frame, area: Rect, label: &str) {
140 let text = format!("zoom {}", label);
141 let w = text.chars().count() as u16;
142 if area.width < w || area.height == 0 {
143 return;
144 }
145 let col = area.x + area.width - w;
146 let row = area.y + area.height - 1;
147 let buf = frame.buffer_mut();
148 buf.set_string(
149 col,
150 row,
151 &text,
152 Style::default().fg(Color::Rgb(120, 120, 120)),
153 );
154}
155
156pub fn draw(
157 frame: &mut Frame,
158 state: &GameState,
159 mode: Mode,
160 zoom_idx: usize,
161 debug: bool,
162 mouse_pos: Option<(u16, u16)>,
163) -> DrawOutput {
164 let lang = t();
165 let area = frame.area();
166 let cols = Layout::horizontal([Constraint::Min(1), Constraint::Length(38)]).split(area);
167
168 let help_text = match mode {
169 Mode::Game => lang.help_game,
170 Mode::Stats => lang.help_stats,
171 Mode::Achievements => lang.help_ach,
172 Mode::Upgrades => lang.help_upgrades,
173 Mode::Prestige => lang.help_prestige,
174 };
175 let help_height = wrapped_height(help_text, cols[0].width).max(1);
176 let left = Layout::vertical([
177 Constraint::Length(3),
178 Constraint::Min(1),
179 Constraint::Length(help_height),
180 ])
181 .split(cols[0]);
182
183 let gain_t = (state.cuques_flash_ticks as f32 / HUD_FLASH_TICKS as f32).clamp(0.0, 1.0);
195 let spend_t = (state.cuques_spend_flash_ticks as f32 / HUD_FLASH_TICKS as f32).clamp(0.0, 1.0);
196 const FLASH_GAIN: (f32, f32, f32) = (80.0, 255.0, 80.0); const FLASH_SPEND: (f32, f32, f32) = (255.0, 90.0, 90.0); const FLASH_REST: (f32, f32, f32) = (255.0, 255.0, 255.0);
199 let (peak, t) = if spend_t > gain_t {
200 (FLASH_SPEND, spend_t)
201 } else {
202 (FLASH_GAIN, gain_t)
203 };
204 let mix = 1.0 - t;
205 let r = peak.0 + (FLASH_REST.0 - peak.0) * mix;
206 let g = peak.1 + (FLASH_REST.1 - peak.1) * mix;
207 let b = peak.2 + (FLASH_REST.2 - peak.2) * mix;
208 let cuques_style = Style::default()
209 .fg(Color::Rgb(
210 r.clamp(0.0, 255.0) as u8,
211 g.clamp(0.0, 255.0) as u8,
212 b.clamp(0.0, 255.0) as u8,
213 ))
214 .add_modifier(Modifier::BOLD);
215 let mut hud_spans: Vec<Span> = vec![
216 Span::raw(format!("{}: ", lang.hud_cuques)),
217 Span::styled(format::big(state.displayed_cuques), cuques_style),
218 Span::raw(format!(
219 " {}: {}",
220 lang.hud_fps,
221 format::rate(state.displayed_fps)
222 )),
223 ];
224 if state.prestige > 0 {
225 hud_spans.push(Span::styled(
226 format!(
227 " {}: {} (+{:.0}%)",
228 lang.prestige_title.trim(),
229 state.prestige,
230 state.prestige as f64
231 ),
232 Style::default()
233 .fg(Color::Rgb(255, 215, 0))
234 .add_modifier(Modifier::BOLD),
235 ));
236 }
237 for b in &state.buffs {
238 let secs = b.ticks_remaining().div_ceil(TICK_HZ);
239 let (label, color) = match b {
240 Buff::ClickFrenzy { mult, .. } => (
241 format!(" [!! FRENZY x{} {}s]", *mult as u64, secs),
242 Color::Rgb(255, 80, 80),
243 ),
244 };
245 hud_spans.push(Span::styled(
246 label,
247 Style::default().fg(color).add_modifier(Modifier::BOLD),
248 ));
249 }
250 for (id, st) in &state.fingerers_state {
255 for m in &st.modifiers {
256 let crate::game::modifier::ModifierDuration::Ticks(remaining) = m.duration else {
257 continue;
258 };
259 let secs = remaining.div_ceil(TICK_HZ);
260 let idx = crate::game::fingerer::FINGERERS
261 .iter()
262 .position(|f| f.id == id);
263 let name = idx
264 .and_then(|i| lang.fingerer_names.get(i).copied())
265 .unwrap_or("?");
266 let mul = m.effects.iter().find_map(|e| match e {
270 crate::game::modifier::ModifierEffect::MulFactor(v) => Some(*v),
271 _ => None,
272 });
273 let label = match mul {
274 Some(v) => format!(" [++ {} x{} {}s]", name, v as u64, secs),
275 None => format!(" [++ {} {}s]", name, secs),
276 };
277 let color = match m.source {
278 crate::game::modifier::ModifierSource::PurpleCoin => Color::Rgb(220, 140, 255),
279 crate::game::modifier::ModifierSource::GreenCoin => Color::Rgb(120, 230, 140),
280 };
281 hud_spans.push(Span::styled(
282 label,
283 Style::default().fg(color).add_modifier(Modifier::BOLD),
284 ));
285 }
286 }
287 let title = hud_title();
288 border::draw_animated(frame, left[0], state, &title);
289 let hud_inner = Rect {
290 x: left[0].x + 1,
291 y: left[0].y + 1,
292 width: left[0].width.saturating_sub(2),
293 height: left[0].height.saturating_sub(2),
294 };
295 let hud = Paragraph::new(Line::from(hud_spans));
296 frame.render_widget(hud, hud_inner);
297
298 let biscuit_rect = biscuit::draw(frame, left[1], state, zoom_idx);
299 let biscuit_focal = biscuit::focal_point(zoom_idx, biscuit_rect);
300 hands::draw(frame, left[1], biscuit_rect, biscuit_focal, state);
301 effects::draw_particles(frame, biscuit_rect, &state.particles);
302 effects::draw_misclicks(frame, &state.misclick_particles);
303 draw_zoom_indicator(
304 frame,
305 left[1],
306 biscuit::level_label(zoom_idx).unwrap_or("100%"),
307 );
308
309 if debug {
310 debug_pane::draw(frame, left[1]);
311 }
312 let mut golden_rects: [Rect; 3] = [Rect::default(); 3];
317 for (i, slot) in state.goldens.iter().enumerate() {
318 if let Some(g) = slot {
319 golden_rects[i] = biscuit::draw_golden(frame, g, biscuit_rect);
320 }
321 }
322 let green_coin_rect = match &state.green_coin {
323 Some(c) => biscuit::draw_green_coin(frame, c, biscuit_rect),
324 None => Rect::default(),
325 };
326
327 toast::draw(frame, left[1], state);
332
333 let help_hits = draw_help(frame, left[2], help_text, mode, mouse_pos);
338
339 let mut upgrade_rows: Vec<(usize, Rect)> = Vec::new();
340 let mut fingerer_rows: Vec<(usize, Rect)> = Vec::new();
341 let mut prestige_reset_rect = Rect::default();
342 match mode {
343 Mode::Game => fingerer_rows = sidebar::draw(frame, cols[1], state, mouse_pos),
344 Mode::Stats => stats::draw(frame, cols[1], state),
345 Mode::Achievements => achievements::draw(frame, cols[1], state),
346 Mode::Upgrades => upgrade_rows = upgrades::draw(frame, cols[1], state, mouse_pos),
347 Mode::Prestige => prestige_reset_rect = prestige::draw(frame, cols[1], state, mouse_pos),
348 }
349
350 DrawOutput {
351 biscuit_rect,
352 biscuit_focal,
353 golden_rects,
354 green_coin_rect,
355 play_area: left[1],
356 upgrade_rows,
357 fingerer_rows,
358 help_hits,
359 prestige_reset_rect,
360 }
361}
362
363fn draw_help(
375 frame: &mut Frame,
376 area: Rect,
377 text: &str,
378 mode: Mode,
379 mouse_pos: Option<(u16, u16)>,
380) -> Vec<(HelpAction, Rect)> {
381 let mut hits: Vec<(HelpAction, Rect)> = Vec::new();
382 if area.width == 0 || area.height == 0 {
383 return hits;
384 }
385 let buf = frame.buffer_mut();
386 let mut cursor_x: u16 = 0;
387 let mut cursor_y: u16 = 0;
388 for line in text.split('\n') {
389 for token in line.split(" ") {
392 let token = token.trim();
393 if token.is_empty() {
394 continue;
395 }
396 let w = token.chars().count() as u16;
397 if cursor_x + w > area.width && cursor_x > 0 {
399 cursor_y += 1;
400 cursor_x = 0;
401 }
402 if cursor_y >= area.height {
403 break;
404 }
405 let action = map_help_token(token, mode);
406 if matches!(action, Some(HelpAction::Quit)) && !crate::platform::CAPABILITIES.can_quit {
412 continue;
413 }
414 let active = matches!(action, Some(HelpAction::OpenMode(m)) if m == mode);
415 let token_rect = Rect {
416 x: area.x + cursor_x,
417 y: area.y + cursor_y,
418 width: w.min(area.width.saturating_sub(cursor_x)),
419 height: 1,
420 };
421 let hovered = action.is_some()
431 && mouse_pos
432 .map(|(mx, my)| {
433 mx >= token_rect.x
434 && mx < token_rect.x + token_rect.width
435 && my == token_rect.y
436 })
437 .unwrap_or(false);
438 let mut style = if active {
439 Style::default()
440 .fg(Color::Rgb(255, 220, 120))
441 .add_modifier(Modifier::BOLD)
442 } else if action.is_some() {
443 Style::default()
444 .fg(Color::Rgb(180, 180, 180))
445 .add_modifier(Modifier::BOLD)
446 } else {
447 Style::default().fg(Color::DarkGray)
448 };
449 if hovered {
450 style = style
451 .fg(Color::Rgb(255, 255, 255))
452 .bg(Color::Rgb(40, 40, 50))
453 .add_modifier(Modifier::BOLD);
454 }
455 buf.set_string(token_rect.x, token_rect.y, token, style);
456 if let Some(a) = action {
457 hits.push((a, token_rect));
458 }
459 cursor_x += w + 2; }
461 cursor_y += 1;
462 cursor_x = 0;
463 if cursor_y >= area.height {
464 break;
465 }
466 }
467 hits
468}
469
470fn map_help_token(token: &str, mode: Mode) -> Option<HelpAction> {
474 let open = token.find('[')?;
477 let close = token[open + 1..].find(']')? + open + 1;
478 let key = &token[open + 1..close];
479 if key.eq_ignore_ascii_case("q") {
481 return Some(HelpAction::Quit);
482 }
483 if mode != Mode::Game && (key.contains("Esc") || key.contains("esc")) {
486 return Some(HelpAction::OpenMode(Mode::Game));
487 }
488 match (mode, key) {
490 (Mode::Game, "u") | (Mode::Game, "U") => Some(HelpAction::OpenMode(Mode::Upgrades)),
491 (Mode::Game, "p") | (Mode::Game, "P") => Some(HelpAction::OpenMode(Mode::Prestige)),
492 (Mode::Game, "s") | (Mode::Game, "S") => Some(HelpAction::OpenMode(Mode::Stats)),
493 (Mode::Game, "a") | (Mode::Game, "A") => Some(HelpAction::OpenMode(Mode::Achievements)),
494 (Mode::Game, "g") | (Mode::Game, "G") => Some(HelpAction::GrabGolden),
495 (Mode::Prestige, "r") | (Mode::Prestige, "R") => Some(HelpAction::PrestigeReset),
496 _ => None,
497 }
498}