1use ratatui::{prelude::*, style::Modifier as StyleMod, widgets::*};
20
21use crate::format;
22use crate::game::powerup::PowerupKind;
23use crate::game::state::{
24 GameState, PURCHASE_FLASH_TICKS, TREE_REFUND_FRACTION, UNLOCK_FLASH_TICKS,
25};
26use crate::game::tree::coord::TreeCoord;
27use crate::game::tree::naming::primitive_blurb;
28use crate::game::tree::node::{self, LOT_H, LOT_W, Rarity};
29use crate::game::tree::primitive::{Op, Primitive, Target};
30use crate::i18n::t;
31use crate::input::TreeRenderState;
32use crate::ui::{TreeButtonAction, border, hud_title};
33
34pub struct TreeDrawOutput {
39 pub node_rects: Vec<(TreeCoord, Rect)>,
40 pub action_button: Option<(TreeButtonAction, Rect, TreeCoord)>,
41}
42
43const PAN_TWEEN_FACTOR: f32 = 0.20;
48const PAN_SNAP_EPSILON: f32 = 0.5;
52
53const VISIBLE_RADIUS_LOTS: i32 = 6;
57
58const INFO_PANE_HEIGHT: u16 = 8;
59const HEADER_HEIGHT: u16 = 3;
60
61pub fn draw(
62 frame: &mut Frame,
63 area: Rect,
64 state: &GameState,
65 mouse_pos: Option<(u16, u16)>,
66 tree_render: &mut TreeRenderState,
67 help_bar_height: u16,
68) -> TreeDrawOutput {
69 let modal_h = area.height.saturating_sub(help_bar_height);
77 if modal_h < HEADER_HEIGHT + INFO_PANE_HEIGHT + 4 {
78 return TreeDrawOutput {
80 node_rects: Vec::new(),
81 action_button: None,
82 };
83 }
84 let modal = Rect {
85 x: area.x,
86 y: area.y,
87 width: area.width,
88 height: modal_h,
89 };
90
91 fill_area(frame, modal);
94
95 let rows = Layout::vertical([
96 Constraint::Length(HEADER_HEIGHT),
97 Constraint::Min(1),
98 Constraint::Length(INFO_PANE_HEIGHT),
99 ])
100 .split(modal);
101 let header_area = rows[0];
102 let canvas_area = rows[1];
103 let info_area = rows[2];
104
105 draw_header(frame, header_area, state);
106 let node_rects = draw_canvas(frame, canvas_area, state, mouse_pos, tree_render);
107 let action_button = draw_info_pane(frame, info_area, state);
108 if let (Some((_, r, _)), Some((mx, my))) = (action_button, mouse_pos)
112 && mx >= r.x
113 && mx < r.x + r.width
114 && my >= r.y
115 && my < r.y + r.height
116 {
117 let buf = frame.buffer_mut();
118 for y in r.y..r.y + r.height {
119 for x in r.x..r.x + r.width {
120 if let Some(cell) = buf.cell_mut((x, y)) {
121 cell.set_style(
122 cell.style()
123 .fg(Color::Rgb(255, 255, 255))
124 .bg(Color::Rgb(40, 40, 50))
125 .add_modifier(StyleMod::BOLD),
126 );
127 }
128 }
129 }
130 }
131 TreeDrawOutput {
132 node_rects,
133 action_button,
134 }
135}
136
137fn fill_area(frame: &mut Frame, area: Rect) {
138 let buf = frame.buffer_mut();
144 let clear = Style::default().fg(Color::Reset).bg(Color::Reset);
145 for y in area.y..area.y.saturating_add(area.height) {
146 for x in area.x..area.x.saturating_add(area.width) {
147 if let Some(cell) = buf.cell_mut((x, y)) {
148 cell.set_char(' ');
149 cell.set_style(clear);
150 }
151 }
152 }
153}
154
155fn draw_header(frame: &mut Frame, area: Rect, state: &GameState) {
158 let lang = t();
163 let title = format!("{}—{}", hud_title(), lang.tree_title);
164 border::draw_animated(frame, area, state, &title);
165 if area.width < 3 || area.height < 3 {
166 return;
167 }
168 let inner = Rect {
169 x: area.x + 1,
170 y: area.y + 1,
171 width: area.width.saturating_sub(2),
172 height: area.height.saturating_sub(2),
173 };
174 let cursor = state.tree.cursor;
175 let line = Line::from(vec![
176 Span::raw(format!("{}: ", lang.hud_cuques)),
177 Span::styled(
178 format::big_mag(state.displayed_cuques),
179 Style::default()
180 .fg(Color::Rgb(180, 255, 180))
181 .add_modifier(StyleMod::BOLD),
182 ),
183 Span::raw(format!(" {}: ", lang.hud_fps)),
184 Span::styled(
185 format::rate(state.displayed_fps),
186 Style::default().fg(Color::Rgb(200, 200, 240)),
187 ),
188 Span::styled(
189 format!(" cursor ({:+}, {:+})", cursor.x, cursor.y),
190 Style::default().fg(Color::Rgb(180, 200, 255)),
191 ),
192 Span::styled(
193 format!(
198 " owned: {}",
199 state
200 .tree
201 .bought
202 .iter()
203 .filter(|c| **c != TreeCoord::ORIGIN)
204 .count()
205 ),
206 Style::default().fg(Color::Rgb(220, 220, 180)),
207 ),
208 ]);
209 frame.render_widget(Paragraph::new(line), inner);
210}
211
212#[derive(Clone)]
215struct VisibleNode {
216 spec_box_x: i32,
217 spec_box_y: i32,
218 box_w: u16,
219 box_h: u16,
220 rarity: Rarity,
221 lot: TreeCoord,
222 dominant_target: Target,
223 cost: crate::bignum::Mag,
224 owned: bool,
225 reachable: bool,
226 affordable: bool,
227 is_anchor: bool,
228 title_short: String,
229 buy_flash: f32,
232 unlock_flash: f32,
233 refund_flash: f32,
234}
235
236fn draw_canvas(
237 frame: &mut Frame,
238 area: Rect,
239 state: &GameState,
240 mouse_pos: Option<(u16, u16)>,
241 tree_render: &mut TreeRenderState,
242) -> Vec<(TreeCoord, Rect)> {
243 if area.width < 10 || area.height < 5 {
244 return Vec::new();
245 }
246
247 let cursor = state.tree.cursor;
248 let canvas_center_x = (area.width / 2) as i32;
249 let canvas_center_y = (area.height / 2) as i32;
250
251 let cursor_centered_x = (cursor.x * LOT_W + LOT_W / 2 - canvas_center_x) as f32;
257 let cursor_centered_y = (cursor.y * LOT_H + LOT_H / 2 - canvas_center_y) as f32;
258
259 if !tree_render.initialized {
260 tree_render.pan_x = cursor_centered_x;
262 tree_render.pan_y = cursor_centered_y;
263 tree_render.target_pan_x = cursor_centered_x;
264 tree_render.target_pan_y = cursor_centered_y;
265 tree_render.prev_cursor = cursor;
266 tree_render.initialized = true;
267 } else {
268 if cursor != tree_render.prev_cursor {
269 tree_render.target_pan_x = cursor_centered_x;
272 tree_render.target_pan_y = cursor_centered_y;
273 tree_render.prev_cursor = cursor;
274 }
275 if tree_render.drag_last.is_none() {
279 let dx = tree_render.target_pan_x - tree_render.pan_x;
280 let dy = tree_render.target_pan_y - tree_render.pan_y;
281 if dx.abs() < PAN_SNAP_EPSILON && dy.abs() < PAN_SNAP_EPSILON {
282 tree_render.pan_x = tree_render.target_pan_x;
283 tree_render.pan_y = tree_render.target_pan_y;
284 } else {
285 tree_render.pan_x += dx * PAN_TWEEN_FACTOR;
286 tree_render.pan_y += dy * PAN_TWEEN_FACTOR;
287 }
288 }
289 }
290
291 let pan_x = tree_render.pan_x.round() as i32;
294 let pan_y = tree_render.pan_y.round() as i32;
295
296 let view_center_canvas_x = pan_x + canvas_center_x;
303 let view_center_canvas_y = pan_y + canvas_center_y;
304 let view_lot_x = view_center_canvas_x.div_euclid(LOT_W);
305 let view_lot_y = view_center_canvas_y.div_euclid(LOT_H);
306 let visible_radius = VISIBLE_RADIUS_LOTS + 2;
307 let mut visible: Vec<VisibleNode> = Vec::new();
308 for dy in -visible_radius..=visible_radius {
309 for dx in -visible_radius..=visible_radius {
310 let lot = TreeCoord::new(view_lot_x + dx, view_lot_y + dy);
311 let Some(spec) = node::node_at(lot.x, lot.y) else {
312 continue;
313 };
314 let owned = state.tree.bought.contains(&lot);
315 let reachable = !owned && state.tree_reachable(lot) && !state.tree_unlock_pending(lot);
319 let affordable = state.affordable_cuques() >= spec.cost;
320 let title_short = truncate(&spec.title, (spec.box_w as usize).saturating_sub(2));
321 let buy_flash = state
322 .tree_buy_flash
323 .get(&lot)
324 .copied()
325 .map(|t| t as f32 / PURCHASE_FLASH_TICKS as f32)
326 .unwrap_or(0.0);
327 let unlock_flash = state
328 .tree_unlock_flash
329 .get(&lot)
330 .copied()
331 .map(|t| t as f32 / UNLOCK_FLASH_TICKS as f32)
332 .unwrap_or(0.0);
333 let refund_flash = state
334 .tree_refund_flash
335 .get(&lot)
336 .copied()
337 .map(|t| t as f32 / PURCHASE_FLASH_TICKS as f32)
338 .unwrap_or(0.0);
339 visible.push(VisibleNode {
340 spec_box_x: spec.box_x,
341 spec_box_y: spec.box_y,
342 box_w: spec.box_w,
343 box_h: spec.box_h,
344 rarity: spec.rarity,
345 lot,
346 dominant_target: spec.dominant_target,
347 cost: spec.cost,
348 owned,
349 reachable,
350 affordable,
351 is_anchor: spec.is_anchor,
352 title_short,
353 buy_flash,
354 unlock_flash,
355 refund_flash,
356 });
357 }
358 }
359
360 let edges: Vec<(TreeCoord, TreeCoord)> = {
362 let mut out = Vec::new();
363 for i in 0..visible.len() {
364 for j in (i + 1)..visible.len() {
365 let a = visible[i].lot;
366 let b = visible[j].lot;
367 if node::edge_exists(a, b) {
368 out.push((a, b));
369 }
370 }
371 }
372 out
373 };
374
375 for (a, b) in &edges {
382 let av = visible.iter().find(|v| v.lot == *a).cloned();
383 let bv = visible.iter().find(|v| v.lot == *b).cloned();
384 if let (Some(av), Some(bv)) = (av, bv) {
385 draw_edge(frame, area, pan_x, pan_y, &av, &bv, state);
386 }
387 }
388
389 let mut click_rects: Vec<(TreeCoord, Rect)> = Vec::new();
390 for v in &visible {
391 if let Some(r) = draw_box(frame, area, pan_x, pan_y, v, cursor, state) {
392 click_rects.push((v.lot, r));
393 }
394 }
395
396 if let Some((mx, my)) = mouse_pos {
398 for &(_, r) in &click_rects {
399 if mx >= r.x && mx < r.x + r.width && my >= r.y && my < r.y + r.height {
400 hover_lift(frame, r);
401 break;
402 }
403 }
404 }
405
406 click_rects
407}
408
409fn canvas_to_screen(area: Rect, pan_x: i32, pan_y: i32, cx: i32, cy: i32) -> Option<(u16, u16)> {
412 let sx = cx - pan_x + area.x as i32;
413 let sy = cy - pan_y + area.y as i32;
414 if sx < area.x as i32
415 || sx >= (area.x as i32 + area.width as i32)
416 || sy < area.y as i32
417 || sy >= (area.y as i32 + area.height as i32)
418 {
419 return None;
420 }
421 Some((sx as u16, sy as u16))
422}
423
424#[allow(clippy::too_many_arguments)]
425fn draw_box(
426 frame: &mut Frame,
427 area: Rect,
428 pan_x: i32,
429 pan_y: i32,
430 v: &VisibleNode,
431 cursor: TreeCoord,
432 state: &GameState,
433) -> Option<Rect> {
434 if v.is_anchor {
438 return draw_anchor(frame, area, pan_x, pan_y, v, cursor, state);
439 }
440 let buf = frame.buffer_mut();
441 let bx = v.spec_box_x;
442 let by = v.spec_box_y;
443 let bw = v.box_w as i32;
444 let bh = v.box_h as i32;
445
446 let (corners, h_char, v_char, base_style) = box_chars_for(v);
448
449 let is_focus = v.lot == cursor;
451 let box_style = if is_focus {
452 base_style
453 .add_modifier(StyleMod::BOLD)
454 .add_modifier(StyleMod::REVERSED)
455 } else {
456 base_style
457 };
458
459 let mut painted_any = false;
461 let mut min_sx = u16::MAX;
462 let mut min_sy = u16::MAX;
463 let mut max_sx = 0u16;
464 let mut max_sy = 0u16;
465
466 for row in 0..bh {
467 for col in 0..bw {
468 let cx = bx + col;
469 let cy = by + row;
470 let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy) else {
471 continue;
472 };
473 let ch = if row == 0 && col == 0 {
474 corners.0
475 } else if row == 0 && col == bw - 1 {
476 corners.1
477 } else if row == bh - 1 && col == 0 {
478 corners.2
479 } else if row == bh - 1 && col == bw - 1 {
480 corners.3
481 } else if row == 0 || row == bh - 1 {
482 h_char
483 } else if col == 0 || col == bw - 1 {
484 v_char
485 } else {
486 let interior_w = (bw - 2) as usize;
490 let r_in = row - 1;
491 if r_in == 0 && interior_w > 0 {
492 v.title_short.chars().nth((col - 1) as usize).unwrap_or(' ')
494 } else if r_in == (bh - 2) - 1 && interior_w > 4 {
495 let cost_str = if v.owned {
498 "[ owned ]".to_string()
499 } else {
500 format::big_mag(v.cost)
501 };
502 cost_str.chars().nth((col - 1) as usize).unwrap_or(' ')
503 } else if r_in > 0
504 && r_in < (bh - 2)
505 && v.rarity != Rarity::Small
506 && r_in == 1
507 && interior_w > 4
508 {
509 let summary = primitive_summary(v);
511 summary.chars().nth((col - 1) as usize).unwrap_or(' ')
512 } else {
513 ' '
514 }
515 };
516
517 if let Some(cell) = buf.cell_mut((sx, sy)) {
518 cell.set_char(ch);
519 cell.set_style(box_style);
520 }
521 painted_any = true;
522 min_sx = min_sx.min(sx);
523 min_sy = min_sy.min(sy);
524 max_sx = max_sx.max(sx);
525 max_sy = max_sy.max(sy);
526 }
527 }
528
529 if !painted_any {
530 return None;
531 }
532 Some(Rect {
533 x: min_sx,
534 y: min_sy,
535 width: max_sx.saturating_sub(min_sx).saturating_add(1),
536 height: max_sy.saturating_sub(min_sy).saturating_add(1),
537 })
538}
539
540fn draw_anchor(
552 frame: &mut Frame,
553 area: Rect,
554 pan_x: i32,
555 pan_y: i32,
556 v: &VisibleNode,
557 cursor: TreeCoord,
558 state: &GameState,
559) -> Option<Rect> {
560 let rows = crate::ui::biscuit::BISCUIT_TINY;
561 let focal = crate::ui::biscuit::BISCUIT_TINY_FOCAL;
562 let is_focus = v.lot == cursor;
563 let phase = state.steady_phase;
564
565 let base_style = Style::default()
568 .fg(Color::Rgb(255, 230, 180))
569 .add_modifier(StyleMod::BOLD);
570
571 let buf = frame.buffer_mut();
572 let mut min_sx = u16::MAX;
573 let mut min_sy = u16::MAX;
574 let mut max_sx = 0u16;
575 let mut max_sy = 0u16;
576 let mut painted_any = false;
577
578 for (row_idx, line) in rows.iter().enumerate() {
579 for (col_idx, ch) in line.chars().enumerate() {
580 let is_focal = col_idx as u16 == focal.0 && row_idx as u16 == focal.1;
584 let glyph = if is_focal { 'O' } else { ch };
585 if glyph == ' ' {
586 continue;
587 }
588 let cx = v.spec_box_x + col_idx as i32;
589 let cy = v.spec_box_y + row_idx as i32;
590 let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy) else {
591 continue;
592 };
593 let style = if is_focus {
594 Style::default()
595 .fg(plasma_color(phase, col_idx as i32, row_idx as i32))
596 .add_modifier(StyleMod::BOLD)
597 } else {
598 base_style
599 };
600 if let Some(cell) = buf.cell_mut((sx, sy)) {
601 cell.set_char(glyph);
602 cell.set_style(style);
603 }
604 painted_any = true;
605 min_sx = min_sx.min(sx);
606 min_sy = min_sy.min(sy);
607 max_sx = max_sx.max(sx);
608 max_sy = max_sy.max(sy);
609 }
610 }
611
612 if !painted_any {
613 return None;
614 }
615 Some(Rect {
616 x: min_sx,
617 y: min_sy,
618 width: max_sx.saturating_sub(min_sx).saturating_add(1),
619 height: max_sy.saturating_sub(min_sy).saturating_add(1),
620 })
621}
622
623fn plasma_color(phase: u32, col: i32, row: i32) -> Color {
630 let t = phase as f32 * 0.06;
631 let cx = col as f32 * 0.45;
632 let cy = row as f32 * 0.85;
633 let v = (cx + t).sin() + (cy + t * 1.27).sin() + ((cx + cy) * 0.6 + t * 0.83).sin();
634 let n = ((v / 3.0) + 1.0) * 0.5; let keys: [(f32, f32, f32); 3] = [
638 (255.0, 215.0, 110.0), (255.0, 110.0, 90.0), (210.0, 130.0, 255.0), ];
642 let pos = n * 3.0;
643 let idx = (pos.floor() as usize) % 3;
644 let frac = pos - pos.floor();
645 let a = keys[idx];
646 let b = keys[(idx + 1) % 3];
647 let r = a.0 + (b.0 - a.0) * frac;
648 let g = a.1 + (b.1 - a.1) * frac;
649 let bb = a.2 + (b.2 - a.2) * frac;
650 Color::Rgb(
651 r.clamp(60.0, 255.0) as u8,
652 g.clamp(60.0, 255.0) as u8,
653 bb.clamp(60.0, 255.0) as u8,
654 )
655}
656
657fn primitive_summary(v: &VisibleNode) -> String {
658 let prefix = match v.rarity {
661 Rarity::Small => "small",
662 Rarity::Notable => "notable",
663 Rarity::Keystone => "KEYSTONE",
664 };
665 let target = match v.dominant_target {
666 Target::Fingerer(i) => crate::i18n::t()
667 .fingerer_names
668 .get(i as usize)
669 .copied()
670 .unwrap_or("?")
671 .to_string(),
672 Target::AllFingerers => "all fingerers".to_string(),
673 Target::Click => "click".to_string(),
674 Target::PowerupSpawn(k) => format!("{} spawn", powerup_short(k)),
675 Target::PowerupReward(k) => format!("{} reward", powerup_short(k)),
676 Target::PowerupDuration(k) => format!("{} time", powerup_short(k)),
677 Target::Prestige => "prestige".to_string(),
678 Target::GreenCoinStrength => "GC pow".to_string(),
679 };
680 format!("{}: {}", prefix, target)
681}
682
683fn powerup_short(k: PowerupKind) -> &'static str {
684 match k {
685 PowerupKind::Lucky => "Lucky",
686 PowerupKind::Frenzy => "Frenzy",
687 PowerupKind::Buff => "Buff",
688 PowerupKind::GreenCoin => "GCoin",
689 }
690}
691
692fn box_chars_for(v: &VisibleNode) -> ((char, char, char, char), char, char, Style) {
706 let owned_corners: (char, char, char, char) = ('╔', '╗', '╚', '╝');
708 let dotted_corners: (char, char, char, char) = ('┌', '┐', '└', '┘');
709
710 let biome = biome_color(v.dominant_target, v.rarity);
711 let unreachable_fg = Color::Rgb(60, 60, 70);
715 let buyable_fg = blend_to_white(biome.bright, 0.15);
719
720 let mut result = if v.owned {
721 (
722 owned_corners,
723 '═',
724 '║',
725 Style::default()
726 .fg(biome.bright)
727 .add_modifier(StyleMod::BOLD),
728 )
729 } else if v.reachable && v.affordable {
730 (
731 dotted_corners,
732 '╌',
733 '╎',
734 Style::default().fg(buyable_fg).add_modifier(StyleMod::BOLD),
735 )
736 } else if v.reachable {
737 (dotted_corners, '╌', '╎', Style::default().fg(biome.dim))
740 } else {
741 (
743 dotted_corners,
744 '╌',
745 '╎',
746 Style::default().fg(unreachable_fg),
747 )
748 };
749
750 if v.buy_flash > 0.001 {
752 let tint = (40.0, 230.0, 80.0); result.3 = Style::default()
754 .fg(blend_to(biome.bright, tint, v.buy_flash))
755 .add_modifier(StyleMod::BOLD);
756 } else if v.refund_flash > 0.001 {
757 let tint = (255.0, 80.0, 80.0); let from = if v.owned {
759 biome.bright
760 } else {
761 unreachable_fg
762 };
763 result.3 = Style::default()
764 .fg(blend_to(from, tint, v.refund_flash))
765 .add_modifier(StyleMod::BOLD);
766 } else if v.unlock_flash > 0.001 {
767 let tint = (255.0, 230.0, 120.0); let from = if v.reachable { buyable_fg } else { biome.dim };
769 result.3 = Style::default()
770 .fg(blend_to(from, tint, v.unlock_flash))
771 .add_modifier(StyleMod::BOLD);
772 }
773 result
774}
775
776fn blend_to_white(base: Color, biome_weight: f32) -> Color {
780 let (br, bg, bb) = color_rgb(base);
781 let mix = biome_weight.clamp(0.0, 1.0);
782 let r = 240.0 * (1.0 - mix) + br * mix;
783 let g = 240.0 * (1.0 - mix) + bg * mix;
784 let b = 240.0 * (1.0 - mix) + bb * mix;
785 Color::Rgb(
786 r.clamp(0.0, 255.0) as u8,
787 g.clamp(0.0, 255.0) as u8,
788 b.clamp(0.0, 255.0) as u8,
789 )
790}
791
792fn blend_to(base: Color, tint: (f32, f32, f32), t: f32) -> Color {
793 let (br, bg, bb) = color_rgb(base);
794 let mix = t.clamp(0.0, 1.0);
795 Color::Rgb(
796 (br + (tint.0 - br) * mix).clamp(0.0, 255.0) as u8,
797 (bg + (tint.1 - bg) * mix).clamp(0.0, 255.0) as u8,
798 (bb + (tint.2 - bb) * mix).clamp(0.0, 255.0) as u8,
799 )
800}
801
802fn color_rgb(c: Color) -> (f32, f32, f32) {
803 match c {
804 Color::Rgb(r, g, b) => (r as f32, g as f32, b as f32),
805 _ => (200.0, 200.0, 200.0),
806 }
807}
808
809struct BiomeColors {
810 bright: Color,
811 dim: Color,
812}
813
814fn biome_color(target: Target, rarity: Rarity) -> BiomeColors {
817 if matches!(rarity, Rarity::Keystone) {
820 return BiomeColors {
821 bright: Color::Rgb(255, 80, 200),
822 dim: Color::Rgb(150, 50, 120),
823 };
824 }
825 match target {
826 Target::Fingerer(idx) => fingerer_biome(idx as usize),
827 Target::AllFingerers => BiomeColors {
828 bright: Color::Rgb(255, 215, 0),
829 dim: Color::Rgb(150, 130, 0),
830 },
831 Target::Click => BiomeColors {
832 bright: Color::Rgb(255, 130, 130),
833 dim: Color::Rgb(150, 80, 80),
834 },
835 Target::PowerupSpawn(_) | Target::PowerupReward(_) | Target::PowerupDuration(_) => {
836 BiomeColors {
837 bright: Color::Rgb(220, 140, 255),
838 dim: Color::Rgb(130, 90, 150),
839 }
840 }
841 Target::Prestige => BiomeColors {
842 bright: Color::Rgb(255, 200, 220),
843 dim: Color::Rgb(150, 110, 130),
844 },
845 Target::GreenCoinStrength => BiomeColors {
846 bright: Color::Rgb(120, 230, 140),
847 dim: Color::Rgb(60, 130, 80),
848 },
849 }
850}
851
852fn fingerer_biome(idx: usize) -> BiomeColors {
853 match idx {
854 0 => BiomeColors {
855 bright: Color::Rgb(255, 220, 120),
856 dim: Color::Rgb(160, 130, 70),
857 },
858 1 => BiomeColors {
859 bright: Color::Rgb(255, 180, 100),
860 dim: Color::Rgb(160, 100, 60),
861 },
862 2 => BiomeColors {
863 bright: Color::Rgb(180, 255, 230),
864 dim: Color::Rgb(90, 150, 130),
865 },
866 3 => BiomeColors {
867 bright: Color::Rgb(255, 150, 180),
868 dim: Color::Rgb(160, 80, 100),
869 },
870 4 => BiomeColors {
871 bright: Color::Rgb(180, 220, 255),
872 dim: Color::Rgb(90, 130, 160),
873 },
874 5 => BiomeColors {
875 bright: Color::Rgb(140, 230, 200),
876 dim: Color::Rgb(70, 130, 100),
877 },
878 6 => BiomeColors {
879 bright: Color::Rgb(200, 160, 255),
880 dim: Color::Rgb(110, 80, 160),
881 },
882 7 => BiomeColors {
883 bright: Color::Rgb(160, 200, 255),
884 dim: Color::Rgb(80, 120, 160),
885 },
886 8 => BiomeColors {
887 bright: Color::Rgb(255, 255, 200),
888 dim: Color::Rgb(160, 160, 110),
889 },
890 _ => BiomeColors {
891 bright: Color::Rgb(255, 255, 255),
892 dim: Color::Rgb(180, 180, 180),
893 },
894 }
895}
896
897fn draw_edge(
905 frame: &mut Frame,
906 area: Rect,
907 pan_x: i32,
908 pan_y: i32,
909 a: &VisibleNode,
910 b: &VisibleNode,
911 state: &GameState,
912) {
913 let a_owned = state.tree.bought.contains(&a.lot);
914 let b_owned = state.tree.bought.contains(&b.lot);
915 let dim_style = Style::default().fg(Color::Rgb(80, 80, 100));
920 let lit_style = if a_owned && b_owned {
924 Style::default()
925 .fg(Color::Rgb(255, 220, 120))
926 .add_modifier(StyleMod::BOLD)
927 } else if a_owned || b_owned {
928 Style::default().fg(Color::Rgb(180, 180, 200))
929 } else {
930 dim_style
931 };
932
933 let anim = state
939 .tree_edge_anims
940 .iter()
941 .find(|an| (an.from == a.lot && an.to == b.lot) || (an.from == b.lot && an.to == a.lot));
942 let path = node::edge_path_cells(a.lot, b.lot);
943 if path.is_empty() {
944 return;
945 }
946 let canonical_lo = if (a.lot.x, a.lot.y) <= (b.lot.x, b.lot.y) {
949 a.lot
950 } else {
951 b.lot
952 };
953 let wave_at_start = anim.map(|an| an.from == canonical_lo).unwrap_or(true);
954 let source_node: &VisibleNode = if let Some(an) = anim {
960 if an.from == a.lot { a } else { b }
961 } else {
962 a
963 };
964 let leading_inside = if wave_at_start {
965 node::count_leading_in_rect(
966 &path,
967 source_node.spec_box_x,
968 source_node.spec_box_y,
969 source_node.box_w,
970 source_node.box_h,
971 )
972 } else {
973 node::count_trailing_in_rect(
974 &path,
975 source_node.spec_box_x,
976 source_node.spec_box_y,
977 source_node.box_w,
978 source_node.box_h,
979 )
980 };
981 let head_path_index = anim
982 .map(|an| (leading_inside + an.visible_advance()).min(path.len().saturating_sub(1)))
983 .unwrap_or(0);
984
985 let in_a = |x: i32, y: i32| opaque_for(a, x, y);
989 let in_b = |x: i32, y: i32| opaque_for(b, x, y);
990
991 let buf = frame.buffer_mut();
992 let path_len = path.len();
993 for i in 0..path_len {
994 let (cx, cy) = path[i];
995 if in_a(cx, cy) || in_b(cx, cy) {
996 continue;
997 }
998 let dist_from_head: i32 = if anim.is_some() {
1005 if wave_at_start {
1006 (head_path_index as i32) - (i as i32)
1007 } else {
1008 (head_path_index as i32) - ((path_len - 1 - i) as i32)
1009 }
1010 } else {
1011 0
1012 };
1013
1014 let prev_raw = if i > 0 { Some(path[i - 1]) } else { None };
1015 let next_raw = path.get(i + 1).copied();
1016 let prev_in_box = prev_raw.is_some_and(|(px, py)| in_a(px, py) || in_b(px, py));
1017 let next_in_box = next_raw.is_some_and(|(nx, ny)| in_a(nx, ny) || in_b(nx, ny));
1018 let prev = prev_raw.filter(|_| !prev_in_box);
1019 let next = next_raw.filter(|_| !next_in_box);
1020 let dir_to_box = if prev_in_box {
1026 prev_raw.map(|p| dir_between((cx, cy), p))
1027 } else if next_in_box {
1028 next_raw.map(|n| dir_between((cx, cy), n))
1029 } else {
1030 None
1031 };
1032 let glyph = path_glyph(
1033 prev.map(|p| dir_between(p, (cx, cy))),
1034 next.map(|n| dir_between((cx, cy), n)),
1035 dir_to_box,
1036 );
1037
1038 let style = if anim.is_some() {
1045 if dist_from_head < 0 {
1046 dim_style
1047 } else {
1048 match dist_from_head {
1049 0 => Style::default()
1050 .fg(Color::Rgb(255, 255, 255))
1051 .add_modifier(StyleMod::BOLD),
1052 1 => Style::default()
1053 .fg(Color::Rgb(120, 220, 255))
1054 .add_modifier(StyleMod::BOLD),
1055 2 => Style::default()
1056 .fg(Color::Rgb(80, 170, 230))
1057 .add_modifier(StyleMod::BOLD),
1058 _ => lit_style,
1059 }
1060 }
1061 } else {
1062 lit_style
1063 };
1064
1065 if let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy)
1066 && let Some(cell) = buf.cell_mut((sx, sy))
1067 {
1068 cell.set_char(glyph);
1069 cell.set_style(style);
1070 }
1071 }
1072 let _ = node::diagonal_route_via;
1075}
1076
1077#[derive(Copy, Clone, PartialEq, Eq, Debug)]
1078enum Dir {
1079 Up,
1080 Down,
1081 Left,
1082 Right,
1083}
1084
1085fn opaque_for(v: &VisibleNode, x: i32, y: i32) -> bool {
1099 let local_col = x - v.spec_box_x;
1100 let local_row = y - v.spec_box_y;
1101 if local_col < 0 || local_row < 0 || local_col >= v.box_w as i32 || local_row >= v.box_h as i32
1102 {
1103 return false;
1104 }
1105 if !v.is_anchor {
1106 return true;
1107 }
1108 let rows = crate::ui::biscuit::BISCUIT_TINY;
1110 let Some(line) = rows.get(local_row as usize) else {
1111 return false;
1112 };
1113 let chars: Vec<char> = line.chars().collect();
1114 let first = chars.iter().position(|c| *c != ' ');
1115 let last = chars.iter().rposition(|c| *c != ' ');
1116 match (first, last) {
1117 (Some(f), Some(l)) => local_col >= f as i32 && local_col <= l as i32,
1118 _ => false,
1119 }
1120}
1121
1122fn dir_between(from: (i32, i32), to: (i32, i32)) -> Dir {
1123 if to.0 > from.0 {
1124 Dir::Right
1125 } else if to.0 < from.0 {
1126 Dir::Left
1127 } else if to.1 > from.1 {
1128 Dir::Down
1129 } else {
1130 Dir::Up
1131 }
1132}
1133
1134fn path_glyph(dir_in: Option<Dir>, dir_out: Option<Dir>, dir_to_box: Option<Dir>) -> char {
1148 use Dir::*;
1149 fn corner(a: Dir, b: Dir) -> char {
1152 let mut ds = [a, b];
1153 ds.sort_by_key(|d| match d {
1154 Up => 0,
1155 Down => 1,
1156 Left => 2,
1157 Right => 3,
1158 });
1159 match (ds[0], ds[1]) {
1160 (Up, Left) => '╯',
1161 (Up, Right) => '╰',
1162 (Down, Left) => '╮',
1163 (Down, Right) => '╭',
1164 (Up, Down) => '│',
1166 (Left, Right) => '─',
1167 _ => '·',
1168 }
1169 }
1170
1171 if let Some(box_dir) = dir_to_box {
1174 let line_dir = dir_in.or(dir_out);
1175 match line_dir {
1176 Some(d) if d == box_dir || d == opposite(box_dir) => {
1181 return match box_dir {
1182 Up | Down => '│',
1183 Left | Right => '─',
1184 };
1185 }
1186 Some(d) => {
1198 let line_edge = if dir_in.is_some() { opposite(d) } else { d };
1199 return corner(line_edge, box_dir);
1200 }
1201 None => {
1202 return match box_dir {
1203 Up | Down => '│',
1204 Left | Right => '─',
1205 };
1206 }
1207 }
1208 }
1209
1210 match (dir_in, dir_out) {
1211 (Some(Right), Some(Right)) | (Some(Left), Some(Left)) => '─',
1213 (Some(Down), Some(Down)) | (Some(Up), Some(Up)) => '│',
1214 (Some(Right), Some(Down)) | (Some(Up), Some(Left)) => '╮',
1221 (Some(Right), Some(Up)) | (Some(Down), Some(Left)) => '╯',
1222 (Some(Left), Some(Down)) | (Some(Up), Some(Right)) => '╭',
1223 (Some(Left), Some(Up)) | (Some(Down), Some(Right)) => '╰',
1224 _ => '·',
1227 }
1228}
1229
1230fn opposite(d: Dir) -> Dir {
1231 match d {
1232 Dir::Up => Dir::Down,
1233 Dir::Down => Dir::Up,
1234 Dir::Left => Dir::Right,
1235 Dir::Right => Dir::Left,
1236 }
1237}
1238
1239fn hover_lift(frame: &mut Frame, r: Rect) {
1240 let buf = frame.buffer_mut();
1241 for y in r.y..r.y + r.height {
1242 for x in r.x..r.x + r.width {
1243 if let Some(cell) = buf.cell_mut((x, y)) {
1244 cell.set_style(cell.style().add_modifier(StyleMod::BOLD));
1245 }
1246 }
1247 }
1248}
1249
1250fn draw_info_pane(
1253 frame: &mut Frame,
1254 area: Rect,
1255 state: &GameState,
1256) -> Option<(TreeButtonAction, Rect, TreeCoord)> {
1257 let lang = t();
1258 let buy_button_label = lang.tree_buy_button;
1262 let refund_button_label = lang.tree_refund_button;
1263 let cursor = state.tree.cursor;
1264 let mut lines: Vec<Line> = Vec::new();
1265 let mut action_button: Option<(TreeButtonAction, Rect, TreeCoord)> = None;
1269 let inner_y = area.y + 1;
1272 let inner_x = area.x;
1273
1274 match node::node_at(cursor.x, cursor.y) {
1275 None => {
1276 lines.push(Line::styled(
1277 lang.tree_empty_lot_fmt
1278 .replacen("{:+}", &format!("{:+}", cursor.x), 1)
1279 .replacen("{:+}", &format!("{:+}", cursor.y), 1),
1280 Style::default().fg(Color::Rgb(120, 120, 130)),
1281 ));
1282 lines.push(Line::raw(""));
1283 lines.push(Line::styled(
1284 lang.tree_empty_lot_hint,
1285 Style::default().fg(Color::Rgb(160, 160, 170)),
1286 ));
1287 }
1288 Some(spec) => {
1289 if spec.is_anchor {
1293 lines.push(Line::from(vec![
1294 Span::styled(
1295 format!("{} ", spec.title),
1296 Style::default()
1297 .fg(Color::Rgb(255, 220, 130))
1298 .add_modifier(StyleMod::BOLD),
1299 ),
1300 Span::styled(
1301 lang.tree_anchor_tag,
1302 Style::default()
1303 .fg(Color::Rgb(255, 200, 100))
1304 .add_modifier(StyleMod::BOLD),
1305 ),
1306 ]));
1307 lines.push(Line::styled(
1308 lang.tree_anchor_blurb,
1309 Style::default().fg(Color::Rgb(200, 200, 210)),
1310 ));
1311 lines.push(Line::raw(""));
1312 lines.push(Line::styled(
1313 lang.tree_anchor_footer,
1314 Style::default().fg(Color::Rgb(160, 160, 170)),
1315 ));
1316 let p = Paragraph::new(lines).block(
1317 Block::default()
1318 .borders(Borders::TOP)
1319 .border_style(Style::default().fg(Color::Rgb(60, 60, 80))),
1320 );
1321 frame.render_widget(p, area);
1322 return action_button;
1323 }
1324
1325 let owned = state.tree.bought.contains(&cursor);
1326 let reachable = state.tree_reachable(cursor);
1327 let affordable = state.affordable_cuques() >= spec.cost;
1328 let rarity_label = match spec.rarity {
1329 Rarity::Small => lang.tree_rarity_small,
1330 Rarity::Notable => lang.tree_rarity_notable,
1331 Rarity::Keystone => lang.tree_rarity_keystone,
1332 };
1333 let rarity_color = match spec.rarity {
1334 Rarity::Small => Color::Rgb(200, 200, 220),
1335 Rarity::Notable => Color::Rgb(255, 220, 120),
1336 Rarity::Keystone => Color::Rgb(255, 80, 200),
1337 };
1338 lines.push(Line::from(vec![
1339 Span::styled(
1340 format!("{} ", spec.title),
1341 Style::default()
1342 .fg(Color::Rgb(255, 255, 255))
1343 .add_modifier(StyleMod::BOLD),
1344 ),
1345 Span::styled(
1346 format!("[{}]", rarity_label),
1347 Style::default()
1348 .fg(rarity_color)
1349 .add_modifier(StyleMod::BOLD),
1350 ),
1351 ]));
1352 for p in &spec.primitives {
1353 lines.push(Line::from(vec![
1354 Span::raw(" • "),
1355 Span::styled(primitive_blurb(*p), Style::default().fg(prim_color(*p))),
1356 ]));
1357 }
1358 lines.push(Line::raw(""));
1359 let action_row = inner_y + lines.len() as u16;
1364 let owned_tag_padded = format!(" {} ", lang.tree_owned_tag);
1365 let cost_label_padded = format!(" {}", lang.tree_cost_label);
1366 let action_hint = if owned {
1367 let can_refund = state.can_refund_tree_node(cursor);
1368 if !can_refund {
1369 let reason = if cursor == TreeCoord::ORIGIN {
1370 lang.tree_refund_reason_origin
1371 } else {
1372 lang.tree_refund_reason_orphan
1373 };
1374 Line::from(vec![
1375 Span::styled(
1376 owned_tag_padded.clone(),
1377 Style::default().fg(Color::Rgb(180, 220, 180)),
1378 ),
1379 Span::styled(
1380 lang.tree_no_refund_fmt.replacen("{}", reason, 1),
1381 Style::default().fg(Color::Rgb(170, 130, 130)),
1382 ),
1383 ])
1384 } else {
1385 let refund = spec
1386 .cost
1387 .mul(crate::bignum::Mag::from_f64(TREE_REFUND_FRACTION));
1388 let loss = spec.cost.saturating_sub(refund);
1389 let label_len = refund_button_label.chars().count() as u16;
1390 action_button = Some((
1391 TreeButtonAction::Refund,
1392 Rect {
1393 x: inner_x + owned_tag_padded.chars().count() as u16,
1394 y: action_row,
1395 width: label_len,
1396 height: 1,
1397 },
1398 cursor,
1399 ));
1400 Line::from(vec![
1401 Span::styled(
1402 owned_tag_padded.clone(),
1403 Style::default().fg(Color::Rgb(180, 220, 180)),
1404 ),
1405 Span::styled(
1406 refund_button_label,
1407 Style::default()
1408 .fg(Color::Rgb(220, 220, 120))
1409 .add_modifier(StyleMod::BOLD)
1410 .add_modifier(StyleMod::UNDERLINED),
1411 ),
1412 Span::styled(
1413 format!(
1414 " {}",
1415 lang.tree_refund_returns_fmt
1416 .replacen("{}", &format::big_mag(refund), 1)
1417 .replacen("{}", &format::big_mag(loss), 1)
1418 ),
1419 Style::default().fg(Color::Rgb(180, 150, 150)),
1420 ),
1421 ])
1422 }
1423 } else if !reachable {
1424 Line::styled(
1425 format!(" {}", lang.tree_unreachable_hint),
1426 Style::default().fg(Color::Rgb(180, 100, 100)),
1427 )
1428 } else if !affordable {
1429 let need_more = lang.tree_cost_need_more_fmt.replacen(
1430 "{}",
1431 &format::big_mag(spec.cost.saturating_sub(state.affordable_cuques())),
1432 1,
1433 );
1434 Line::styled(
1435 format!(
1436 "{}{} {}",
1437 cost_label_padded,
1438 format::big_mag(spec.cost),
1439 need_more
1440 ),
1441 Style::default().fg(Color::Rgb(220, 100, 100)),
1442 )
1443 } else {
1444 let cost_text = format::big_mag(spec.cost);
1445 let prefix_cols = cost_label_padded.chars().count()
1447 + cost_text.chars().count()
1448 + " ".chars().count();
1449 let label_len = buy_button_label.chars().count() as u16;
1450 action_button = Some((
1451 TreeButtonAction::Buy,
1452 Rect {
1453 x: inner_x + prefix_cols as u16,
1454 y: action_row,
1455 width: label_len,
1456 height: 1,
1457 },
1458 cursor,
1459 ));
1460 Line::from(vec![
1461 Span::raw(cost_label_padded.clone()),
1462 Span::styled(
1463 cost_text,
1464 Style::default()
1465 .fg(Color::Rgb(120, 255, 120))
1466 .add_modifier(StyleMod::BOLD),
1467 ),
1468 Span::raw(" "),
1469 Span::styled(
1470 buy_button_label,
1471 Style::default()
1472 .fg(Color::Rgb(220, 220, 120))
1473 .add_modifier(StyleMod::BOLD)
1474 .add_modifier(StyleMod::UNDERLINED),
1475 ),
1476 ])
1477 };
1478 lines.push(action_hint);
1479 }
1480 }
1481
1482 let p = Paragraph::new(lines).block(
1483 Block::default()
1484 .borders(Borders::TOP)
1485 .border_style(Style::default().fg(Color::Rgb(60, 60, 80))),
1486 );
1487 frame.render_widget(p, area);
1488 action_button
1489}
1490
1491fn prim_color(p: Primitive) -> Color {
1492 if p.is_bane() {
1493 Color::Rgb(255, 130, 130)
1494 } else {
1495 match p.op {
1496 Op::AddPercent => Color::Rgb(180, 230, 255),
1497 Op::MulFactor => Color::Rgb(255, 220, 120),
1498 Op::FlatAdd => Color::Rgb(180, 255, 200),
1499 Op::CostMul => Color::Rgb(220, 180, 255),
1500 Op::SpawnRateMul | Op::EffectMul => Color::Rgb(255, 200, 240),
1501 }
1502 }
1503}
1504
1505fn truncate(s: &str, max: usize) -> String {
1506 if max == 0 {
1507 return String::new();
1508 }
1509 let count = s.chars().count();
1510 if count <= max {
1511 return s.to_string();
1512 }
1513 let take = max.saturating_sub(1).max(1);
1514 let mut out: String = s.chars().take(take).collect();
1515 out.push('…');
1516 out
1517}