Skip to main content

cuqueclicker_lib/ui/
border.rs

1use ratatui::{prelude::*, widgets::*};
2
3use crate::game::state::{
4    ACHIEVEMENT_FLASH_TICKS, Buff, GameState, LUCKY_FLASH_TICKS, PURCHASE_FLASH_TICKS,
5};
6
7// Resting baseline — what the border looks like when nothing is happening.
8const BASELINE: (f32, f32, f32) = (200.0, 200.0, 210.0);
9// Active carrier — what the "non-tint" cells are during an active event.
10// Pure white gives high contrast against every tint so pulses pop.
11const ACTIVE_CARRIER: (f32, f32, f32) = (255.0, 255.0, 255.0);
12
13// Each event owns a (tint, cycle_length_in_cells) pair. When active, that
14// event adds a sine wave of "direction from baseline toward tint" modulated
15// by its strength. Cycles are kept coprime so simultaneous events drift out
16// of sync and produce moiré-like plaids of color instead of averaging to
17// mush.
18const FRENZY_TINT: (f32, f32, f32) = (255.0, 60.0, 60.0);
19const FRENZY_CYCLE: f32 = 13.0;
20const BUFF_TINT: (f32, f32, f32) = (200.0, 100.0, 255.0);
21const BUFF_CYCLE: f32 = 17.0;
22const LUCKY_TINT: (f32, f32, f32) = (255.0, 215.0, 0.0);
23const LUCKY_CYCLE: f32 = 23.0;
24const PURCHASE_TINT: (f32, f32, f32) = (40.0, 230.0, 80.0);
25const PURCHASE_CYCLE: f32 = 11.0;
26// Achievement-unlock channel: warm gold like Lucky but on its own coprime
27// cycle so an unlock during a Lucky/Frenzy doesn't average out into the
28// existing tint — it lays a distinct moiré on top.
29const ACHIEVEMENT_TINT: (f32, f32, f32) = (255.0, 200.0, 100.0);
30const ACHIEVEMENT_CYCLE: f32 = 19.0;
31
32// Permanent subtle undertone once any prestige has been earned.
33const PRESTIGE_TINT: (f32, f32, f32) = (200.0, 170.0, 80.0);
34const PRESTIGE_WEIGHT: f32 = 0.08;
35
36pub fn draw_animated(frame: &mut Frame, area: Rect, state: &GameState, title: &str) {
37    let block = Block::bordered().title(title);
38    frame.render_widget(block, area);
39
40    if area.width < 2 || area.height < 2 {
41        return;
42    }
43
44    let buf = frame.buffer_mut();
45    let mut i: usize = 0;
46    let last_x = area.x + area.width - 1;
47    let last_y = area.y + area.height - 1;
48
49    for x in area.x..=last_x {
50        recolor(buf, x, area.y, cell_color(i, state));
51        i += 1;
52    }
53    for y in (area.y + 1)..=last_y {
54        recolor(buf, last_x, y, cell_color(i, state));
55        i += 1;
56    }
57    if area.height > 1 {
58        for x in (area.x..last_x).rev() {
59            recolor(buf, x, last_y, cell_color(i, state));
60            i += 1;
61        }
62    }
63    if area.width > 1 && area.height > 2 {
64        for y in ((area.y + 1)..last_y).rev() {
65            recolor(buf, area.x, y, cell_color(i, state));
66            i += 1;
67        }
68    }
69}
70
71fn recolor(buf: &mut Buffer, x: u16, y: u16, color: Color) {
72    if x >= buf.area.x + buf.area.width || y >= buf.area.y + buf.area.height {
73        return;
74    }
75    let cell = &mut buf[(x, y)];
76    cell.set_fg(color);
77    cell.modifier.insert(Modifier::BOLD);
78}
79
80fn cell_color(i: usize, state: &GameState) -> Color {
81    let phase = state.border_phase as f32;
82
83    // Flashes plateau at full strength for most of their duration, then
84    // smoothstep-fade over the last fraction. Pure timing — no bulk-buy
85    // scaling here. The carrier path needs to reach 1.0 (full white) on
86    // every flash so the pulse swings WHITE↔tint with maximum contrast.
87    // Bulk-buy intensity is folded in below as a wave-amplitude bonus,
88    // not as a carrier dampener.
89    let purchase_s = plateau_fade(state.purchase_flash_ticks, PURCHASE_FLASH_TICKS);
90    let lucky_s = plateau_fade(state.lucky_flash_ticks, LUCKY_FLASH_TICKS);
91    let achievement_s = plateau_fade(state.achievement_flash_ticks, ACHIEVEMENT_FLASH_TICKS);
92    let frenzy_s = state
93        .buffs
94        .iter()
95        .filter_map(|b| match b {
96            Buff::ClickFrenzy { .. } => Some(b.strength()),
97            _ => None,
98        })
99        .fold(0.0_f32, f32::max);
100    let buff_s = state
101        .buffs
102        .iter()
103        .filter_map(|b| match b {
104            Buff::FingererBoost { .. } => Some(b.strength()),
105            _ => None,
106        })
107        .fold(0.0_f32, f32::max);
108
109    // Carrier smoothly blends from resting gray to pure white as total
110    // activity rises, so pulses swing between WHITE and tint (high contrast)
111    // instead of GRAY and tint (dull). When activity decays back to 0 the
112    // carrier eases back to gray, avoiding a jarring cut.
113    let activity = purchase_s
114        .max(lucky_s)
115        .max(achievement_s)
116        .max(frenzy_s)
117        .max(buff_s);
118    let carrier_r = BASELINE.0 + (ACTIVE_CARRIER.0 - BASELINE.0) * activity;
119    let carrier_g = BASELINE.1 + (ACTIVE_CARRIER.1 - BASELINE.1) * activity;
120    let carrier_b = BASELINE.2 + (ACTIVE_CARRIER.2 - BASELINE.2) * activity;
121
122    let mut r = carrier_r;
123    let mut g = carrier_g;
124    let mut b = carrier_b;
125
126    // Each event adds a wave-modulated deviation from the (white) carrier
127    // toward its tint. Channels are summed independently and clamped, so
128    // simultaneous events produce chromatic combinations (red+blue → hot
129    // magenta, red+gold → orange, etc.) rather than a muddy average.
130    //
131    // Bulk-buy boosts the wave amplitude on the purchase channel only —
132    // a max-buy paints louder green peaks without dimming the white
133    // carrier (which gives us the contrast).
134    let purchase_amp = state.purchase_flash_strength.clamp(1.0, 3.0);
135    for (tint, cycle, strength, amp) in [
136        (PURCHASE_TINT, PURCHASE_CYCLE, purchase_s, purchase_amp),
137        (LUCKY_TINT, LUCKY_CYCLE, lucky_s, 1.0),
138        (ACHIEVEMENT_TINT, ACHIEVEMENT_CYCLE, achievement_s, 1.0),
139        (BUFF_TINT, BUFF_CYCLE, buff_s, 1.0),
140        (FRENZY_TINT, FRENZY_CYCLE, frenzy_s, 1.0),
141    ] {
142        if strength > 0.001 {
143            let wave01 = (((i as f32 + phase) * std::f32::consts::TAU / cycle).sin() + 1.0) * 0.5;
144            let contribution = (wave01 * strength * amp).min(1.0);
145            r += (tint.0 - carrier_r) * contribution;
146            g += (tint.1 - carrier_g) * contribution;
147            b += (tint.2 - carrier_b) * contribution;
148        }
149    }
150
151    let mut r = r.clamp(0.0, 255.0);
152    let mut g = g.clamp(0.0, 255.0);
153    let mut b = b.clamp(0.0, 255.0);
154
155    if state.prestige > 0 {
156        r += (PRESTIGE_TINT.0 - r) * PRESTIGE_WEIGHT;
157        g += (PRESTIGE_TINT.1 - g) * PRESTIGE_WEIGHT;
158        b += (PRESTIGE_TINT.2 - b) * PRESTIGE_WEIGHT;
159    }
160
161    Color::Rgb(r as u8, g as u8, b as u8)
162}
163
164fn smoothstep(t: f32) -> f32 {
165    let t = t.clamp(0.0, 1.0);
166    t * t * (3.0 - 2.0 * t)
167}
168
169/// 1.0 for the bulk of the flash, smoothstep-decays only in the last ~40%.
170/// Shapes like: ---[=============\___].
171pub fn plateau_fade(remaining: u32, total: u32) -> f32 {
172    if total == 0 {
173        return 0.0;
174    }
175    let fade_ticks = (total as f32 * 0.4).max(1.0);
176    let r = remaining as f32;
177    if r >= fade_ticks {
178        1.0
179    } else {
180        smoothstep(r / fade_ticks)
181    }
182}
183
184/// Recolor the rectangular border of `area` by walking it cell-by-cell with
185/// the same wave-tint logic as `draw_animated`, but driven by a single
186/// `(tint, strength)` pair. Used to flash secondary panel borders (Fingerers
187/// / Upgrades sidebar) on purchase events so the whole panel reads as
188/// "something just happened here", not just the row that changed.
189///
190/// Caller is expected to have already rendered a `Block::bordered()` over
191/// `area`; this only mutates color/style of the existing border cells.
192pub fn paint_border_flash(
193    frame: &mut Frame,
194    area: Rect,
195    state: &GameState,
196    tint: (f32, f32, f32),
197    cycle: f32,
198    strength: f32,
199) {
200    if strength <= 0.001 || area.width < 2 || area.height < 2 {
201        return;
202    }
203    let buf = frame.buffer_mut();
204    // Use the steady phase clock so an active HUD-border event (Frenzy,
205    // Lucky, Achievement) accelerating the global `border_phase` doesn't
206    // also accelerate this panel border's wave. Each shimmer surface
207    // marches to its own beat.
208    let phase = state.steady_phase as f32;
209    let last_x = area.x + area.width - 1;
210    let last_y = area.y + area.height - 1;
211
212    let mut paint = |x: u16, y: u16, i: usize| {
213        if x >= buf.area.x + buf.area.width || y >= buf.area.y + buf.area.height {
214            return;
215        }
216        let wave01 = (((i as f32 + phase) * std::f32::consts::TAU / cycle).sin() + 1.0) * 0.5;
217        // Carrier: resting gray → white as strength rises, then tint sits on
218        // top via the same wave logic as the HUD border. Identical look feel.
219        let carrier_r = BASELINE.0 + (ACTIVE_CARRIER.0 - BASELINE.0) * strength;
220        let carrier_g = BASELINE.1 + (ACTIVE_CARRIER.1 - BASELINE.1) * strength;
221        let carrier_b = BASELINE.2 + (ACTIVE_CARRIER.2 - BASELINE.2) * strength;
222        let contribution = wave01 * strength;
223        let r = carrier_r + (tint.0 - carrier_r) * contribution;
224        let g = carrier_g + (tint.1 - carrier_g) * contribution;
225        let b = carrier_b + (tint.2 - carrier_b) * contribution;
226        let cell = &mut buf[(x, y)];
227        cell.set_fg(Color::Rgb(
228            r.clamp(0.0, 255.0) as u8,
229            g.clamp(0.0, 255.0) as u8,
230            b.clamp(0.0, 255.0) as u8,
231        ));
232        cell.modifier.insert(Modifier::BOLD);
233    };
234
235    let mut i = 0usize;
236    for x in area.x..=last_x {
237        paint(x, area.y, i);
238        i += 1;
239    }
240    for y in (area.y + 1)..=last_y {
241        paint(last_x, y, i);
242        i += 1;
243    }
244    if area.height > 1 {
245        for x in (area.x..last_x).rev() {
246            paint(x, last_y, i);
247            i += 1;
248        }
249    }
250    if area.width > 1 && area.height > 2 {
251        for y in ((area.y + 1)..last_y).rev() {
252            paint(area.x, y, i);
253            i += 1;
254        }
255    }
256}
257
258/// Tint constants exported so callers (sidebar, upgrades, achievements)
259/// can request a matching panel-border flash without redefining the
260/// palette. Same values the HUD title border uses for those events, so
261/// HUD + panel always pulse in the same hue.
262pub const PANEL_PURCHASE_TINT: (f32, f32, f32) = PURCHASE_TINT;
263pub const PANEL_PURCHASE_CYCLE: f32 = PURCHASE_CYCLE;
264pub const PANEL_UNAFFORDABLE_TINT: (f32, f32, f32) = (255.0, 70.0, 70.0);
265pub const PANEL_UNAFFORDABLE_CYCLE: f32 = 7.0;
266pub const PANEL_ACHIEVEMENT_TINT: (f32, f32, f32) = ACHIEVEMENT_TINT;
267pub const PANEL_ACHIEVEMENT_CYCLE: f32 = ACHIEVEMENT_CYCLE;