use ratatui::{prelude::*, style::Modifier as StyleMod, widgets::*};
use crate::format;
use crate::game::powerup::PowerupKind;
use crate::game::state::{
GameState, PURCHASE_FLASH_TICKS, TREE_REFUND_FRACTION, UNLOCK_FLASH_TICKS,
};
use crate::game::tree::coord::TreeCoord;
use crate::game::tree::naming::primitive_blurb;
use crate::game::tree::node::{self, LOT_H, LOT_W, Rarity};
use crate::game::tree::primitive::{Op, Primitive, Target};
use crate::i18n::t;
use crate::input::TreeRenderState;
use crate::ui::{TreeButtonAction, border, hud_title};
pub struct TreeDrawOutput {
pub node_rects: Vec<(TreeCoord, Rect)>,
pub action_button: Option<(TreeButtonAction, Rect, TreeCoord)>,
}
const PAN_TWEEN_FACTOR: f32 = 0.20;
const PAN_SNAP_EPSILON: f32 = 0.5;
const VISIBLE_RADIUS_LOTS: i32 = 6;
const INFO_PANE_HEIGHT: u16 = 8;
const HEADER_HEIGHT: u16 = 3;
pub fn draw(
frame: &mut Frame,
area: Rect,
state: &GameState,
mouse_pos: Option<(u16, u16)>,
tree_render: &mut TreeRenderState,
help_bar_height: u16,
) -> TreeDrawOutput {
let modal_h = area.height.saturating_sub(help_bar_height);
if modal_h < HEADER_HEIGHT + INFO_PANE_HEIGHT + 4 {
return TreeDrawOutput {
node_rects: Vec::new(),
action_button: None,
};
}
let modal = Rect {
x: area.x,
y: area.y,
width: area.width,
height: modal_h,
};
fill_area(frame, modal);
let rows = Layout::vertical([
Constraint::Length(HEADER_HEIGHT),
Constraint::Min(1),
Constraint::Length(INFO_PANE_HEIGHT),
])
.split(modal);
let header_area = rows[0];
let canvas_area = rows[1];
let info_area = rows[2];
draw_header(frame, header_area, state);
let node_rects = draw_canvas(frame, canvas_area, state, mouse_pos, tree_render);
let action_button = draw_info_pane(frame, info_area, state);
if let (Some((_, r, _)), Some((mx, my))) = (action_button, mouse_pos)
&& mx >= r.x
&& mx < r.x + r.width
&& my >= r.y
&& my < r.y + r.height
{
let buf = frame.buffer_mut();
for y in r.y..r.y + r.height {
for x in r.x..r.x + r.width {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(
cell.style()
.fg(Color::Rgb(255, 255, 255))
.bg(Color::Rgb(40, 40, 50))
.add_modifier(StyleMod::BOLD),
);
}
}
}
}
TreeDrawOutput {
node_rects,
action_button,
}
}
fn fill_area(frame: &mut Frame, area: Rect) {
let buf = frame.buffer_mut();
let clear = Style::default().fg(Color::Reset).bg(Color::Reset);
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_char(' ');
cell.set_style(clear);
}
}
}
}
fn draw_header(frame: &mut Frame, area: Rect, state: &GameState) {
let lang = t();
let title = format!("{}—{}", hud_title(), lang.tree_title);
border::draw_animated(frame, area, state, &title);
if area.width < 3 || area.height < 3 {
return;
}
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
let cursor = state.tree.cursor;
let line = Line::from(vec![
Span::raw(format!("{}: ", lang.hud_cuques)),
Span::styled(
format::big_mag(state.displayed_cuques),
Style::default()
.fg(Color::Rgb(180, 255, 180))
.add_modifier(StyleMod::BOLD),
),
Span::raw(format!(" {}: ", lang.hud_fps)),
Span::styled(
format::rate(state.displayed_fps),
Style::default().fg(Color::Rgb(200, 200, 240)),
),
Span::styled(
format!(" cursor ({:+}, {:+})", cursor.x, cursor.y),
Style::default().fg(Color::Rgb(180, 200, 255)),
),
Span::styled(
format!(
" owned: {}",
state
.tree
.bought
.iter()
.filter(|c| **c != TreeCoord::ORIGIN)
.count()
),
Style::default().fg(Color::Rgb(220, 220, 180)),
),
]);
frame.render_widget(Paragraph::new(line), inner);
}
#[derive(Clone)]
struct VisibleNode {
spec_box_x: i32,
spec_box_y: i32,
box_w: u16,
box_h: u16,
rarity: Rarity,
lot: TreeCoord,
dominant_target: Target,
cost: crate::bignum::Mag,
owned: bool,
reachable: bool,
affordable: bool,
is_anchor: bool,
title_short: String,
buy_flash: f32,
unlock_flash: f32,
refund_flash: f32,
}
fn draw_canvas(
frame: &mut Frame,
area: Rect,
state: &GameState,
mouse_pos: Option<(u16, u16)>,
tree_render: &mut TreeRenderState,
) -> Vec<(TreeCoord, Rect)> {
if area.width < 10 || area.height < 5 {
return Vec::new();
}
let cursor = state.tree.cursor;
let canvas_center_x = (area.width / 2) as i32;
let canvas_center_y = (area.height / 2) as i32;
let cursor_centered_x = (cursor.x * LOT_W + LOT_W / 2 - canvas_center_x) as f32;
let cursor_centered_y = (cursor.y * LOT_H + LOT_H / 2 - canvas_center_y) as f32;
if !tree_render.initialized {
tree_render.pan_x = cursor_centered_x;
tree_render.pan_y = cursor_centered_y;
tree_render.target_pan_x = cursor_centered_x;
tree_render.target_pan_y = cursor_centered_y;
tree_render.prev_cursor = cursor;
tree_render.initialized = true;
} else {
if cursor != tree_render.prev_cursor {
tree_render.target_pan_x = cursor_centered_x;
tree_render.target_pan_y = cursor_centered_y;
tree_render.prev_cursor = cursor;
}
if tree_render.drag_last.is_none() {
let dx = tree_render.target_pan_x - tree_render.pan_x;
let dy = tree_render.target_pan_y - tree_render.pan_y;
if dx.abs() < PAN_SNAP_EPSILON && dy.abs() < PAN_SNAP_EPSILON {
tree_render.pan_x = tree_render.target_pan_x;
tree_render.pan_y = tree_render.target_pan_y;
} else {
tree_render.pan_x += dx * PAN_TWEEN_FACTOR;
tree_render.pan_y += dy * PAN_TWEEN_FACTOR;
}
}
}
let pan_x = tree_render.pan_x.round() as i32;
let pan_y = tree_render.pan_y.round() as i32;
let view_center_canvas_x = pan_x + canvas_center_x;
let view_center_canvas_y = pan_y + canvas_center_y;
let view_lot_x = view_center_canvas_x.div_euclid(LOT_W);
let view_lot_y = view_center_canvas_y.div_euclid(LOT_H);
let visible_radius = VISIBLE_RADIUS_LOTS + 2;
let mut visible: Vec<VisibleNode> = Vec::new();
for dy in -visible_radius..=visible_radius {
for dx in -visible_radius..=visible_radius {
let lot = TreeCoord::new(view_lot_x + dx, view_lot_y + dy);
let Some(spec) = node::node_at(lot.x, lot.y) else {
continue;
};
let owned = state.tree.bought.contains(&lot);
let reachable = !owned && state.tree_reachable(lot) && !state.tree_unlock_pending(lot);
let affordable = state.affordable_cuques() >= spec.cost;
let title_short = truncate(&spec.title, (spec.box_w as usize).saturating_sub(2));
let buy_flash = state
.tree_buy_flash
.get(&lot)
.copied()
.map(|t| t as f32 / PURCHASE_FLASH_TICKS as f32)
.unwrap_or(0.0);
let unlock_flash = state
.tree_unlock_flash
.get(&lot)
.copied()
.map(|t| t as f32 / UNLOCK_FLASH_TICKS as f32)
.unwrap_or(0.0);
let refund_flash = state
.tree_refund_flash
.get(&lot)
.copied()
.map(|t| t as f32 / PURCHASE_FLASH_TICKS as f32)
.unwrap_or(0.0);
visible.push(VisibleNode {
spec_box_x: spec.box_x,
spec_box_y: spec.box_y,
box_w: spec.box_w,
box_h: spec.box_h,
rarity: spec.rarity,
lot,
dominant_target: spec.dominant_target,
cost: spec.cost,
owned,
reachable,
affordable,
is_anchor: spec.is_anchor,
title_short,
buy_flash,
unlock_flash,
refund_flash,
});
}
}
let edges: Vec<(TreeCoord, TreeCoord)> = {
let mut out = Vec::new();
for i in 0..visible.len() {
for j in (i + 1)..visible.len() {
let a = visible[i].lot;
let b = visible[j].lot;
if node::edge_exists(a, b) {
out.push((a, b));
}
}
}
out
};
for (a, b) in &edges {
let av = visible.iter().find(|v| v.lot == *a).cloned();
let bv = visible.iter().find(|v| v.lot == *b).cloned();
if let (Some(av), Some(bv)) = (av, bv) {
draw_edge(frame, area, pan_x, pan_y, &av, &bv, state);
}
}
let mut click_rects: Vec<(TreeCoord, Rect)> = Vec::new();
for v in &visible {
if let Some(r) = draw_box(frame, area, pan_x, pan_y, v, cursor, state) {
click_rects.push((v.lot, r));
}
}
if let Some((mx, my)) = mouse_pos {
for &(_, r) in &click_rects {
if mx >= r.x && mx < r.x + r.width && my >= r.y && my < r.y + r.height {
hover_lift(frame, r);
break;
}
}
}
click_rects
}
fn canvas_to_screen(area: Rect, pan_x: i32, pan_y: i32, cx: i32, cy: i32) -> Option<(u16, u16)> {
let sx = cx - pan_x + area.x as i32;
let sy = cy - pan_y + area.y as i32;
if sx < area.x as i32
|| sx >= (area.x as i32 + area.width as i32)
|| sy < area.y as i32
|| sy >= (area.y as i32 + area.height as i32)
{
return None;
}
Some((sx as u16, sy as u16))
}
#[allow(clippy::too_many_arguments)]
fn draw_box(
frame: &mut Frame,
area: Rect,
pan_x: i32,
pan_y: i32,
v: &VisibleNode,
cursor: TreeCoord,
state: &GameState,
) -> Option<Rect> {
if v.is_anchor {
return draw_anchor(frame, area, pan_x, pan_y, v, cursor, state);
}
let buf = frame.buffer_mut();
let bx = v.spec_box_x;
let by = v.spec_box_y;
let bw = v.box_w as i32;
let bh = v.box_h as i32;
let (corners, h_char, v_char, base_style) = box_chars_for(v);
let is_focus = v.lot == cursor;
let box_style = if is_focus {
base_style
.add_modifier(StyleMod::BOLD)
.add_modifier(StyleMod::REVERSED)
} else {
base_style
};
let mut painted_any = false;
let mut min_sx = u16::MAX;
let mut min_sy = u16::MAX;
let mut max_sx = 0u16;
let mut max_sy = 0u16;
for row in 0..bh {
for col in 0..bw {
let cx = bx + col;
let cy = by + row;
let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy) else {
continue;
};
let ch = if row == 0 && col == 0 {
corners.0
} else if row == 0 && col == bw - 1 {
corners.1
} else if row == bh - 1 && col == 0 {
corners.2
} else if row == bh - 1 && col == bw - 1 {
corners.3
} else if row == 0 || row == bh - 1 {
h_char
} else if col == 0 || col == bw - 1 {
v_char
} else {
let interior_w = (bw - 2) as usize;
let r_in = row - 1;
if r_in == 0 && interior_w > 0 {
v.title_short.chars().nth((col - 1) as usize).unwrap_or(' ')
} else if r_in == (bh - 2) - 1 && interior_w > 4 {
let cost_str = if v.owned {
"[ owned ]".to_string()
} else {
format::big_mag(v.cost)
};
cost_str.chars().nth((col - 1) as usize).unwrap_or(' ')
} else if r_in > 0
&& r_in < (bh - 2)
&& v.rarity != Rarity::Small
&& r_in == 1
&& interior_w > 4
{
let summary = primitive_summary(v);
summary.chars().nth((col - 1) as usize).unwrap_or(' ')
} else {
' '
}
};
if let Some(cell) = buf.cell_mut((sx, sy)) {
cell.set_char(ch);
cell.set_style(box_style);
}
painted_any = true;
min_sx = min_sx.min(sx);
min_sy = min_sy.min(sy);
max_sx = max_sx.max(sx);
max_sy = max_sy.max(sy);
}
}
if !painted_any {
return None;
}
Some(Rect {
x: min_sx,
y: min_sy,
width: max_sx.saturating_sub(min_sx).saturating_add(1),
height: max_sy.saturating_sub(min_sy).saturating_add(1),
})
}
fn draw_anchor(
frame: &mut Frame,
area: Rect,
pan_x: i32,
pan_y: i32,
v: &VisibleNode,
cursor: TreeCoord,
state: &GameState,
) -> Option<Rect> {
let rows = crate::ui::biscuit::BISCUIT_TINY;
let focal = crate::ui::biscuit::BISCUIT_TINY_FOCAL;
let is_focus = v.lot == cursor;
let phase = state.steady_phase;
let base_style = Style::default()
.fg(Color::Rgb(255, 230, 180))
.add_modifier(StyleMod::BOLD);
let buf = frame.buffer_mut();
let mut min_sx = u16::MAX;
let mut min_sy = u16::MAX;
let mut max_sx = 0u16;
let mut max_sy = 0u16;
let mut painted_any = false;
for (row_idx, line) in rows.iter().enumerate() {
for (col_idx, ch) in line.chars().enumerate() {
let is_focal = col_idx as u16 == focal.0 && row_idx as u16 == focal.1;
let glyph = if is_focal { 'O' } else { ch };
if glyph == ' ' {
continue;
}
let cx = v.spec_box_x + col_idx as i32;
let cy = v.spec_box_y + row_idx as i32;
let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy) else {
continue;
};
let style = if is_focus {
Style::default()
.fg(plasma_color(phase, col_idx as i32, row_idx as i32))
.add_modifier(StyleMod::BOLD)
} else {
base_style
};
if let Some(cell) = buf.cell_mut((sx, sy)) {
cell.set_char(glyph);
cell.set_style(style);
}
painted_any = true;
min_sx = min_sx.min(sx);
min_sy = min_sy.min(sy);
max_sx = max_sx.max(sx);
max_sy = max_sy.max(sy);
}
}
if !painted_any {
return None;
}
Some(Rect {
x: min_sx,
y: min_sy,
width: max_sx.saturating_sub(min_sx).saturating_add(1),
height: max_sy.saturating_sub(min_sy).saturating_add(1),
})
}
fn plasma_color(phase: u32, col: i32, row: i32) -> Color {
let t = phase as f32 * 0.06;
let cx = col as f32 * 0.45;
let cy = row as f32 * 0.85;
let v = (cx + t).sin() + (cy + t * 1.27).sin() + ((cx + cy) * 0.6 + t * 0.83).sin();
let n = ((v / 3.0) + 1.0) * 0.5;
let keys: [(f32, f32, f32); 3] = [
(255.0, 215.0, 110.0), (255.0, 110.0, 90.0), (210.0, 130.0, 255.0), ];
let pos = n * 3.0;
let idx = (pos.floor() as usize) % 3;
let frac = pos - pos.floor();
let a = keys[idx];
let b = keys[(idx + 1) % 3];
let r = a.0 + (b.0 - a.0) * frac;
let g = a.1 + (b.1 - a.1) * frac;
let bb = a.2 + (b.2 - a.2) * frac;
Color::Rgb(
r.clamp(60.0, 255.0) as u8,
g.clamp(60.0, 255.0) as u8,
bb.clamp(60.0, 255.0) as u8,
)
}
fn primitive_summary(v: &VisibleNode) -> String {
let prefix = match v.rarity {
Rarity::Small => "small",
Rarity::Notable => "notable",
Rarity::Keystone => "KEYSTONE",
};
let target = match v.dominant_target {
Target::Fingerer(i) => crate::i18n::t()
.fingerer_names
.get(i as usize)
.copied()
.unwrap_or("?")
.to_string(),
Target::AllFingerers => "all fingerers".to_string(),
Target::Click => "click".to_string(),
Target::PowerupSpawn(k) => format!("{} spawn", powerup_short(k)),
Target::PowerupReward(k) => format!("{} reward", powerup_short(k)),
Target::PowerupDuration(k) => format!("{} time", powerup_short(k)),
Target::Prestige => "prestige".to_string(),
Target::GreenCoinStrength => "GC pow".to_string(),
};
format!("{}: {}", prefix, target)
}
fn powerup_short(k: PowerupKind) -> &'static str {
match k {
PowerupKind::Lucky => "Lucky",
PowerupKind::Frenzy => "Frenzy",
PowerupKind::Buff => "Buff",
PowerupKind::GreenCoin => "GCoin",
}
}
fn box_chars_for(v: &VisibleNode) -> ((char, char, char, char), char, char, Style) {
let owned_corners: (char, char, char, char) = ('╔', '╗', '╚', '╝');
let dotted_corners: (char, char, char, char) = ('┌', '┐', '└', '┘');
let biome = biome_color(v.dominant_target, v.rarity);
let unreachable_fg = Color::Rgb(60, 60, 70);
let buyable_fg = blend_to_white(biome.bright, 0.15);
let mut result = if v.owned {
(
owned_corners,
'═',
'║',
Style::default()
.fg(biome.bright)
.add_modifier(StyleMod::BOLD),
)
} else if v.reachable && v.affordable {
(
dotted_corners,
'╌',
'╎',
Style::default().fg(buyable_fg).add_modifier(StyleMod::BOLD),
)
} else if v.reachable {
(dotted_corners, '╌', '╎', Style::default().fg(biome.dim))
} else {
(
dotted_corners,
'╌',
'╎',
Style::default().fg(unreachable_fg),
)
};
if v.buy_flash > 0.001 {
let tint = (40.0, 230.0, 80.0); result.3 = Style::default()
.fg(blend_to(biome.bright, tint, v.buy_flash))
.add_modifier(StyleMod::BOLD);
} else if v.refund_flash > 0.001 {
let tint = (255.0, 80.0, 80.0); let from = if v.owned {
biome.bright
} else {
unreachable_fg
};
result.3 = Style::default()
.fg(blend_to(from, tint, v.refund_flash))
.add_modifier(StyleMod::BOLD);
} else if v.unlock_flash > 0.001 {
let tint = (255.0, 230.0, 120.0); let from = if v.reachable { buyable_fg } else { biome.dim };
result.3 = Style::default()
.fg(blend_to(from, tint, v.unlock_flash))
.add_modifier(StyleMod::BOLD);
}
result
}
fn blend_to_white(base: Color, biome_weight: f32) -> Color {
let (br, bg, bb) = color_rgb(base);
let mix = biome_weight.clamp(0.0, 1.0);
let r = 240.0 * (1.0 - mix) + br * mix;
let g = 240.0 * (1.0 - mix) + bg * mix;
let b = 240.0 * (1.0 - mix) + bb * mix;
Color::Rgb(
r.clamp(0.0, 255.0) as u8,
g.clamp(0.0, 255.0) as u8,
b.clamp(0.0, 255.0) as u8,
)
}
fn blend_to(base: Color, tint: (f32, f32, f32), t: f32) -> Color {
let (br, bg, bb) = color_rgb(base);
let mix = t.clamp(0.0, 1.0);
Color::Rgb(
(br + (tint.0 - br) * mix).clamp(0.0, 255.0) as u8,
(bg + (tint.1 - bg) * mix).clamp(0.0, 255.0) as u8,
(bb + (tint.2 - bb) * mix).clamp(0.0, 255.0) as u8,
)
}
fn color_rgb(c: Color) -> (f32, f32, f32) {
match c {
Color::Rgb(r, g, b) => (r as f32, g as f32, b as f32),
_ => (200.0, 200.0, 200.0),
}
}
struct BiomeColors {
bright: Color,
dim: Color,
}
fn biome_color(target: Target, rarity: Rarity) -> BiomeColors {
if matches!(rarity, Rarity::Keystone) {
return BiomeColors {
bright: Color::Rgb(255, 80, 200),
dim: Color::Rgb(150, 50, 120),
};
}
match target {
Target::Fingerer(idx) => fingerer_biome(idx as usize),
Target::AllFingerers => BiomeColors {
bright: Color::Rgb(255, 215, 0),
dim: Color::Rgb(150, 130, 0),
},
Target::Click => BiomeColors {
bright: Color::Rgb(255, 130, 130),
dim: Color::Rgb(150, 80, 80),
},
Target::PowerupSpawn(_) | Target::PowerupReward(_) | Target::PowerupDuration(_) => {
BiomeColors {
bright: Color::Rgb(220, 140, 255),
dim: Color::Rgb(130, 90, 150),
}
}
Target::Prestige => BiomeColors {
bright: Color::Rgb(255, 200, 220),
dim: Color::Rgb(150, 110, 130),
},
Target::GreenCoinStrength => BiomeColors {
bright: Color::Rgb(120, 230, 140),
dim: Color::Rgb(60, 130, 80),
},
}
}
fn fingerer_biome(idx: usize) -> BiomeColors {
match idx {
0 => BiomeColors {
bright: Color::Rgb(255, 220, 120),
dim: Color::Rgb(160, 130, 70),
},
1 => BiomeColors {
bright: Color::Rgb(255, 180, 100),
dim: Color::Rgb(160, 100, 60),
},
2 => BiomeColors {
bright: Color::Rgb(180, 255, 230),
dim: Color::Rgb(90, 150, 130),
},
3 => BiomeColors {
bright: Color::Rgb(255, 150, 180),
dim: Color::Rgb(160, 80, 100),
},
4 => BiomeColors {
bright: Color::Rgb(180, 220, 255),
dim: Color::Rgb(90, 130, 160),
},
5 => BiomeColors {
bright: Color::Rgb(140, 230, 200),
dim: Color::Rgb(70, 130, 100),
},
6 => BiomeColors {
bright: Color::Rgb(200, 160, 255),
dim: Color::Rgb(110, 80, 160),
},
7 => BiomeColors {
bright: Color::Rgb(160, 200, 255),
dim: Color::Rgb(80, 120, 160),
},
8 => BiomeColors {
bright: Color::Rgb(255, 255, 200),
dim: Color::Rgb(160, 160, 110),
},
_ => BiomeColors {
bright: Color::Rgb(255, 255, 255),
dim: Color::Rgb(180, 180, 180),
},
}
}
fn draw_edge(
frame: &mut Frame,
area: Rect,
pan_x: i32,
pan_y: i32,
a: &VisibleNode,
b: &VisibleNode,
state: &GameState,
) {
let a_owned = state.tree.bought.contains(&a.lot);
let b_owned = state.tree.bought.contains(&b.lot);
let dim_style = Style::default().fg(Color::Rgb(80, 80, 100));
let lit_style = if a_owned && b_owned {
Style::default()
.fg(Color::Rgb(255, 220, 120))
.add_modifier(StyleMod::BOLD)
} else if a_owned || b_owned {
Style::default().fg(Color::Rgb(180, 180, 200))
} else {
dim_style
};
let anim = state
.tree_edge_anims
.iter()
.find(|an| (an.from == a.lot && an.to == b.lot) || (an.from == b.lot && an.to == a.lot));
let path = node::edge_path_cells(a.lot, b.lot);
if path.is_empty() {
return;
}
let canonical_lo = if (a.lot.x, a.lot.y) <= (b.lot.x, b.lot.y) {
a.lot
} else {
b.lot
};
let wave_at_start = anim.map(|an| an.from == canonical_lo).unwrap_or(true);
let source_node: &VisibleNode = if let Some(an) = anim {
if an.from == a.lot { a } else { b }
} else {
a
};
let leading_inside = if wave_at_start {
node::count_leading_in_rect(
&path,
source_node.spec_box_x,
source_node.spec_box_y,
source_node.box_w,
source_node.box_h,
)
} else {
node::count_trailing_in_rect(
&path,
source_node.spec_box_x,
source_node.spec_box_y,
source_node.box_w,
source_node.box_h,
)
};
let head_path_index = anim
.map(|an| (leading_inside + an.visible_advance()).min(path.len().saturating_sub(1)))
.unwrap_or(0);
let in_a = |x: i32, y: i32| opaque_for(a, x, y);
let in_b = |x: i32, y: i32| opaque_for(b, x, y);
let buf = frame.buffer_mut();
let path_len = path.len();
for i in 0..path_len {
let (cx, cy) = path[i];
if in_a(cx, cy) || in_b(cx, cy) {
continue;
}
let dist_from_head: i32 = if anim.is_some() {
if wave_at_start {
(head_path_index as i32) - (i as i32)
} else {
(head_path_index as i32) - ((path_len - 1 - i) as i32)
}
} else {
0
};
let prev_raw = if i > 0 { Some(path[i - 1]) } else { None };
let next_raw = path.get(i + 1).copied();
let prev_in_box = prev_raw.is_some_and(|(px, py)| in_a(px, py) || in_b(px, py));
let next_in_box = next_raw.is_some_and(|(nx, ny)| in_a(nx, ny) || in_b(nx, ny));
let prev = prev_raw.filter(|_| !prev_in_box);
let next = next_raw.filter(|_| !next_in_box);
let dir_to_box = if prev_in_box {
prev_raw.map(|p| dir_between((cx, cy), p))
} else if next_in_box {
next_raw.map(|n| dir_between((cx, cy), n))
} else {
None
};
let glyph = path_glyph(
prev.map(|p| dir_between(p, (cx, cy))),
next.map(|n| dir_between((cx, cy), n)),
dir_to_box,
);
let style = if anim.is_some() {
if dist_from_head < 0 {
dim_style
} else {
match dist_from_head {
0 => Style::default()
.fg(Color::Rgb(255, 255, 255))
.add_modifier(StyleMod::BOLD),
1 => Style::default()
.fg(Color::Rgb(120, 220, 255))
.add_modifier(StyleMod::BOLD),
2 => Style::default()
.fg(Color::Rgb(80, 170, 230))
.add_modifier(StyleMod::BOLD),
_ => lit_style,
}
}
} else {
lit_style
};
if let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy)
&& let Some(cell) = buf.cell_mut((sx, sy))
{
cell.set_char(glyph);
cell.set_style(style);
}
}
let _ = node::diagonal_route_via;
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
enum Dir {
Up,
Down,
Left,
Right,
}
fn opaque_for(v: &VisibleNode, x: i32, y: i32) -> bool {
let local_col = x - v.spec_box_x;
let local_row = y - v.spec_box_y;
if local_col < 0 || local_row < 0 || local_col >= v.box_w as i32 || local_row >= v.box_h as i32
{
return false;
}
if !v.is_anchor {
return true;
}
let rows = crate::ui::biscuit::BISCUIT_TINY;
let Some(line) = rows.get(local_row as usize) else {
return false;
};
let chars: Vec<char> = line.chars().collect();
let first = chars.iter().position(|c| *c != ' ');
let last = chars.iter().rposition(|c| *c != ' ');
match (first, last) {
(Some(f), Some(l)) => local_col >= f as i32 && local_col <= l as i32,
_ => false,
}
}
fn dir_between(from: (i32, i32), to: (i32, i32)) -> Dir {
if to.0 > from.0 {
Dir::Right
} else if to.0 < from.0 {
Dir::Left
} else if to.1 > from.1 {
Dir::Down
} else {
Dir::Up
}
}
fn path_glyph(dir_in: Option<Dir>, dir_out: Option<Dir>, dir_to_box: Option<Dir>) -> char {
use Dir::*;
fn corner(a: Dir, b: Dir) -> char {
let mut ds = [a, b];
ds.sort_by_key(|d| match d {
Up => 0,
Down => 1,
Left => 2,
Right => 3,
});
match (ds[0], ds[1]) {
(Up, Left) => '╯',
(Up, Right) => '╰',
(Down, Left) => '╮',
(Down, Right) => '╭',
(Up, Down) => '│',
(Left, Right) => '─',
_ => '·',
}
}
if let Some(box_dir) = dir_to_box {
let line_dir = dir_in.or(dir_out);
match line_dir {
Some(d) if d == box_dir || d == opposite(box_dir) => {
return match box_dir {
Up | Down => '│',
Left | Right => '─',
};
}
Some(d) => {
let line_edge = if dir_in.is_some() { opposite(d) } else { d };
return corner(line_edge, box_dir);
}
None => {
return match box_dir {
Up | Down => '│',
Left | Right => '─',
};
}
}
}
match (dir_in, dir_out) {
(Some(Right), Some(Right)) | (Some(Left), Some(Left)) => '─',
(Some(Down), Some(Down)) | (Some(Up), Some(Up)) => '│',
(Some(Right), Some(Down)) | (Some(Up), Some(Left)) => '╮',
(Some(Right), Some(Up)) | (Some(Down), Some(Left)) => '╯',
(Some(Left), Some(Down)) | (Some(Up), Some(Right)) => '╭',
(Some(Left), Some(Up)) | (Some(Down), Some(Right)) => '╰',
_ => '·',
}
}
fn opposite(d: Dir) -> Dir {
match d {
Dir::Up => Dir::Down,
Dir::Down => Dir::Up,
Dir::Left => Dir::Right,
Dir::Right => Dir::Left,
}
}
fn hover_lift(frame: &mut Frame, r: Rect) {
let buf = frame.buffer_mut();
for y in r.y..r.y + r.height {
for x in r.x..r.x + r.width {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(cell.style().add_modifier(StyleMod::BOLD));
}
}
}
}
fn draw_info_pane(
frame: &mut Frame,
area: Rect,
state: &GameState,
) -> Option<(TreeButtonAction, Rect, TreeCoord)> {
let lang = t();
let buy_button_label = lang.tree_buy_button;
let refund_button_label = lang.tree_refund_button;
let cursor = state.tree.cursor;
let mut lines: Vec<Line> = Vec::new();
let mut action_button: Option<(TreeButtonAction, Rect, TreeCoord)> = None;
let inner_y = area.y + 1;
let inner_x = area.x;
match node::node_at(cursor.x, cursor.y) {
None => {
lines.push(Line::styled(
lang.tree_empty_lot_fmt
.replacen("{:+}", &format!("{:+}", cursor.x), 1)
.replacen("{:+}", &format!("{:+}", cursor.y), 1),
Style::default().fg(Color::Rgb(120, 120, 130)),
));
lines.push(Line::raw(""));
lines.push(Line::styled(
lang.tree_empty_lot_hint,
Style::default().fg(Color::Rgb(160, 160, 170)),
));
}
Some(spec) => {
if spec.is_anchor {
lines.push(Line::from(vec![
Span::styled(
format!("{} ", spec.title),
Style::default()
.fg(Color::Rgb(255, 220, 130))
.add_modifier(StyleMod::BOLD),
),
Span::styled(
lang.tree_anchor_tag,
Style::default()
.fg(Color::Rgb(255, 200, 100))
.add_modifier(StyleMod::BOLD),
),
]));
lines.push(Line::styled(
lang.tree_anchor_blurb,
Style::default().fg(Color::Rgb(200, 200, 210)),
));
lines.push(Line::raw(""));
lines.push(Line::styled(
lang.tree_anchor_footer,
Style::default().fg(Color::Rgb(160, 160, 170)),
));
let p = Paragraph::new(lines).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::Rgb(60, 60, 80))),
);
frame.render_widget(p, area);
return action_button;
}
let owned = state.tree.bought.contains(&cursor);
let reachable = state.tree_reachable(cursor);
let affordable = state.affordable_cuques() >= spec.cost;
let rarity_label = match spec.rarity {
Rarity::Small => lang.tree_rarity_small,
Rarity::Notable => lang.tree_rarity_notable,
Rarity::Keystone => lang.tree_rarity_keystone,
};
let rarity_color = match spec.rarity {
Rarity::Small => Color::Rgb(200, 200, 220),
Rarity::Notable => Color::Rgb(255, 220, 120),
Rarity::Keystone => Color::Rgb(255, 80, 200),
};
lines.push(Line::from(vec![
Span::styled(
format!("{} ", spec.title),
Style::default()
.fg(Color::Rgb(255, 255, 255))
.add_modifier(StyleMod::BOLD),
),
Span::styled(
format!("[{}]", rarity_label),
Style::default()
.fg(rarity_color)
.add_modifier(StyleMod::BOLD),
),
]));
for p in &spec.primitives {
lines.push(Line::from(vec![
Span::raw(" • "),
Span::styled(primitive_blurb(*p), Style::default().fg(prim_color(*p))),
]));
}
lines.push(Line::raw(""));
let action_row = inner_y + lines.len() as u16;
let owned_tag_padded = format!(" {} ", lang.tree_owned_tag);
let cost_label_padded = format!(" {}", lang.tree_cost_label);
let action_hint = if owned {
let can_refund = state.can_refund_tree_node(cursor);
if !can_refund {
let reason = if cursor == TreeCoord::ORIGIN {
lang.tree_refund_reason_origin
} else {
lang.tree_refund_reason_orphan
};
Line::from(vec![
Span::styled(
owned_tag_padded.clone(),
Style::default().fg(Color::Rgb(180, 220, 180)),
),
Span::styled(
lang.tree_no_refund_fmt.replacen("{}", reason, 1),
Style::default().fg(Color::Rgb(170, 130, 130)),
),
])
} else {
let refund = spec
.cost
.mul(crate::bignum::Mag::from_f64(TREE_REFUND_FRACTION));
let loss = spec.cost.saturating_sub(refund);
let label_len = refund_button_label.chars().count() as u16;
action_button = Some((
TreeButtonAction::Refund,
Rect {
x: inner_x + owned_tag_padded.chars().count() as u16,
y: action_row,
width: label_len,
height: 1,
},
cursor,
));
Line::from(vec![
Span::styled(
owned_tag_padded.clone(),
Style::default().fg(Color::Rgb(180, 220, 180)),
),
Span::styled(
refund_button_label,
Style::default()
.fg(Color::Rgb(220, 220, 120))
.add_modifier(StyleMod::BOLD)
.add_modifier(StyleMod::UNDERLINED),
),
Span::styled(
format!(
" {}",
lang.tree_refund_returns_fmt
.replacen("{}", &format::big_mag(refund), 1)
.replacen("{}", &format::big_mag(loss), 1)
),
Style::default().fg(Color::Rgb(180, 150, 150)),
),
])
}
} else if !reachable {
Line::styled(
format!(" {}", lang.tree_unreachable_hint),
Style::default().fg(Color::Rgb(180, 100, 100)),
)
} else if !affordable {
let need_more = lang.tree_cost_need_more_fmt.replacen(
"{}",
&format::big_mag(spec.cost.saturating_sub(state.affordable_cuques())),
1,
);
Line::styled(
format!(
"{}{} {}",
cost_label_padded,
format::big_mag(spec.cost),
need_more
),
Style::default().fg(Color::Rgb(220, 100, 100)),
)
} else {
let cost_text = format::big_mag(spec.cost);
let prefix_cols = cost_label_padded.chars().count()
+ cost_text.chars().count()
+ " ".chars().count();
let label_len = buy_button_label.chars().count() as u16;
action_button = Some((
TreeButtonAction::Buy,
Rect {
x: inner_x + prefix_cols as u16,
y: action_row,
width: label_len,
height: 1,
},
cursor,
));
Line::from(vec![
Span::raw(cost_label_padded.clone()),
Span::styled(
cost_text,
Style::default()
.fg(Color::Rgb(120, 255, 120))
.add_modifier(StyleMod::BOLD),
),
Span::raw(" "),
Span::styled(
buy_button_label,
Style::default()
.fg(Color::Rgb(220, 220, 120))
.add_modifier(StyleMod::BOLD)
.add_modifier(StyleMod::UNDERLINED),
),
])
};
lines.push(action_hint);
}
}
let p = Paragraph::new(lines).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::Rgb(60, 60, 80))),
);
frame.render_widget(p, area);
action_button
}
fn prim_color(p: Primitive) -> Color {
if p.is_bane() {
Color::Rgb(255, 130, 130)
} else {
match p.op {
Op::AddPercent => Color::Rgb(180, 230, 255),
Op::MulFactor => Color::Rgb(255, 220, 120),
Op::FlatAdd => Color::Rgb(180, 255, 200),
Op::CostMul => Color::Rgb(220, 180, 255),
Op::SpawnRateMul | Op::EffectMul => Color::Rgb(255, 200, 240),
}
}
}
fn truncate(s: &str, max: usize) -> String {
if max == 0 {
return String::new();
}
let count = s.chars().count();
if count <= max {
return s.to_string();
}
let take = max.saturating_sub(1).max(1);
let mut out: String = s.chars().take(take).collect();
out.push('…');
out
}