Skip to main content

cuqueclicker_lib/ui/
border.rs

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