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
## [unreleased]
### added
- click to collapse chapters in level list
### 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.

View file

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

View file

@ -10,7 +10,7 @@ use marble_machinations::*;
use config::Config;
use editor::{Editor, ExitState};
use level::{Chapter, Level};
use level::Chapter;
use solution::Solution;
use theme::*;
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"));
struct Game {
levels: Vec<LevelListEntry>,
level_scroll: usize,
chapters: Vec<Chapter>,
level_scroll: i32,
solutions: HashMap<String, Vec<Solution>>,
open_editor: Option<Editor>,
selected_level: usize,
selected_level: (usize, usize),
selected_solution: usize,
delete_solution: Option<usize>,
editing_solution_name: bool,
@ -32,12 +32,6 @@ struct Game {
edit_settings: Option<Config>,
}
#[derive(Debug)]
enum LevelListEntry {
Level(Level),
Chapter(String, usize),
}
fn main() {
let (mut rl, thread) = raylib::init().resizable().title(TITLE_TEXT).build();
rl.set_target_fps(60);
@ -52,15 +46,15 @@ fn main() {
impl Game {
fn new(rl: &mut RaylibHandle, thread: &RaylibThread) -> Self {
let levels = get_levels();
let chapters = get_chapters();
let solutions = get_solutions();
Self {
levels,
chapters,
level_scroll: 0,
solutions,
open_editor: None,
selected_level: 0,
selected_level: (0, 0),
selected_solution: 0,
delete_solution: None,
editing_solution_name: false,
@ -147,71 +141,89 @@ impl Game {
const ENTRY_SPACING: i32 = 65;
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.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 {
self.level_scroll -= 1;
self.level_scroll -= ENTRY_SPACING;
}
}
for (row_index, level_index) in (self.level_scroll..self.levels.len()).enumerate() {
let level = &mut self.levels[level_index];
let y = 10 + row_index as i32 * ENTRY_SPACING;
let bounds = Rectangle {
x: 5.,
y: y as f32 - 5.,
width: level_list_width as f32 - 10.,
height: ENTRY_SPACING as f32 - 5.,
};
let mut y = 10 - self.level_scroll;
for (chapter_i, chapter) in self.chapters.iter_mut().enumerate() {
let bounds = rect(5, y - 5, level_list_width - 10, ENTRY_SPACING - 5);
d.draw_rectangle_rec(bounds, BG_DARK);
d.draw_text(&chapter.title, 10, y, 30, FG_CHAPTER_TITLE);
let subtitle = format!("{} levels", chapter.levels.len());
d.draw_text(&subtitle, 10, y + 30, 20, Color::WHITE);
y += ENTRY_SPACING;
let clicked_this =
self.globals.mouse.left_click() && self.globals.mouse.is_over(bounds);
match level {
LevelListEntry::Chapter(title, level_count) => {
d.draw_rectangle_rec(bounds, BG_DARK);
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 {
self.editing_solution_name = false;
self.selected_level = level_index;
self.selected_solution = 0;
self.delete_solution = None;
// select the last solution of the level, if there is one
if let Some(solutions) = self.solutions.get(level.id()) {
self.selected_solution = solutions.len().saturating_sub(1);
}
}
d.draw_rectangle_rec(bounds, widget_bg(self.selected_level == level_index));
let mut title_color = Color::WHITE;
if clicked_this {
chapter.visible = !chapter.visible;
}
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.selected_level = (chapter_i, level_index);
self.selected_solution = 0;
self.delete_solution = None;
// select the last solution of the level, if there is one
if let Some(solutions) = self.solutions.get(level.id()) {
if solutions.iter().any(|s| s.score.is_some()) {
title_color = Color::LIGHTGREEN;
}
self.selected_solution = solutions.len().saturating_sub(1);
}
d.draw_text(level.name(), 10, y, 30, title_color);
let solution_count = self
.solutions
.get(level.id())
.map(Vec::len)
.unwrap_or_default();
let subtext = format!("solutions: {solution_count}");
let subtext_color = if solution_count > 0 {
Color::GOLD
} else {
Color::LIGHTGRAY
};
d.draw_text(&subtext, 10, y + 30, 20, subtext_color);
}
d.draw_rectangle_rec(
bounds,
widget_bg(self.selected_level == (chapter_i, level_index)),
);
let mut title_color = Color::WHITE;
if let Some(solutions) = self.solutions.get(level.id()) {
if solutions.iter().any(|s| s.score.is_some()) {
title_color = Color::LIGHTGREEN;
}
}
d.draw_text(level.name(), 10, y, 30, title_color);
let solution_count = self
.solutions
.get(level.id())
.map(Vec::len)
.unwrap_or_default();
let subtext = format!("solutions: {solution_count}");
let subtext_color = if solution_count > 0 {
Color::GOLD
} else {
Color::LIGHTGRAY
};
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.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();
for d in read_dir("levels").unwrap().flatten() {
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());
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
let mut custom_levels = Vec::new();
let custom_level_dir = userdata_dir().join("levels");
@ -372,15 +378,13 @@ fn get_levels() -> Vec<LevelListEntry> {
}
}
}
level_list.push(LevelListEntry::Chapter(
"Custom levels".into(),
custom_levels.len(),
));
for l in custom_levels {
level_list.push(LevelListEntry::Level(l));
}
chapters.push(Chapter {
title: "Custom levels".into(),
levels: custom_levels,
visible: true,
});
level_list
chapters
}
fn get_solutions() -> HashMap<String, Vec<Solution>> {