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)]
71pub struct DrawOutput {
72 pub biscuit_rect: Rect,
73 pub biscuit_focal: (u16, u16),
79 pub powerup_rects: Vec<(u64, Rect)>,
85 pub play_area: Rect,
91 pub upgrade_rows: Vec<(usize, Rect)>,
97 pub fingerer_rows: Vec<(usize, Rect)>,
99 pub help_hits: Vec<(HelpAction, Rect)>,
105 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 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); const FLASH_SPEND: (f32, f32, f32) = (255.0, 90.0, 90.0); 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 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 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 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 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 toast::draw(frame, left[1], state);
327
328 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
357fn 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 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 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 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 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; }
455 cursor_y += 1;
456 cursor_x = 0;
457 if cursor_y >= area.height {
458 break;
459 }
460 }
461 hits
462}
463
464fn map_help_token(token: &str, mode: Mode) -> Option<HelpAction> {
468 let open = token.find('[')?;
471 let close = token[open + 1..].find(']')? + open + 1;
472 let key = &token[open + 1..close];
473 if key.eq_ignore_ascii_case("q") {
475 return Some(HelpAction::Quit);
476 }
477 if mode != Mode::Game && (key.contains("Esc") || key.contains("esc")) {
480 return Some(HelpAction::OpenMode(Mode::Game));
481 }
482 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}