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