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 ratatui::Frame;
use crate::app::{App, Die, Pane, Particle, Stats};
use crate::render3d::color::Rgb;
#[derive(Clone, Copy)]
pub(crate) struct ArenaStyle {
pub(crate) background: Rgb, pub(crate) floor: Rgb, pub(crate) wall: Rgb, pub(crate) lip_top: f32, pub(crate) ambient: f32, pub(crate) key: Rgb, pub(crate) fill: Rgb, }
impl ArenaStyle {
pub(crate) const DEFAULT: ArenaStyle = ArenaStyle {
background: Rgb(16, 11, 9), floor: Rgb(22, 64, 42), wall: Rgb(66, 40, 28), lip_top: 1.35, ambient: 0.28, key: Rgb(255, 222, 176), fill: Rgb(104, 80, 62), };
}
#[cfg(test)]
thread_local! {
static STYLE_OVERRIDE: std::cell::Cell<Option<ArenaStyle>> =
const { std::cell::Cell::new(None) };
}
#[cfg(test)]
pub(crate) fn set_arena_style(style: Option<ArenaStyle>) {
STYLE_OVERRIDE.with(|c| c.set(style));
}
fn arena_style() -> ArenaStyle {
#[cfg(test)]
{
if let Some(style) = STYLE_OVERRIDE.with(|c| c.get()) {
return style;
}
}
ArenaStyle::DEFAULT
}
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::render3d::texture::Texture>)>>,
>;
fn cached_texture(
cache: &TexCache,
base: Rgb,
bake: impl FnOnce() -> crate::render3d::texture::Texture,
) -> std::sync::Arc<crate::render3d::texture::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
}
const FLOOR_THICK: f32 = 0.2;
fn dquad(
corners: [crate::render3d::math::Vec3; 4],
n: crate::render3d::math::Vec3,
uvs: [(f32, f32); 4],
) -> crate::render3d::mesh::Mesh {
use crate::render3d::mesh::{Mesh, Vertex};
let verts = corners
.iter()
.zip(uvs)
.map(|(&p, (u, v))| Vertex::new(p, n).with_uv(u, v))
.collect();
Mesh::new(verts, vec![0, 1, 2, 0, 2, 3, 0, 2, 1, 0, 3, 2])
}
fn grain_texture(base: Rgb) -> std::sync::Arc<crate::render3d::texture::Texture> {
use crate::render3d::texture::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)
})
}
fn felt_texture(base: Rgb) -> std::sync::Arc<crate::render3d::texture::Texture> {
use crate::render3d::texture::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)
})
}
fn velvet_texture(base: Rgb) -> std::sync::Arc<crate::render3d::texture::Texture> {
use crate::render3d::texture::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)
})
}
fn floor_texture(base: Rgb) -> std::sync::Arc<crate::render3d::texture::Texture> {
use crate::render3d::texture::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)
})
}
fn backdrop_texture() -> std::sync::Arc<crate::render3d::texture::Texture> {
use crate::render3d::texture::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(60, 200, 210),
Rgb(220, 200, 60),
Rgb(70, 200, 90),
Rgb(210, 90, 210),
Rgb(220, 70, 70),
Rgb(80, 130, 240),
Rgb(130, 230, 130),
Rgb(230, 130, 230),
];
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)
}
pub fn render(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let chunks = Layout::vertical([
Constraint::Min(5), Constraint::Length(4), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
render_arena(frame, app, chunks[0]);
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),
}
}
#[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: &[crate::render3d::dice::FaceGeom],
rot: crate::render3d::math::Quat,
to_cam: crate::render3d::math::Vec3,
) -> (crate::render3d::math::Vec3, f32) {
let (mut best, mut second) = (f32::NEG_INFINITY, f32::NEG_INFINITY);
let mut centroid = crate::render3d::math::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: &crate::render3d::camera::Camera,
center: crate::render3d::math::Vec3,
cols: f32,
rows: f32,
) -> (f32, f32) {
use crate::render3d_view::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: crate::render3d::math::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: &crate::render3d::camera::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(
crate::render3d::dice::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)) = crate::render3d_view::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)) = crate::render3d_view::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 render_arena(frame: &mut Frame, app: &mut App, area: Rect) {
use crate::render3d::dice;
use crate::render3d::light::Light;
use crate::render3d::material::Material;
use crate::render3d::math::{Quat, Vec3};
use crate::render3d::object::SceneObject;
use crate::render3d::scene::Scene;
use crate::render3d::transform::Transform;
use crate::render3d_view::{self, RenderMode};
let title = 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()
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(title.bold());
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width < 4 || inner.height < 3 {
return;
}
app.arena_w = inner.width as f32;
app.arena_h = inner.height as f32;
let aspect = render3d_view::arena_aspect(inner.width as f32, inner.height as f32);
let flash = app.flash();
let camera =
render3d_view::live_camera(app.camera_shake(), aspect, app.focus(), app.clock(), flash);
let style = arena_style();
let mut scene = Scene::new();
scene.background = style.background;
scene.fog = Some((16.0, 40.0));
{
let z = -22.0;
let (x0, x1, y0, y1) = (-44.0, 44.0, -9.0, 22.0);
let quad = dquad(
[
Vec3::new(x0, y1, z),
Vec3::new(x1, y1, z),
Vec3::new(x1, y0, z),
Vec3::new(x0, y0, z),
],
Vec3::Z,
[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
);
let mat = Material::default()
.with_ambient(3.1) .with_diffuse(0.0)
.with_specular(0.0)
.with_texture(backdrop_texture());
scene.add_object(SceneObject::new(quad).with_material(mat));
}
{
use crate::render3d::mesh::{Mesh, Vertex};
const COLS: usize = 16; const ROWS: usize = 9; const FOLDS: f32 = 3.0; const AMP: f32 = 0.28; let (y_top, y_bot, cz) = (2.6f32, -3.15f32, -5.0f32);
let x_out = 13.8f32; let inner_x = |v: f32, edge_phase: f32| 6.15 - 0.2 * v + AMP * (v * 4.4 + edge_phase).sin();
let mat = Material::default()
.with_color(Rgb(104, 32, 33)) .with_ambient(1.5)
.with_diffuse(0.95)
.with_specular(0.0)
.with_texture(velvet_texture(Rgb(104, 32, 33)));
for side in [-1.0f32, 1.0] {
let phase = if side < 0.0 { 0.0 } else { 0.9 }; let grid: Vec<Vec<Vec3>> = (0..=ROWS)
.map(|r| {
let v = r as f32 / ROWS as f32;
let y = y_top + (y_bot - y_top) * v;
let xi = inner_x(v, phase * 2.3 + 1.1);
(0..=COLS)
.map(|c| {
let u = c as f32 / COLS as f32;
let wave = (u * FOLDS * std::f32::consts::TAU + phase).sin();
Vec3::new(side * (xi + (x_out - xi) * u), y, cz + AMP * wave)
})
.collect()
})
.collect();
let mut verts: Vec<Vertex> = Vec::new();
let mut idx: Vec<u32> = Vec::new();
for r in 0..ROWS {
for c in 0..COLS {
let (v0, v1) = (r as f32 / ROWS as f32, (r + 1) as f32 / ROWS as f32);
let (u0, u1) = (c as f32 / COLS as f32, (c + 1) as f32 / COLS as f32);
let p = [
grid[r][c],
grid[r][c + 1],
grid[r + 1][c + 1],
grid[r + 1][c],
];
let mut n = (p[1] - p[0]).cross(p[3] - p[0]).normalize_or_zero();
if n.z < 0.0 {
n = -n;
}
let base = verts.len() as u32;
for (&pt, uv) in p.iter().zip([(u0, v0), (u1, v0), (u1, v1), (u0, v1)]) {
verts.push(Vertex::new(pt, n).with_uv(uv.0, uv.1));
}
idx.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
idx.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);
}
}
scene.add_object(SceneObject::new(Mesh::new(verts, idx)).with_material(mat.clone()));
}
}
{
use crate::physics::{HX, HY, HZ};
use crate::render3d::mesh::{Mesh, Vertex};
let wall_h = style.lip_top; let flare = 0.12; let floor_thick = FLOOR_THICK;
let felt_mat = Material::default()
.with_ambient(0.9)
.with_diffuse(1.05)
.with_specular(0.0)
.with_texture(felt_texture(style.floor));
let felt_edge_mat = Material::default()
.with_color(style.floor.scale(0.42))
.with_ambient(0.5)
.with_diffuse(0.5)
.with_specular(0.0);
let wall_mat = Material::default()
.with_ambient(0.95)
.with_diffuse(0.9)
.with_specular(0.10)
.with_shininess(6.0)
.with_texture(grain_texture(style.wall));
let rail_mat = Material::default()
.with_color(Rgb(150, 108, 72))
.with_ambient(0.8)
.with_diffuse(1.0)
.with_specular(0.14)
.with_shininess(10.0);
let outer_mat = Material::default()
.with_ambient(1.7)
.with_diffuse(0.5)
.with_specular(0.0)
.with_texture(grain_texture(style.wall));
let poly = [
Vec3::new(-HX, -HY, -HZ), Vec3::new(HX, -HY, -HZ), Vec3::new(HX, -HY, HZ), Vec3::new(-HX, -HY, HZ), ];
const OPEN_EDGE: usize = 2;
let rim: Vec<Vec3> = poly
.iter()
.map(|&p| {
p + Vec3::new(p.x, 0.0, p.z).normalize_or_zero() * flare
+ Vec3::new(0.0, wall_h, 0.0)
})
.collect();
let mut fv = vec![Vertex::new(Vec3::new(0.0, -HY, 0.0), Vec3::Y).with_uv(0.5, 0.5)];
for &p in &poly {
let uv = (p.x / (2.0 * HX) + 0.5, p.z / (2.0 * HZ) + 0.5);
fv.push(Vertex::new(p, Vec3::Y).with_uv(uv.0, uv.1));
}
let n = poly.len() as u32;
let mut fi = Vec::new();
for i in 0..n {
let (a, b) = (1 + i, 1 + (i + 1) % n);
fi.extend_from_slice(&[0, a, b, 0, b, a]); }
scene.add_object(SceneObject::new(Mesh::new(fv, fi)).with_material(felt_mat));
let rail_w = 0.6; let radial = |p: Vec3| Vec3::new(p.x, 0.0, p.z).normalize_or_zero();
let top_outer: Vec<Vec3> = poly
.iter()
.enumerate()
.map(|(i, &p)| rim[i] + radial(p) * rail_w)
.collect();
let outer_bot: Vec<Vec3> = top_outer
.iter()
.map(|&p| Vec3::new(p.x, -HY - floor_thick, p.z))
.collect();
let quad = |a: Vec3, b: Vec3, c: Vec3, d: Vec3, nrm: Vec3| -> Mesh {
dquad(
[a, b, c, d],
nrm,
[(0.0, 1.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0)],
)
};
for i in 0..poly.len() {
if i == OPEN_EDGE {
continue; }
let j = (i + 1) % poly.len();
let mut n_in = (poly[j] - poly[i])
.cross(rim[i] - poly[i])
.normalize_or_zero();
let mid = (poly[i] + poly[j]) * 0.5;
if n_in.dot(Vec3::new(-mid.x, 0.0, -mid.z)) < 0.0 {
n_in = -n_in;
}
scene.add_object(
SceneObject::new(quad(poly[i], poly[j], rim[j], rim[i], n_in))
.with_material(wall_mat.clone()),
);
scene.add_object(
SceneObject::new(quad(rim[i], rim[j], top_outer[j], top_outer[i], Vec3::Y))
.with_material(rail_mat.clone()),
);
let n_out = radial((top_outer[i] + top_outer[j]) * 0.5);
scene.add_object(
SceneObject::new(quad(
top_outer[i],
top_outer[j],
outer_bot[j],
outer_bot[i],
n_out,
))
.with_material(outer_mat.clone()),
);
}
let cap_mat = Material::default()
.with_ambient(3.4)
.with_diffuse(0.4)
.with_specular(0.0)
.with_texture(grain_texture(style.wall));
for &v in &[OPEN_EDGE, (OPEN_EDGE + 1) % poly.len()] {
scene.add_object(
SceneObject::new(quad(poly[v], rim[v], top_outer[v], outer_bot[v], Vec3::Z))
.with_material(cap_mat.clone()),
);
}
let (fr, fl) = (poly[2], poly[3]);
let drop = |p: Vec3, d: f32| Vec3::new(p.x, p.y - d, p.z);
let felt_lip = 0.06; scene.add_object(
SceneObject::new(quad(
fl,
fr,
drop(fr, felt_lip),
drop(fl, felt_lip),
Vec3::Z,
))
.with_material(felt_edge_mat),
);
scene.add_object(
SceneObject::new(quad(
drop(fl, felt_lip),
drop(fr, felt_lip),
drop(fr, floor_thick),
drop(fl, floor_thick),
Vec3::Z,
))
.with_material(wall_mat.clone()),
);
}
{
use crate::physics::HY;
let y = -HY - 1.15; let (x0, x1, z0, z1) = (-26.0, 26.0, -26.0, 9.0);
let ts = 5.0; let uv = |x: f32, z: f32| (x / ts, z / ts);
let floor = dquad(
[
Vec3::new(x0, y, z1),
Vec3::new(x1, y, z1),
Vec3::new(x1, y, z0),
Vec3::new(x0, y, z0),
],
Vec3::Y,
[uv(x0, z1), uv(x1, z1), uv(x1, z0), uv(x0, z0)],
);
let mat = Material::default()
.with_ambient(2.8)
.with_diffuse(0.0)
.with_specular(0.0)
.with_texture(floor_texture(Rgb(156, 116, 80))); scene.add_object(SceneObject::new(floor).with_material(mat));
}
{
use crate::physics::HY;
let y0 = -HY - 1.15 + 0.03; let rug_mat = |c: Rgb| {
Material::default()
.with_color(c)
.with_ambient(2.6)
.with_diffuse(0.0)
.with_specular(0.0)
};
let rug_quad = |x0: f32, x1: f32, z0: f32, z1: f32, y: f32| {
dquad(
[
Vec3::new(x0, y, z1),
Vec3::new(x1, y, z1),
Vec3::new(x1, y, z0),
Vec3::new(x0, y, z0),
],
Vec3::Y,
[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
)
};
scene.add_object(
SceneObject::new(rug_quad(-8.0, 8.0, -4.5, 6.8, y0))
.with_material(rug_mat(Rgb(50, 16, 18))),
);
scene.add_object(
SceneObject::new(rug_quad(-7.0, 7.0, -3.9, 5.9, y0 + 0.02))
.with_material(rug_mat(Rgb(96, 30, 31))),
);
}
{
use crate::physics::{HY, HZ};
let top = -HY - FLOOR_THICK; let bot = top - 0.6; let (x0, x1, z0, z1) = (-5.4, 5.4, -(HZ + 1.7), HZ + 2.5); let ts = 2.6;
let uv = |a: f32, b: f32| (a / ts, b / ts);
let top_mat = Material::default()
.with_ambient(1.6)
.with_diffuse(1.0)
.with_specular(0.07)
.with_shininess(7.0)
.with_texture(grain_texture(Rgb(96, 70, 50))); let apron_mat = Material::default()
.with_ambient(2.6) .with_diffuse(0.6)
.with_specular(0.0)
.with_texture(grain_texture(Rgb(74, 54, 38))); scene.add_object(
SceneObject::new(dquad(
[
Vec3::new(x0, top, z1),
Vec3::new(x1, top, z1),
Vec3::new(x1, top, z0),
Vec3::new(x0, top, z0),
],
Vec3::Y,
[uv(x0, z1), uv(x1, z1), uv(x1, z0), uv(x0, z0)],
))
.with_material(top_mat),
);
scene.add_object(
SceneObject::new(dquad(
[
Vec3::new(x0, top, z1),
Vec3::new(x1, top, z1),
Vec3::new(x1, bot, z1),
Vec3::new(x0, bot, z1),
],
Vec3::Z,
[uv(x0, top), uv(x1, top), uv(x1, bot), uv(x0, bot)],
))
.with_material(apron_mat.clone()),
);
for (xf, nx) in [(x0, -1.0f32), (x1, 1.0f32)] {
scene.add_object(
SceneObject::new(dquad(
[
Vec3::new(xf, top, z0),
Vec3::new(xf, top, z1),
Vec3::new(xf, bot, z1),
Vec3::new(xf, bot, z0),
],
Vec3::new(nx, 0.0, 0.0),
[uv(z0, top), uv(z1, top), uv(z1, bot), uv(z0, bot)],
))
.with_material(apron_mat.clone()),
);
}
}
{
use crate::physics::{HX, HY, HZ};
use crate::render3d::mesh::{Mesh, Vertex};
let chip_cols = [
Rgb(198, 44, 44), Rgb(28, 140, 64), Rgb(30, 30, 36), Rgb(216, 216, 206), Rgb(44, 84, 184), ];
let seg = 12u32;
let (r, ch) = (0.42, 0.09); let table_y = -HY - FLOOR_THICK; let chip_rnd = |si: u32, k: u32| {
hash32(
si.wrapping_mul(0x9E37_79B1)
.wrapping_add(k.wrapping_mul(2_246_822_519))
.wrapping_add(1),
)
};
let mut chip_stack = |cx: f32, cz: f32, n: usize, si: u32| {
for j in 0..n {
let col = chip_cols[chip_rnd(si.wrapping_mul(101).wrapping_add(7), j as u32)
as usize
% chip_cols.len()];
let (y0, y1) = (table_y + j as f32 * ch, table_y + j as f32 * ch + ch * 0.82);
let mut verts = Vec::new();
let mut idx = Vec::new();
for s in 0..seg {
let (a0, a1) = (
std::f32::consts::TAU * s as f32 / seg as f32,
std::f32::consts::TAU * (s + 1) as f32 / seg as f32,
);
let (n0, n1) = (
Vec3::new(a0.cos(), 0.0, a0.sin()),
Vec3::new(a1.cos(), 0.0, a1.sin()),
);
let b = verts.len() as u32;
verts.push(Vertex::new(Vec3::new(cx + n0.x * r, y0, cz + n0.z * r), n0));
verts.push(Vertex::new(Vec3::new(cx + n1.x * r, y0, cz + n1.z * r), n1));
verts.push(Vertex::new(Vec3::new(cx + n1.x * r, y1, cz + n1.z * r), n1));
verts.push(Vertex::new(Vec3::new(cx + n0.x * r, y1, cz + n0.z * r), n0));
idx.extend_from_slice(&[
b,
b + 1,
b + 2,
b,
b + 2,
b + 3,
b,
b + 2,
b + 1,
b,
b + 3,
b + 2,
]);
}
let cb = verts.len() as u32;
verts.push(Vertex::new(Vec3::new(cx, y1, cz), Vec3::Y));
for s in 0..seg {
let a = std::f32::consts::TAU * s as f32 / seg as f32;
verts.push(Vertex::new(
Vec3::new(cx + a.cos() * r, y1, cz + a.sin() * r),
Vec3::Y,
));
}
for s in 0..seg {
let (a, b) = (cb + 1 + s, cb + 1 + (s + 1) % seg);
idx.extend_from_slice(&[cb, a, b, cb, b, a]); }
let mat = Material::default()
.with_color(col)
.with_ambient(0.7) .with_diffuse(0.9)
.with_specular(0.12)
.with_shininess(10.0);
scene.add_object(SceneObject::new(Mesh::new(verts, idx)).with_material(mat));
}
};
let spots = [
(-(HX - 0.3), HZ + 0.2),
(-(HX - 1.15), HZ + 0.5),
(HX - 0.2, HZ + 0.5),
(HX - 1.1, HZ + 0.2),
];
for (si, &(cx, cz)) in spots.iter().enumerate() {
let n = 3 + (chip_rnd(si as u32, 7) % 5) as usize; chip_stack(cx, cz, n, si as u32);
}
}
if !app.shaking() {
use crate::physics::{DIE_R, HY};
use crate::render3d::mesh::{Mesh, Vertex};
let f = style.floor;
let tone = |k: f32| {
Material::default()
.with_color(f.scale(k))
.with_ambient(0.9)
.with_diffuse(0.65)
.with_specular(0.0)
};
let layers = [
(1.55_f32, 0.015_f32, 0.72_f32),
(1.06_f32, 0.028_f32, 0.5_f32),
];
for die in &app.dice {
if die.pos.y + HY > 0.8 {
continue; }
let src = crate::render3d::dice::mesh_for(die.sides);
let mut idx = src.indices.clone();
for tri in src.indices.chunks_exact(3) {
idx.extend_from_slice(&[tri[0], tri[2], tri[1]]);
}
for &(expand, dy, dark) in &layers {
let verts: Vec<Vertex> = src
.vertices
.iter()
.map(|v| {
let wp = die.rot * (v.position * DIE_R) + die.pos; let sx = die.pos.x + (wp.x - die.pos.x) * expand;
let sz = die.pos.z + (wp.z - die.pos.z) * expand;
Vertex::new(Vec3::new(sx, -HY + dy, sz), Vec3::Y) })
.collect();
scene.add_object(
SceneObject::new(Mesh::new(verts, idx.clone())).with_material(tone(dark)),
);
}
}
}
if !app.shaking() {
for die in &app.dice {
let color = if die.kept {
die_rgb(die.color_idx)
} else {
Rgb(90, 90, 90)
};
scene.add_object(
SceneObject::new(dice::mesh_for(die.sides))
.with_material(
Material::default()
.with_color(color)
.with_specular(0.28)
.with_shininess(12.0),
)
.with_transform(Transform {
position: die.pos,
rotation: die.rot,
scale: Vec3::splat(crate::physics::DIE_R),
}),
);
}
}
if app.shaking() {
let grip = 0.35 + 0.65 * app.power();
let t = app.shake_t() * crate::app::CUP_SWAY_RATE; let sway = app.cup_offset() * crate::physics::HX * 0.6;
let bob = (t * 2.0).sin().abs() * 0.09 * grip; let lean = t.cos() * 0.14 * grip; let rattle = (app.shake_t() * 23.0).sin() * 0.05 * grip;
scene.add_object(
SceneObject::new(dice::cup())
.with_material(
Material::default()
.with_color(Rgb(176, 182, 190))
.with_ambient(0.95)
.with_diffuse(0.55)
.with_specular(0.9)
.with_shininess(24.0),
)
.with_transform(Transform {
position: Vec3::new(sway, -1.15 + bob, crate::physics::HZ * 0.25),
rotation: Quat::from_rotation_z(lean + rattle) * Quat::from_rotation_x(0.18),
scale: Vec3::new(0.9, 1.05, 0.9),
}),
);
}
scene.add_light(Light::ambient(Rgb(255, 236, 210), style.ambient));
let kt = app.clock();
let key_sway = Vec3::new((kt * 0.19).sin() * 0.16, 0.0, (kt * 0.13).cos() * 0.12);
scene.add_light(Light::Point {
position: Vec3::new(0.0, 2.4, 1.1) + key_sway,
color: style.key,
intensity: 3.0 * (1.0 + 0.22 * app.impact_energy()), });
scene.add_light(Light::Point {
position: Vec3::new(0.0, 3.4, -4.8), color: Rgb(198, 214, 255), intensity: 1.0,
});
scene.add_light(Light::directional(Vec3::new(0.3, -0.5, -0.35), style.fill));
if flash > 0.0 {
scene.add_light(Light::Point {
position: Vec3::new(0.0, 2.8, 0.6),
color: Rgb(255, 216, 140),
intensity: flash * 4.0,
});
}
render3d_view::draw(
frame.buffer_mut(),
inner,
&scene,
&camera,
RenderMode::HalfBlock,
);
if !app.shaking() {
let (cols, rows) = (inner.width as f32, inner.height as f32);
let ref_center = 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);
}
}
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);
}