allow collapsing chapters in level list

This commit is contained in:
Crispy 2025-04-13 00:32:05 +02:00
parent 69fe8546f5
commit 522a027f7a
3 changed files with 89 additions and 77 deletions

View file

@ -2,6 +2,8 @@
Game store page: https://crispypin.itch.io/marble-machinations Game store page: https://crispypin.itch.io/marble-machinations
## [unreleased] ## [unreleased]
### added
- click to collapse chapters in level list
### fixed ### fixed
- When two input bindings had the same trigger but one has a strict subset of the others modifiers, both would activate when the one with more modifiers was pressed. For example (Ctrl+S -> Save) would also trigger (S -> Wire Tool). Now, Shift+S will still trigger Wire Tool, unless Shift+S (or eg. Shift+Ctrl+S) is bound to something else. - When two input bindings had the same trigger but one has a strict subset of the others modifiers, both would activate when the one with more modifiers was pressed. For example (Ctrl+S -> Save) would also trigger (S -> Wire Tool). Now, Shift+S will still trigger Wire Tool, unless Shift+S (or eg. Shift+Ctrl+S) is bound to something else.

View file

@ -6,6 +6,8 @@ use crate::board::Board;
pub struct Chapter { pub struct Chapter {
pub title: String, pub title: String,
pub levels: Vec<Level>, pub levels: Vec<Level>,
#[serde(default = "default_true")]
pub visible: bool,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@ -81,3 +83,7 @@ impl Stage {
&self.output &self.output
} }
} }
fn default_true() -> bool {
true
}

View file

@ -10,7 +10,7 @@ use marble_machinations::*;
use config::Config; use config::Config;
use editor::{Editor, ExitState}; use editor::{Editor, ExitState};
use level::{Chapter, Level}; use level::Chapter;
use solution::Solution; use solution::Solution;
use theme::*; use theme::*;
use ui::{simple_option_button, tex32_button, text_button, text_input, ShapedText, Tooltip}; use ui::{simple_option_button, tex32_button, text_button, text_input, ShapedText, Tooltip};
@ -19,11 +19,11 @@ use util::*;
const TITLE_TEXT: &str = concat!("Marble Machinations v", env!("CARGO_PKG_VERSION")); const TITLE_TEXT: &str = concat!("Marble Machinations v", env!("CARGO_PKG_VERSION"));
struct Game { struct Game {
levels: Vec<LevelListEntry>, chapters: Vec<Chapter>,
level_scroll: usize, level_scroll: i32,
solutions: HashMap<String, Vec<Solution>>, solutions: HashMap<String, Vec<Solution>>,
open_editor: Option<Editor>, open_editor: Option<Editor>,
selected_level: usize, selected_level: (usize, usize),
selected_solution: usize, selected_solution: usize,
delete_solution: Option<usize>, delete_solution: Option<usize>,
editing_solution_name: bool, editing_solution_name: bool,
@ -32,12 +32,6 @@ struct Game {
edit_settings: Option<Config>, edit_settings: Option<Config>,
} }
#[derive(Debug)]
enum LevelListEntry {
Level(Level),
Chapter(String, usize),
}
fn main() { fn main() {
let (mut rl, thread) = raylib::init().resizable().title(TITLE_TEXT).build(); let (mut rl, thread) = raylib::init().resizable().title(TITLE_TEXT).build();
rl.set_target_fps(60); rl.set_target_fps(60);
@ -52,15 +46,15 @@ fn main() {
impl Game { impl Game {
fn new(rl: &mut RaylibHandle, thread: &RaylibThread) -> Self { fn new(rl: &mut RaylibHandle, thread: &RaylibThread) -> Self {
let levels = get_levels(); let chapters = get_chapters();
let solutions = get_solutions(); let solutions = get_solutions();
Self { Self {
levels, chapters,
level_scroll: 0, level_scroll: 0,
solutions, solutions,
open_editor: None, open_editor: None,
selected_level: 0, selected_level: (0, 0),
selected_solution: 0, selected_solution: 0,
delete_solution: None, delete_solution: None,
editing_solution_name: false, editing_solution_name: false,
@ -147,38 +141,49 @@ impl Game {
const ENTRY_SPACING: i32 = 65; const ENTRY_SPACING: i32 = 65;
let fit_on_screen = (d.get_screen_height() / ENTRY_SPACING) as usize; let fit_on_screen = (d.get_screen_height() / ENTRY_SPACING) as usize;
let max_scroll = self.levels.len().saturating_sub(fit_on_screen); let max_scroll = self
.chapters
.iter()
.map(|c| 1 + if c.visible { c.levels.len() } else { 0 })
.sum::<usize>()
.saturating_sub(fit_on_screen) as i32
* ENTRY_SPACING;
if self.globals.mouse.pos().x < level_list_width as f32 { if self.globals.mouse.pos().x < level_list_width as f32 {
if self.globals.mouse.scroll() == Some(Scroll::Down) && self.level_scroll < max_scroll { if self.globals.mouse.scroll() == Some(Scroll::Down) && self.level_scroll < max_scroll {
self.level_scroll += 1; self.level_scroll += ENTRY_SPACING;
} }
if self.globals.mouse.scroll() == Some(Scroll::Up) && self.level_scroll > 0 { if self.globals.mouse.scroll() == Some(Scroll::Up) && self.level_scroll > 0 {
self.level_scroll -= 1; self.level_scroll -= ENTRY_SPACING;
} }
} }
for (row_index, level_index) in (self.level_scroll..self.levels.len()).enumerate() { let mut y = 10 - self.level_scroll;
let level = &mut self.levels[level_index]; for (chapter_i, chapter) in self.chapters.iter_mut().enumerate() {
let y = 10 + row_index as i32 * ENTRY_SPACING; let bounds = rect(5, y - 5, level_list_width - 10, ENTRY_SPACING - 5);
let bounds = Rectangle { d.draw_rectangle_rec(bounds, BG_DARK);
x: 5., d.draw_text(&chapter.title, 10, y, 30, FG_CHAPTER_TITLE);
y: y as f32 - 5., let subtitle = format!("{} levels", chapter.levels.len());
width: level_list_width as f32 - 10., d.draw_text(&subtitle, 10, y + 30, 20, Color::WHITE);
height: ENTRY_SPACING as f32 - 5., y += ENTRY_SPACING;
};
let clicked_this = let clicked_this =
self.globals.mouse.left_click() && self.globals.mouse.is_over(bounds); self.globals.mouse.left_click() && self.globals.mouse.is_over(bounds);
match level {
LevelListEntry::Chapter(title, level_count) => { if clicked_this {
d.draw_rectangle_rec(bounds, BG_DARK); chapter.visible = !chapter.visible;
d.draw_text(title, 10, y, 30, FG_CHAPTER_TITLE);
let subtitle = format!("{level_count} levels");
d.draw_text(&subtitle, 10, y + 30, 20, Color::WHITE);
} }
LevelListEntry::Level(level) => {
if clicked_this && self.selected_level != level_index { if !chapter.visible {
continue;
}
for (level_index, level) in chapter.levels.iter().enumerate() {
let bounds = rect(5, y - 5, level_list_width - 10, ENTRY_SPACING - 5);
let clicked_this =
self.globals.mouse.left_click() && self.globals.mouse.is_over(bounds);
if clicked_this && self.selected_level != (chapter_i, level_index) {
self.editing_solution_name = false; self.editing_solution_name = false;
self.selected_level = level_index; self.selected_level = (chapter_i, level_index);
self.selected_solution = 0; self.selected_solution = 0;
self.delete_solution = None; self.delete_solution = None;
// select the last solution of the level, if there is one // select the last solution of the level, if there is one
@ -186,7 +191,10 @@ impl Game {
self.selected_solution = solutions.len().saturating_sub(1); self.selected_solution = solutions.len().saturating_sub(1);
} }
} }
d.draw_rectangle_rec(bounds, widget_bg(self.selected_level == level_index)); d.draw_rectangle_rec(
bounds,
widget_bg(self.selected_level == (chapter_i, level_index)),
);
let mut title_color = Color::WHITE; let mut title_color = Color::WHITE;
if let Some(solutions) = self.solutions.get(level.id()) { if let Some(solutions) = self.solutions.get(level.id()) {
@ -207,11 +215,15 @@ impl Game {
Color::LIGHTGRAY Color::LIGHTGRAY
}; };
d.draw_text(&subtext, 10, y + 30, 20, subtext_color); d.draw_text(&subtext, 10, y + 30, 20, subtext_color);
} y += ENTRY_SPACING;
} }
} }
if let Some(LevelListEntry::Level(level)) = self.levels.get(self.selected_level) { if let Some(level) = self
.chapters
.get(self.selected_level.0)
.and_then(|c| c.levels.get(self.selected_level.1))
{
d.draw_text(level.name(), level_list_width + 10, 10, 40, Color::CYAN); d.draw_text(level.name(), level_list_width + 10, 10, 40, Color::CYAN);
d.draw_text(level.id(), level_list_width + 10, 50, 10, Color::GRAY); d.draw_text(level.id(), level_list_width + 10, 50, 10, Color::GRAY);
@ -343,7 +355,7 @@ impl Game {
} }
} }
fn get_levels() -> Vec<LevelListEntry> { fn get_chapters() -> Vec<Chapter> {
let mut chapters = Vec::<Chapter>::new(); let mut chapters = Vec::<Chapter>::new();
for d in read_dir("levels").unwrap().flatten() { for d in read_dir("levels").unwrap().flatten() {
if let Ok(text) = read_to_string(d.path()) { if let Ok(text) = read_to_string(d.path()) {
@ -354,12 +366,6 @@ fn get_levels() -> Vec<LevelListEntry> {
} }
chapters.sort_unstable_by_key(|c| c.title.clone()); chapters.sort_unstable_by_key(|c| c.title.clone());
let mut level_list = Vec::new();
for c in chapters {
level_list.push(LevelListEntry::Chapter(c.title, c.levels.len()));
level_list.extend(c.levels.into_iter().map(LevelListEntry::Level));
}
// user levels // user levels
let mut custom_levels = Vec::new(); let mut custom_levels = Vec::new();
let custom_level_dir = userdata_dir().join("levels"); let custom_level_dir = userdata_dir().join("levels");
@ -372,15 +378,13 @@ fn get_levels() -> Vec<LevelListEntry> {
} }
} }
} }
level_list.push(LevelListEntry::Chapter( chapters.push(Chapter {
"Custom levels".into(), title: "Custom levels".into(),
custom_levels.len(), levels: custom_levels,
)); visible: true,
for l in custom_levels { });
level_list.push(LevelListEntry::Level(l));
}
level_list chapters
} }
fn get_solutions() -> HashMap<String, Vec<Solution>> { fn get_solutions() -> HashMap<String, Vec<Solution>> {