Skip to main content

cuqueclicker_lib/ui/
upgrades.rs

1use ratatui::{prelude::*, widgets::*};
2
3use crate::format;
4use crate::game::state::{GameState, PURCHASE_FLASH_TICKS, UNLOCK_FLASH_TICKS};
5use crate::game::upgrade::{self, UPGRADES};
6use crate::i18n::t;
7use crate::ui::border;
8
9/// Indent applied to wrapped continuation lines so a long description hangs
10/// under the title indent instead of falling back to col 0.
11const HANGING_INDENT: &str = "    ";
12const FLASH_TINT: (f32, f32, f32) = (40.0, 230.0, 80.0);
13const UNAFFORDABLE_TINT: (f32, f32, f32) = (255.0, 60.0, 60.0);
14const UNLOCK_TINT: (f32, f32, f32) = (120.0, 255.0, 140.0);
15const FLASH_REST: (f32, f32, f32) = (200.0, 200.0, 210.0);
16const FLASH_CARRIER: (f32, f32, f32) = (255.0, 255.0, 255.0);
17const FLASH_CYCLE: f32 = 11.0;
18
19/// Returns one entry per visible upgrade row: the live `UPGRADES` index
20/// and the click-target rect on screen. Aligned 1:1 with the rendered
21/// rows, so the click router can map a click coordinate to an
22/// `Action::BuyUpgrade(idx)` without re-parsing the panel layout.
23pub fn draw(
24    frame: &mut Frame,
25    area: Rect,
26    state: &GameState,
27    mouse_pos: Option<(u16, u16)>,
28) -> Vec<(usize, Rect)> {
29    let lang = t();
30    let available = upgrade::available_ids(state);
31    let visible: Vec<usize> = available.iter().take(10).copied().collect();
32    // Inner content width (panel width minus the bordered block's chrome) —
33    // used to pre-wrap descriptions with a hanging indent so continuation
34    // lines line up under the title text instead of falling back to col 0.
35    let desc_width = area.width.saturating_sub(2 + HANGING_INDENT.len() as u16) as usize;
36
37    let mut lines: Vec<Line> = Vec::new();
38    // Track each rendered upgrade's row span as we go: (slot, content_start,
39    // content_height_excl_blank). Descriptions wrap to a variable line
40    // count via `wrap_hanging`, so the old `slot * ROWS_PER_UPGRADE`
41    // assumption was off-by-one whenever any earlier desc wrapped — the
42    // click hit-test would attribute the click to the next slot down.
43    // We compute exact starts here once and feed them to both the click
44    // router AND `paint_flashes`.
45    let mut row_spans: Vec<UpgradeRowSpan> = Vec::new();
46
47    if visible.is_empty() {
48        for l in lang.upgrades_none.lines() {
49            lines.push(Line::from(l.to_string()).style(Style::default().fg(Color::DarkGray)));
50        }
51    } else {
52        for (slot, &u_idx) in visible.iter().enumerate() {
53            let hotkey = if slot == 9 {
54                '0'
55            } else {
56                (b'1' + slot as u8) as char
57            };
58            let u = &UPGRADES[u_idx];
59            // Match `state::buy_upgrade`'s gate so the cost color and the
60            // click-buy outcome agree on what "affordable" means.
61            let affordable = state.affordable_cuques() >= u.cost;
62            let name = lang.upgrade_names.get(u_idx).copied().unwrap_or("?");
63            let desc = lang.upgrade_descs.get(u_idx).copied().unwrap_or("");
64            let cost_style = if affordable {
65                Style::default()
66                    .fg(Color::Rgb(0, 255, 80))
67                    .add_modifier(Modifier::BOLD)
68            } else {
69                Style::default().fg(Color::Rgb(220, 70, 70))
70            };
71            // Record the line index where THIS upgrade's title starts.
72            let content_start = lines.len() as u16;
73            lines.push(Line::from(vec![
74                Span::styled(format!("[{}] ", hotkey), Style::default().fg(Color::Yellow)),
75                Span::styled(
76                    name.to_string(),
77                    Style::default().add_modifier(Modifier::BOLD),
78                ),
79            ]));
80            // J13: pre-wrap with a hanging indent so wrapped lines align
81            // under the title text. The panel renders with `Wrap { trim }`
82            // disabled below, so each entry here is a finished line.
83            let wrapped = wrap_hanging(desc, desc_width);
84            for desc_line in &wrapped {
85                lines.push(Line::from(vec![Span::styled(
86                    desc_line.clone(),
87                    Style::default().fg(Color::DarkGray),
88                )]));
89            }
90            lines.push(Line::from(vec![
91                Span::raw(HANGING_INDENT),
92                Span::styled(format!("{} {}", lang.cost, format::big(u.cost)), cost_style),
93            ]));
94            // Content (clickable) height = title + N wrapped desc + cost.
95            let content_height = (1 + wrapped.len() as u16 + 1).max(1);
96            row_spans.push(UpgradeRowSpan {
97                u_idx,
98                content_start,
99                content_height,
100            });
101            lines.push(Line::raw("")); // blank separator (NOT clickable)
102        }
103    }
104
105    let p = Paragraph::new(lines).block(Block::bordered().title(lang.upgrades_title));
106    frame.render_widget(p, area);
107
108    paint_flashes(frame, area, state, &row_spans);
109
110    // Panel-border flash mirrors sidebar.rs: green on any active purchase
111    // flash for visible upgrades, red on any active unaffordable flash.
112    let any_purchase = visible
113        .iter()
114        .filter_map(|&i| state.upgrade_flash_ticks.get(i).copied())
115        .max()
116        .unwrap_or(0);
117    let any_unaff = visible
118        .iter()
119        .filter_map(|&i| state.upgrade_unaffordable_flash.get(i).copied())
120        .max()
121        .unwrap_or(0);
122    // Carrier-strength is timing-only — bulk-buy scaling is applied as a
123    // wave-amplitude bonus inside `paint_border_flash`, not as a carrier
124    // dampener — so the panel border reaches full white on every flash.
125    let purchase_strength = border::plateau_fade(any_purchase, PURCHASE_FLASH_TICKS);
126    let unaff_strength = border::plateau_fade(any_unaff, PURCHASE_FLASH_TICKS / 2);
127    // Unaffordable wins when active — see the matching comment in
128    // sidebar.rs. Ensures a click on an unaffordable upgrade always lights
129    // the panel border red, even if a recent successful purchase's green
130    // hasn't fully decayed yet.
131    if unaff_strength > 0.001 {
132        border::paint_border_flash(
133            frame,
134            area,
135            state,
136            border::PANEL_UNAFFORDABLE_TINT,
137            border::PANEL_UNAFFORDABLE_CYCLE,
138            unaff_strength,
139        );
140    } else if purchase_strength > 0.001 {
141        border::paint_border_flash(
142            frame,
143            area,
144            state,
145            border::PANEL_PURCHASE_TINT,
146            border::PANEL_PURCHASE_CYCLE,
147            purchase_strength,
148        );
149    }
150
151    // Build per-row click rects from the SAME `row_spans` we collected
152    // during render — guarantees a click on row Y is attributed to the
153    // upgrade that actually drew at row Y, even when a previous upgrade's
154    // description wrapped to multiple lines.
155    if area.width < 3 || area.height < 3 {
156        return Vec::new();
157    }
158    let inner_x = area.x + 1;
159    let inner_y = area.y + 1;
160    let inner_w = area.width.saturating_sub(2);
161    let inner_h = area.height.saturating_sub(2);
162    let mut rows: Vec<(usize, Rect)> = Vec::new();
163    for span in &row_spans {
164        if span.content_start >= inner_h {
165            break;
166        }
167        let height = span.content_height.min(inner_h - span.content_start);
168        rows.push((
169            span.u_idx,
170            Rect {
171                x: inner_x,
172                y: inner_y + span.content_start,
173                width: inner_w,
174                height,
175            },
176        ));
177    }
178    paint_hover(frame, &rows, mouse_pos);
179    rows
180}
181
182/// Layout span for one rendered upgrade entry: the line index where its
183/// content starts (relative to the bordered block's interior), and how
184/// many lines the content covers (excluding the trailing blank separator).
185/// Computed per-frame from the actual rendered line count so wrapped
186/// descriptions don't desync the click hit-test from the visual layout.
187struct UpgradeRowSpan {
188    u_idx: usize,
189    content_start: u16,
190    content_height: u16,
191}
192
193/// Same hover-paint pattern as `sidebar::paint_hover`.
194fn paint_hover(frame: &mut Frame, rows: &[(usize, Rect)], mouse_pos: Option<(u16, u16)>) {
195    let Some((mx, my)) = mouse_pos else { return };
196    let Some(&(_, r)) = rows
197        .iter()
198        .find(|&&(_, r)| mx >= r.x && mx < r.x + r.width && my >= r.y && my < r.y + r.height)
199    else {
200        return;
201    };
202    let buf = frame.buffer_mut();
203    for dy in 0..r.height {
204        let y = r.y + dy;
205        if y >= buf.area.y + buf.area.height {
206            break;
207        }
208        for dx in 0..r.width {
209            let x = r.x + dx;
210            if x >= buf.area.x + buf.area.width {
211                break;
212            }
213            let cell = &mut buf[(x, y)];
214            if let Color::Rgb(r, g, b) = cell.fg {
215                cell.set_fg(Color::Rgb(
216                    (r as u16 + 30).min(255) as u8,
217                    (g as u16 + 30).min(255) as u8,
218                    (b as u16 + 30).min(255) as u8,
219                ));
220            }
221            cell.modifier.insert(Modifier::BOLD);
222            cell.set_bg(Color::Rgb(28, 28, 36));
223        }
224    }
225}
226
227/// Word-wrap `text` at `width` columns, prepending `HANGING_INDENT` to every
228/// produced line. Returns at least one line (the indent + first word) so a
229/// caller never has to special-case empty input.
230fn wrap_hanging(text: &str, width: usize) -> Vec<String> {
231    let indent = HANGING_INDENT;
232    if width <= indent.len() {
233        return vec![format!("{indent}{text}")];
234    }
235    let avail = width - indent.len();
236    let mut out: Vec<String> = Vec::new();
237    let mut row = String::new();
238    for word in text.split_whitespace() {
239        if row.is_empty() {
240            row.push_str(word);
241        } else if row.len() + 1 + word.len() <= avail {
242            row.push(' ');
243            row.push_str(word);
244        } else {
245            out.push(format!("{indent}{row}"));
246            row.clear();
247            row.push_str(word);
248        }
249    }
250    if !row.is_empty() || out.is_empty() {
251        out.push(format!("{indent}{row}"));
252    }
253    out
254}
255
256fn paint_flashes(frame: &mut Frame, area: Rect, state: &GameState, row_spans: &[UpgradeRowSpan]) {
257    if area.width < 3 || area.height < 3 {
258        return;
259    }
260    // Steady phase clock — see sidebar.rs for the rationale. Decoupled
261    // from the HUD-border's speed so concurrent shimmers don't entrain.
262    let phase = state.steady_phase as f32;
263    let inner_x = area.x + 1;
264    let inner_y = area.y + 1;
265    let inner_right = area.x + area.width - 1;
266    let inner_bottom = area.y + area.height - 1;
267    // Bulk-buy boosts wave amplitude only; carrier-strength is timing-only
268    // so the row reaches full white-↔-tint contrast on every flash.
269    let bulk_amp = state.purchase_flash_strength.clamp(1.0, 3.0);
270    let buf = frame.buffer_mut();
271
272    for span in row_spans {
273        let purchase_ticks = state
274            .upgrade_flash_ticks
275            .get(span.u_idx)
276            .copied()
277            .unwrap_or(0);
278        let unaff_ticks = state
279            .upgrade_unaffordable_flash
280            .get(span.u_idx)
281            .copied()
282            .unwrap_or(0);
283        let unlock_ticks = state
284            .upgrade_unlock_flash
285            .get(span.u_idx)
286            .copied()
287            .unwrap_or(0);
288        let (strength, tint, amp) = if purchase_ticks > 0 {
289            (
290                smoothstep(purchase_ticks as f32 / PURCHASE_FLASH_TICKS as f32),
291                FLASH_TINT,
292                bulk_amp,
293            )
294        } else if unaff_ticks > 0 {
295            (
296                smoothstep(unaff_ticks as f32 / (PURCHASE_FLASH_TICKS as f32 / 2.0)),
297                UNAFFORDABLE_TINT,
298                1.0,
299            )
300        } else if unlock_ticks > 0 {
301            (
302                smoothstep(unlock_ticks as f32 / UNLOCK_FLASH_TICKS as f32),
303                UNLOCK_TINT,
304                1.0,
305            )
306        } else {
307            continue;
308        };
309        if strength <= 0.001 {
310            continue;
311        }
312        let row_start = inner_y + span.content_start;
313        let carrier_r = FLASH_REST.0 + (FLASH_CARRIER.0 - FLASH_REST.0) * strength;
314        let carrier_g = FLASH_REST.1 + (FLASH_CARRIER.1 - FLASH_REST.1) * strength;
315        let carrier_b = FLASH_REST.2 + (FLASH_CARRIER.2 - FLASH_REST.2) * strength;
316        for dy in 0..span.content_height {
317            let row = row_start + dy;
318            if row >= inner_bottom {
319                break;
320            }
321            for col in inner_x..inner_right {
322                let rel = (col - area.x) as f32;
323                let wave01 =
324                    (((rel + phase) * std::f32::consts::TAU / FLASH_CYCLE).sin() + 1.0) * 0.5;
325                let contribution = (wave01 * strength * amp).min(1.0);
326                let r = carrier_r + (tint.0 - carrier_r) * contribution;
327                let g = carrier_g + (tint.1 - carrier_g) * contribution;
328                let b = carrier_b + (tint.2 - carrier_b) * contribution;
329                let cell = &mut buf[(col, row)];
330                cell.set_fg(Color::Rgb(r as u8, g as u8, b as u8));
331                cell.modifier.insert(Modifier::BOLD);
332            }
333        }
334    }
335}
336
337fn smoothstep(t: f32) -> f32 {
338    let t = t.clamp(0.0, 1.0);
339    t * t * (3.0 - 2.0 * t)
340}