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