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
62pub struct DrawOutput {
63 pub biscuit_rect: Rect,
64 pub golden_rect: Rect,
65 pub play_area: Rect,
71 pub upgrade_rows: Vec<(usize, Rect)>,
77 pub fingerer_rows: Vec<(usize, Rect)>,
79 pub help_hits: Vec<(HelpAction, Rect)>,
85 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 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); const FLASH_SPEND: (f32, f32, f32) = (255.0, 90.0, 90.0); 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 toast::draw(frame, left[1], state);
274
275 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
303fn 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 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 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 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 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; }
401 cursor_y += 1;
402 cursor_x = 0;
403 if cursor_y >= area.height {
404 break;
405 }
406 }
407 hits
408}
409
410fn map_help_token(token: &str, mode: Mode) -> Option<HelpAction> {
414 let open = token.find('[')?;
417 let close = token[open + 1..].find(']')? + open + 1;
418 let key = &token[open + 1..close];
419 if key.eq_ignore_ascii_case("q") {
421 return Some(HelpAction::Quit);
422 }
423 if mode != Mode::Game && (key.contains("Esc") || key.contains("esc")) {
426 return Some(HelpAction::OpenMode(Mode::Game));
427 }
428 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}