use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};
use crate::app::{App, Die, Pane, Particle, Stats};
use crate::paint::Rgb;
#[derive(Clone, Copy)]
pub(crate) struct ArenaStyle {
pub(crate) floor: Rgb, pub(crate) wall: Rgb, pub(crate) lip_top: f32, }
impl ArenaStyle {
pub(crate) const DEFAULT: ArenaStyle = ArenaStyle {
floor: Rgb(22, 64, 42), wall: Rgb(66, 40, 28), lip_top: 0.85, };
}
fn hash32(mut h: u32) -> u32 {
h ^= h >> 16;
h = h.wrapping_mul(0x7feb_352d);
h ^= h >> 15;
h = h.wrapping_mul(0x846c_a68b);
h ^ (h >> 16)
}
fn noise2(x: u32, y: u32) -> f32 {
let h = hash32(
x.wrapping_mul(1973)
.wrapping_add(y.wrapping_mul(9277))
.wrapping_add(26699),
);
(h as f32 / u32::MAX as f32) * 2.0 - 1.0
}
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
type TexCache =
std::sync::OnceLock<std::sync::Mutex<Vec<([u8; 3], std::sync::Arc<crate::paint::Texture>)>>>;
fn cached_texture(
cache: &TexCache,
base: Rgb,
bake: impl FnOnce() -> crate::paint::Texture,
) -> std::sync::Arc<crate::paint::Texture> {
let key = [base.0, base.1, base.2];
let mut slots = cache
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
.lock()
.unwrap();
if let Some((_, tex)) = slots.iter().find(|(c, _)| *c == key) {
return tex.clone();
}
let tex = std::sync::Arc::new(bake());
slots.push((key, tex.clone()));
if slots.len() > 6 {
slots.remove(0); }
tex
}
pub(crate) fn grain_texture(base: Rgb) -> std::sync::Arc<crate::paint::Texture> {
use crate::paint::Texture;
static CACHE: TexCache = TexCache::new();
cached_texture(&CACHE, base, || {
const W: u32 = 192;
const H: u32 = 80;
let bc = [base.0 as f32, base.1 as f32, base.2 as f32];
let mut data = vec![0u8; (W * H * 4) as usize];
for y in 0..H {
for x in 0..W {
let n = 0.7 * noise2(x / 5, y / 5) + 0.3 * noise2(x, y);
let f = 1.0 + 0.10 * n; let i = ((y * W + x) * 4) as usize;
for c in 0..3 {
data[i + c] = (bc[c] * f).clamp(0.0, 255.0) as u8;
}
data[i + 3] = 255;
}
}
Texture::from_rgba(W, H, data)
})
}
pub(crate) fn felt_texture(base: Rgb) -> std::sync::Arc<crate::paint::Texture> {
use crate::paint::Texture;
static CACHE: TexCache = TexCache::new();
cached_texture(&CACHE, base, || {
const W: u32 = 200;
const H: u32 = 128; let bc = [base.0 as f32, base.1 as f32, base.2 as f32];
let mut data = vec![0u8; (W * H * 4) as usize];
for y in 0..H {
let v = y as f32 / (H - 1) as f32;
for x in 0..W {
let u = x as f32 / (W - 1) as f32;
let mottle = 0.06 * noise2(x / 9, y / 9) + 0.02 * noise2(x / 2, y / 2);
let au = smoothstep(0.0, 0.2, u.min(1.0 - u));
let av = smoothstep(0.0, 0.44 / (2.0 * crate::physics::HZ), v);
let ao = 0.5 + 0.5 * au.min(av);
let dc = ((u - 0.5) * (u - 0.5) + (v - 0.5) * (v - 0.5)).sqrt();
let sheen = 1.0 + 0.05 * (1.0 - smoothstep(0.0, 0.55, dc));
let f = (1.0 + mottle) * ao * sheen;
let i = ((y * W + x) * 4) as usize;
for c in 0..3 {
data[i + c] = (bc[c] * f).clamp(0.0, 255.0) as u8;
}
data[i + 3] = 255;
}
}
Texture::from_rgba(W, H, data)
})
}
pub(crate) fn velvet_texture(base: Rgb) -> std::sync::Arc<crate::paint::Texture> {
use crate::paint::Texture;
static CACHE: TexCache = TexCache::new();
cached_texture(&CACHE, base, || {
const W: u32 = 128;
const H: u32 = 128;
let bc = [base.0 as f32, base.1 as f32, base.2 as f32];
let mut data = vec![0u8; (W * H * 4) as usize];
for y in 0..H {
let v = y as f32 / (H - 1) as f32;
let header = 1.0 - 0.16 * (1.0 - smoothstep(0.0, 0.2, v));
for x in 0..W {
let streak = 0.09 * noise2(x / 3, y / 24) + 0.04 * noise2(x, y / 7);
let f = (1.0 + streak) * header;
let i = ((y * W + x) * 4) as usize;
for c in 0..3 {
data[i + c] = (bc[c] * f).clamp(0.0, 255.0) as u8;
}
data[i + 3] = 255;
}
}
Texture::from_rgba(W, H, data)
})
}
pub(crate) fn floor_texture(base: Rgb) -> std::sync::Arc<crate::paint::Texture> {
use crate::paint::Texture;
static CACHE: TexCache = TexCache::new();
cached_texture(&CACHE, base, || {
const W: u32 = 192;
const H: u32 = 96;
const PLANKS: f32 = 4.0; let bc = [base.0 as f32, base.1 as f32, base.2 as f32];
let mut data = vec![0u8; (W * H * 4) as usize];
for x in 0..W {
let u = x as f32 / W as f32;
let fp = u * PLANKS;
let idx = fp.floor();
let frac = fp - idx; let groove = smoothstep(0.0, 0.09, frac) * smoothstep(0.0, 0.09, 1.0 - frac);
let shade = 0.82
+ 0.30
* (hash32((idx as u32).wrapping_mul(2_654_435_761)) as f32 / u32::MAX as f32);
for y in 0..H {
let grain = 0.05 * noise2(x / 2, y / 10) + 0.03 * noise2(x, y);
let f = shade * (0.38 + 0.62 * groove) * (1.0 + grain);
let i = ((y * W + x) * 4) as usize;
for c in 0..3 {
data[i + c] = (bc[c] * f).clamp(0.0, 255.0) as u8;
}
data[i + 3] = 255;
}
}
Texture::from_rgba(W, H, data)
})
}
pub(crate) fn backdrop_texture() -> std::sync::Arc<crate::paint::Texture> {
use crate::paint::Texture;
use std::sync::{Arc, OnceLock};
static CACHE: OnceLock<Arc<Texture>> = OnceLock::new();
CACHE
.get_or_init(|| {
const W: u32 = 192;
const H: u32 = 128;
fn h(n: u32) -> f32 {
let mut x = n.wrapping_mul(747_796_405).wrapping_add(2_891_336_453);
x ^= x >> 16;
x = x.wrapping_mul(0x7feb_352d);
x ^= x >> 15;
(x % 100_000) as f32 / 100_000.0
}
let horizon = [134.0, 100.0, 69.0]; let ceiling = 0.12; let cols: [[f32; 3]; 8] = [
[255.0, 182.0, 96.0],
[255.0, 122.0, 80.0],
[255.0, 220.0, 140.0],
[150.0, 190.0, 255.0],
[240.0, 110.0, 150.0],
[130.0, 240.0, 210.0],
[255.0, 160.0, 70.0],
[200.0, 140.0, 255.0],
];
let blobs: Vec<(f32, f32, f32, [f32; 3], f32)> = (0..34u32)
.map(|i| {
let bx = h(i * 13 + 1) * W as f32;
let by = (0.04 + h(i * 13 + 2) * 0.34) * H as f32;
let br = 6.0 + h(i * 13 + 3) * 24.0;
let bc = cols[i as usize % cols.len()];
let bi = 0.4 + h(i * 13 + 5) * 0.6;
(bx, by, br, bc, bi)
})
.collect();
let mut data = vec![0u8; (W * H * 4) as usize];
for y in 0..H {
let v = y as f32 / (H - 1) as f32; let base_bri = ceiling + (1.0 - ceiling) * smoothstep(0.0, 0.6, v);
let vb = 0.6; let half = 0.045; let rail = 1.0 - 0.4 * (1.0 - smoothstep(half, half * 2.2, (v - vb).abs()));
let below = smoothstep(vb, vb + 2.0 * half, v) * (1.0 - smoothstep(0.66, 0.8, v));
let bri = base_bri * rail;
let wall = [
horizon[0] * (1.0 + 0.06 * below), horizon[1] * (1.0 - 0.03 * below), horizon[2] * (1.0 - 0.12 * below), ];
for x in 0..W {
let mut col = [wall[0] * bri, wall[1] * bri, wall[2] * bri];
for &(bx, by, br, bc, bi) in &blobs {
let (dx, dy) = (x as f32 - bx, y as f32 - by);
let d2 = (dx * dx + dy * dy) / (br * br);
if d2 < 1.0 {
let f = (1.0 - d2) * (1.0 - d2) * bi; for c in 0..3 {
col[c] += bc[c] * f;
}
}
}
let i = ((y * W + x) * 4) as usize;
for c in 0..3 {
data[i + c] = col[c].clamp(0.0, 255.0) as u8;
}
data[i + 3] = 255;
}
}
Arc::new(Texture::from_rgba(W, H, data))
})
.clone()
}
const PALETTE: [Rgb; 8] = [
Rgb(24, 214, 230), Rgb(248, 206, 20), Rgb(36, 214, 74), Rgb(228, 44, 228), Rgb(244, 40, 40), Rgb(48, 108, 255), Rgb(120, 246, 120), Rgb(246, 108, 246), ];
pub(crate) fn die_rgb(idx: usize) -> Rgb {
PALETTE[idx % PALETTE.len()]
}
fn die_color(idx: usize) -> Color {
let Rgb(r, g, b) = die_rgb(idx);
Color::Rgb(r, g, b)
}
#[derive(Clone, Copy, PartialEq)]
enum ThrowTier {
Lob,
Toss,
Rocket,
Peak,
}
fn throw_tier(power: f32) -> ThrowTier {
if power >= 0.92 {
ThrowTier::Peak
} else if power >= 0.70 {
ThrowTier::Rocket
} else if power >= 0.33 {
ThrowTier::Toss
} else {
ThrowTier::Lob
}
}
const NUMBER_PLATE: Color = Color::Rgb(20, 24, 30);
const FACE_FRAC_W: f32 = 0.92;
const FACE_FRAC_H: f32 = 0.78;
fn face_ink(die: &Die, clarity: f32) -> Option<(Color, Modifier)> {
const FACE_CLARITY_HIDE: f32 = 0.15;
if !die.settled {
if clarity < FACE_CLARITY_HIDE {
return None;
}
return Some((Color::Rgb(150, 158, 152), Modifier::DIM));
}
if !die.kept {
return Some((Color::DarkGray, Modifier::empty()));
}
Some(if crate::app::crit_face(die.sides, die.final_value) {
(Color::Yellow, Modifier::BOLD)
} else if crate::app::fumble_face(die.sides, die.final_value) {
(Color::Red, Modifier::BOLD)
} else {
(Color::White, Modifier::BOLD)
})
}
pub(crate) fn read_face(
faces: &[tinhorn_core::dice_geom::FaceGeom],
rot: glam::Quat,
to_cam: glam::Vec3,
) -> (glam::Vec3, f32) {
let (mut best, mut second) = (f32::NEG_INFINITY, f32::NEG_INFINITY);
let mut centroid = glam::Vec3::ZERO;
for &(c, normal) in faces {
let facing = (rot * normal).dot(to_cam);
if facing > best {
second = best;
best = facing;
centroid = c;
} else if facing > second {
second = facing;
}
}
(centroid, best - second)
}
#[rustfmt::skip]
const DIGIT_FONT: [[u8; 5]; 10] = [
[0b111, 0b101, 0b101, 0b101, 0b111], [0b010, 0b110, 0b010, 0b010, 0b111], [0b111, 0b001, 0b111, 0b100, 0b111], [0b111, 0b001, 0b111, 0b001, 0b111], [0b101, 0b101, 0b111, 0b001, 0b001], [0b111, 0b100, 0b111, 0b001, 0b111], [0b111, 0b100, 0b111, 0b101, 0b111], [0b111, 0b001, 0b010, 0b010, 0b010], [0b111, 0b101, 0b111, 0b101, 0b111], [0b111, 0b101, 0b111, 0b001, 0b111], ];
fn die_screen_extent(
camera: &tinhorn_core::view_math::Camera,
center: glam::Vec3,
cols: f32,
rows: f32,
) -> (f32, f32) {
use tinhorn_core::view_math::project_to_cell;
let r = crate::physics::DIE_R;
let forward = (camera.target - camera.position).normalize_or_zero();
let right = forward.cross(camera.up).normalize_or_zero();
let up = right.cross(forward).normalize_or_zero();
let span = |axis: glam::Vec3| -> f32 {
match (
project_to_cell(camera, center - axis * r, cols, rows),
project_to_cell(camera, center + axis * r, cols, rows),
) {
(Some(a), Some(b)) => (b.0 - a.0).hypot(b.1 - a.1),
_ => 0.0,
}
};
(span(right), span(up))
}
pub(crate) fn number_scale(die_w: f32, die_h: f32, n_digits: i32) -> i32 {
let mut scale = 0;
for s in 1..=4 {
let w = ((4 * n_digits - 1) * s) as f32;
let h = ((5 * s + 1) / 2) as f32;
if w <= die_w && h <= die_h {
scale = s;
}
}
scale
}
fn draw_die_number(
frame: &mut Frame,
inner: Rect,
camera: &tinhorn_core::view_math::Camera,
die: &Die,
cols: f32,
rows: f32,
scale: i32,
) {
let to_cam = (camera.position - die.pos).normalize_or_zero();
let (read_centroid, clarity) = read_face(
tinhorn_core::dice_geom::face_geometry(die.sides),
die.rot,
to_cam,
);
let Some((ink, mods)) = face_ink(die, clarity) else {
return;
};
let label = die.shown.to_string();
if scale < 1 {
let anchor = die.pos + die.rot * (read_centroid * crate::physics::DIE_R);
let Some((cx, cy)) = tinhorn_core::view_math::project_to_cell(camera, anchor, cols, rows)
else {
return;
};
let style = Style::default().bg(NUMBER_PLATE).fg(ink).add_modifier(mods);
let x = (inner.x as f32 + cx - label.len() as f32 / 2.0).round() as i32;
let y = (inner.y as f32 + cy).round() as i32;
let max_x = (inner.right() as i32 - label.len() as i32).max(inner.x as i32);
let x = x.clamp(inner.x as i32, max_x) as u16;
let y = y.clamp(inner.y as i32, inner.bottom() as i32 - 1) as u16;
frame.buffer_mut().set_string(x, y, &label, style);
} else {
let Some((cx, cy)) = tinhorn_core::view_math::project_to_cell(camera, die.pos, cols, rows)
else {
return;
};
let center = (inner.x as f32 + cx, inner.y as f32 + cy);
let base = if die.kept {
die_rgb(die.color_idx)
} else {
Rgb(120, 120, 120)
};
let outline = Color::Rgb(
(base.0 as f32 * 0.42) as u8,
(base.1 as f32 * 0.42) as u8,
(base.2 as f32 * 0.42) as u8,
);
draw_big_number(frame, inner, center, &label, scale, ink, outline);
}
}
fn draw_big_number(
frame: &mut Frame,
area: Rect,
center: (f32, f32),
label: &str,
scale: i32,
ink: Color,
outline: Color,
) {
let (cx, cy) = center;
let digits: Vec<u8> = label.bytes().filter(u8::is_ascii_digit).collect();
let n = digits.len() as i32;
if n == 0 {
return;
}
let gw = (4 * n - 1) * scale; let h_sub = 5 * scale; let gh = (h_sub + 1) / 2; let lit = |x: i32, sub: i32| -> bool {
if x < 0 || sub < 0 || x >= gw || sub >= h_sub {
return false;
}
let span = 4 * scale; let di = x / span;
let local_x = x - di * span;
if di >= n || local_x >= 3 * scale {
return false;
}
let (fx, fy) = ((local_x / scale) as usize, (sub / scale) as usize);
(DIGIT_FONT[(digits[di as usize] - b'0') as usize][fy] >> (2 - fx)) & 1 == 1
};
#[derive(PartialEq)]
enum Px {
Ink,
Outline,
Clear,
}
let sub = |x: i32, s: i32| -> Px {
if lit(x, s) {
Px::Ink
} else if (-1..=1).any(|dx| (-1..=1).any(|ds| lit(x + dx, s + ds))) {
Px::Outline
} else {
Px::Clear
}
};
let x0 = (cx - gw as f32 / 2.0).round() as i32;
let y0 = (cy - gh as f32 / 2.0).round() as i32;
let buf = frame.buffer_mut();
for row in -1..=gh {
for col in -1..=gw {
let (x, y) = (x0 + col, y0 + row);
if x < area.x as i32
|| x >= area.right() as i32
|| y < area.y as i32
|| y >= area.bottom() as i32
{
continue;
}
let (up, lo) = (sub(col, 2 * row), sub(col, 2 * row + 1));
if up == Px::Clear && lo == Px::Clear {
continue; }
let cell = &mut buf[(x as u16, y as u16)];
let (die_up, die_lo) = (cell.fg, cell.bg);
let paint = |p: &Px, die: Color| match p {
Px::Ink => ink,
Px::Outline => outline,
Px::Clear => die,
};
cell.set_char('▀');
cell.set_style(
Style::default()
.fg(paint(&up, die_up))
.bg(paint(&lo, die_lo)),
);
}
}
}
fn arena_title(app: &App) -> String {
if app.shaking() {
" 🎲 tinhorn — shaking… ".to_string()
} else if app.all_settled() {
" 🎲 tinhorn — settled ".to_string()
} else if app.spawned {
match app.last_throw.map(|t| throw_tier(t.power)) {
Some(ThrowTier::Lob) => " 🎲 tinhorn — a timid lob… ".to_string(),
Some(ThrowTier::Toss) => " 🎲 tinhorn — a clean toss… ".to_string(),
Some(ThrowTier::Rocket | ThrowTier::Peak) => {
" 🎲 tinhorn — a rocket throw… ".to_string()
}
None => " 🎲 tinhorn — rolling… ".to_string(),
}
} else {
" 🎲 tinhorn ".to_string()
}
}
fn draw_arena_overlays(
frame: &mut Frame,
app: &App,
inner: Rect,
camera: &tinhorn_core::view_math::Camera,
) {
if !app.shaking() {
let (cols, rows) = (inner.width as f32, inner.height as f32);
let ref_center = glam::Vec3::new(0.0, -crate::physics::HY + crate::physics::DIE_R, 0.0);
let (ref_w, ref_h) = die_screen_extent(camera, ref_center, cols, rows);
let max_digits = app
.dice
.iter()
.map(|d| d.sides.to_string().len() as i32)
.max()
.unwrap_or(1);
let num_scale = number_scale(ref_w * FACE_FRAC_W, ref_h * FACE_FRAC_H, max_digits);
for die in &app.dice {
draw_die_number(frame, inner, camera, die, cols, rows, num_scale);
}
}
{
let buf = frame.buffer_mut();
for p in &app.particles {
draw_particle(buf, inner, p);
}
if app.shaking() {
draw_power_meter(buf, inner, app);
}
if let Some(throw) = app.release_echo() {
draw_release_echo(buf, inner, throw);
}
}
if app.dice.is_empty() && !app.shaking() && app.release_echo().is_none() {
let hint = Paragraph::new(Line::from(
" roll something — the dice tumble in 3D ".dark_gray(),
))
.alignment(Alignment::Center);
frame.render_widget(hint, inner);
}
}
pub fn render_bevy(
frame: &mut Frame,
app: &mut App,
pixels: &[u8],
img_w: u32,
img_h: u32,
) -> (u16, u16) {
let area = frame.area();
let chunks = Layout::vertical([
Constraint::Min(5),
Constraint::Length(4),
Constraint::Length(1),
Constraint::Length(1),
])
.split(area);
let arena_area = chunks[0];
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(arena_title(app).bold());
let inner = block.inner(arena_area);
frame.render_widget(block, arena_area);
let mut view = (0u16, 0u16);
if inner.width >= 4 && inner.height >= 3 {
app.arena_w = inner.width as f32;
app.arena_h = inner.height as f32;
view = (
inner.width * ARENA_SS as u16,
inner.height * 2 * ARENA_SS as u16,
);
blit_bevy_arena(frame.buffer_mut(), inner, pixels, img_w, img_h);
let aspect = tinhorn_core::view_math::arena_aspect(inner.width as f32, inner.height as f32);
let camera = tinhorn_core::view_math::live_camera(
app.camera_shake(),
aspect,
app.focus(),
app.clock(),
app.flash(),
);
draw_arena_overlays(frame, app, inner, &camera);
}
render_results(frame, app, chunks[1]);
render_input(frame, app, chunks[2]);
render_help(frame, app, chunks[3]);
let scroll = app.pane_scroll;
match app.pane {
Pane::None => {}
Pane::Help => app.pane_scroll = render_help_overlay(frame, area, scroll),
Pane::History => app.pane_scroll = render_history_overlay(frame, app, area, scroll),
Pane::Stats => app.pane_scroll = render_stats_overlay(frame, app, area, scroll),
}
view
}
const ARENA_SS: u32 = 2;
fn blit_bevy_arena(
buf: &mut ratatui::buffer::Buffer,
inner: Rect,
pixels: &[u8],
img_w: u32,
img_h: u32,
) {
let stride = (img_w as usize * 4).div_ceil(256) * 256; if img_w == 0 || img_h == 0 || pixels.len() < stride * img_h as usize {
return;
}
let ss = (img_w / (inner.width as u32).max(1)).max(1);
let block = |cx: u32, cy: u32| -> (u8, u8, u8) {
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
for dy in 0..ss {
for dx in 0..ss {
let px = (cx * ss + dx).min(img_w - 1) as usize;
let py = (cy * ss + dy).min(img_h - 1) as usize;
let i = py * stride + px * 4;
r += pixels[i] as u32;
g += pixels[i + 1] as u32;
b += pixels[i + 2] as u32;
}
}
let n = (ss * ss).max(1);
((r / n) as u8, (g / n) as u8, (b / n) as u8)
};
let (fw, fh) = (inner.width as f32, inner.height as f32 * 2.0);
let graded = |c: (u8, u8, u8), nx: f32, ny: f32| -> Color {
let d2 = ((nx * nx + ny * ny) / 0.5).min(1.0);
let f = 1.0 - 0.34 * d2 * d2;
let (fr, fg, fb) = (f * 1.04, f, f * 0.95);
Color::Rgb(
(c.0 as f32 * fr).min(255.0) as u8,
(c.1 as f32 * fg).min(255.0) as u8,
(c.2 as f32 * fb) as u8,
)
};
for row in 0..inner.height {
for col in 0..inner.width {
let up = block(col as u32, row as u32 * 2);
let lo = block(col as u32, row as u32 * 2 + 1);
let nx = (col as f32 + 0.5) / fw - 0.5;
let cell = &mut buf[(inner.x + col, inner.y + row)];
cell.set_char('▀');
cell.set_style(
Style::default()
.fg(graded(up, nx, (row as f32 * 2.0 + 0.5) / fh - 0.5))
.bg(graded(lo, nx, (row as f32 * 2.0 + 1.5) / fh - 0.5)),
);
}
}
}
const METER_WIDTH: usize = 14;
fn power_bar(power: f32) -> String {
let filled = ((power * METER_WIDTH as f32).round() as usize).min(METER_WIDTH);
"▓".repeat(filled) + &"░".repeat(METER_WIDTH - filled)
}
fn draw_release_echo(buf: &mut ratatui::buffer::Buffer, inner: Rect, throw: crate::app::Throw) {
let (word, color) = release_grade(throw.power);
let label = format!("caught {} {word}", power_bar(throw.power));
let w = label.chars().count() as u16;
if inner.width < w || inner.height < 2 {
return;
}
let x = inner.x + (inner.width - w) / 2;
let mut style = Style::default().fg(color);
if throw.age > 0.9 {
style = style.add_modifier(Modifier::DIM);
} else {
style = style.add_modifier(Modifier::BOLD);
}
buf.set_string(x, inner.y, label, style);
}
fn release_grade(power: f32) -> (&'static str, Color) {
match throw_tier(power) {
ThrowTier::Peak => ("— the peak!", Color::Yellow),
ThrowTier::Rocket => ("— a rocket", Color::Red),
ThrowTier::Toss => ("— a clean toss", Color::Cyan),
ThrowTier::Lob => ("— a timid lob", Color::DarkGray),
}
}
fn draw_particle(buf: &mut ratatui::buffer::Buffer, inner: Rect, p: &Particle) {
let x = inner.x as i32 + p.x.round() as i32;
let y = inner.y as i32 + p.y.round() as i32;
if x < inner.x as i32
|| x >= inner.right() as i32
|| y < inner.y as i32
|| y >= inner.bottom() as i32
{
return;
}
let mut style = Style::default().fg(if p.bright {
Color::Yellow
} else {
Color::DarkGray
});
if p.fade() > 0.55 {
style = style.add_modifier(Modifier::DIM);
} else if p.bright {
style = style.add_modifier(Modifier::BOLD);
}
buf.set_string(x as u16, y as u16, p.glyph.to_string(), style);
}
fn draw_power_meter(buf: &mut ratatui::buffer::Buffer, inner: Rect, app: &App) {
let power = app.power();
if inner.height >= 5 {
let label = format!("power {} throw ↵", power_bar(power));
let w = label.chars().count() as u16;
if inner.width < w {
return;
}
let x = inner.x + (inner.width - w) / 2;
let y = inner.bottom() - 4;
let bar_color = if power < 0.5 {
Color::Green
} else if power < 0.85 {
Color::Yellow
} else {
Color::Red
};
buf.set_string(x, y, label, Style::default().fg(bar_color));
}
}
fn render_results(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(" result ".bold());
let inner = block.inner(area);
frame.render_widget(block, area);
if let Some(err) = &app.error {
let p = Paragraph::new(Line::from(vec![
Span::styled("⚠ ", Style::default().fg(Color::Red)),
Span::styled(err.clone(), Style::default().fg(Color::Red)),
]));
frame.render_widget(p, inner);
return;
}
if app.dice.is_empty() {
let p = Paragraph::new(Span::styled(
"type a dice expression below — Enter does the rest",
Style::default().fg(Color::DarkGray),
));
frame.render_widget(p, inner);
return;
}
let settled = app.all_settled();
let mut chips: Vec<Span> = Vec::new();
for (i, die) in app.dice.iter().enumerate() {
if i > 0 {
chips.push(Span::raw(" "));
}
let val = die.shown;
let style = if !die.kept {
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM)
} else if die.settled {
Style::default()
.fg(die_color(die.color_idx))
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
chips.push(Span::styled(format!("[{val}]"), style));
}
if app.modifier != 0 {
let sign = if app.modifier > 0 { "+" } else { "−" };
chips.push(Span::styled(
format!(" {sign}{}", app.modifier.abs()),
Style::default().fg(Color::Gray),
));
}
let (total_label, total_style) = if settled {
(
format!(" {} ", app.total()),
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD),
)
} else {
(
" … ".to_string(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
)
};
let mut total_spans = vec![
Span::styled(" Σ total ", Style::default().fg(Color::Gray)),
Span::styled(total_label, total_style),
Span::raw(" "),
];
match (app.stake, app.verdict()) {
(Some(stake), Some((success, margin))) => {
let bg = if success { Color::Green } else { Color::Red };
total_spans.push(Span::styled(
format!("{} ", stake.label()),
Style::default().fg(Color::Gray),
));
total_spans.push(Span::styled(
format!(" {} ", crate::app::verdict_text(success, margin)),
Style::default()
.fg(Color::Black)
.bg(bg)
.add_modifier(Modifier::BOLD),
));
}
(Some(stake), None) => {
total_spans.push(Span::styled(
format!("{} (rolling…)", stake.label()),
Style::default().fg(Color::DarkGray),
));
}
_ => {
total_spans.push(Span::styled(
if settled { "" } else { "(rolling…)" },
Style::default().fg(Color::DarkGray),
));
}
}
if settled {
let crits = app.crit_dice().count();
if crits > 0 {
let all_d20 = app.crit_dice().all(|d| d.sides == 20);
let mut label = if all_d20 {
" ✦ natural 20".to_string()
} else {
" ✦ crit".to_string()
};
if crits > 1 {
label.push_str(&format!(" ×{crits}"));
}
total_spans.push(Span::styled(
label,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
let fumbles = app.fumble_dice().count();
if fumbles > 0 {
let all_d20 = app.fumble_dice().all(|d| d.sides == 20);
let mut label = if all_d20 {
" natural 1".to_string()
} else {
" fumble".to_string()
};
if fumbles > 1 {
label.push_str(&format!(" ×{fumbles}"));
}
total_spans.push(Span::styled(
label,
Style::default().fg(Color::Red).add_modifier(Modifier::DIM),
));
}
}
let p = Paragraph::new(vec![Line::from(chips), Line::from(total_spans)]);
frame.render_widget(p, inner);
}
fn render_help(frame: &mut Frame, app: &App, area: Rect) {
let key = |k| Span::styled(k, Style::default().fg(Color::Cyan).bold());
let help = if app.shaking() {
Line::from(vec![
Span::styled(" › shaking… ", Style::default().fg(Color::Yellow)),
key("Enter"),
Span::raw(" · "),
key("Tab"),
Span::raw(" throw "),
key("Esc"),
Span::raw(" put them down"),
])
} else {
let mut spans = vec![
Span::styled(" › ", Style::default().fg(Color::Cyan)),
key("Enter"),
Span::raw(format!(" {} ", app.mode.label())),
key("Tab"),
Span::raw(" mode "),
key("?"),
Span::raw(" help "),
key("^H"),
Span::raw(" history "),
key("^S"),
Span::raw(" stats "),
];
spans.push(key("^Q"));
spans.push(Span::raw(if app.muted {
" unmute 🔇 "
} else {
" mute "
}));
spans.push(key("Esc"));
spans.push(Span::raw(" quit"));
Line::from(spans)
};
frame.render_widget(Paragraph::new(help), area);
}
fn syntax_row<'a>(example: &'a str, meaning: &'a str) -> Line<'a> {
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{example:<11}"), Style::default().fg(Color::Cyan)),
Span::styled(meaning, Style::default().fg(Color::Gray)),
])
}
fn heading(text: impl Into<String>) -> Line<'static> {
Line::from(Span::styled(
text.into(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))
}
fn close_hint() -> Line<'static> {
Line::from(Span::styled(
" ↑ ↓ scroll · Esc · q to close",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
))
}
fn overlay_panel(frame: &mut Frame, area: Rect, title: &str, lines: Vec<Line>, scroll: u16) -> u16 {
let content_h = lines.len() as u16;
let inner_w = lines.iter().map(Line::width).max().unwrap_or(0) as u16;
let panel_w = (inner_w + 4).min(area.width); let panel_h = (content_h + 2).min(area.height);
let rect = centered(panel_w, panel_h, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.padding(Padding::horizontal(1))
.title(title.to_string().bold());
let inner_h = block.inner(rect).height;
let scroll = scroll.min(content_h.saturating_sub(inner_h));
let para = Paragraph::new(lines)
.block(block)
.alignment(Alignment::Left)
.scroll((scroll, 0));
frame.render_widget(Clear, rect); frame.render_widget(para, rect);
scroll
}
fn render_help_overlay(frame: &mut Frame, area: Rect, scroll: u16) -> u16 {
let lines = vec![
heading("Dice"),
syntax_row("3d6", "three six-sided dice"),
syntax_row("d20", "one die (count defaults to 1)"),
syntax_row("d6+d8", "combine different dice"),
syntax_row("2d20-1", "add or subtract a flat modifier"),
Line::raw(""),
heading("Keep / drop"),
syntax_row("2d20kh1", "advantage — keep the highest 1"),
syntax_row("2d20kl1", "disadvantage — keep the lowest 1"),
syntax_row("4d6dl1", "drop the lowest 1 (ability scores)"),
syntax_row("4d6dh1", "drop the highest 1"),
Line::raw(""),
heading("Stakes & multiply"),
syntax_row("d20 > 15", "beat a target (or 'vs'); < N rolls under"),
syntax_row("4d6*2", "double this term's sum (modifiers stack)"),
Line::raw(""),
heading("Exploding"),
syntax_row("3d6!", "a max face rolls another die"),
syntax_row("d10!>8", "explode on any face over 8 (also !=N, !<N)"),
Line::raw(""),
heading("The Throw"),
syntax_row(
"Enter",
"shake the cup; Enter again throws — harder at the peak",
),
syntax_row("Tab", "cycle Enter's mode: shake → roll → insta"),
syntax_row("Esc", "put them down. Power never touches the values."),
Line::from(Span::styled(
" Separators: + - , space or just write dice next to each other.",
Style::default().fg(Color::DarkGray),
)),
close_hint(),
];
overlay_panel(frame, area, " 🎲 dice notation ", lines, scroll)
}
fn render_history_overlay(frame: &mut Frame, app: &App, area: Rect, scroll: u16) -> u16 {
let mut lines: Vec<Line> = Vec::new();
if app.history.is_empty() {
lines.push(Line::from(Span::styled(
" no rolls yet — shake some dice loose with Enter",
Style::default().fg(Color::DarkGray),
)));
} else {
for (n, e) in app.history.iter().rev().enumerate() {
let idx = app.history.len() - n; let faces = e
.values
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(" ");
lines.push(Line::from(vec![
Span::styled(
format!(" {idx:>3}. "),
Style::default().fg(Color::DarkGray),
),
Span::styled(format!("{:<12}", e.expr), Style::default().fg(Color::Cyan)),
Span::styled(format!("[{faces}] "), Style::default().fg(Color::Gray)),
Span::styled(
format!("= {}", e.total),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
]));
}
}
lines.push(Line::raw(""));
lines.push(close_hint());
overlay_panel(frame, area, " 🎲 history ", lines, scroll)
}
fn render_stats_overlay(frame: &mut Frame, app: &mut App, area: Rect, scroll: u16) -> u16 {
let lines = match app.stats() {
Ok(s) => stats_lines(&s),
Err(e) => vec![
Line::from(Span::styled(
" can't compute stats — the expression doesn't parse:",
Style::default().fg(Color::Red),
)),
Line::from(Span::styled(
format!(" {e}"),
Style::default().fg(Color::Red),
)),
Line::raw(""),
close_hint(),
],
};
overlay_panel(frame, area, " 🎲 statistics ", lines, scroll)
}
fn stats_lines(s: &Stats) -> Vec<Line<'static>> {
let mut lines = vec![
heading(format!("Odds for {}", s.expr)),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("min {} max {} avg {:.1}", s.min, s.max, s.mean),
Style::default().fg(Color::Gray),
),
]),
Line::from(Span::styled(
format!(" (estimated from {} samples)", s.samples),
Style::default().fg(Color::DarkGray),
)),
Line::raw(""),
];
if let (Some(stake), Some(odds)) = (s.stake, s.success_odds) {
lines.insert(
1,
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{}: ", stake.label()),
Style::default().fg(Color::Gray),
),
Span::styled(
format!("{:.0}% to succeed", odds * 100.0),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
]),
);
}
if !s.dist.is_empty() {
let peak = s
.dist
.iter()
.map(|b| b.fraction)
.fold(0.0_f64, f64::max)
.max(1e-9);
for b in &s.dist {
let filled = (b.fraction / peak * 18.0).round() as usize;
let bar: String = "█".repeat(filled);
lines.push(Line::from(vec![
Span::styled(
format!(" {:>4} ", b.total),
Style::default().fg(Color::DarkGray),
),
Span::styled(bar, Style::default().fg(Color::Cyan)),
Span::styled(
format!(" {:>4.1}%", b.fraction * 100.0),
Style::default().fg(Color::Gray),
),
]));
}
lines.push(Line::raw(""));
}
lines.push(heading("This session"));
if s.session.count == 0 {
lines.push(Line::from(Span::styled(
format!(
" no rolls of {} yet ({} rolls total)",
s.expr, s.total_rolls
),
Style::default().fg(Color::DarkGray),
)));
} else {
lines.push(Line::from(Span::styled(
format!(
" {} rolls low {} high {} mean {:.1}",
s.session.count, s.session.min, s.session.max, s.session.mean
),
Style::default().fg(Color::Gray),
)));
}
lines.push(Line::raw(""));
lines.push(close_hint());
lines
}
fn centered(w: u16, h: u16, area: Rect) -> Rect {
let [row] = Layout::vertical([Constraint::Length(h)])
.flex(Flex::Center)
.areas(area);
let [cell] = Layout::horizontal([Constraint::Length(w)])
.flex(Flex::Center)
.areas(row);
cell
}
fn render_input(frame: &mut Frame, app: &App, area: Rect) {
const PROMPT: &str = "dice ▸ ";
let [prompt_area, text_area] = Layout::horizontal([
Constraint::Length(PROMPT.chars().count() as u16),
Constraint::Min(0),
])
.areas(area);
frame.render_widget(
Paragraph::new(Span::styled(
PROMPT,
Style::default().fg(Color::Cyan).bold(),
)),
prompt_area,
);
let at = app.cursor_byte();
let (before, rest) = app.input.split_at(at);
let (under, after) = match rest.chars().next() {
Some(c) => (&rest[..c.len_utf8()], &rest[c.len_utf8()..]),
None => (" ", ""),
};
let caret_col = Span::raw(before).width() as u16;
let scroll_x = caret_col.saturating_sub(text_area.width.saturating_sub(1));
let line = Line::from(vec![
Span::raw(before),
Span::styled(under, Style::default().fg(Color::Black).bg(Color::Cyan)),
Span::raw(after),
]);
frame.render_widget(Paragraph::new(line).scroll((0, scroll_x)), text_area);
}