use std::time::Instant;
use rand::Rng;
use rapier2d::prelude::{nalgebra, vector};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::Span,
widgets::Paragraph,
Frame,
};
use crate::error::AppResult;
use crate::event::{AppEvent, EventContext};
use crate::physics::{BallColor, PhysicsConfig, PhysicsWorld};
use crate::render::{density_to_color, density_to_foreground, physics_to_subpixel, BrailleCanvas};
use crate::save::{apply_options_to_menu, options_from_menu, LevelConfig, SavedShape};
use crate::shapes::{
ascii_art::{get_ascii_art, get_rotated_ascii_art},
ShapeManager,
};
use crate::timing::FrameTimer;
use crate::ui::{HelpMenu, OptionsMenu, ShapeMenu, StatusBar, StatusBarInfo};
const PHYSICS_DT: f32 = 1.0 / 60.0;
const MAX_PHYSICS_STEPS: u32 = 5;
const BALLS_PER_SPAWN: usize = 10;
const BURST_STRENGTH: f32 = 37.5;
const BURST_RADIUS: f32 = 5.0;
pub const MAX_BALLS: usize = 15000;
const KEY_RELEASE_TIMEOUT_MS: u128 = 100;
fn calculate_spawn_rate(elapsed_secs: f32) -> f32 {
if elapsed_secs < 0.5 {
5.0
} else if elapsed_secs < 1.5 {
5.0 + (elapsed_secs - 0.5) * 25.0
} else if elapsed_secs < 3.0 {
30.0 + (elapsed_secs - 1.5) * (70.0 / 1.5)
} else {
(100.0 * (1.3_f32).powf(elapsed_secs - 3.0)).min(300.0)
}
}
fn spawn_balls_in_grid(
physics_world: &mut PhysicsWorld,
count: usize,
world_width: f32,
world_height: f32,
spawn_color_percent: i32,
) {
if count == 0 {
return;
}
let ball_radius = physics_world.config().ball_radius;
let ideal_spacing = ball_radius * 1.5;
let cols_ideal = (world_width / ideal_spacing).floor() as usize;
let rows_ideal = (world_height / ideal_spacing).floor() as usize;
let max_ideal = cols_ideal * rows_ideal;
let (cols, rows, spacing_x, spacing_y) = if count <= max_ideal {
let aspect = world_width / world_height;
let rows = ((count as f32 / aspect).sqrt().ceil() as usize).max(1);
let cols = ((count as f32 / rows as f32).ceil() as usize).max(1);
let spacing_x = world_width / (cols as f32 + 1.0);
let spacing_y = world_height / (rows as f32 + 1.0);
(cols, rows, spacing_x, spacing_y)
} else {
let aspect = world_width / world_height;
let rows = ((count as f32 / aspect).sqrt().ceil() as usize).max(1);
let cols = ((count as f32 / rows as f32).ceil() as usize).max(1);
let spacing_x = (world_width / (cols as f32 + 1.0)).max(ball_radius * 2.0);
let spacing_y = (world_height / (rows as f32 + 1.0)).max(ball_radius * 2.0);
(cols, rows, spacing_x, spacing_y)
};
let mut spawned = 0;
let mut rng = rand::thread_rng();
for row in 0..rows {
if spawned >= count {
break;
}
for col in 0..cols {
if spawned >= count {
break;
}
let x = spacing_x * (col as f32 + 0.5);
let y = spacing_y * (row as f32 + 0.5);
let color = if spawn_color_percent > 0 && rng.gen_range(0..100) < spawn_color_percent {
BallColor::random_color()
} else {
BallColor::White
};
let _ = physics_world.spawn_ball_with_velocity_and_color(x, y, 0.0, 0.0, color);
spawned += 1;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UiState {
Simulation,
OptionsMenu,
ShapeMenu,
FileExplorer,
HelpMenu,
}
pub struct App {
physics_world: PhysicsWorld,
terminal_size: (u16, u16),
last_resize_time: Instant,
prev_terminal_size: (u16, u16),
running: bool,
ui_state: UiState,
options_menu: OptionsMenu,
shape_menu: ShapeMenu,
help_menu: HelpMenu,
shape_manager: ShapeManager,
frame_timer: FrameTimer,
canvas: BrailleCanvas,
physics_config: PhysicsConfig,
target_ball_count: usize,
ball_count_on_menu_open: usize,
mouse_held: bool,
mouse_position: Option<(f32, f32)>,
is_in_spawn_zone: bool,
spawn_hold_start: Option<Instant>,
space_held: bool,
space_hold_start: Option<Instant>,
mouse_spawn_accumulator: f32,
space_spawn_accumulator: f32,
last_space_event: Option<Instant>,
held_spawn_section: Option<u8>,
section_hold_start: Option<Instant>,
last_section_event: Option<Instant>,
section_spawn_accumulator: f32,
active_geysers: [Option<Instant>; 6],
shape_dragging: bool,
drag_offset: Option<(f32, f32)>,
last_click_time: Option<Instant>,
last_click_position: Option<(f32, f32)>,
file_explorer: crate::ui::FileExplorer,
last_arrow_key_press: [Option<Instant>; 4],
}
impl App {
pub fn new(
width: u16,
height: u16,
initial_balls: usize,
place_shapes: bool,
color_mode: bool,
) -> AppResult<Self> {
let mut options_menu = OptionsMenu::default();
options_menu.set_ball_count(initial_balls.min(MAX_BALLS));
options_menu.set_color_mode(color_mode);
let target_ball_count = options_menu.ball_count();
let canvas_height = height.saturating_sub(StatusBar::HEIGHT);
let world_width = f32::from(width);
let world_height = f32::from(canvas_height);
let physics_config = PhysicsConfig {
gravity: vector![0.0, -options_menu.gravity()],
friction: options_menu.friction(),
..Default::default()
};
let mut physics_world =
PhysicsWorld::new(world_width, world_height, physics_config.clone());
let spawn_color_percent = options_menu.spawn_color_percent();
spawn_balls_in_grid(
&mut physics_world,
target_ball_count,
world_width,
world_height,
spawn_color_percent,
);
let canvas = BrailleCanvas::new(width, canvas_height);
let frame_timer = FrameTimer::new();
let shape_menu = ShapeMenu::new();
let help_menu = HelpMenu::new();
let mut shape_manager = ShapeManager::new();
if place_shapes {
let (rigid_bodies, colliders, _, _, _) = physics_world.shape_components_mut();
shape_manager.place_initial_shapes(world_width, world_height, rigid_bodies, colliders);
for shape in shape_manager.shapes() {
let ascii_art = get_ascii_art(shape.shape_type());
let displacement_radius =
(ascii_art.width().max(ascii_art.height()) as f32 / 2.0) + 2.0;
let (x, y) = shape.position();
physics_world.displace_balls_from_shape(x, y, displacement_radius);
}
}
Ok(Self {
physics_world,
terminal_size: (width, height),
last_resize_time: Instant::now(),
prev_terminal_size: (width, height),
running: true,
ui_state: UiState::Simulation,
options_menu,
shape_menu,
help_menu,
shape_manager,
frame_timer,
canvas,
physics_config,
target_ball_count,
ball_count_on_menu_open: target_ball_count,
mouse_held: false,
mouse_position: None,
is_in_spawn_zone: false,
spawn_hold_start: None,
space_held: false,
space_hold_start: None,
mouse_spawn_accumulator: 0.0,
space_spawn_accumulator: 0.0,
last_space_event: None,
held_spawn_section: None,
section_hold_start: None,
last_section_event: None,
section_spawn_accumulator: 0.0,
active_geysers: [None; 6],
shape_dragging: false,
drag_offset: None,
last_click_time: None,
last_click_position: None,
file_explorer: crate::ui::FileExplorer::new(),
last_arrow_key_press: [None; 4],
})
}
pub fn is_running(&self) -> bool {
self.running
}
pub fn handle_event(&mut self, event: AppEvent) -> AppResult<()> {
match event {
AppEvent::Quit => {
self.running = false;
}
AppEvent::ToggleOptions => {
if !self.options_menu.visible {
self.shape_menu.hide();
self.ball_count_on_menu_open = self.options_menu.ball_count();
self.options_menu.show();
self.ui_state = UiState::OptionsMenu;
} else {
self.options_menu.hide();
self.ui_state = UiState::Simulation;
self.check_auto_reset()?;
}
}
AppEvent::ToggleShapes => {
if !self.shape_menu.visible {
self.options_menu.hide();
self.shape_menu.show();
self.ui_state = UiState::ShapeMenu;
} else {
self.shape_menu.hide();
self.ui_state = UiState::Simulation;
}
}
AppEvent::ToggleColorMode => {
self.options_menu.toggle_color_mode();
}
AppEvent::ToggleHelp => {
if !self.help_menu.visible {
self.options_menu.hide();
self.shape_menu.hide();
self.file_explorer.hide();
self.help_menu.show();
self.ui_state = UiState::HelpMenu;
} else {
self.help_menu.hide();
self.ui_state = UiState::Simulation;
}
}
AppEvent::CloseHelpMenu => {
self.help_menu.hide();
self.ui_state = UiState::Simulation;
}
AppEvent::HelpMenuScrollUp => {
self.help_menu.scroll_up(1);
}
AppEvent::HelpMenuScrollDown => {
self.help_menu.scroll_down(1);
}
AppEvent::HelpMenuPageUp => {
self.help_menu.page_up();
}
AppEvent::HelpMenuPageDown => {
self.help_menu.page_down();
}
AppEvent::HelpMenuScrollToTop => {
self.help_menu.scroll_to_top();
}
AppEvent::HelpMenuScrollToBottom => {
self.help_menu.scroll_to_bottom();
}
AppEvent::CloseShapeMenu => {
self.shape_menu.hide();
self.ui_state = UiState::Simulation;
}
AppEvent::ShapeMenuUp => {
self.shape_menu.select_up();
}
AppEvent::ShapeMenuDown => {
self.shape_menu.select_down();
}
AppEvent::ShapeMenuLeft => {
self.shape_menu.select_left();
}
AppEvent::ShapeMenuRight => {
self.shape_menu.select_right();
}
AppEvent::PlaceSelectedShape => {
self.place_selected_shape()?;
}
AppEvent::ClearShapes => {
self.clear_all_shapes();
}
AppEvent::RotateShapeClockwise => {
self.shape_manager
.rotate_selected_clockwise(self.physics_world.rigid_body_set_mut());
}
AppEvent::RotateShapeCounterClockwise => {
self.shape_manager
.rotate_selected_counter_clockwise(self.physics_world.rigid_body_set_mut());
}
AppEvent::MoveSelectedShape { dx, dy } => {
self.shape_manager
.move_selected(dx, dy, self.physics_world.rigid_body_set_mut());
}
AppEvent::DeleteSelectedShape => {
self.delete_selected_shape();
}
AppEvent::DeselectShape => {
self.shape_manager.deselect();
self.shape_dragging = false;
self.drag_offset = None;
}
AppEvent::CycleColorForward => {
self.shape_manager.cycle_selected_color_forward();
}
AppEvent::CycleColorBackward => {
self.shape_manager.cycle_selected_color_backward();
}
AppEvent::ShapeMenuClick { x, y } => {
self.handle_shape_menu_click(x, y)?;
}
AppEvent::CloseMenu => {
self.options_menu.hide();
self.shape_menu.hide();
self.ui_state = UiState::Simulation;
self.check_auto_reset()?;
}
AppEvent::Reset => {
self.reset_simulation()?;
}
AppEvent::MenuUp => {
self.options_menu.select_previous();
}
AppEvent::MenuDown => {
self.options_menu.select_next();
}
AppEvent::MenuIncrease => {
self.options_menu.increase_value();
self.apply_menu_changes();
}
AppEvent::MenuDecrease => {
self.options_menu.decrease_value();
self.apply_menu_changes();
}
AppEvent::MenuStartEdit => {
self.options_menu.start_editing();
}
AppEvent::MenuEditChar(c) => {
self.options_menu.handle_edit_char(c);
}
AppEvent::MenuEditBackspace => {
self.options_menu.handle_edit_backspace();
}
AppEvent::MenuConfirmEdit => {
if self.options_menu.confirm_edit() {
self.apply_menu_changes();
}
}
AppEvent::MenuCancelEdit => {
self.options_menu.cancel_edit();
}
AppEvent::SpawnBalls { x, y } => {
self.spawn_balls_at(x, y)?;
}
AppEvent::ApplyBurst { x, y } => {
let force_mult = self.options_menu.force_percent();
self.physics_world
.apply_burst(x, y, BURST_STRENGTH * force_mult, BURST_RADIUS);
}
AppEvent::MouseDown {
x,
y,
in_spawn_zone,
} => {
let now = Instant::now();
let is_double_click = if let (Some(last_time), Some((last_x, last_y))) =
(self.last_click_time, self.last_click_position)
{
let elapsed = now.duration_since(last_time).as_millis();
let distance = ((x - last_x).powi(2) + (y - last_y).powi(2)).sqrt();
elapsed < 400 && distance < 2.0
} else {
false
};
self.last_click_time = Some(now);
self.last_click_position = Some((x, y));
let shape_selected = self
.shape_manager
.select_at(x, y, self.physics_world.collider_set())
.is_some();
if shape_selected {
if is_double_click {
self.delete_selected_shape();
} else {
self.shape_dragging = true;
if let Some(shape) = self.shape_manager.selected_shape() {
let (sx, sy) = shape.position();
self.drag_offset = Some((x - sx, y - sy));
}
}
} else {
self.mouse_held = true;
self.mouse_position = Some((x, y));
self.is_in_spawn_zone = in_spawn_zone;
self.spawn_hold_start = Some(Instant::now());
self.mouse_spawn_accumulator = 1.0;
if in_spawn_zone {
self.spawn_balls_at(x, y)?;
} else {
let force_mult = self.options_menu.force_percent();
self.physics_world.apply_burst(
x,
y,
BURST_STRENGTH * force_mult,
BURST_RADIUS,
);
}
}
}
AppEvent::MouseDrag { x, y } => {
if self.shape_dragging {
let (target_x, target_y) = if let Some((ox, oy)) = self.drag_offset {
(x - ox, y - oy)
} else {
(x, y)
};
self.shape_manager.move_selected_to(
target_x,
target_y,
self.physics_world.rigid_body_set_mut(),
);
} else if self.mouse_held {
self.mouse_position = Some((x, y));
}
}
AppEvent::MouseUp => {
self.mouse_held = false;
self.mouse_position = None;
self.is_in_spawn_zone = false;
self.spawn_hold_start = None;
self.mouse_spawn_accumulator = 0.0;
self.shape_dragging = false;
self.drag_offset = None;
}
AppEvent::StartShapeDrag { x, y } => {
if self.shape_manager.selected_id().is_some() {
self.shape_dragging = true;
if let Some(shape) = self.shape_manager.selected_shape() {
let (sx, sy) = shape.position();
self.drag_offset = Some((x - sx, y - sy));
}
}
}
AppEvent::DragShape { x, y } => {
if self.shape_dragging {
let (target_x, target_y) = if let Some((ox, oy)) = self.drag_offset {
(x - ox, y - oy)
} else {
(x, y)
};
self.shape_manager.move_selected_to(
target_x,
target_y,
self.physics_world.rigid_body_set_mut(),
);
}
}
AppEvent::EndShapeDrag => {
self.shape_dragging = false;
self.drag_offset = None;
}
AppEvent::DoubleClick { x, y } => {
if self
.shape_manager
.select_at(x, y, self.physics_world.collider_set())
.is_some()
{
self.delete_selected_shape();
}
}
AppEvent::NumberBurst { digit } => {
self.apply_number_burst(digit);
if (1..=6).contains(&digit) {
self.active_geysers[(digit - 1) as usize] = Some(Instant::now());
}
}
AppEvent::Nudge { dx, dy } => {
if self.check_arrow_key_cooldown(dx, dy) {
self.physics_world.nudge_all(dx, dy);
}
}
AppEvent::SpawnAtSection { digit } => {
let now = Instant::now();
let is_new_key = self.held_spawn_section != Some(digit);
if is_new_key {
self.held_spawn_section = Some(digit);
self.section_hold_start = Some(now);
self.section_spawn_accumulator = 1.0; self.spawn_at_section(digit)?;
}
self.last_section_event = Some(now);
}
AppEvent::SpaceDown => {
let now = Instant::now();
if !self.space_held {
self.space_held = true;
self.space_hold_start = Some(now);
self.space_spawn_accumulator = 1.0;
self.spawn_across_full_width()?;
}
self.last_space_event = Some(now);
}
AppEvent::SpaceUp => {
self.space_held = false;
self.space_hold_start = None;
self.space_spawn_accumulator = 0.0;
self.last_space_event = None;
}
AppEvent::Resize {
new_width,
new_height,
delta_width,
delta_height,
} => {
self.handle_resize(new_width, new_height, delta_width, delta_height)?;
}
AppEvent::SaveLevel => {
self.save_level()?;
}
AppEvent::LoadLevel => {
self.load_level()?;
}
AppEvent::OpenSaveExplorer => {
self.file_explorer.show_save();
self.ui_state = UiState::FileExplorer;
}
AppEvent::OpenLoadExplorer => {
self.file_explorer.show_load();
self.ui_state = UiState::FileExplorer;
}
AppEvent::CloseFileExplorer => {
self.file_explorer.hide();
self.ui_state = UiState::Simulation;
}
AppEvent::FileExplorerUp => {
self.file_explorer.select_previous();
}
AppEvent::FileExplorerDown => {
self.file_explorer.select_next();
}
AppEvent::FileExplorerConfirm => {
self.handle_file_explorer_confirm()?;
}
AppEvent::FileExplorerChar(c) => {
self.file_explorer.handle_char(c);
}
AppEvent::FileExplorerBackspace => {
self.file_explorer.handle_backspace();
}
AppEvent::None => {}
}
Ok(())
}
fn apply_menu_changes(&mut self) {
self.frame_timer
.set_fps_cap(self.options_menu.fps_cap_enabled());
self.physics_config.gravity = vector![0.0, -self.options_menu.gravity()];
self.physics_config.friction = self.options_menu.friction();
self.physics_world
.update_config(self.physics_config.clone());
self.target_ball_count = self.options_menu.ball_count();
}
fn check_auto_reset(&mut self) -> AppResult<()> {
let new_ball_count = self.options_menu.ball_count();
if new_ball_count != self.ball_count_on_menu_open {
self.target_ball_count = new_ball_count;
self.reset_simulation()?;
}
Ok(())
}
fn reset_simulation(&mut self) -> AppResult<()> {
self.physics_world.clear_balls();
let (world_width, world_height) = self.physics_world.dimensions();
let spawn_color_percent = self.options_menu.spawn_color_percent();
spawn_balls_in_grid(
&mut self.physics_world,
self.target_ball_count,
world_width,
world_height,
spawn_color_percent,
);
Ok(())
}
fn place_selected_shape(&mut self) -> AppResult<()> {
let shape_type = self.shape_menu.selected_shape_type();
let (world_width, world_height) = self.physics_world.dimensions();
let position = self
.shape_manager
.find_random_position(shape_type, world_width, world_height, 100)
.unwrap_or_else(|| {
let center_x = world_width / 2.0;
let center_y = world_height * 0.375; (center_x, center_y)
});
let (rigid_bodies, colliders, _, _, _) = self.physics_world.shape_components_mut();
self.shape_manager
.add_shape(shape_type, position.0, position.1, rigid_bodies, colliders);
let ascii_art = get_ascii_art(shape_type);
let displacement_radius = (ascii_art.width().max(ascii_art.height()) as f32 / 2.0) + 2.0;
self.physics_world
.displace_balls_from_shape(position.0, position.1, displacement_radius);
self.shape_menu.hide();
self.ui_state = UiState::Simulation;
Ok(())
}
fn check_arrow_key_cooldown(&mut self, dx: f32, dy: f32) -> bool {
const ARROW_KEY_COOLDOWN_MS: u128 = 500;
let dir_index = if dy > 0.0 {
0 } else if dy < 0.0 {
1 } else if dx < 0.0 {
2 } else {
3 };
let now = Instant::now();
let can_proceed = match self.last_arrow_key_press[dir_index] {
Some(last_time) => now.duration_since(last_time).as_millis() >= ARROW_KEY_COOLDOWN_MS,
None => true, };
if can_proceed {
self.last_arrow_key_press[dir_index] = Some(now);
}
can_proceed
}
fn clear_all_shapes(&mut self) {
let (rigid_bodies, colliders, islands, impulse_joints, multibody_joints) =
self.physics_world.shape_components_mut();
self.shape_manager.clear_all(
rigid_bodies,
colliders,
islands,
impulse_joints,
multibody_joints,
);
}
fn delete_selected_shape(&mut self) {
let (rigid_bodies, colliders, islands, impulse_joints, multibody_joints) =
self.physics_world.shape_components_mut();
self.shape_manager.remove_selected(
rigid_bodies,
colliders,
islands,
impulse_joints,
multibody_joints,
);
self.shape_dragging = false;
self.drag_offset = None;
}
fn handle_shape_menu_click(&mut self, x: u16, y: u16) -> AppResult<()> {
let area = ratatui::layout::Rect {
x: 0,
y: 0,
width: self.terminal_size.0,
height: self.terminal_size.1,
};
let popup_width = (area.width * 60 / 100).max(30);
let popup_height = (area.height * 50 / 100).max(12);
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
if x >= popup_x && x < popup_x + popup_width && y >= popup_y && y < popup_y + popup_height {
let local_x = x - popup_x;
let local_y = y - popup_y;
if let Some(_shape_type) =
self.shape_menu
.handle_click(local_x, local_y, popup_width, popup_height)
{
self.place_selected_shape()?;
}
} else {
self.shape_menu.hide();
self.ui_state = UiState::Simulation;
}
Ok(())
}
fn apply_number_burst(&mut self, digit: u8) {
let (world_width, _world_height) = self.physics_world.dimensions();
let term_width = self.terminal_size.0;
let max_digits = 6.min((term_width / 6) as u8).max(1);
if digit > max_digits || digit == 0 {
return;
}
let zone_width_physics = world_width / max_digits as f32;
let x = (digit as f32 - 0.5) * zone_width_physics;
let y = 1.0;
let burst_radius = zone_width_physics / 2.0;
let force_mult = self.options_menu.force_percent();
let effective_radius = burst_radius.max(BURST_RADIUS);
if self.options_menu.color_mode() {
let color = BallColor::from_geyser(digit);
self.physics_world.apply_directional_burst_with_color(
x,
y,
0.0, 1.0, BURST_STRENGTH * force_mult,
effective_radius,
color,
);
} else {
self.physics_world.apply_directional_burst(
x,
y,
0.0, 1.0, BURST_STRENGTH * force_mult,
effective_radius,
);
}
}
fn spawn_balls_at(&mut self, x: f32, y: f32) -> AppResult<()> {
let spawn_color_percent = self.options_menu.spawn_color_percent();
for i in 0..BALLS_PER_SPAWN {
let offset_x = (i % 3) as f32 - 1.0;
let offset_y = (i / 3) as f32 * 0.5;
let color = Self::choose_spawn_color(spawn_color_percent);
let _ = self.physics_world.spawn_ball_with_velocity_and_color(
x + offset_x,
y + offset_y,
0.0,
0.0,
color,
);
}
Ok(())
}
fn choose_spawn_color(spawn_color_percent: i32) -> BallColor {
if spawn_color_percent <= 0 {
return BallColor::White;
}
let mut rng = rand::thread_rng();
if rng.gen_range(0..100) < spawn_color_percent {
BallColor::random_color()
} else {
BallColor::White
}
}
fn spawn_at_section(&mut self, digit: u8) -> AppResult<()> {
let (world_width, world_height) = self.physics_world.dimensions();
let term_width = self.terminal_size.0;
let spawn_color_percent = self.options_menu.spawn_color_percent();
let max_digits = 6.min((term_width / 6) as u8).max(1);
if digit > max_digits || digit == 0 {
return Ok(());
}
let zone_width_physics = world_width / max_digits as f32;
let zone_start_x = (digit as f32 - 1.0) * zone_width_physics;
let y = world_height - 1.0;
let num_balls = BALLS_PER_SPAWN;
let spacing = zone_width_physics / (num_balls as f32 + 1.0);
for i in 0..num_balls {
let x = zone_start_x + spacing * (i as f32 + 1.0);
let color = Self::choose_spawn_color(spawn_color_percent);
let _ = self
.physics_world
.spawn_ball_with_velocity_and_color(x, y, 0.0, 0.0, color);
}
Ok(())
}
fn spawn_across_full_width(&mut self) -> AppResult<()> {
let (world_width, world_height) = self.physics_world.dimensions();
let spawn_color_percent = self.options_menu.spawn_color_percent();
let y = world_height - 1.0;
let num_balls = BALLS_PER_SPAWN * 2; let spacing = world_width / (num_balls as f32 + 1.0);
for i in 0..num_balls {
let x = spacing * (i as f32 + 1.0);
let color = Self::choose_spawn_color(spawn_color_percent);
let _ = self
.physics_world
.spawn_ball_with_velocity_and_color(x, y, 0.0, 0.0, color);
}
Ok(())
}
fn handle_resize(
&mut self,
new_width: u16,
new_height: u16,
delta_width: i32,
delta_height: i32,
) -> AppResult<()> {
let now = Instant::now();
let elapsed = now
.duration_since(self.last_resize_time)
.as_secs_f32()
.max(0.001);
let (old_world_width, old_world_height) = self.physics_world.dimensions();
let canvas_height = new_height.saturating_sub(StatusBar::HEIGHT);
let new_world_width = f32::from(new_width);
let new_world_height = f32::from(canvas_height);
let velocity_x = delta_width as f32 / elapsed;
let velocity_y = delta_height as f32 / elapsed;
self.physics_world.apply_boundary_shrink_force(
old_world_width,
old_world_height,
new_world_width,
new_world_height,
velocity_x,
velocity_y,
);
self.physics_world
.update_boundaries(new_world_width, new_world_height);
self.canvas.resize(new_width, canvas_height);
self.prev_terminal_size = self.terminal_size;
self.terminal_size = (new_width, new_height);
self.last_resize_time = now;
Ok(())
}
pub fn update_physics(&mut self, delta: std::time::Duration) {
self.frame_timer.add_physics_time(delta.as_secs_f32());
let mut steps = 0;
while self.frame_timer.physics_accumulator() >= PHYSICS_DT && steps < MAX_PHYSICS_STEPS {
self.physics_world.step();
self.frame_timer.consume_physics_step(PHYSICS_DT);
steps += 1;
}
self.frame_timer
.clamp_accumulator(MAX_PHYSICS_STEPS, PHYSICS_DT);
self.update_hold_effects();
self.update_geyser_visual();
}
fn update_hold_effects(&mut self) {
self.check_key_release_timeouts();
self.update_mouse_hold();
self.update_space_hold();
self.update_section_hold();
}
fn check_key_release_timeouts(&mut self) {
let now = Instant::now();
if let Some(last_event) = self.last_space_event {
if now.duration_since(last_event).as_millis() > KEY_RELEASE_TIMEOUT_MS {
self.space_held = false;
self.space_hold_start = None;
self.space_spawn_accumulator = 0.0;
self.last_space_event = None;
}
}
if let Some(last_event) = self.last_section_event {
if now.duration_since(last_event).as_millis() > KEY_RELEASE_TIMEOUT_MS {
self.held_spawn_section = None;
self.section_hold_start = None;
self.section_spawn_accumulator = 0.0;
self.last_section_event = None;
}
}
}
fn update_section_hold(&mut self) {
let Some(digit) = self.held_spawn_section else {
return;
};
let Some(start_time) = self.section_hold_start else {
return;
};
if self.physics_world.ball_count() >= MAX_BALLS {
return;
}
let (world_width, world_height) = self.physics_world.dimensions();
let term_width = self.terminal_size.0;
let spawn_color_percent = self.options_menu.spawn_color_percent();
let max_digits = 6.min((term_width / 6) as u8).max(1);
if digit > max_digits || digit == 0 {
return;
}
let zone_width_physics = world_width / max_digits as f32;
let zone_start_x = (digit as f32 - 1.0) * zone_width_physics;
let y = world_height - 1.0;
let elapsed = start_time.elapsed().as_secs_f32();
let spawn_rate = calculate_spawn_rate(elapsed);
self.section_spawn_accumulator += spawn_rate * PHYSICS_DT;
let balls_to_spawn = self.section_spawn_accumulator as usize;
if balls_to_spawn > 0 {
self.section_spawn_accumulator -= balls_to_spawn as f32;
let remaining_capacity = MAX_BALLS.saturating_sub(self.physics_world.ball_count());
let actual_spawn = balls_to_spawn.min(remaining_capacity);
let spacing = zone_width_physics / (actual_spawn as f32 + 1.0);
for i in 0..actual_spawn {
let x = zone_start_x + spacing * (i as f32 + 1.0);
let color = Self::choose_spawn_color(spawn_color_percent);
let _ = self
.physics_world
.spawn_ball_with_velocity_and_color(x, y, 0.0, 0.0, color);
}
}
}
fn update_geyser_visual(&mut self) {
for geyser in &mut self.active_geysers {
if let Some(activation_time) = geyser {
if activation_time.elapsed().as_millis() > 150 {
*geyser = None;
}
}
}
}
fn update_space_hold(&mut self) {
if !self.space_held {
return;
}
let Some(start_time) = self.space_hold_start else {
return;
};
if self.physics_world.ball_count() >= MAX_BALLS {
return;
}
let (world_width, world_height) = self.physics_world.dimensions();
let y = world_height - 1.0;
let spawn_color_percent = self.options_menu.spawn_color_percent();
let elapsed = start_time.elapsed().as_secs_f32();
let spawn_rate = calculate_spawn_rate(elapsed);
self.space_spawn_accumulator += spawn_rate * PHYSICS_DT;
let balls_to_spawn = self.space_spawn_accumulator as usize;
if balls_to_spawn > 0 {
self.space_spawn_accumulator -= balls_to_spawn as f32;
let remaining_capacity = MAX_BALLS.saturating_sub(self.physics_world.ball_count());
let actual_spawn = balls_to_spawn.min(remaining_capacity);
let spacing = world_width / (actual_spawn as f32 + 1.0);
for i in 0..actual_spawn {
let x = spacing * (i as f32 + 1.0);
let color = Self::choose_spawn_color(spawn_color_percent);
let _ = self
.physics_world
.spawn_ball_with_velocity_and_color(x, y, 0.0, 0.0, color);
}
}
}
fn update_mouse_hold(&mut self) {
if !self.mouse_held {
return;
}
let Some((x, y)) = self.mouse_position else {
return;
};
if self.is_in_spawn_zone {
let Some(start_time) = self.spawn_hold_start else {
return;
};
if self.physics_world.ball_count() >= MAX_BALLS {
return;
}
let spawn_color_percent = self.options_menu.spawn_color_percent();
let elapsed = start_time.elapsed().as_secs_f32();
let spawn_rate = calculate_spawn_rate(elapsed);
self.mouse_spawn_accumulator += spawn_rate * PHYSICS_DT;
let balls_to_spawn = self.mouse_spawn_accumulator as usize;
if balls_to_spawn > 0 {
self.mouse_spawn_accumulator -= balls_to_spawn as f32;
let remaining_capacity = MAX_BALLS.saturating_sub(self.physics_world.ball_count());
let actual_spawn = balls_to_spawn.min(remaining_capacity);
for i in 0..actual_spawn {
let offset_x = (i % 3) as f32 - 1.0;
let offset_y = (i / 3) as f32 * 0.5;
let color = Self::choose_spawn_color(spawn_color_percent);
let _ = self.physics_world.spawn_ball_with_velocity_and_color(
x + offset_x,
y + offset_y,
0.0,
0.0,
color,
);
}
}
} else {
let force_mult = self.options_menu.force_percent();
self.physics_world
.apply_burst(x, y, BURST_STRENGTH * force_mult * 0.3, BURST_RADIUS);
}
}
pub fn render(&mut self, frame: &mut Frame) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(StatusBar::HEIGHT)])
.split(area);
let canvas_area = chunks[0];
let status_area = chunks[1];
self.update_canvas();
self.render_canvas(frame, canvas_area);
self.render_shapes(frame, canvas_area);
let active_geysers_bool: [bool; 6] = [
self.active_geysers[0].is_some(),
self.active_geysers[1].is_some(),
self.active_geysers[2].is_some(),
self.active_geysers[3].is_some(),
self.active_geysers[4].is_some(),
self.active_geysers[5].is_some(),
];
let status_info = StatusBarInfo {
fps: if self.options_menu.show_fps() {
Some(self.frame_timer.current_fps())
} else {
None
},
ball_count: self.physics_world.ball_count(),
gravity_percent: self.options_menu.gravity_percent(),
force_percent: (self.options_menu.force_percent() * 100.0) as i32,
active_geysers: active_geysers_bool,
color_mode: self.options_menu.color_mode(),
};
StatusBar::render(frame, status_area, &status_info);
if self.ui_state == UiState::OptionsMenu {
self.options_menu.render(frame, area);
}
if self.ui_state == UiState::ShapeMenu {
self.shape_menu.render(frame, area);
}
if self.ui_state == UiState::FileExplorer {
self.file_explorer.render(frame, area);
}
if self.ui_state == UiState::HelpMenu {
self.help_menu.render(frame, area);
}
}
fn render_shapes(&self, frame: &mut Frame, canvas_area: Rect) {
let (world_width, world_height) = self.physics_world.dimensions();
for shape in self.shape_manager.shapes() {
let (phys_x, phys_y) = shape.position();
let norm_x = phys_x / world_width;
let norm_y = 1.0 - (phys_y / world_height);
let ascii_art = get_rotated_ascii_art(shape.shape_type(), shape.rotation_degrees());
let term_x =
(norm_x * canvas_area.width as f32) as i16 - (ascii_art.width() as i16 / 2);
let term_y =
(norm_y * canvas_area.height as f32) as i16 - (ascii_art.height() as i16 / 2);
let color = shape.render_color();
let style = Style::default().fg(color);
for (dy, line) in ascii_art.lines().iter().enumerate() {
let row = term_y + dy as i16;
if row < 0 || row >= canvas_area.height as i16 {
continue;
}
let row_u16 = row as u16;
for (dx, ch) in line.chars().enumerate() {
if ch == ' ' {
continue;
}
let col = term_x + dx as i16;
if col < 0 || col >= canvas_area.width as i16 {
continue;
}
let col_u16 = col as u16;
let char_area = Rect {
x: canvas_area.x + col_u16,
y: canvas_area.y + row_u16,
width: 1,
height: 1,
};
let para = Paragraph::new(Span::styled(ch.to_string(), style));
frame.render_widget(para, char_area);
}
}
}
}
fn update_canvas(&mut self) {
self.canvas.clear();
let (world_width, world_height) = self.physics_world.dimensions();
let (canvas_width, canvas_height) = self.canvas.dimensions();
if self.options_menu.color_mode() {
let positions: Vec<(u32, u32, BallColor)> = self
.physics_world
.ball_positions_with_colors()
.map(|(px, py, color)| {
let (sx, sy) = physics_to_subpixel(
px,
py,
world_width,
world_height,
canvas_width,
canvas_height,
);
(sx, sy, color)
})
.collect();
self.canvas.plot_batch_parallel_with_colors(&positions);
} else {
let positions: Vec<(u32, u32)> = self
.physics_world
.ball_positions()
.map(|(px, py)| {
physics_to_subpixel(
px,
py,
world_width,
world_height,
canvas_width,
canvas_height,
)
})
.collect();
self.canvas.plot_batch_parallel(&positions);
}
self.canvas.sync_from_atomic();
}
fn render_canvas(&self, frame: &mut Frame, area: Rect) {
let color_mode = self.options_menu.color_mode();
for row in 0..area.height {
let mut line_spans = Vec::with_capacity(area.width as usize);
for col in 0..area.width {
let ch = self.canvas.get_char(col, row);
let ball_count = self.canvas.get_ball_count(col, row);
let bg = density_to_color(ball_count);
let fg = if color_mode && ball_count > 0 {
self.canvas.get_dominant_color(col, row).to_ratatui_color()
} else {
density_to_foreground(ball_count)
};
let style = match bg {
Some(bg_color) => Style::default().fg(fg).bg(bg_color),
None => Style::default().fg(fg),
};
line_spans.push(ratatui::text::Span::styled(ch.to_string(), style));
}
let line = ratatui::text::Line::from(line_spans);
let para = Paragraph::new(line);
let row_area = Rect {
x: area.x,
y: area.y + row,
width: area.width,
height: 1,
};
frame.render_widget(para, row_area);
}
}
pub fn event_context(&self) -> EventContext {
let (world_width, world_height) = self.physics_world.dimensions();
EventContext {
terminal_width: self.terminal_size.0,
terminal_height: self.terminal_size.1,
menu_open: self.ui_state == UiState::OptionsMenu,
menu_editing: self.options_menu.is_editing(),
shape_menu_open: self.ui_state == UiState::ShapeMenu,
file_explorer_open: self.ui_state == UiState::FileExplorer,
file_explorer_editing: self.file_explorer.is_editing_filename(),
help_menu_open: self.ui_state == UiState::HelpMenu,
world_width,
world_height,
shape_selected: self.shape_manager.selected_id().is_some(),
shape_dragging: self.shape_dragging,
}
}
pub fn begin_frame(&mut self) -> std::time::Duration {
self.frame_timer.begin_frame()
}
pub fn end_frame(&mut self) {
self.frame_timer.end_frame();
}
fn save_level(&self) -> AppResult<()> {
let mut config = LevelConfig::new();
for shape in self.shape_manager.shapes() {
config.shapes.push(SavedShape::from_shape(shape));
}
config.options = options_from_menu(&self.options_menu);
config.save_to_file("level.json")?;
Ok(())
}
fn load_level(&mut self) -> AppResult<()> {
self.load_level_from_path("level.json")
}
pub fn load_level_from_path(&mut self, path: &str) -> AppResult<()> {
let config = LevelConfig::load_from_file(path)?;
self.clear_all_shapes();
apply_options_to_menu(&config.options, &mut self.options_menu);
self.apply_menu_changes();
self.target_ball_count = self.options_menu.ball_count();
let (world_width, world_height) = self.physics_world.dimensions();
for saved_shape in &config.shapes {
let (shape_type, x, y, rotation_degrees, color) = saved_shape.to_shape_params();
let clamped_x = x.clamp(1.0, world_width - 1.0);
let clamped_y = y.clamp(1.0, world_height - 1.0);
let (rigid_bodies, colliders, _, _, _) = self.physics_world.shape_components_mut();
let id = self.shape_manager.add_shape(
shape_type,
clamped_x,
clamped_y,
rigid_bodies,
colliders,
);
if let Some(shape) = self.shape_manager.get_shape_mut(id) {
let rotations = (rotation_degrees / 90).rem_euclid(4);
for _ in 0..rotations {
shape.rotate_clockwise();
}
if let Some(handle) = shape.rigid_body_handle() {
if let Some(body) = self.physics_world.rigid_body_set_mut().get_mut(handle) {
let rotation = shape.rotation_radians();
let isometry = rapier2d::prelude::Isometry::new(
vector![clamped_x, clamped_y],
rotation,
);
body.set_position(isometry, true);
}
}
shape.set_color(color);
}
}
self.reset_simulation()?;
for shape in self.shape_manager.shapes() {
let ascii_art = get_ascii_art(shape.shape_type());
let displacement_radius =
(ascii_art.width().max(ascii_art.height()) as f32 / 2.0) + 2.0;
let (x, y) = shape.position();
self.physics_world
.displace_balls_from_shape(x, y, displacement_radius);
}
Ok(())
}
fn handle_file_explorer_confirm(&mut self) -> AppResult<()> {
use crate::ui::FileExplorerMode;
let mode = self.file_explorer.mode();
if let Some(path) = self.file_explorer.get_target_path() {
match mode {
FileExplorerMode::Save => {
self.save_level_to_path(&path)?;
}
FileExplorerMode::Load => {
self.load_level_from_path(&path)?;
}
}
}
self.file_explorer.hide();
self.ui_state = UiState::Simulation;
Ok(())
}
fn save_level_to_path(&self, path: &str) -> AppResult<()> {
let mut config = LevelConfig::new();
for shape in self.shape_manager.shapes() {
config.shapes.push(SavedShape::from_shape(shape));
}
config.options = options_from_menu(&self.options_menu);
config.save_to_file(path)?;
Ok(())
}
}