Skip to main content

cuqueclicker_lib/ui/
sidebar.rs

1use ratatui::{prelude::*, widgets::*};
2
3use crate::format;
4use crate::game::fingerer::{self, FINGERERS};
5use crate::game::state::{GameState, PURCHASE_FLASH_TICKS, UNLOCK_FLASH_TICKS};
6use crate::i18n::t;
7use crate::ui::border;
8
9const ROWS_PER_FINGERER: u16 = 4;
10const FLASH_TINT: (f32, f32, f32) = (40.0, 230.0, 80.0);
11const UNAFFORDABLE_TINT: (f32, f32, f32) = (255.0, 60.0, 60.0);
12/// Brighter, more saturated green for the "now affordable!" one-shot —
13/// sits a notch above the regular purchase flash hue so the player can
14/// tell the two events apart on glance.
15const UNLOCK_TINT: (f32, f32, f32) = (120.0, 255.0, 140.0);
16// Resting color the flash starts from / returns to (neutral gray similar to
17// default terminal text, so the transition in and out is gentle).
18const FLASH_REST: (f32, f32, f32) = (200.0, 200.0, 210.0);
19// Active carrier: pure white so the wave pulses white<->green with maximum
20// contrast during the flash.
21const FLASH_CARRIER: (f32, f32, f32) = (255.0, 255.0, 255.0);
22const FLASH_CYCLE: f32 = 11.0;
23
24/// Returns one entry per visible fingerer row: the live `FINGERERS` index
25/// and the click-target rect on screen. Aligned 1:1 with the rendered rows
26/// so the click router can map a click coordinate to an
27/// `Action::BuyFingerer` without re-parsing the panel layout.
28pub fn draw(
29    frame: &mut Frame,
30    area: Rect,
31    state: &GameState,
32    mouse_pos: Option<(u16, u16)>,
33) -> Vec<(usize, Rect)> {
34    let lang = t();
35    let mut lines: Vec<Line> = Vec::new();
36    let visible: Vec<usize> = (0..FINGERERS.len())
37        .filter(|&i| fingerer::visible(i, state.fingerer_count_idx(i), state.lifetime_cuques))
38        .collect();
39
40    for (slot, &i) in visible.iter().enumerate() {
41        if slot >= 10 {
42            break;
43        }
44        let hotkey = if slot == 9 {
45            '0'
46        } else {
47            (b'1' + slot as u8) as char
48        };
49        let k = &FINGERERS[i];
50        let owned = state.fingerer_count_idx(i);
51        let cost = state.cost(i);
52        let affordable = state.can_buy(i);
53        let cost_style = if affordable {
54            Style::default()
55                .fg(Color::Rgb(0, 255, 80))
56                .add_modifier(Modifier::BOLD)
57        } else {
58            Style::default().fg(Color::Rgb(220, 70, 70))
59        };
60        let name = lang.fingerer_names.get(i).copied().unwrap_or("?");
61        lines.push(Line::from(vec![
62            Span::styled(format!("[{}] ", hotkey), Style::default().fg(Color::Yellow)),
63            Span::raw(k.icon),
64            Span::raw(" "),
65            Span::styled(
66                name.to_string(),
67                Style::default().add_modifier(Modifier::BOLD),
68            ),
69        ]));
70        lines.push(Line::from(vec![
71            Span::raw(format!("    {}: {}  ", lang.owned, owned)),
72            Span::styled(format!("{} {}", lang.cost, format::big(cost)), cost_style),
73        ]));
74        let mult = state.fingerer_mult(i);
75        let effective = k.fps_per_unit * mult;
76        let mult_tag = if mult > 1.0001 {
77            format!(" (x{:.1})", mult)
78        } else {
79            String::new()
80        };
81        lines.push(Line::from(format!(
82            "    +{} {}{}",
83            format::rate(effective),
84            lang.fps_each,
85            mult_tag,
86        )));
87        lines.push(Line::raw(""));
88    }
89    let p = Paragraph::new(lines).block(Block::bordered().title(lang.fingerers_title));
90    frame.render_widget(p, area);
91
92    paint_flashes(frame, area, state, &visible);
93
94    // Panel-border flash on purchase: green if any fingerer's row flash is
95    // burning, red if any unaffordable click was just rejected. Mirrors the
96    // HUD title's behavior so the whole panel pulses, not just the row.
97    let any_purchase = visible
98        .iter()
99        .filter_map(|&i| state.fingerer_flash_ticks.get(i).copied())
100        .max()
101        .unwrap_or(0);
102    let any_unaff = visible
103        .iter()
104        .filter_map(|&i| state.fingerer_unaffordable_flash.get(i).copied())
105        .max()
106        .unwrap_or(0);
107    // Carrier-strength is timing-only — no bulk-buy scaling — so the
108    // panel border reaches full white on every flash. The wave amplitude
109    // booster (bulk-buy intensity) is handled inside `paint_border_flash`.
110    let purchase_strength = border::plateau_fade(any_purchase, PURCHASE_FLASH_TICKS);
111    let unaff_strength = border::plateau_fade(any_unaff, PURCHASE_FLASH_TICKS / 2);
112    // Unaffordable wins when active. The unaff flash lasts half as long
113    // as purchase (10 vs 20 ticks), so any active unaff IS the most
114    // recent action — clicking unaffordable while a previous buy's green
115    // is still decaying must immediately show red, not get suppressed.
116    // After ~0.5s the unaff fades and green resumes until it expires too.
117    if unaff_strength > 0.001 {
118        border::paint_border_flash(
119            frame,
120            area,
121            state,
122            border::PANEL_UNAFFORDABLE_TINT,
123            border::PANEL_UNAFFORDABLE_CYCLE,
124            unaff_strength,
125        );
126    } else if purchase_strength > 0.001 {
127        border::paint_border_flash(
128            frame,
129            area,
130            state,
131            border::PANEL_PURCHASE_TINT,
132            border::PANEL_PURCHASE_CYCLE,
133            purchase_strength,
134        );
135    }
136
137    if area.width < 3 || area.height < 3 {
138        return Vec::new();
139    }
140    let inner_x = area.x + 1;
141    let inner_y = area.y + 1;
142    let inner_w = area.width.saturating_sub(2);
143    let inner_h = area.height.saturating_sub(2);
144    let mut rows: Vec<(usize, Rect)> = Vec::new();
145    for (slot, &i) in visible.iter().enumerate() {
146        if slot >= 10 {
147            break;
148        }
149        let row_top = slot as u16 * ROWS_PER_FINGERER;
150        if row_top >= inner_h {
151            break;
152        }
153        // Skip the trailing blank separator (3 useful rows out of 4).
154        let height = (ROWS_PER_FINGERER - 1).min(inner_h - row_top);
155        rows.push((
156            i,
157            Rect {
158                x: inner_x,
159                y: inner_y + row_top,
160                width: inner_w,
161                height,
162            },
163        ));
164    }
165    paint_hover(frame, &rows, mouse_pos);
166    rows
167}
168
169/// Paint a subtle brightness lift on whichever row the mouse is currently
170/// over. Only the cells that already have content keep their styled
171/// foreground; this just bumps brightness, so a hovered row reads as
172/// "live" without changing the underlying color hierarchy. Cheap: at
173/// most 10 rows × ~36 cols × 3 lines = ~1k cells per hover frame.
174fn paint_hover(frame: &mut Frame, rows: &[(usize, Rect)], mouse_pos: Option<(u16, u16)>) {
175    let Some((mx, my)) = mouse_pos else { return };
176    let Some(&(_, r)) = rows
177        .iter()
178        .find(|&&(_, r)| mx >= r.x && mx < r.x + r.width && my >= r.y && my < r.y + r.height)
179    else {
180        return;
181    };
182    let buf = frame.buffer_mut();
183    for dy in 0..r.height {
184        let y = r.y + dy;
185        if y >= buf.area.y + buf.area.height {
186            break;
187        }
188        for dx in 0..r.width {
189            let x = r.x + dx;
190            if x >= buf.area.x + buf.area.width {
191                break;
192            }
193            let cell = &mut buf[(x, y)];
194            // Lift the existing fg by a fixed amount and ensure BOLD.
195            // The cell's fg may already be tinted (cost-color, flash, etc) —
196            // brightening it preserves hue but makes the row pop.
197            if let Color::Rgb(r, g, b) = cell.fg {
198                cell.set_fg(Color::Rgb(
199                    (r as u16 + 30).min(255) as u8,
200                    (g as u16 + 30).min(255) as u8,
201                    (b as u16 + 30).min(255) as u8,
202                ));
203            }
204            cell.modifier.insert(Modifier::BOLD);
205            // Subtle bg tint so even blank cells in the row signal "hover."
206            cell.set_bg(Color::Rgb(28, 28, 36));
207        }
208    }
209}
210
211fn paint_flashes(frame: &mut Frame, area: Rect, state: &GameState, visible: &[usize]) {
212    if area.width < 3 || area.height < 3 {
213        return;
214    }
215    // Steady phase clock — independent of HUD-border speed-ups so an
216    // achievement / frenzy / lucky event firing on the title border
217    // doesn't drag the sidebar's "can't-buy" or "purchase" shimmer along.
218    let phase = state.steady_phase as f32;
219    let inner_x = area.x + 1;
220    let inner_y = area.y + 1;
221    let inner_right = area.x + area.width - 1;
222    let inner_bottom = area.y + area.height - 1;
223    // Bulk-buy intensifier: 1.0..3.0 multiplier on the WAVE AMPLITUDE only
224    // (not the carrier brightness). A single buy already pulses fully white
225    // ↔ tint with maximum contrast; bulk-buy just pushes the tint peaks
226    // harder so a max-buy reads louder without dimming the white carrier.
227    let bulk_amp = state.purchase_flash_strength.clamp(1.0, 3.0);
228    let buf = frame.buffer_mut();
229
230    for (slot, &fingerer_idx) in visible.iter().enumerate() {
231        if slot >= 10 {
232            break;
233        }
234        let purchase_ticks = state
235            .fingerer_flash_ticks
236            .get(fingerer_idx)
237            .copied()
238            .unwrap_or(0);
239        let unaff_ticks = state
240            .fingerer_unaffordable_flash
241            .get(fingerer_idx)
242            .copied()
243            .unwrap_or(0);
244        let unlock_ticks = state
245            .fingerer_unlock_flash
246            .get(fingerer_idx)
247            .copied()
248            .unwrap_or(0);
249        // Per-row tint priority:
250        //   purchase    (you just bought)        — wins, longest, with bulk amp
251        //   unaffordable (you tried + failed)    — wins over unlock
252        //   unlock      (just became affordable) — quietly announces the row
253        // `strength` is the carrier blend (timing only, 0..1); `amp`
254        // boosts the wave's tint contribution for bulk buys.
255        let (strength, tint, amp) = if purchase_ticks > 0 {
256            (
257                smoothstep(purchase_ticks as f32 / PURCHASE_FLASH_TICKS as f32),
258                FLASH_TINT,
259                bulk_amp,
260            )
261        } else if unaff_ticks > 0 {
262            (
263                smoothstep(unaff_ticks as f32 / (PURCHASE_FLASH_TICKS as f32 / 2.0)),
264                UNAFFORDABLE_TINT,
265                1.0,
266            )
267        } else if unlock_ticks > 0 {
268            (
269                smoothstep(unlock_ticks as f32 / UNLOCK_FLASH_TICKS as f32),
270                UNLOCK_TINT,
271                1.0,
272            )
273        } else {
274            continue;
275        };
276        if strength <= 0.001 {
277            continue;
278        }
279        let row_start = inner_y + slot as u16 * ROWS_PER_FINGERER;
280        // Carrier eases from resting gray to pure WHITE on full strength —
281        // never washed out by bulk-buy. The wave below paints tint on top.
282        let carrier_r = FLASH_REST.0 + (FLASH_CARRIER.0 - FLASH_REST.0) * strength;
283        let carrier_g = FLASH_REST.1 + (FLASH_CARRIER.1 - FLASH_REST.1) * strength;
284        let carrier_b = FLASH_REST.2 + (FLASH_CARRIER.2 - FLASH_REST.2) * strength;
285        // First 3 of the 4 rows hold the fingerer's text; the 4th is the
286        // blank separator.
287        for dy in 0..3u16 {
288            let row = row_start + dy;
289            if row >= inner_bottom {
290                break;
291            }
292            for col in inner_x..inner_right {
293                let rel = (col - area.x) as f32;
294                let wave01 =
295                    (((rel + phase) * std::f32::consts::TAU / FLASH_CYCLE).sin() + 1.0) * 0.5;
296                let contribution = (wave01 * strength * amp).min(1.0);
297                let r = carrier_r + (tint.0 - carrier_r) * contribution;
298                let g = carrier_g + (tint.1 - carrier_g) * contribution;
299                let b = carrier_b + (tint.2 - carrier_b) * contribution;
300                let cell = &mut buf[(col, row)];
301                cell.set_fg(Color::Rgb(r as u8, g as u8, b as u8));
302                cell.modifier.insert(Modifier::BOLD);
303            }
304        }
305    }
306}
307
308fn smoothstep(t: f32) -> f32 {
309    let t = t.clamp(0.0, 1.0);
310    t * t * (3.0 - 2.0 * t)
311}