Skip to main content

cuqueclicker_lib/ui/
biscuit.rs

1use ratatui::{prelude::*, widgets::*};
2
3use crate::game::powerup::{Powerup, PowerupKind};
4use crate::game::state::{Buff, CLENCH_SQUASH_TICKS, CLENCH_TICKS, GameState};
5
6/// Asshole-spin animation frames. Cycled by `total_clicks % N` while a
7/// spacebar hold has been detected (≥1s of continuous repeat). `*` lands
8/// every 5th frame as a "flash" sparkle in the rotation so the cycle reads
9/// as an actually-spinning asshole occasionally bursting into a star,
10/// rather than four indistinguishable line strokes.
11const SPIN_FRAMES: [char; 5] = ['\\', '|', '/', '-', '*'];
12
13/// Resolve a prestige count to (tint_color, mix_strength) for the cuque
14/// body. The tint is a noble hue the body bleeds toward; the mix is how
15/// strongly the tint dominates the resting tan. Steps are coarse on
16/// purpose — a player needs to PRESTIGE several times in a tier to start
17/// glimpsing the next one, so a tier transition reads as earned rather
18/// than continuous drift. Caps at "divine white" past tier 5.
19fn prestige_body_tint(prestige: u64) -> ((f32, f32, f32), f32) {
20    // Pure tan when at 0; each tier biases the cuque toward a distinct
21    // hue. Mix grows with tier so endgame is dramatic, but capped < 0.7
22    // so the body never goes monochrome — you can still tell it's a cuque.
23    match prestige {
24        0 => ((220.0, 170.0, 150.0), 0.0),
25        1..=2 => ((255.0, 200.0, 110.0), 0.18),   // warm gold
26        3..=5 => ((255.0, 215.0, 80.0), 0.32),    // saturated gold
27        6..=9 => ((230.0, 220.0, 235.0), 0.40),   // silver-pink
28        10..=14 => ((180.0, 230.0, 255.0), 0.50), // ethereal cyan
29        15..=24 => ((220.0, 200.0, 255.0), 0.55), // celestial violet
30        _ => ((255.0, 250.0, 240.0), 0.65),       // divine white
31    }
32}
33
34// IMPORTANT: the focal cell (asshole) is intentionally a SPACE in each
35// art slice below. The renderer overpaints that cell with the live glyph
36// (`O` / `*` / spin frame `\ | / - *`) at draw time, using the
37// `asshole_col` / `asshole_row` declared on each `BiscuitArt`. We do NOT
38// substitute glyphs into the strings — that ran into the obvious trap of
39// `replace('O', '|')` collateral-damaging the `|` walls on rows 9-21,
40// and the burning-pulse overpaint also got fooled into picking the
41// wrong row when searching for a stand-in glyph.
42// Body walls at cols 1 and 59 → visual center column 30. The right-side
43// curve rows (2-8 top, 21-27 bottom) used to extend 1 column further from
44// center than their left-side mirrors, so the right wall `|` sat at the
45// same column as the curve `\` above it (no rounding offset) while the
46// left side had `/` 1 col inside the wall — visibly asymmetric. Shifted
47// the right cluster on each curve row 1 col left so the right contour now
48// rounds into the wall the same way the left does.
49//
50// Rows 0, 1, 28, 29 (the underscore row + adjacent `__,-~~` row) sit at
51// half-column offsets from the body center; their |L|/|R| asymmetry is
52// inherent to the even-width middle gap, and a 1-col shift just flips
53// which side is shorter. Left as-is.
54const BISCUIT_FULL: &[&str] = &[
55    r"                    ____________________                    ",
56    r"              __,-~~                    ~~-,__              ",
57    r"           ,-~'                               `~-,          ",
58    r"        ,-'                                       `-,       ",
59    r"      ,'                                             `.     ",
60    r"     /         -~-~-~-              -~-~-~-            \    ",
61    r"    /                                                   \   ",
62    r"   /             -~~-~-~~-                               \  ",
63    r"  /                                                       \ ",
64    r" |          -~-~-~-~-             -~-~-~-~-                |",
65    r" |                                                         |",
66    r" |                                                         |",
67    r" |                  \\\\\\\\   |   ////////                |",
68    r" |                   \\\\\\\\  |  ////////                 |",
69    r" |                    \\\\\\\\\|/////////                  |",
70    r" |         ~ - - - - -                   - - - - - ~       |",
71    r" |                    /////////|\\\\\\\\\                  |",
72    r" |                   ////////  |  \\\\\\\\                 |",
73    r" |                  ////////   |   \\\\\\\\                |",
74    r" |                                                         |",
75    r" |          -~-~-~-~-             -~-~-~-~-                |",
76    r"  \                                                       / ",
77    r"   \             -~~-~-~~-                               /  ",
78    r"    \                                                   /   ",
79    r"     \         -~-~-~-              -~-~-~-            /    ",
80    r"      `.                                             ,'     ",
81    r"        `-,                                       ,-'       ",
82    r"           `~-,                               ,-~'          ",
83    r"              `~-,,_                      _,,-~'            ",
84    r"                   `~-,,______________,,-~'                 ",
85];
86
87const BISCUIT_MEDIUM: &[&str] = &[
88    r"            ________________            ",
89    r"         ,-~                 ~-,        ",
90    r"      ,-'                        `-,    ",
91    r"    ,'                              `.  ",
92    r"   /         -~-~-      -~-~-         \ ",
93    r"  /                                    \",
94    r" |          \\\\\   |   /////           |",
95    r" |           \\\\\  |  /////            |",
96    r" |            \\\\\\|//////             |",
97    r" |    ~ - - -               - - - ~     |",
98    r" |            //////|\\\\\\             |",
99    r" |           /////  |  \\\\\            |",
100    r" |          /////   |   \\\\\           |",
101    r"  \                                    /",
102    r"   \         -~-~-      -~-~-         / ",
103    r"    `.                              ,'  ",
104    r"      `-,                        ,-'    ",
105    r"         `~-,,_______________,,-~'      ",
106];
107
108// Body walls sit at cols 1 and 25 → visual center column 13. The rounded
109// contour rows (0-3, 9-11) used to terminate one column short of the wall
110// on the right side, leaving a 2-column gap between e.g. `\` (row 3) and
111// `|` (row 4). Symmetrized around col 13 so each row's left margin and
112// right margin are equal — same shape now reads as a closed oval.
113const BISCUIT_SMALL: &[&str] = &[
114    r"        ___________       ",
115    r"     ,-~           ~-,    ",
116    r"   ,'                 `.  ",
117    r"  /    -~-~-  -~-~-     \ ",
118    r" |       \\\ | ///       | ",
119    r" |        \\\|///        | ",
120    r" | ~ - -           - - ~ | ",
121    r" |        ///|\\\        | ",
122    r" |       /// | \\\       | ",
123    r"  \    -~-~-  -~-~-     / ",
124    r"   `.                 ,'  ",
125    r"     `-,,_________,,-'    ",
126];
127
128/// 16 cols × 8 rows. Borrowed by the tree-modal anchor (the (0, 0) lot)
129/// to render the cuque-as-anchor instead of a regular upgrade box.
130pub(crate) const BISCUIT_TINY: &[&str] = &[
131    r"     ______     ",
132    r"   ,~      ~,   ",
133    r"  /          \  ",
134    r" |    \|/     | ",
135    r" | -       -  | ",
136    r" |    /|\     | ",
137    r"  \          /  ",
138    r"   `-,____,-'   ",
139];
140/// Focal cell ("the asshole") of `BISCUIT_TINY`, expressed in art-local
141/// (col, row) coords. Anchor render in the tree modal paints an `O` at
142/// this cell so it reads as a cuque, not just an outline.
143pub(crate) const BISCUIT_TINY_FOCAL: (u16, u16) = (7, 4);
144
145/// One zoom level. `(asshole_col, asshole_row)` are the exact in-art
146/// coordinates of the focal cell — declared at author time, never searched
147/// for at draw time. The renderer prints the static `rows` verbatim (the
148/// focal cell is a SPACE in the source) and then paints exactly one cell
149/// at that coordinate with the live glyph (`O` / `*` / spin frame). Pros:
150///   - centering doesn't depend on the glyph (spin frames are ASCII chars
151///     that also appear elsewhere in the cuque outline; substitution
152///     would either miss or hit-too-many cells);
153///   - the burning-pulse overpaint targets exactly one cell, every frame,
154///     with no string search to mis-fire on outline `|` walls;
155///   - reorganizing or extending the art is a localized edit — bump the
156///     coords here and the renderer follows.
157///
158/// Authors of new levels MUST update both coords when adding art.
159struct BiscuitArt {
160    rows: &'static [&'static str],
161    asshole_col: u16,
162    asshole_row: u16,
163    label: Option<&'static str>,
164}
165
166const BISCUIT_LEVELS: &[BiscuitArt] = &[
167    BiscuitArt {
168        rows: BISCUIT_FULL,
169        asshole_col: 31,
170        asshole_row: 15,
171        label: None,
172    },
173    BiscuitArt {
174        rows: BISCUIT_MEDIUM,
175        asshole_col: 20,
176        asshole_row: 9,
177        label: Some("70%"),
178    },
179    BiscuitArt {
180        rows: BISCUIT_SMALL,
181        asshole_col: 13,
182        asshole_row: 6,
183        label: Some("45%"),
184    },
185    BiscuitArt {
186        rows: BISCUIT_TINY,
187        asshole_col: 7,
188        asshole_row: 4,
189        label: Some("25%"),
190    },
191];
192
193pub fn level_count() -> usize {
194    BISCUIT_LEVELS.len()
195}
196
197/// Screen coordinates of the focal cell ("the asshole") for the given
198/// zoom level + biscuit rect. Used by `hands::draw` to orbit the ring
199/// around the visual cuque center rather than the bounding-box center
200/// (which differs by up to 1 column in TINY/FULL because each art's
201/// `asshole_col` isn't exactly `width / 2`).
202pub fn focal_point(zoom_idx: usize, biscuit: Rect) -> (u16, u16) {
203    let level = &BISCUIT_LEVELS[zoom_idx.min(BISCUIT_LEVELS.len() - 1)];
204    (biscuit.x + level.asshole_col, biscuit.y + level.asshole_row)
205}
206
207pub fn level_label(idx: usize) -> Option<&'static str> {
208    BISCUIT_LEVELS.get(idx).and_then(|a| a.label)
209}
210
211/// Draw the biscuit. Reads:
212///
213/// - `state.clench_ticks` — counts down a click flash. While >0, the eye
214///   becomes `*` and the body shifts pink. The first `CLENCH_SQUASH_TICKS`
215///   of that countdown also drop the top blank row, giving a one-frame
216///   vertical squash before the spring back.
217/// - active `ClickFrenzy` buff — biscuit is tinted toward red and shakes
218///   ±1 col on clench frames. Pure visual chaos, no behavior change.
219/// - `state.session_ticks` — drives a slow ambient breathing color cycle
220///   so the biscuit isn't completely static at idle.
221pub fn draw(frame: &mut Frame, area: Rect, state: &GameState, zoom_idx: usize) -> Rect {
222    let level = &BISCUIT_LEVELS[zoom_idx.min(BISCUIT_LEVELS.len() - 1)];
223    let art = level.rows;
224    let clenched = state.clench_ticks > 0;
225    // First CLENCH_SQUASH_TICKS frames of the clench: render a vertically
226    // squashed variant of the art so the cuque visibly contracts, then
227    // springs back. clench_ticks counts down from CLENCH_TICKS so "early in
228    // the clench" means clench_ticks is large.
229    let squash = clenched && state.clench_ticks + CLENCH_SQUASH_TICKS > CLENCH_TICKS;
230
231    // CRITICAL: the squash transformation MUST preserve total row count.
232    // `hands::draw` reads `biscuit.height` and `biscuit.y` to compute the
233    // orbital center + radii — if either changes per-frame, every hand
234    // around the cuque jitters on each click. The squash is built by
235    // dropping the rows immediately above + below the eye and padding with
236    // a blank row at top and bottom. Net: same height, eye stays at the
237    // same screen y, outer outline contracts inward toward the eye, hands
238    // around the biscuit don't move.
239    let render_art_owned: Vec<String> = if squash {
240        squashed_art(art, level.asshole_row as usize)
241    } else {
242        art.iter().map(|s| s.to_string()).collect()
243    };
244    let render_art: Vec<&str> = render_art_owned.iter().map(|s| s.as_str()).collect();
245
246    let w = render_art
247        .iter()
248        .map(|s| s.chars().count())
249        .max()
250        .unwrap_or(0) as u16;
251    let h = render_art.len() as u16;
252    // Anchor placement to the ASSHOLE column declared on the art level.
253    // Centering the bounding box by `(area.width - w) / 2` integer-truncates
254    // and combines with each art's different in-art asshole column, so the
255    // asshole drifts left/right as the player zooms. Anchoring the asshole
256    // itself to a fixed screen column keeps the focal point stationary on
257    // every zoom — the surrounding cuque shifts; the asshole doesn't. The
258    // declared column is independent of which glyph happens to live in
259    // that cell, so spin animation frames (`\ | / -`) work without breaking
260    // alignment.
261    let target_asshole_col = area.x + area.width / 2;
262    let x_base = target_asshole_col
263        .saturating_sub(level.asshole_col)
264        .max(area.x)
265        .min((area.x + area.width).saturating_sub(w));
266    let y_base = area.y + area.height.saturating_sub(h) / 2;
267
268    // The stable rect is what we RETURN to callers (hands, particles,
269    // golden). It must NOT depend on per-frame transients like the Frenzy
270    // shake — otherwise the orbital hands and floating particles jitter on
271    // every clench. Frenzy shake is applied only to the render position
272    // below.
273    let stable_rect = Rect {
274        x: x_base,
275        y: y_base,
276        width: w.min(area.width),
277        height: h.min(area.height.saturating_sub(y_base - area.y)),
278    };
279
280    // Frenzy shake: ±1 col jitter while clenched and frenzied. Drives off
281    // session_ticks so successive frames pick different offsets without
282    // needing per-render RNG state.
283    let frenzy_active = state
284        .buffs
285        .iter()
286        .any(|b| matches!(b, Buff::ClickFrenzy { .. }));
287    let shake = if frenzy_active && clenched {
288        (state.session_ticks % 3) as i32 - 1
289    } else {
290        0
291    };
292    let render_x = ((x_base as i32 + shake)
293        .max(area.x as i32)
294        .min((area.x + area.width).saturating_sub(stable_rect.width) as i32))
295        as u16;
296    let render_rect = Rect {
297        x: render_x,
298        y: stable_rect.y,
299        width: stable_rect.width,
300        height: stable_rect.height,
301    };
302
303    // Render the static art lines as-is — the focal cell is a literal SPACE
304    // in the source. The asshole glyph is painted directly into its
305    // declared (asshole_col, asshole_row) cell after the body draw.
306    let lines: Vec<Line> = render_art
307        .iter()
308        .map(|s| Line::from(s.to_string()))
309        .collect();
310
311    // Color blend:
312    //   - resting tan (220, 170, 150) when calm.
313    //   - clenched pink (255, 120, 140); during Frenzy bias the pink redder.
314    //   - idle: slow ±~5% sinusoidal breath on brightness, so the biscuit
315    //     never freezes between events.
316    let base = if clenched {
317        if frenzy_active {
318            (255.0_f32, 80.0, 110.0)
319        } else {
320            (255.0_f32, 120.0, 140.0)
321        }
322    } else {
323        let t = (state.session_ticks as f32) / 25.0; // ~8s period at 20Hz
324        let breath = 1.0 + 0.05 * t.sin();
325        // Resting tan, then re-tinted toward the prestige tier color.
326        // Higher tiers earn nobler hues so endgame feels visibly rewarded —
327        // tan → warm gold → silver-pink → ethereal cyan → divine white.
328        let (tint, mix) = prestige_body_tint(state.prestige);
329        let base_r = 220.0 * breath;
330        let base_g = 170.0 * breath;
331        let base_b = 150.0 * breath;
332        let r = base_r + (tint.0 - base_r) * mix;
333        let g = base_g + (tint.1 - base_g) * mix;
334        let b = base_b + (tint.2 - base_b) * mix;
335        (
336            r.clamp(0.0, 255.0),
337            g.clamp(0.0, 255.0),
338            b.clamp(0.0, 255.0),
339        )
340    };
341
342    let color = Color::Rgb(base.0 as u8, base.1 as u8, base.2 as u8);
343    let p = Paragraph::new(lines).style(Style::default().fg(color));
344    frame.render_widget(p, render_rect);
345
346    // Asshole glyph picker:
347    //  - not clenched              → resting `O` (cuque body color)
348    //  - clenched, space NOT held  → burning `*` with a hot pulsing red
349    //  - clenched, space HELD ≥ 1s → spin frame `\ | / - *`, advanced by
350    //                                `total_clicks % 5` so each repeat tick
351    //                                rotates one step (`*` is the flash
352    //                                frame in the cycle).
353    //
354    // Painted directly into the declared (asshole_col, asshole_row) cell —
355    // no string substitution, no row search. The squash transform
356    // preserves the asshole row index, so this works in both calm and
357    // squashed states.
358    let space_held = state.space_held();
359    let asshole_glyph: char = if !clenched {
360        'O'
361    } else if space_held {
362        SPIN_FRAMES[(state.total_clicks as usize) % SPIN_FRAMES.len()]
363    } else {
364        '*'
365    };
366    let buf = frame.buffer_mut();
367    let cx = render_rect.x + level.asshole_col;
368    let cy = render_rect.y + level.asshole_row;
369    if cx < buf.area.x + buf.area.width && cy < buf.area.y + buf.area.height {
370        let style = if clenched {
371            // Pulse the focal cell at ~5Hz (period ~4 ticks at 20Hz) so the
372            // asshole reads as actively burning, distinct from the merely
373            // pink cuque body.
374            let phase = (state.session_ticks as f32) * 0.8;
375            let pulse = (phase.sin() + 1.0) * 0.5;
376            let r = (200.0 + 55.0 * pulse) as u8;
377            let g = (30.0 + 60.0 * pulse) as u8;
378            Style::default()
379                .fg(Color::Rgb(r, g, 0))
380                .add_modifier(Modifier::BOLD)
381        } else {
382            // Resting `O` matches the cuque body color so it doesn't pop.
383            Style::default().fg(color)
384        };
385        let cell = &mut buf[(cx, cy)];
386        cell.set_char(asshole_glyph);
387        cell.set_style(style);
388    }
389    // Return the STABLE rect so hands / particles / golden see a steady
390    // biscuit position even when render_rect was shifted by the Frenzy
391    // shake or vertically squeezed by the squash padding.
392    stable_rect
393}
394
395/// Render the golden cuque marker. Position is resolved against the CURRENT
396/// `biscuit` rect every frame from the golden's stored fractional anchor —
397/// so the marker travels with the biscuit on zoom and resize, instead of
398/// stranding in the old screen position. Returned `Rect` is the actual
399/// drawn rect, used by the click router for hit-testing.
400///
401/// Build the "squashed" frame of a biscuit ASCII level by removing the rows
402/// immediately above and below the asshole row and padding with a blank
403/// row at top and bottom.
404///
405/// Why this shape: a real squash needs the centerline (asshole) to stay
406/// anchored while the upper and lower halves contract toward it — that's
407/// what reads as a flattened ellipsoid. Just shrinking from the top makes
408/// the cuque look like the topmost row is flickering, not pulsing.
409///
410/// Why the blank padding: total row count MUST be preserved. The biscuit
411/// rect that this function feeds is read by `hands::draw` to place the
412/// orbital fingerers — any change to rect.height (or rect.y, via
413/// recentering) would shift every hand around the cuque on every click.
414/// Padding keeps the rect identical between calm and squashed states.
415///
416/// Critical invariant: the asshole row's index in the output is the same
417/// as `asshole_row` in the input, so the renderer can use the level's
418/// declared `asshole_row` regardless of squash state.
419///
420/// Falls back to a plain copy if the art is too short to safely drop two
421/// rows around the asshole row.
422fn squashed_art(art: &[&str], asshole_row: usize) -> Vec<String> {
423    let n = art.len();
424    if n < 5 || asshole_row == 0 || asshole_row + 1 >= n {
425        return art.iter().map(|s| s.to_string()).collect();
426    }
427    let width = art.iter().map(|s| s.chars().count()).max().unwrap_or(0);
428    let blank: String = " ".repeat(width);
429
430    let mut out: Vec<String> = Vec::with_capacity(n);
431    out.push(blank.clone()); // top pad replaces the dropped (asshole_row - 1)
432    for s in art.iter().take(asshole_row - 1) {
433        out.push((*s).to_string());
434    }
435    out.push(art[asshole_row].to_string());
436    for s in art.iter().skip(asshole_row + 2) {
437        out.push((*s).to_string());
438    }
439    out.push(blank); // bottom pad replaces the dropped (asshole_row + 1)
440    debug_assert_eq!(out.len(), n);
441    out
442}
443
444/// Per-kind palette/glyph table. `bright`/`dim`/`accent` are the three
445/// colors the shimmer wave samples; `bg` stays low-key so the eye reads
446/// the *text* sliding through hues, not a blinking box. Centralized here
447/// so a new `PowerupKind` is one extra arm.
448struct PowerupPalette {
449    center: char,
450    bright: (f32, f32, f32),
451    dim: (f32, f32, f32),
452    accent: (f32, f32, f32),
453    bg: Color,
454}
455
456fn powerup_palette(kind: PowerupKind) -> PowerupPalette {
457    match kind {
458        PowerupKind::Lucky => PowerupPalette {
459            center: '$',
460            bright: (255.0, 230.0, 80.0),
461            dim: (140.0, 90.0, 0.0),
462            accent: (255.0, 170.0, 30.0),
463            bg: Color::Rgb(40, 25, 0),
464        },
465        PowerupKind::Frenzy => PowerupPalette {
466            center: '!',
467            bright: (255.0, 110.0, 110.0),
468            dim: (120.0, 0.0, 0.0),
469            accent: (255.0, 200.0, 60.0),
470            bg: Color::Rgb(50, 0, 0),
471        },
472        PowerupKind::Buff => PowerupPalette {
473            center: '+',
474            bright: (230.0, 160.0, 255.0),
475            dim: (80.0, 20.0, 110.0),
476            accent: (140.0, 220.0, 255.0),
477            bg: Color::Rgb(35, 0, 45),
478        },
479        PowerupKind::GreenCoin => PowerupPalette {
480            center: '$',
481            bright: (140.0, 255.0, 160.0),
482            dim: (10.0, 80.0, 30.0),
483            accent: (200.0, 255.0, 110.0),
484            bg: Color::Rgb(0, 30, 10),
485        },
486    }
487}
488
489/// J9 juice: the marker shimmers. Each character of the 5-wide marker
490/// samples its own foreground color from a horizontally-traveling wave
491/// between a `bright` peak, a `dim` trough, and an `accent` highlight on
492/// the off-phase. The bg stays a constant low-key tint, so what the player
493/// sees is the TEXT itself sliding through colors — not a flashing box.
494/// In the final 20% of life the wave speeds up and the trough darkens so
495/// a soon-to-expire powerup visibly accelerates without losing legibility.
496///
497/// Unified across kinds: `Lucky`/`Frenzy`/`Buff`/`GreenCoin` differ only
498/// in their `powerup_palette` entry. Adding a new kind = add a palette
499/// arm; this function inherits it.
500pub fn draw_powerup(frame: &mut Frame, powerup: &Powerup, biscuit: Rect) -> Rect {
501    let buf = frame.buffer_mut();
502    let PowerupPalette {
503        center,
504        bright,
505        dim,
506        accent,
507        bg,
508    } = powerup_palette(powerup.kind);
509
510    // Wave speed (rad/tick) and trough depth both bump in alarm mode. The
511    // normal phase pulses at ~1.9 Hz (0.6 rad/tick × 20 ticks/s ÷ TAU);
512    // the alarm phase doubles to ~4.8 Hz so a soon-to-expire powerup
513    // visibly speeds up. Earlier values (0.22 / 0.55) read as too sleepy.
514    let life_total = powerup.kind.lifetime_ticks();
515    let life_frac = (powerup.life_ticks as f32 / life_total as f32).clamp(0.0, 1.0);
516    let alarm = life_frac < 0.20;
517    let speed = if alarm { 1.5 } else { 0.6 };
518    let dim_pull = if alarm { 1.0 } else { 0.6 };
519    // Phase advances every tick; per-cell offset shifts the wave across the
520    // 5-cell width so neighbors land at different points of the gradient.
521    let phase = (life_total - powerup.life_ticks) as f32 * speed;
522    let cell_offset = std::f32::consts::TAU / 5.0; // one full cycle across width
523
524    let lines: [String; 3] = [
525        ".---.".to_string(),
526        format!("( {} )", center),
527        "`---'".to_string(),
528    ];
529    let w: u16 = 5;
530    let h: u16 = 3;
531
532    let area = buf.area;
533    if area.width == 0 || area.height == 0 || biscuit.width < w || biscuit.height < h {
534        return Rect::default();
535    }
536
537    let (anchor_col, anchor_row) =
538        crate::game::state::biscuit_frac_to_screen(powerup.frac_x, powerup.frac_y, biscuit);
539    let mut col = anchor_col;
540    let mut row = anchor_row;
541    // Keep the 5x3 marker fully inside the biscuit so it never overlaps the
542    // sidebar / HUD chrome, then clamp once more to the screen for safety.
543    if col + w > biscuit.x + biscuit.width {
544        col = (biscuit.x + biscuit.width).saturating_sub(w);
545    }
546    if row + h > biscuit.y + biscuit.height {
547        row = (biscuit.y + biscuit.height).saturating_sub(h);
548    }
549    if col < biscuit.x {
550        col = biscuit.x;
551    }
552    if row < biscuit.y {
553        row = biscuit.y;
554    }
555    if col + w > area.x + area.width {
556        col = (area.x + area.width).saturating_sub(w);
557    }
558    if row + h > area.y + area.height {
559        row = (area.y + area.height).saturating_sub(h);
560    }
561
562    // Per-character horizontal gradient: walk every cell, sample the wave
563    // for that cell's column offset, and write a 1-char styled span. Cheap
564    // (15 cells max) and gives "shimmering text" instead of "blinking box".
565    for (dy, line) in lines.iter().enumerate() {
566        let y = row + dy as u16;
567        if y >= area.y + area.height {
568            break;
569        }
570        for (i, ch) in line.chars().enumerate() {
571            let x = col + i as u16;
572            if x >= area.x + area.width {
573                break;
574            }
575            let arg = phase + i as f32 * cell_offset;
576            let wave_main = (arg.sin() + 1.0) * 0.5; // 0..1
577            // Accent rides on a quarter-phase shift so it brightens in
578            // between the main bright peaks rather than reinforcing them.
579            let wave_accent = ((arg + std::f32::consts::FRAC_PI_2).sin() + 1.0) * 0.5;
580            // Pull the trough darker by `dim_pull` so alarm mode visibly
581            // crushes the dim end without affecting peak readability.
582            let dim_dim = (
583                dim.0 * (1.0 - 0.4 * dim_pull),
584                dim.1 * (1.0 - 0.4 * dim_pull),
585                dim.2 * (1.0 - 0.4 * dim_pull),
586            );
587            let main_r = dim_dim.0 + (bright.0 - dim_dim.0) * wave_main;
588            let main_g = dim_dim.1 + (bright.1 - dim_dim.1) * wave_main;
589            let main_b = dim_dim.2 + (bright.2 - dim_dim.2) * wave_main;
590            // Cap accent contribution at 35% so it tints without washing
591            // out the bright peak.
592            let accent_w = wave_accent * 0.35;
593            let r = main_r + (accent.0 - main_r) * accent_w;
594            let g = main_g + (accent.1 - main_g) * accent_w;
595            let b = main_b + (accent.2 - main_b) * accent_w;
596            let style = Style::default()
597                .fg(Color::Rgb(
598                    r.clamp(0.0, 255.0) as u8,
599                    g.clamp(0.0, 255.0) as u8,
600                    b.clamp(0.0, 255.0) as u8,
601                ))
602                .bg(bg)
603                .add_modifier(Modifier::BOLD);
604            buf.set_string(x, y, ch.to_string(), style);
605        }
606    }
607
608    Rect {
609        x: col,
610        y: row,
611        width: w,
612        height: h,
613    }
614}