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