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 HELP_BAR_HEIGHT: u16 = 2;
61
62const INFO_PANE_HEIGHT: u16 = 8;
63const HEADER_HEIGHT: u16 = 3;
64
65pub fn draw(
66 frame: &mut Frame,
67 area: Rect,
68 state: &GameState,
69 mouse_pos: Option<(u16, u16)>,
70 tree_render: &mut TreeRenderState,
71) -> TreeDrawOutput {
72 let modal_h = area.height.saturating_sub(HELP_BAR_HEIGHT);
73 if modal_h < HEADER_HEIGHT + INFO_PANE_HEIGHT + 4 {
74 return TreeDrawOutput {
76 node_rects: Vec::new(),
77 action_button: None,
78 };
79 }
80 let modal = Rect {
81 x: area.x,
82 y: area.y,
83 width: area.width,
84 height: modal_h,
85 };
86
87 fill_area(frame, modal);
90
91 let rows = Layout::vertical([
92 Constraint::Length(HEADER_HEIGHT),
93 Constraint::Min(1),
94 Constraint::Length(INFO_PANE_HEIGHT),
95 ])
96 .split(modal);
97 let header_area = rows[0];
98 let canvas_area = rows[1];
99 let info_area = rows[2];
100
101 draw_header(frame, header_area, state);
102 let node_rects = draw_canvas(frame, canvas_area, state, mouse_pos, tree_render);
103 let action_button = draw_info_pane(frame, info_area, state);
104 if let (Some((_, r, _)), Some((mx, my))) = (action_button, mouse_pos)
108 && mx >= r.x
109 && mx < r.x + r.width
110 && my >= r.y
111 && my < r.y + r.height
112 {
113 let buf = frame.buffer_mut();
114 for y in r.y..r.y + r.height {
115 for x in r.x..r.x + r.width {
116 if let Some(cell) = buf.cell_mut((x, y)) {
117 cell.set_style(
118 cell.style()
119 .fg(Color::Rgb(255, 255, 255))
120 .bg(Color::Rgb(40, 40, 50))
121 .add_modifier(StyleMod::BOLD),
122 );
123 }
124 }
125 }
126 }
127 TreeDrawOutput {
128 node_rects,
129 action_button,
130 }
131}
132
133fn fill_area(frame: &mut Frame, area: Rect) {
134 let buf = frame.buffer_mut();
140 let clear = Style::default().fg(Color::Reset).bg(Color::Reset);
141 for y in area.y..area.y.saturating_add(area.height) {
142 for x in area.x..area.x.saturating_add(area.width) {
143 if let Some(cell) = buf.cell_mut((x, y)) {
144 cell.set_char(' ');
145 cell.set_style(clear);
146 }
147 }
148 }
149}
150
151fn draw_header(frame: &mut Frame, area: Rect, state: &GameState) {
154 let lang = t();
159 let title = format!("{}—{}", hud_title(), lang.tree_title);
160 border::draw_animated(frame, area, state, &title);
161 if area.width < 3 || area.height < 3 {
162 return;
163 }
164 let inner = Rect {
165 x: area.x + 1,
166 y: area.y + 1,
167 width: area.width.saturating_sub(2),
168 height: area.height.saturating_sub(2),
169 };
170 let cursor = state.tree.cursor;
171 let line = Line::from(vec![
172 Span::raw(format!("{}: ", lang.hud_cuques)),
173 Span::styled(
174 format::big(state.displayed_cuques),
175 Style::default()
176 .fg(Color::Rgb(180, 255, 180))
177 .add_modifier(StyleMod::BOLD),
178 ),
179 Span::raw(format!(" {}: ", lang.hud_fps)),
180 Span::styled(
181 format::rate(state.displayed_fps),
182 Style::default().fg(Color::Rgb(200, 200, 240)),
183 ),
184 Span::styled(
185 format!(" cursor ({:+}, {:+})", cursor.x, cursor.y),
186 Style::default().fg(Color::Rgb(180, 200, 255)),
187 ),
188 Span::styled(
189 format!(
194 " owned: {}",
195 state
196 .tree
197 .bought
198 .iter()
199 .filter(|c| **c != TreeCoord::ORIGIN)
200 .count()
201 ),
202 Style::default().fg(Color::Rgb(220, 220, 180)),
203 ),
204 ]);
205 frame.render_widget(Paragraph::new(line), inner);
206}
207
208#[derive(Clone)]
211struct VisibleNode {
212 spec_box_x: i32,
213 spec_box_y: i32,
214 box_w: u16,
215 box_h: u16,
216 rarity: Rarity,
217 lot: TreeCoord,
218 dominant_target: Target,
219 cost: f64,
220 owned: bool,
221 reachable: bool,
222 affordable: bool,
223 is_anchor: bool,
224 title_short: String,
225 buy_flash: f32,
228 unlock_flash: f32,
229 refund_flash: f32,
230}
231
232fn draw_canvas(
233 frame: &mut Frame,
234 area: Rect,
235 state: &GameState,
236 mouse_pos: Option<(u16, u16)>,
237 tree_render: &mut TreeRenderState,
238) -> Vec<(TreeCoord, Rect)> {
239 if area.width < 10 || area.height < 5 {
240 return Vec::new();
241 }
242
243 let cursor = state.tree.cursor;
244 let canvas_center_x = (area.width / 2) as i32;
245 let canvas_center_y = (area.height / 2) as i32;
246
247 let cursor_centered_x = (cursor.x * LOT_W + LOT_W / 2 - canvas_center_x) as f32;
253 let cursor_centered_y = (cursor.y * LOT_H + LOT_H / 2 - canvas_center_y) as f32;
254
255 if !tree_render.initialized {
256 tree_render.pan_x = cursor_centered_x;
258 tree_render.pan_y = cursor_centered_y;
259 tree_render.target_pan_x = cursor_centered_x;
260 tree_render.target_pan_y = cursor_centered_y;
261 tree_render.prev_cursor = cursor;
262 tree_render.initialized = true;
263 } else {
264 if cursor != tree_render.prev_cursor {
265 tree_render.target_pan_x = cursor_centered_x;
268 tree_render.target_pan_y = cursor_centered_y;
269 tree_render.prev_cursor = cursor;
270 }
271 if tree_render.drag_last.is_none() {
275 let dx = tree_render.target_pan_x - tree_render.pan_x;
276 let dy = tree_render.target_pan_y - tree_render.pan_y;
277 if dx.abs() < PAN_SNAP_EPSILON && dy.abs() < PAN_SNAP_EPSILON {
278 tree_render.pan_x = tree_render.target_pan_x;
279 tree_render.pan_y = tree_render.target_pan_y;
280 } else {
281 tree_render.pan_x += dx * PAN_TWEEN_FACTOR;
282 tree_render.pan_y += dy * PAN_TWEEN_FACTOR;
283 }
284 }
285 }
286
287 let pan_x = tree_render.pan_x.round() as i32;
290 let pan_y = tree_render.pan_y.round() as i32;
291
292 let view_center_canvas_x = pan_x + canvas_center_x;
299 let view_center_canvas_y = pan_y + canvas_center_y;
300 let view_lot_x = view_center_canvas_x.div_euclid(LOT_W);
301 let view_lot_y = view_center_canvas_y.div_euclid(LOT_H);
302 let visible_radius = VISIBLE_RADIUS_LOTS + 2;
303 let mut visible: Vec<VisibleNode> = Vec::new();
304 for dy in -visible_radius..=visible_radius {
305 for dx in -visible_radius..=visible_radius {
306 let lot = TreeCoord::new(view_lot_x + dx, view_lot_y + dy);
307 let Some(spec) = node::node_at(lot.x, lot.y) else {
308 continue;
309 };
310 let owned = state.tree.bought.contains(&lot);
311 let reachable = !owned && state.tree_reachable(lot) && !state.tree_unlock_pending(lot);
315 let affordable = state.affordable_cuques() >= spec.cost;
316 let title_short = truncate(&spec.title, (spec.box_w as usize).saturating_sub(2));
317 let buy_flash = state
318 .tree_buy_flash
319 .get(&lot)
320 .copied()
321 .map(|t| t as f32 / PURCHASE_FLASH_TICKS as f32)
322 .unwrap_or(0.0);
323 let unlock_flash = state
324 .tree_unlock_flash
325 .get(&lot)
326 .copied()
327 .map(|t| t as f32 / UNLOCK_FLASH_TICKS as f32)
328 .unwrap_or(0.0);
329 let refund_flash = state
330 .tree_refund_flash
331 .get(&lot)
332 .copied()
333 .map(|t| t as f32 / PURCHASE_FLASH_TICKS as f32)
334 .unwrap_or(0.0);
335 visible.push(VisibleNode {
336 spec_box_x: spec.box_x,
337 spec_box_y: spec.box_y,
338 box_w: spec.box_w,
339 box_h: spec.box_h,
340 rarity: spec.rarity,
341 lot,
342 dominant_target: spec.dominant_target,
343 cost: spec.cost,
344 owned,
345 reachable,
346 affordable,
347 is_anchor: spec.is_anchor,
348 title_short,
349 buy_flash,
350 unlock_flash,
351 refund_flash,
352 });
353 }
354 }
355
356 let edges: Vec<(TreeCoord, TreeCoord)> = {
358 let mut out = Vec::new();
359 for i in 0..visible.len() {
360 for j in (i + 1)..visible.len() {
361 let a = visible[i].lot;
362 let b = visible[j].lot;
363 if node::edge_exists(a, b) {
364 out.push((a, b));
365 }
366 }
367 }
368 out
369 };
370
371 for (a, b) in &edges {
378 let av = visible.iter().find(|v| v.lot == *a).cloned();
379 let bv = visible.iter().find(|v| v.lot == *b).cloned();
380 if let (Some(av), Some(bv)) = (av, bv) {
381 draw_edge(frame, area, pan_x, pan_y, &av, &bv, state);
382 }
383 }
384
385 let mut click_rects: Vec<(TreeCoord, Rect)> = Vec::new();
386 for v in &visible {
387 if let Some(r) = draw_box(frame, area, pan_x, pan_y, v, cursor, state) {
388 click_rects.push((v.lot, r));
389 }
390 }
391
392 if let Some((mx, my)) = mouse_pos {
394 for &(_, r) in &click_rects {
395 if mx >= r.x && mx < r.x + r.width && my >= r.y && my < r.y + r.height {
396 hover_lift(frame, r);
397 break;
398 }
399 }
400 }
401
402 click_rects
403}
404
405fn canvas_to_screen(area: Rect, pan_x: i32, pan_y: i32, cx: i32, cy: i32) -> Option<(u16, u16)> {
408 let sx = cx - pan_x + area.x as i32;
409 let sy = cy - pan_y + area.y as i32;
410 if sx < area.x as i32
411 || sx >= (area.x as i32 + area.width as i32)
412 || sy < area.y as i32
413 || sy >= (area.y as i32 + area.height as i32)
414 {
415 return None;
416 }
417 Some((sx as u16, sy as u16))
418}
419
420#[allow(clippy::too_many_arguments)]
421fn draw_box(
422 frame: &mut Frame,
423 area: Rect,
424 pan_x: i32,
425 pan_y: i32,
426 v: &VisibleNode,
427 cursor: TreeCoord,
428 state: &GameState,
429) -> Option<Rect> {
430 if v.is_anchor {
434 return draw_anchor(frame, area, pan_x, pan_y, v, cursor, state);
435 }
436 let buf = frame.buffer_mut();
437 let bx = v.spec_box_x;
438 let by = v.spec_box_y;
439 let bw = v.box_w as i32;
440 let bh = v.box_h as i32;
441
442 let (corners, h_char, v_char, base_style) = box_chars_for(v);
444
445 let is_focus = v.lot == cursor;
447 let box_style = if is_focus {
448 base_style
449 .add_modifier(StyleMod::BOLD)
450 .add_modifier(StyleMod::REVERSED)
451 } else {
452 base_style
453 };
454
455 let mut painted_any = false;
457 let mut min_sx = u16::MAX;
458 let mut min_sy = u16::MAX;
459 let mut max_sx = 0u16;
460 let mut max_sy = 0u16;
461
462 for row in 0..bh {
463 for col in 0..bw {
464 let cx = bx + col;
465 let cy = by + row;
466 let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy) else {
467 continue;
468 };
469 let ch = if row == 0 && col == 0 {
470 corners.0
471 } else if row == 0 && col == bw - 1 {
472 corners.1
473 } else if row == bh - 1 && col == 0 {
474 corners.2
475 } else if row == bh - 1 && col == bw - 1 {
476 corners.3
477 } else if row == 0 || row == bh - 1 {
478 h_char
479 } else if col == 0 || col == bw - 1 {
480 v_char
481 } else {
482 let interior_w = (bw - 2) as usize;
486 let r_in = row - 1;
487 if r_in == 0 && interior_w > 0 {
488 v.title_short.chars().nth((col - 1) as usize).unwrap_or(' ')
490 } else if r_in == (bh - 2) - 1 && interior_w > 4 {
491 let cost_str = if v.owned {
494 "[ owned ]".to_string()
495 } else {
496 format::big(v.cost).to_string()
497 };
498 cost_str.chars().nth((col - 1) as usize).unwrap_or(' ')
499 } else if r_in > 0
500 && r_in < (bh - 2)
501 && v.rarity != Rarity::Small
502 && r_in == 1
503 && interior_w > 4
504 {
505 let summary = primitive_summary(v);
507 summary.chars().nth((col - 1) as usize).unwrap_or(' ')
508 } else {
509 ' '
510 }
511 };
512
513 if let Some(cell) = buf.cell_mut((sx, sy)) {
514 cell.set_char(ch);
515 cell.set_style(box_style);
516 }
517 painted_any = true;
518 min_sx = min_sx.min(sx);
519 min_sy = min_sy.min(sy);
520 max_sx = max_sx.max(sx);
521 max_sy = max_sy.max(sy);
522 }
523 }
524
525 if !painted_any {
526 return None;
527 }
528 Some(Rect {
529 x: min_sx,
530 y: min_sy,
531 width: max_sx.saturating_sub(min_sx).saturating_add(1),
532 height: max_sy.saturating_sub(min_sy).saturating_add(1),
533 })
534}
535
536fn draw_anchor(
548 frame: &mut Frame,
549 area: Rect,
550 pan_x: i32,
551 pan_y: i32,
552 v: &VisibleNode,
553 cursor: TreeCoord,
554 state: &GameState,
555) -> Option<Rect> {
556 let rows = crate::ui::biscuit::BISCUIT_TINY;
557 let focal = crate::ui::biscuit::BISCUIT_TINY_FOCAL;
558 let is_focus = v.lot == cursor;
559 let phase = state.steady_phase;
560
561 let base_style = Style::default()
564 .fg(Color::Rgb(255, 230, 180))
565 .add_modifier(StyleMod::BOLD);
566
567 let buf = frame.buffer_mut();
568 let mut min_sx = u16::MAX;
569 let mut min_sy = u16::MAX;
570 let mut max_sx = 0u16;
571 let mut max_sy = 0u16;
572 let mut painted_any = false;
573
574 for (row_idx, line) in rows.iter().enumerate() {
575 for (col_idx, ch) in line.chars().enumerate() {
576 let is_focal = col_idx as u16 == focal.0 && row_idx as u16 == focal.1;
580 let glyph = if is_focal { 'O' } else { ch };
581 if glyph == ' ' {
582 continue;
583 }
584 let cx = v.spec_box_x + col_idx as i32;
585 let cy = v.spec_box_y + row_idx as i32;
586 let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy) else {
587 continue;
588 };
589 let style = if is_focus {
590 Style::default()
591 .fg(plasma_color(phase, col_idx as i32, row_idx as i32))
592 .add_modifier(StyleMod::BOLD)
593 } else {
594 base_style
595 };
596 if let Some(cell) = buf.cell_mut((sx, sy)) {
597 cell.set_char(glyph);
598 cell.set_style(style);
599 }
600 painted_any = true;
601 min_sx = min_sx.min(sx);
602 min_sy = min_sy.min(sy);
603 max_sx = max_sx.max(sx);
604 max_sy = max_sy.max(sy);
605 }
606 }
607
608 if !painted_any {
609 return None;
610 }
611 Some(Rect {
612 x: min_sx,
613 y: min_sy,
614 width: max_sx.saturating_sub(min_sx).saturating_add(1),
615 height: max_sy.saturating_sub(min_sy).saturating_add(1),
616 })
617}
618
619fn plasma_color(phase: u32, col: i32, row: i32) -> Color {
626 let t = phase as f32 * 0.06;
627 let cx = col as f32 * 0.45;
628 let cy = row as f32 * 0.85;
629 let v = (cx + t).sin() + (cy + t * 1.27).sin() + ((cx + cy) * 0.6 + t * 0.83).sin();
630 let n = ((v / 3.0) + 1.0) * 0.5; let keys: [(f32, f32, f32); 3] = [
634 (255.0, 215.0, 110.0), (255.0, 110.0, 90.0), (210.0, 130.0, 255.0), ];
638 let pos = n * 3.0;
639 let idx = (pos.floor() as usize) % 3;
640 let frac = pos - pos.floor();
641 let a = keys[idx];
642 let b = keys[(idx + 1) % 3];
643 let r = a.0 + (b.0 - a.0) * frac;
644 let g = a.1 + (b.1 - a.1) * frac;
645 let bb = a.2 + (b.2 - a.2) * frac;
646 Color::Rgb(
647 r.clamp(60.0, 255.0) as u8,
648 g.clamp(60.0, 255.0) as u8,
649 bb.clamp(60.0, 255.0) as u8,
650 )
651}
652
653fn primitive_summary(v: &VisibleNode) -> String {
654 let prefix = match v.rarity {
657 Rarity::Small => "small",
658 Rarity::Notable => "notable",
659 Rarity::Keystone => "KEYSTONE",
660 };
661 let target = match v.dominant_target {
662 Target::Fingerer(i) => crate::i18n::t()
663 .fingerer_names
664 .get(i as usize)
665 .copied()
666 .unwrap_or("?")
667 .to_string(),
668 Target::AllFingerers => "all fingerers".to_string(),
669 Target::Click => "click".to_string(),
670 Target::PowerupSpawn(k) => format!("{} spawn", powerup_short(k)),
671 Target::PowerupReward(k) => format!("{} reward", powerup_short(k)),
672 Target::PowerupDuration(k) => format!("{} time", powerup_short(k)),
673 Target::Prestige => "prestige".to_string(),
674 Target::GreenCoinStrength => "GC pow".to_string(),
675 };
676 format!("{}: {}", prefix, target)
677}
678
679fn powerup_short(k: PowerupKind) -> &'static str {
680 match k {
681 PowerupKind::Lucky => "Lucky",
682 PowerupKind::Frenzy => "Frenzy",
683 PowerupKind::Buff => "Buff",
684 PowerupKind::GreenCoin => "GCoin",
685 }
686}
687
688fn box_chars_for(v: &VisibleNode) -> ((char, char, char, char), char, char, Style) {
702 let owned_corners: (char, char, char, char) = ('╔', '╗', '╚', '╝');
704 let dotted_corners: (char, char, char, char) = ('┌', '┐', '└', '┘');
705
706 let biome = biome_color(v.dominant_target, v.rarity);
707 let unreachable_fg = Color::Rgb(60, 60, 70);
711 let buyable_fg = blend_to_white(biome.bright, 0.15);
715
716 let mut result = if v.owned {
717 (
718 owned_corners,
719 '═',
720 '║',
721 Style::default()
722 .fg(biome.bright)
723 .add_modifier(StyleMod::BOLD),
724 )
725 } else if v.reachable && v.affordable {
726 (
727 dotted_corners,
728 '╌',
729 '╎',
730 Style::default().fg(buyable_fg).add_modifier(StyleMod::BOLD),
731 )
732 } else if v.reachable {
733 (dotted_corners, '╌', '╎', Style::default().fg(biome.dim))
736 } else {
737 (
739 dotted_corners,
740 '╌',
741 '╎',
742 Style::default().fg(unreachable_fg),
743 )
744 };
745
746 if v.buy_flash > 0.001 {
748 let tint = (40.0, 230.0, 80.0); result.3 = Style::default()
750 .fg(blend_to(biome.bright, tint, v.buy_flash))
751 .add_modifier(StyleMod::BOLD);
752 } else if v.refund_flash > 0.001 {
753 let tint = (255.0, 80.0, 80.0); let from = if v.owned {
755 biome.bright
756 } else {
757 unreachable_fg
758 };
759 result.3 = Style::default()
760 .fg(blend_to(from, tint, v.refund_flash))
761 .add_modifier(StyleMod::BOLD);
762 } else if v.unlock_flash > 0.001 {
763 let tint = (255.0, 230.0, 120.0); let from = if v.reachable { buyable_fg } else { biome.dim };
765 result.3 = Style::default()
766 .fg(blend_to(from, tint, v.unlock_flash))
767 .add_modifier(StyleMod::BOLD);
768 }
769 result
770}
771
772fn blend_to_white(base: Color, biome_weight: f32) -> Color {
776 let (br, bg, bb) = color_rgb(base);
777 let mix = biome_weight.clamp(0.0, 1.0);
778 let r = 240.0 * (1.0 - mix) + br * mix;
779 let g = 240.0 * (1.0 - mix) + bg * mix;
780 let b = 240.0 * (1.0 - mix) + bb * mix;
781 Color::Rgb(
782 r.clamp(0.0, 255.0) as u8,
783 g.clamp(0.0, 255.0) as u8,
784 b.clamp(0.0, 255.0) as u8,
785 )
786}
787
788fn blend_to(base: Color, tint: (f32, f32, f32), t: f32) -> Color {
789 let (br, bg, bb) = color_rgb(base);
790 let mix = t.clamp(0.0, 1.0);
791 Color::Rgb(
792 (br + (tint.0 - br) * mix).clamp(0.0, 255.0) as u8,
793 (bg + (tint.1 - bg) * mix).clamp(0.0, 255.0) as u8,
794 (bb + (tint.2 - bb) * mix).clamp(0.0, 255.0) as u8,
795 )
796}
797
798fn color_rgb(c: Color) -> (f32, f32, f32) {
799 match c {
800 Color::Rgb(r, g, b) => (r as f32, g as f32, b as f32),
801 _ => (200.0, 200.0, 200.0),
802 }
803}
804
805struct BiomeColors {
806 bright: Color,
807 dim: Color,
808}
809
810fn biome_color(target: Target, rarity: Rarity) -> BiomeColors {
813 if matches!(rarity, Rarity::Keystone) {
816 return BiomeColors {
817 bright: Color::Rgb(255, 80, 200),
818 dim: Color::Rgb(150, 50, 120),
819 };
820 }
821 match target {
822 Target::Fingerer(idx) => fingerer_biome(idx as usize),
823 Target::AllFingerers => BiomeColors {
824 bright: Color::Rgb(255, 215, 0),
825 dim: Color::Rgb(150, 130, 0),
826 },
827 Target::Click => BiomeColors {
828 bright: Color::Rgb(255, 130, 130),
829 dim: Color::Rgb(150, 80, 80),
830 },
831 Target::PowerupSpawn(_) | Target::PowerupReward(_) | Target::PowerupDuration(_) => {
832 BiomeColors {
833 bright: Color::Rgb(220, 140, 255),
834 dim: Color::Rgb(130, 90, 150),
835 }
836 }
837 Target::Prestige => BiomeColors {
838 bright: Color::Rgb(255, 200, 220),
839 dim: Color::Rgb(150, 110, 130),
840 },
841 Target::GreenCoinStrength => BiomeColors {
842 bright: Color::Rgb(120, 230, 140),
843 dim: Color::Rgb(60, 130, 80),
844 },
845 }
846}
847
848fn fingerer_biome(idx: usize) -> BiomeColors {
849 match idx {
850 0 => BiomeColors {
851 bright: Color::Rgb(255, 220, 120),
852 dim: Color::Rgb(160, 130, 70),
853 },
854 1 => BiomeColors {
855 bright: Color::Rgb(255, 180, 100),
856 dim: Color::Rgb(160, 100, 60),
857 },
858 2 => BiomeColors {
859 bright: Color::Rgb(180, 255, 230),
860 dim: Color::Rgb(90, 150, 130),
861 },
862 3 => BiomeColors {
863 bright: Color::Rgb(255, 150, 180),
864 dim: Color::Rgb(160, 80, 100),
865 },
866 4 => BiomeColors {
867 bright: Color::Rgb(180, 220, 255),
868 dim: Color::Rgb(90, 130, 160),
869 },
870 5 => BiomeColors {
871 bright: Color::Rgb(140, 230, 200),
872 dim: Color::Rgb(70, 130, 100),
873 },
874 6 => BiomeColors {
875 bright: Color::Rgb(200, 160, 255),
876 dim: Color::Rgb(110, 80, 160),
877 },
878 7 => BiomeColors {
879 bright: Color::Rgb(160, 200, 255),
880 dim: Color::Rgb(80, 120, 160),
881 },
882 8 => BiomeColors {
883 bright: Color::Rgb(255, 255, 200),
884 dim: Color::Rgb(160, 160, 110),
885 },
886 _ => BiomeColors {
887 bright: Color::Rgb(255, 255, 255),
888 dim: Color::Rgb(180, 180, 180),
889 },
890 }
891}
892
893fn draw_edge(
901 frame: &mut Frame,
902 area: Rect,
903 pan_x: i32,
904 pan_y: i32,
905 a: &VisibleNode,
906 b: &VisibleNode,
907 state: &GameState,
908) {
909 let a_owned = state.tree.bought.contains(&a.lot);
910 let b_owned = state.tree.bought.contains(&b.lot);
911 let dim_style = Style::default().fg(Color::Rgb(80, 80, 100));
916 let lit_style = if a_owned && b_owned {
920 Style::default()
921 .fg(Color::Rgb(255, 220, 120))
922 .add_modifier(StyleMod::BOLD)
923 } else if a_owned || b_owned {
924 Style::default().fg(Color::Rgb(180, 180, 200))
925 } else {
926 dim_style
927 };
928
929 let anim = state
935 .tree_edge_anims
936 .iter()
937 .find(|an| (an.from == a.lot && an.to == b.lot) || (an.from == b.lot && an.to == a.lot));
938 let path = node::edge_path_cells(a.lot, b.lot);
939 if path.is_empty() {
940 return;
941 }
942 let canonical_lo = if (a.lot.x, a.lot.y) <= (b.lot.x, b.lot.y) {
945 a.lot
946 } else {
947 b.lot
948 };
949 let wave_at_start = anim.map(|an| an.from == canonical_lo).unwrap_or(true);
950 let source_node: &VisibleNode = if let Some(an) = anim {
956 if an.from == a.lot { a } else { b }
957 } else {
958 a
959 };
960 let leading_inside = if wave_at_start {
961 node::count_leading_in_rect(
962 &path,
963 source_node.spec_box_x,
964 source_node.spec_box_y,
965 source_node.box_w,
966 source_node.box_h,
967 )
968 } else {
969 node::count_trailing_in_rect(
970 &path,
971 source_node.spec_box_x,
972 source_node.spec_box_y,
973 source_node.box_w,
974 source_node.box_h,
975 )
976 };
977 let head_path_index = anim
978 .map(|an| (leading_inside + an.visible_advance()).min(path.len().saturating_sub(1)))
979 .unwrap_or(0);
980
981 let in_a = |x: i32, y: i32| opaque_for(a, x, y);
985 let in_b = |x: i32, y: i32| opaque_for(b, x, y);
986
987 let buf = frame.buffer_mut();
988 let path_len = path.len();
989 for i in 0..path_len {
990 let (cx, cy) = path[i];
991 if in_a(cx, cy) || in_b(cx, cy) {
992 continue;
993 }
994 let dist_from_head: i32 = if anim.is_some() {
1001 if wave_at_start {
1002 (head_path_index as i32) - (i as i32)
1003 } else {
1004 (head_path_index as i32) - ((path_len - 1 - i) as i32)
1005 }
1006 } else {
1007 0
1008 };
1009
1010 let prev_raw = if i > 0 { Some(path[i - 1]) } else { None };
1011 let next_raw = path.get(i + 1).copied();
1012 let prev_in_box = prev_raw.is_some_and(|(px, py)| in_a(px, py) || in_b(px, py));
1013 let next_in_box = next_raw.is_some_and(|(nx, ny)| in_a(nx, ny) || in_b(nx, ny));
1014 let prev = prev_raw.filter(|_| !prev_in_box);
1015 let next = next_raw.filter(|_| !next_in_box);
1016 let dir_to_box = if prev_in_box {
1022 prev_raw.map(|p| dir_between((cx, cy), p))
1023 } else if next_in_box {
1024 next_raw.map(|n| dir_between((cx, cy), n))
1025 } else {
1026 None
1027 };
1028 let glyph = path_glyph(
1029 prev.map(|p| dir_between(p, (cx, cy))),
1030 next.map(|n| dir_between((cx, cy), n)),
1031 dir_to_box,
1032 );
1033
1034 let style = if anim.is_some() {
1041 if dist_from_head < 0 {
1042 dim_style
1043 } else {
1044 match dist_from_head {
1045 0 => Style::default()
1046 .fg(Color::Rgb(255, 255, 255))
1047 .add_modifier(StyleMod::BOLD),
1048 1 => Style::default()
1049 .fg(Color::Rgb(120, 220, 255))
1050 .add_modifier(StyleMod::BOLD),
1051 2 => Style::default()
1052 .fg(Color::Rgb(80, 170, 230))
1053 .add_modifier(StyleMod::BOLD),
1054 _ => lit_style,
1055 }
1056 }
1057 } else {
1058 lit_style
1059 };
1060
1061 if let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy)
1062 && let Some(cell) = buf.cell_mut((sx, sy))
1063 {
1064 cell.set_char(glyph);
1065 cell.set_style(style);
1066 }
1067 }
1068 let _ = node::diagonal_route_via;
1071}
1072
1073#[derive(Copy, Clone, PartialEq, Eq, Debug)]
1074enum Dir {
1075 Up,
1076 Down,
1077 Left,
1078 Right,
1079}
1080
1081fn opaque_for(v: &VisibleNode, x: i32, y: i32) -> bool {
1095 let local_col = x - v.spec_box_x;
1096 let local_row = y - v.spec_box_y;
1097 if local_col < 0 || local_row < 0 || local_col >= v.box_w as i32 || local_row >= v.box_h as i32
1098 {
1099 return false;
1100 }
1101 if !v.is_anchor {
1102 return true;
1103 }
1104 let rows = crate::ui::biscuit::BISCUIT_TINY;
1106 let Some(line) = rows.get(local_row as usize) else {
1107 return false;
1108 };
1109 let chars: Vec<char> = line.chars().collect();
1110 let first = chars.iter().position(|c| *c != ' ');
1111 let last = chars.iter().rposition(|c| *c != ' ');
1112 match (first, last) {
1113 (Some(f), Some(l)) => local_col >= f as i32 && local_col <= l as i32,
1114 _ => false,
1115 }
1116}
1117
1118fn dir_between(from: (i32, i32), to: (i32, i32)) -> Dir {
1119 if to.0 > from.0 {
1120 Dir::Right
1121 } else if to.0 < from.0 {
1122 Dir::Left
1123 } else if to.1 > from.1 {
1124 Dir::Down
1125 } else {
1126 Dir::Up
1127 }
1128}
1129
1130fn path_glyph(dir_in: Option<Dir>, dir_out: Option<Dir>, dir_to_box: Option<Dir>) -> char {
1144 use Dir::*;
1145 fn corner(a: Dir, b: Dir) -> char {
1148 let mut ds = [a, b];
1149 ds.sort_by_key(|d| match d {
1150 Up => 0,
1151 Down => 1,
1152 Left => 2,
1153 Right => 3,
1154 });
1155 match (ds[0], ds[1]) {
1156 (Up, Left) => '╯',
1157 (Up, Right) => '╰',
1158 (Down, Left) => '╮',
1159 (Down, Right) => '╭',
1160 (Up, Down) => '│',
1162 (Left, Right) => '─',
1163 _ => '·',
1164 }
1165 }
1166
1167 if let Some(box_dir) = dir_to_box {
1170 let line_dir = dir_in.or(dir_out);
1171 match line_dir {
1172 Some(d) if d == box_dir || d == opposite(box_dir) => {
1177 return match box_dir {
1178 Up | Down => '│',
1179 Left | Right => '─',
1180 };
1181 }
1182 Some(d) => {
1194 let line_edge = if dir_in.is_some() { opposite(d) } else { d };
1195 return corner(line_edge, box_dir);
1196 }
1197 None => {
1198 return match box_dir {
1199 Up | Down => '│',
1200 Left | Right => '─',
1201 };
1202 }
1203 }
1204 }
1205
1206 match (dir_in, dir_out) {
1207 (Some(Right), Some(Right)) | (Some(Left), Some(Left)) => '─',
1209 (Some(Down), Some(Down)) | (Some(Up), Some(Up)) => '│',
1210 (Some(Right), Some(Down)) | (Some(Up), Some(Left)) => '╮',
1217 (Some(Right), Some(Up)) | (Some(Down), Some(Left)) => '╯',
1218 (Some(Left), Some(Down)) | (Some(Up), Some(Right)) => '╭',
1219 (Some(Left), Some(Up)) | (Some(Down), Some(Right)) => '╰',
1220 _ => '·',
1223 }
1224}
1225
1226fn opposite(d: Dir) -> Dir {
1227 match d {
1228 Dir::Up => Dir::Down,
1229 Dir::Down => Dir::Up,
1230 Dir::Left => Dir::Right,
1231 Dir::Right => Dir::Left,
1232 }
1233}
1234
1235fn hover_lift(frame: &mut Frame, r: Rect) {
1236 let buf = frame.buffer_mut();
1237 for y in r.y..r.y + r.height {
1238 for x in r.x..r.x + r.width {
1239 if let Some(cell) = buf.cell_mut((x, y)) {
1240 cell.set_style(cell.style().add_modifier(StyleMod::BOLD));
1241 }
1242 }
1243 }
1244}
1245
1246fn draw_info_pane(
1249 frame: &mut Frame,
1250 area: Rect,
1251 state: &GameState,
1252) -> Option<(TreeButtonAction, Rect, TreeCoord)> {
1253 let lang = t();
1254 let buy_button_label = lang.tree_buy_button;
1258 let refund_button_label = lang.tree_refund_button;
1259 let cursor = state.tree.cursor;
1260 let mut lines: Vec<Line> = Vec::new();
1261 let mut action_button: Option<(TreeButtonAction, Rect, TreeCoord)> = None;
1265 let inner_y = area.y + 1;
1268 let inner_x = area.x;
1269
1270 match node::node_at(cursor.x, cursor.y) {
1271 None => {
1272 lines.push(Line::styled(
1273 lang.tree_empty_lot_fmt
1274 .replacen("{:+}", &format!("{:+}", cursor.x), 1)
1275 .replacen("{:+}", &format!("{:+}", cursor.y), 1),
1276 Style::default().fg(Color::Rgb(120, 120, 130)),
1277 ));
1278 lines.push(Line::raw(""));
1279 lines.push(Line::styled(
1280 lang.tree_empty_lot_hint,
1281 Style::default().fg(Color::Rgb(160, 160, 170)),
1282 ));
1283 }
1284 Some(spec) => {
1285 if spec.is_anchor {
1289 lines.push(Line::from(vec![
1290 Span::styled(
1291 format!("{} ", spec.title),
1292 Style::default()
1293 .fg(Color::Rgb(255, 220, 130))
1294 .add_modifier(StyleMod::BOLD),
1295 ),
1296 Span::styled(
1297 lang.tree_anchor_tag,
1298 Style::default()
1299 .fg(Color::Rgb(255, 200, 100))
1300 .add_modifier(StyleMod::BOLD),
1301 ),
1302 ]));
1303 lines.push(Line::styled(
1304 lang.tree_anchor_blurb,
1305 Style::default().fg(Color::Rgb(200, 200, 210)),
1306 ));
1307 lines.push(Line::raw(""));
1308 lines.push(Line::styled(
1309 lang.tree_anchor_footer,
1310 Style::default().fg(Color::Rgb(160, 160, 170)),
1311 ));
1312 let p = Paragraph::new(lines).block(
1313 Block::default()
1314 .borders(Borders::TOP)
1315 .border_style(Style::default().fg(Color::Rgb(60, 60, 80))),
1316 );
1317 frame.render_widget(p, area);
1318 return action_button;
1319 }
1320
1321 let owned = state.tree.bought.contains(&cursor);
1322 let reachable = state.tree_reachable(cursor);
1323 let affordable = state.affordable_cuques() >= spec.cost;
1324 let rarity_label = match spec.rarity {
1325 Rarity::Small => lang.tree_rarity_small,
1326 Rarity::Notable => lang.tree_rarity_notable,
1327 Rarity::Keystone => lang.tree_rarity_keystone,
1328 };
1329 let rarity_color = match spec.rarity {
1330 Rarity::Small => Color::Rgb(200, 200, 220),
1331 Rarity::Notable => Color::Rgb(255, 220, 120),
1332 Rarity::Keystone => Color::Rgb(255, 80, 200),
1333 };
1334 lines.push(Line::from(vec![
1335 Span::styled(
1336 format!("{} ", spec.title),
1337 Style::default()
1338 .fg(Color::Rgb(255, 255, 255))
1339 .add_modifier(StyleMod::BOLD),
1340 ),
1341 Span::styled(
1342 format!("[{}]", rarity_label),
1343 Style::default()
1344 .fg(rarity_color)
1345 .add_modifier(StyleMod::BOLD),
1346 ),
1347 ]));
1348 for p in &spec.primitives {
1349 lines.push(Line::from(vec![
1350 Span::raw(" • "),
1351 Span::styled(primitive_blurb(*p), Style::default().fg(prim_color(*p))),
1352 ]));
1353 }
1354 lines.push(Line::raw(""));
1355 let action_row = inner_y + lines.len() as u16;
1360 let owned_tag_padded = format!(" {} ", lang.tree_owned_tag);
1361 let cost_label_padded = format!(" {}", lang.tree_cost_label);
1362 let action_hint = if owned {
1363 let can_refund = state.can_refund_tree_node(cursor);
1364 if !can_refund {
1365 let reason = if cursor == TreeCoord::ORIGIN {
1366 lang.tree_refund_reason_origin
1367 } else {
1368 lang.tree_refund_reason_orphan
1369 };
1370 Line::from(vec![
1371 Span::styled(
1372 owned_tag_padded.clone(),
1373 Style::default().fg(Color::Rgb(180, 220, 180)),
1374 ),
1375 Span::styled(
1376 lang.tree_no_refund_fmt.replacen("{}", reason, 1),
1377 Style::default().fg(Color::Rgb(170, 130, 130)),
1378 ),
1379 ])
1380 } else {
1381 let refund = (spec.cost * TREE_REFUND_FRACTION).floor();
1382 let loss = spec.cost - refund;
1383 let label_len = refund_button_label.chars().count() as u16;
1384 action_button = Some((
1385 TreeButtonAction::Refund,
1386 Rect {
1387 x: inner_x + owned_tag_padded.chars().count() as u16,
1388 y: action_row,
1389 width: label_len,
1390 height: 1,
1391 },
1392 cursor,
1393 ));
1394 Line::from(vec![
1395 Span::styled(
1396 owned_tag_padded.clone(),
1397 Style::default().fg(Color::Rgb(180, 220, 180)),
1398 ),
1399 Span::styled(
1400 refund_button_label,
1401 Style::default()
1402 .fg(Color::Rgb(220, 220, 120))
1403 .add_modifier(StyleMod::BOLD)
1404 .add_modifier(StyleMod::UNDERLINED),
1405 ),
1406 Span::styled(
1407 format!(
1408 " {}",
1409 lang.tree_refund_returns_fmt
1410 .replacen("{}", &format::big(refund), 1)
1411 .replacen("{}", &format::big(loss), 1)
1412 ),
1413 Style::default().fg(Color::Rgb(180, 150, 150)),
1414 ),
1415 ])
1416 }
1417 } else if !reachable {
1418 Line::styled(
1419 format!(" {}", lang.tree_unreachable_hint),
1420 Style::default().fg(Color::Rgb(180, 100, 100)),
1421 )
1422 } else if !affordable {
1423 let need_more = lang.tree_cost_need_more_fmt.replacen(
1424 "{}",
1425 &format::big(spec.cost - state.affordable_cuques()),
1426 1,
1427 );
1428 Line::styled(
1429 format!(
1430 "{}{} {}",
1431 cost_label_padded,
1432 format::big(spec.cost),
1433 need_more
1434 ),
1435 Style::default().fg(Color::Rgb(220, 100, 100)),
1436 )
1437 } else {
1438 let cost_text = format::big(spec.cost);
1439 let prefix_cols = cost_label_padded.chars().count()
1441 + cost_text.chars().count()
1442 + " ".chars().count();
1443 let label_len = buy_button_label.chars().count() as u16;
1444 action_button = Some((
1445 TreeButtonAction::Buy,
1446 Rect {
1447 x: inner_x + prefix_cols as u16,
1448 y: action_row,
1449 width: label_len,
1450 height: 1,
1451 },
1452 cursor,
1453 ));
1454 Line::from(vec![
1455 Span::raw(cost_label_padded.clone()),
1456 Span::styled(
1457 cost_text,
1458 Style::default()
1459 .fg(Color::Rgb(120, 255, 120))
1460 .add_modifier(StyleMod::BOLD),
1461 ),
1462 Span::raw(" "),
1463 Span::styled(
1464 buy_button_label,
1465 Style::default()
1466 .fg(Color::Rgb(220, 220, 120))
1467 .add_modifier(StyleMod::BOLD)
1468 .add_modifier(StyleMod::UNDERLINED),
1469 ),
1470 ])
1471 };
1472 lines.push(action_hint);
1473 }
1474 }
1475
1476 let p = Paragraph::new(lines).block(
1477 Block::default()
1478 .borders(Borders::TOP)
1479 .border_style(Style::default().fg(Color::Rgb(60, 60, 80))),
1480 );
1481 frame.render_widget(p, area);
1482 action_button
1483}
1484
1485fn prim_color(p: Primitive) -> Color {
1486 if p.is_bane() {
1487 Color::Rgb(255, 130, 130)
1488 } else {
1489 match p.op {
1490 Op::AddPercent => Color::Rgb(180, 230, 255),
1491 Op::MulFactor => Color::Rgb(255, 220, 120),
1492 Op::FlatAdd => Color::Rgb(180, 255, 200),
1493 Op::CostMul => Color::Rgb(220, 180, 255),
1494 Op::SpawnRateMul | Op::EffectMul => Color::Rgb(255, 200, 240),
1495 }
1496 }
1497}
1498
1499fn truncate(s: &str, max: usize) -> String {
1500 if max == 0 {
1501 return String::new();
1502 }
1503 let count = s.chars().count();
1504 if count <= max {
1505 return s.to_string();
1506 }
1507 let take = max.saturating_sub(1).max(1);
1508 let mut out: String = s.chars().take(take).collect();
1509 out.push('…');
1510 out
1511}