Skip to main content

cuqueclicker_lib/ui/
hands.rs

1use ratatui::prelude::*;
2
3use crate::game::fingerer::FINGERERS;
4use crate::game::state::GameState;
5
6const PER_TYPE_CAP: usize = 40;
7const PER_RING: usize = 48;
8
9/// True when `(col, row)` falls on a cell currently occupied by an orbital
10/// hand glyph. Used by the click router so a click landing on a `[]` or
11/// `:*` decoration outside the biscuit is treated as a no-op rather than a
12/// misclick (otherwise the misclick "·" briefly replaces the glyph and
13/// reads as flicker).
14///
15/// Mirrors the placement math in `draw()` exactly — keep in sync if either
16/// changes. Cheap: at most `PER_TYPE_CAP * FINGERERS.len()` hand
17/// candidates, and we early-return on the first match.
18pub fn occupied_at(col: u16, row: u16, biscuit: Rect, state: &GameState) -> bool {
19    if biscuit.width == 0 || biscuit.height == 0 {
20        return false;
21    }
22    let cx = biscuit.x as f32 + biscuit.width as f32 / 2.0;
23    let cy = biscuit.y as f32 + biscuit.height as f32 / 2.0;
24    let base_rx = (biscuit.width as f32 / 2.0 + 3.0).max(6.0);
25    let base_ry = (biscuit.height as f32 / 2.0 + 2.0).max(3.0);
26
27    let mut glyphs: Vec<(usize, &str)> = Vec::new();
28    for (idx, f) in FINGERERS.iter().enumerate() {
29        let n = (state.fingerer_count(f.id) as usize).min(PER_TYPE_CAP);
30        for _ in 0..n {
31            glyphs.push((idx, f.icon));
32        }
33    }
34    if glyphs.is_empty() {
35        return false;
36    }
37
38    let total = glyphs.len();
39    let bx = biscuit.x as i32;
40    let br = bx + biscuit.width as i32;
41    let by = biscuit.y as i32;
42    let bb = by + biscuit.height as i32;
43
44    for (i, (type_idx, icon)) in glyphs.iter().enumerate() {
45        let ring = i / PER_RING;
46        let slot = i % PER_RING;
47        let slot_count = ((total - ring * PER_RING).min(PER_RING)) as f32;
48        let angle = (slot as f32 / slot_count) * std::f32::consts::TAU
49            + (ring as f32 * 0.15)
50            + (*type_idx as f32 * 0.07);
51        // Mirror the per-tier poke math from `draw()` exactly so a click
52        // landing on a hand-glyph cell at this frame is detected
53        // identically (otherwise the hit-test could drop just as the
54        // pulse pops the glyph inward).
55        let speed = FINGERERS
56            .get(*type_idx)
57            .map(|f| f.poke_speed.max(0.1))
58            .unwrap_or(1.0);
59        let tier_divisor = (5.0 / speed).max(1.0) as u64;
60        let tier_phase = state.session_ticks / tier_divisor;
61        let poke = if ((i * 7) as u64 + tier_phase).is_multiple_of(23) {
62            1.2
63        } else {
64            0.0
65        };
66        let rx = base_rx + ring as f32 * 4.0 - poke;
67        let ry = base_ry + ring as f32 * 2.0 - poke * 0.5;
68        let px = cx + rx * angle.cos();
69        let py = cy + ry * angle.sin();
70        let g_col = px.round() as i32;
71        let g_row = py.round() as i32;
72
73        let icon_w = icon.chars().count() as i32;
74        // Hands inside the biscuit footprint aren't drawn (they get
75        // suppressed in `draw`) — don't count them as occupied.
76        if g_row >= by && g_row < bb && g_col < br && g_col + icon_w > bx {
77            continue;
78        }
79        // The glyph occupies cells [g_col, g_col + icon_w) on row g_row.
80        if (row as i32) == g_row && (col as i32) >= g_col && (col as i32) < g_col + icon_w {
81            return true;
82        }
83    }
84    false
85}
86
87pub fn draw(frame: &mut Frame, play_area: Rect, biscuit: Rect, state: &GameState) {
88    if play_area.width == 0 || play_area.height == 0 {
89        return;
90    }
91    let buf = frame.buffer_mut();
92    let cx = biscuit.x as f32 + biscuit.width as f32 / 2.0;
93    let cy = biscuit.y as f32 + biscuit.height as f32 / 2.0;
94
95    let base_rx = (biscuit.width as f32 / 2.0 + 3.0).max(6.0);
96    let base_ry = (biscuit.height as f32 / 2.0 + 2.0).max(3.0);
97
98    let mut glyphs: Vec<(usize, &str)> = Vec::new();
99    for (idx, f) in FINGERERS.iter().enumerate() {
100        let n = (state.fingerer_count(f.id) as usize).min(PER_TYPE_CAP);
101        for _ in 0..n {
102            glyphs.push((idx, f.icon));
103        }
104    }
105    if glyphs.is_empty() {
106        return;
107    }
108
109    let total = glyphs.len();
110
111    for (i, (type_idx, icon)) in glyphs.iter().enumerate() {
112        let ring = i / PER_RING;
113        let slot = i % PER_RING;
114        let slot_count = ((total - ring * PER_RING).min(PER_RING)) as f32;
115        let angle = (slot as f32 / slot_count) * std::f32::consts::TAU
116            + (ring as f32 * 0.15)
117            + (*type_idx as f32 * 0.07);
118        // Per-tier poke timing: each fingerer tier carries its own
119        // `poke_speed` (1.0 = baseline). Tier-aware tick_phase = ticks
120        // divided by a per-tier divisor — high speed = small divisor =
121        // fast pulse; low speed = big divisor = slow majestic pulse.
122        // Hand of God ends up ~10× slower than Index Finger, so a heavy
123        // crust of HoG pulses with statelier authority while finger-tier
124        // hands twitch fast.
125        let speed = FINGERERS
126            .get(*type_idx)
127            .map(|f| f.poke_speed.max(0.1))
128            .unwrap_or(1.0);
129        let tier_divisor = (5.0 / speed).max(1.0) as u64;
130        let tier_phase = state.session_ticks / tier_divisor;
131        let poke = if ((i * 7) as u64 + tier_phase).is_multiple_of(23) {
132            1.2
133        } else {
134            0.0
135        };
136        let rx = base_rx + ring as f32 * 4.0 - poke;
137        let ry = base_ry + ring as f32 * 2.0 - poke * 0.5;
138        let px = cx + rx * angle.cos();
139        let py = cy + ry * angle.sin();
140        let col = px.round() as i32;
141        let row = py.round() as i32;
142
143        let icon_w = icon.chars().count() as i32;
144        if col < play_area.x as i32
145            || col + icon_w > (play_area.x + play_area.width) as i32
146            || row < play_area.y as i32
147            || row >= (play_area.y + play_area.height) as i32
148        {
149            continue;
150        }
151        let bx = biscuit.x as i32;
152        let br = bx + biscuit.width as i32;
153        let by = biscuit.y as i32;
154        let bb = by + biscuit.height as i32;
155        if row >= by && row < bb && col < br && col + icon_w > bx {
156            continue;
157        }
158        let color = hand_color(*type_idx, poke > 0.0);
159        buf.set_string(col as u16, row as u16, *icon, Style::default().fg(color));
160    }
161}
162
163fn hand_color(type_idx: usize, poking: bool) -> Color {
164    let palette = [
165        Color::Rgb(220, 170, 130), // Dedo: warm tan
166        Color::Rgb(230, 180, 160), // Mao: pinker
167        Color::Rgb(200, 230, 220), // Luva: mint
168        Color::Rgb(255, 130, 170), // Beijo Grego: rose pink
169        Color::Rgb(180, 200, 230), // Robotico: steel blue
170        Color::Rgb(200, 160, 220), // Tentaculo: purple
171        Color::Rgb(255, 200, 120), // Vortice: amber
172        Color::Rgb(140, 180, 255), // Buraco: cold blue
173        Color::Rgb(255, 180, 255), // Cosmic: magenta
174        Color::Rgb(255, 230, 150), // Deus: divine gold
175    ];
176    let c = palette[type_idx.min(palette.len() - 1)];
177    if poking { brighten(c) } else { c }
178}
179
180fn brighten(c: Color) -> Color {
181    match c {
182        Color::Rgb(r, g, b) => Color::Rgb(
183            (r as u16 + 40).min(255) as u8,
184            (g as u16 + 40).min(255) as u8,
185            (b as u16 + 40).min(255) as u8,
186        ),
187        other => other,
188    }
189}