split crate into game and marble engine library

This commit is contained in:
Crispy 2025-03-27 18:05:59 +01:00
parent d5bb0f7ba0
commit 8b1eaaa630
20 changed files with 241 additions and 456 deletions

View file

@ -0,0 +1,10 @@
[package]
name = "marble_machinations"
version = "0.2.1"
edition = "2021"
[dependencies]
marble_engine = { path = "../marble_engine" }
raylib = "5.0.2"
serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.128"

View file

@ -0,0 +1,9 @@
use std::{fs::File, io::Write};
fn main() {
let version = concat!("v", env!("CARGO_PKG_VERSION"));
File::create("../version.txt")
.unwrap()
.write_all(version.as_bytes())
.unwrap();
}

View file

@ -0,0 +1,65 @@
use std::{
fs::{self, File},
io::Write,
path::PathBuf,
};
use serde::{Deserialize, Serialize};
use crate::util::userdata_dir;
use marble_engine::board::Board;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Blueprint {
id: usize,
pub name: String,
pub board: String,
#[serde(skip, default)]
tile_board: Option<Board>,
}
impl Blueprint {
pub fn new(content: &Board, id: usize) -> Self {
Self {
id,
name: format!("Blueprint {id}"),
board: content.serialize(),
tile_board: Some(content.clone()),
}
}
pub fn id(&self) -> usize {
self.id
}
pub fn convert_board(&mut self) -> &Board {
if self.tile_board.is_none() {
self.tile_board = Some(Board::parse(&self.board));
}
self.tile_board.as_ref().unwrap()
}
pub fn get_board(&self) -> Option<&Board> {
self.tile_board.as_ref()
}
fn path(&self) -> PathBuf {
let dir = userdata_dir().join("blueprints");
fs::create_dir_all(&dir).unwrap();
dir.join(format!("blueprint_{}.json", &self.id))
}
pub fn save(&self) {
let path = self.path();
let json = serde_json::to_string_pretty(self).unwrap();
let mut file = File::create(path).unwrap();
file.write_all(json.as_bytes()).unwrap();
}
pub fn remove_file(&self) {
let path = self.path();
if let Err(e) = fs::remove_file(path) {
eprint!("Error removing blueprint file: {e}");
}
}
}

View file

@ -0,0 +1,26 @@
use std::collections::HashMap;
use raylib::ffi::KeyboardKey;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct Config {
hotkeys: Hotkeys,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct Hotkeys {
map: HashMap<String, Hotkey>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Hotkey {
modifiers: Vec<u32>,
trigger: Trigger,
}
#[derive(Debug, Deserialize, Serialize)]
pub enum Trigger {
Mouse(u32),
Key(u32),
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,81 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Chapter {
pub title: String,
pub levels: Vec<Level>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Level {
id: String,
name: String,
description: String,
#[serde(default)]
init_board: Option<String>,
/// no stages means sandbox
#[serde(default)]
stages: Vec<Stage>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Stage {
input: IOData,
output: IOData,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum IOData {
Bytes(Vec<u8>),
Text(String),
}
impl IOData {
pub fn as_bytes(&self) -> &[u8] {
match self {
IOData::Bytes(b) => b,
IOData::Text(t) => t.as_bytes(),
}
}
pub fn is_text(&self) -> bool {
matches!(self, IOData::Text(_))
}
}
impl Level {
pub fn id(&self) -> &str {
&self.id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn description(&self) -> &str {
&self.description
}
pub fn is_sandbox(&self) -> bool {
self.stages.is_empty()
}
pub fn init_board(&self) -> Option<String> {
self.init_board.clone()
}
pub fn stages(&self) -> &[Stage] {
&self.stages
}
}
impl Stage {
pub fn input(&self) -> &IOData {
&self.input
}
pub fn output(&self) -> &IOData {
&self.output
}
}

View file

@ -0,0 +1,9 @@
pub mod blueprint;
pub mod config;
pub mod editor;
pub mod level;
pub mod marble_engine;
pub mod solution;
pub mod theme;
pub mod ui;
pub mod util;

View file

@ -0,0 +1,379 @@
use std::{
collections::HashMap,
fs::{read_dir, read_to_string},
};
use raylib::prelude::*;
use marble_machinations::*;
use editor::{Editor, ExitState};
use level::{Chapter, Level};
use solution::Solution;
use theme::*;
use ui::{simple_option_button, tex32_button, text_button, text_input, ShapedText, Tooltip};
use util::*;
const TITLE_TEXT: &str = concat!("Marble Machinations v", env!("CARGO_PKG_VERSION"));
struct Game {
levels: Vec<LevelListEntry>,
level_scroll: usize,
solutions: HashMap<String, Vec<Solution>>,
open_editor: Option<Editor>,
textures: Textures,
selected_level: usize,
selected_solution: usize,
delete_solution: Option<usize>,
editing_solution_name: bool,
level_desc_text: ShapedText,
}
#[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);
rl.set_window_min_size(640, 480);
rl.set_mouse_cursor(MouseCursor::MOUSE_CURSOR_CROSSHAIR);
rl.set_exit_key(None);
rl.set_trace_log(TraceLogLevel::LOG_WARNING);
let mut game = Game::new(&mut rl, &thread);
game.run(&mut rl, &thread);
}
impl Game {
fn new(rl: &mut RaylibHandle, thread: &RaylibThread) -> Self {
let mut textures = Textures::default();
textures.load_dir("assets", rl, thread);
textures.load_dir("assets/tiles", rl, thread);
textures.load_dir("assets/digits", rl, thread);
let levels = get_levels();
let solutions = get_solutions();
let selected_solution = 0;
Self {
levels,
level_scroll: 0,
solutions,
open_editor: None,
textures,
selected_level: 0,
selected_solution,
delete_solution: None,
editing_solution_name: false,
level_desc_text: ShapedText::new(20),
}
}
fn run(&mut self, rl: &mut RaylibHandle, thread: &RaylibThread) {
while !rl.window_should_close() {
let mut d = rl.begin_drawing(thread);
if let Some(editor) = &mut self.open_editor {
editor.update(&d);
editor.draw(&mut d, &self.textures);
match editor.get_exit_state() {
ExitState::Dont => (),
ExitState::ExitAndSave => {
let solution = &mut self.solutions.get_mut(editor.level_id()).unwrap()
[self.selected_solution];
solution.board = editor.source_board().serialize();
solution.score = editor.score();
solution.save();
self.open_editor = None;
}
ExitState::Save => {
let solution = &mut self.solutions.get_mut(editor.level_id()).unwrap()
[self.selected_solution];
solution.board = editor.source_board().serialize();
solution.score = editor.score();
solution.save();
}
}
} else {
self.draw(&mut d);
}
}
}
fn draw(&mut self, d: &mut RaylibDrawHandle) {
d.clear_background(BG_DARK);
let mut tooltip = Tooltip::default();
tooltip.init_frame(d);
d.draw_text(
TITLE_TEXT,
d.get_screen_width() - 275,
d.get_screen_height() - 20,
20,
Color::GRAY,
);
let level_list_width = (d.get_screen_width() / 3).min(400);
let screen_height = d.get_screen_height();
d.draw_rectangle(0, 0, level_list_width, screen_height, BG_MEDIUM);
let mouse = MouseInput::get(d);
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);
if mouse.pos().x < level_list_width as f32 {
if mouse.scroll() == Some(Scroll::Down) && self.level_scroll < max_scroll {
self.level_scroll += 1;
}
if mouse.scroll() == Some(Scroll::Up) && self.level_scroll > 0 {
self.level_scroll -= 1;
}
}
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 clicked_this = mouse.left_click() && 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 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);
}
}
}
if let Some(LevelListEntry::Level(level)) = self.levels.get(self.selected_level) {
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);
let mut y = 70;
self.level_desc_text.set_text(level.description());
self.level_desc_text
.update_width(d, d.get_render_width() - level_list_width - 30);
self.level_desc_text.draw(d, level_list_width + 10, y);
y += self.level_desc_text.height() + 10;
if let Some(solutions) = self.solutions.get_mut(level.id()) {
let solution_entry_height = 40;
let entry_width = 200;
let mut solution_y = y;
for (solution_index, solution) in solutions.iter().enumerate() {
if simple_option_button(
(d, &mouse),
rect(
level_list_width + 10,
solution_y,
entry_width,
solution_entry_height,
),
solution_index,
&mut self.selected_solution,
) {
self.editing_solution_name = false;
}
let name_color = if solution.score.is_some() {
Color::LIME
} else {
Color::ORANGE
};
d.draw_text(
&solution.name,
level_list_width + 15,
solution_y + 5,
20,
name_color,
);
d.draw_text(
&solution.score_text(),
level_list_width + 15,
solution_y + 25,
10,
Color::WHITE,
);
if tex32_button(
(d, &mouse),
(level_list_width + entry_width + 15, solution_y + 4),
self.textures.get("cancel"),
(&mut tooltip, "delete"),
) {
self.delete_solution = Some(solution_index);
}
solution_y += solution_entry_height + 10;
}
let next_id = get_free_id(solutions, Solution::id);
if text_button(
d,
&mouse,
level_list_width + 10,
solution_y,
entry_width,
"new solution",
) {
self.selected_solution = solutions.len();
solutions.push(Solution::new(level, next_id));
}
if let Some(i) = self.delete_solution {
let text = format!("really delete solution '{}'?", &solutions[i].name);
let y = (solution_y + 40).max(240);
let x = level_list_width + 10;
d.draw_text(&text, x, y, 20, Color::ORANGE);
if text_button(d, &mouse, x, y + 30, 100, "yes") {
solutions[i].remove_file();
solutions.remove(i);
self.delete_solution = None;
}
if text_button(d, &mouse, x + 110, y + 30, 100, "no") {
self.delete_solution = None;
}
}
if let Some(solution) = solutions.get_mut(self.selected_solution) {
let column_x = level_list_width + entry_width + 56;
let bounds = Rectangle::new(column_x as f32, y as f32, 220., 30.);
if text_input(
d,
&mouse,
bounds,
&mut solution.name,
&mut self.editing_solution_name,
24,
true,
) {
solution.save();
}
let id_text = format!("{}", solution.id());
d.draw_text(&id_text, column_x, y + 35, 10, Color::GRAY);
if text_button(d, &mouse, column_x, y + 50, 220, "clone") {
let cloned = solution.new_copy(next_id);
self.selected_solution = solutions.len();
solutions.push(cloned);
return;
}
if text_button(d, &mouse, column_x, y + 85, 220, "edit") {
let mut editor = Editor::new(solution.clone(), level.clone());
editor.center_view(d);
self.open_editor = Some(editor);
}
}
} else {
self.solutions.insert(level.id().to_owned(), Vec::new());
}
}
tooltip.draw(d);
}
}
fn get_levels() -> Vec<LevelListEntry> {
let mut chapters = Vec::<Chapter>::new();
for d in read_dir("levels").unwrap().flatten() {
if let Ok(text) = read_to_string(d.path()) {
if let Ok(chapter) = serde_json::from_str(&text) {
chapters.push(chapter);
}
}
}
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");
if custom_level_dir.is_dir() {
for d in read_dir(custom_level_dir).unwrap().flatten() {
if let Ok(text) = read_to_string(d.path()) {
if let Ok(level) = serde_json::from_str(&text) {
custom_levels.push(level);
}
}
}
}
level_list.push(LevelListEntry::Chapter(
"Custom levels".into(),
custom_levels.len(),
));
for l in custom_levels {
level_list.push(LevelListEntry::Level(l));
}
level_list
}
fn get_solutions() -> HashMap<String, Vec<Solution>> {
let mut by_level = HashMap::new();
let solution_dir = userdata_dir().join("solutions");
if let Ok(dir_contents) = read_dir(solution_dir) {
for dir in dir_contents.flatten() {
if dir.path().is_dir() {
let level_name = dir.file_name().to_string_lossy().to_string();
let mut solutions = Vec::new();
if let Ok(files) = read_dir(dir.path()) {
for file in files.flatten() {
let s = read_to_string(file.path())
.ok()
.as_deref()
.and_then(|s| serde_json::from_str(s).ok());
if let Some(solution) = s {
solutions.push(solution);
}
}
solutions.sort_unstable_by_key(Solution::id);
}
by_level.insert(level_name, solutions);
}
}
}
by_level
}

View file

@ -0,0 +1,104 @@
use marble_engine::{
board::Board,
pos::{Pos, PosInt},
tile::{Direction, Tile},
Machine,
};
use raylib::{
color::Color,
drawing::{RaylibDraw, RaylibDrawHandle},
math::Vector2,
};
use crate::{
theme::TILE_TEXTURE_SIZE,
ui::draw_usize_small,
util::{draw_scaled_texture, Textures},
};
pub trait PosRaylib {
fn to_vec(self) -> Vector2;
fn from_vec(vec: Vector2) -> Self;
}
impl PosRaylib for Pos {
fn from_vec(vec: Vector2) -> Self {
Self {
x: vec.x as PosInt,
y: vec.y as PosInt,
}
}
fn to_vec(self) -> Vector2 {
Vector2 {
x: self.x as f32,
y: self.y as f32,
}
}
}
pub fn draw_board(
board: &Board,
d: &mut RaylibDrawHandle,
textures: &Textures,
offset: Vector2,
scale: f32,
) {
let tile_size = (TILE_TEXTURE_SIZE * scale) as i32;
let start_x = (-offset.x as i32) / tile_size - 1;
let tile_width = d.get_screen_width() / tile_size + 2;
let start_y = (-offset.y as i32) / tile_size - 1;
let tile_height = d.get_screen_height() / tile_size + 2;
for x in start_x..(start_x + tile_width) {
for y in start_y..(start_y + tile_height) {
let px = x * tile_size + offset.x as i32;
let py = y * tile_size + offset.y as i32;
if let Some(tile) = board.get((x, y).into()) {
let texname = tile.texture();
if texname.is_empty() {
continue;
}
let texture = textures.get(texname);
draw_scaled_texture(d, texture, px, py, scale);
#[cfg(debug_assertions)]
// TODO: some in-game option to show power direction
if let Tile::Powerable(_, state) = &tile {
for dir in Direction::ALL {
if state.get_dir(dir) {
let texture = textures.get(dir.debug_arrow_texture_name());
draw_scaled_texture(d, texture, px, py, scale);
}
}
}
} else {
d.draw_rectangle(px, py, tile_size, tile_size, Color::new(0, 0, 0, 80));
}
}
}
}
pub fn draw_marble_values(
machine: &Machine,
d: &mut RaylibDrawHandle,
textures: &Textures,
offset: Vector2,
scale: f32,
) {
let tile_size = (TILE_TEXTURE_SIZE * scale) as i32;
for marble in machine.marbles() {
let x = marble.x;
let y = marble.y;
if let Some(tile) = machine.board().get(*marble) {
let px = x as i32 * tile_size + offset.x as i32;
let py = y as i32 * tile_size + offset.y as i32;
if let Tile::Marble { value, dir } = tile {
let texture = textures.get(dir.arrow_texture_name());
let pos = Vector2::new(px as f32, py as f32);
let faded_white = Color::new(255, 255, 255, 100);
d.draw_texture_ex(texture, pos, 0., scale, faded_white);
draw_usize_small(d, textures, value as usize, px, py, scale);
}
}
}
}

View file

@ -0,0 +1,76 @@
use std::{
fs::{self, File},
io::Write,
path::PathBuf,
};
use serde::{Deserialize, Serialize};
use crate::{level::Level, util::userdata_dir};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Solution {
solution_id: usize,
level_id: String,
pub name: String,
pub board: String,
#[serde(default)]
pub score: Option<Score>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Score {
pub cycles: usize,
pub tiles: usize,
}
impl Solution {
pub fn new(level: &Level, id: usize) -> Self {
Self {
solution_id: id,
level_id: level.id().to_owned(),
name: format!("Unnamed {id}"),
board: level.init_board().unwrap_or(String::from(" ")),
score: None,
}
}
pub fn new_copy(&self, id: usize) -> Self {
let mut new = self.clone();
new.solution_id = id;
new.score = None;
new
}
pub fn id(&self) -> usize {
self.solution_id
}
fn path(&self) -> PathBuf {
let dir = userdata_dir().join("solutions").join(&self.level_id);
fs::create_dir_all(&dir).unwrap();
dir.join(format!("solution_{}.json", &self.solution_id))
}
pub fn save(&self) {
let path = self.path();
let json = serde_json::to_string_pretty(self).unwrap();
let mut file = File::create(path).unwrap();
file.write_all(json.as_bytes()).unwrap();
}
pub fn remove_file(&self) {
let path = self.path();
if let Err(e) = fs::remove_file(path) {
eprint!("Error removing solution file: {e}");
}
}
pub fn score_text(&self) -> String {
if let Some(score) = &self.score {
format!("C: {} T: {}", score.cycles, score.tiles)
} else {
"unsolved".into()
}
}
}

View file

@ -0,0 +1,30 @@
use raylib::prelude::*;
pub const TILE_TEXTURE_SIZE: f32 = 16.0;
pub const BG_WORLD: Color = gray(48);
pub const FG_GRID: Color = gray(64);
pub const BG_DARK: Color = gray(32);
pub const BG_MEDIUM: Color = gray(48);
pub const BG_LIGHT: Color = gray(64);
pub const BG_WIDGET: Color = gray(64);
pub const BG_WIDGET_ACTIVE: Color = rgb(80, 120, 180);
pub const FG_MARBLE_VALUE: Color = rgb(255, 80, 40);
pub const FG_CHAPTER_TITLE: Color = rgb(255, 160, 40);
pub const fn widget_bg(highlight: bool) -> Color {
if highlight {
BG_WIDGET_ACTIVE
} else {
BG_WIDGET
}
}
pub const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::new(r, g, b, 255)
}
pub const fn gray(value: u8) -> Color {
Color::new(value, value, value, 255)
}

View file

@ -0,0 +1,417 @@
use std::ops::Range;
use crate::{theme::*, util::draw_scaled_texture, util::MouseInput, util::Scroll, util::Textures};
use raylib::prelude::*;
#[derive(Debug)]
pub struct ShapedText {
text: String,
max_width: i32,
font_size: i32,
lines: Vec<Range<usize>>,
}
impl ShapedText {
pub fn new(font_size: i32) -> Self {
Self {
text: String::new(),
font_size,
max_width: 500,
lines: Vec::new(),
}
}
pub fn set_text(&mut self, text: &str) {
if text != self.text {
self.text = text.to_owned();
self.lines.clear();
}
}
pub fn height(&self) -> i32 {
self.font_size * self.lines.len() as i32
}
pub fn update_width(&mut self, d: &RaylibHandle, width: i32) {
if self.max_width == width && !self.lines.is_empty() {
return;
}
self.max_width = width;
self.lines.clear();
// todo remove leading space on broken lines
// todo fix splitting very long words
let mut line_start = 0;
let mut line_end = 0;
for (i, c) in self.text.char_indices() {
if c == ' ' {
let line = &self.text[line_start..i];
let new_line_length = d.measure_text(line, self.font_size);
if new_line_length <= self.max_width {
line_end = i;
} else {
self.lines.push(line_start..line_end);
line_start = line_end;
}
}
if c == '\n' {
let line = &self.text[line_start..i];
let new_line_length = d.measure_text(line, self.font_size);
if new_line_length <= self.max_width {
self.lines.push(line_start..i);
line_end = i + 1;
line_start = i + 1;
} else {
self.lines.push(line_start..line_end);
self.lines.push(line_end..(i + 1));
line_start = i + 1;
line_end = i + 1;
}
}
}
let i = self.text.len();
let line = &self.text[line_start..i];
let new_line_length = d.measure_text(line, self.font_size);
if new_line_length <= self.max_width {
self.lines.push(line_start..i);
} else {
self.lines.push(line_start..line_end);
self.lines.push(line_end..i);
}
}
pub fn draw(&self, d: &mut RaylibDrawHandle, x: i32, y: i32) {
// d.draw_rectangle(x, y, self.max_width, 4, Color::RED);
for (i, line) in self.lines.iter().enumerate() {
let line = &self.text[line.clone()];
let line_y = y + self.font_size * i as i32;
d.draw_text(line, x, line_y, self.font_size, Color::WHITE);
}
}
}
#[derive(Debug, Default)]
pub struct Tooltip {
text: Option<&'static str>,
mouse_x: i32,
mouse_y: i32,
screen_width: i32,
screen_height: i32,
}
impl Tooltip {
pub fn init_frame(&mut self, d: &RaylibHandle) {
let p = d.get_mouse_position();
*self = Self {
text: None,
mouse_x: p.x as i32,
mouse_y: p.y as i32,
screen_width: d.get_screen_width(),
screen_height: d.get_screen_height(),
};
}
pub fn add(&mut self, x: i32, y: i32, width: i32, height: i32, text: &'static str) {
if self.mouse_x >= x
&& self.mouse_y >= y
&& self.mouse_x <= (x + width)
&& self.mouse_y <= (y + height)
{
self.text = Some(text);
}
}
pub fn reset(&mut self) {
self.text = None;
}
pub fn add_rec(&mut self, bounds: Rectangle, text: &'static str) {
self.add(
bounds.x as i32,
bounds.y as i32,
bounds.width as i32,
bounds.height as i32,
text,
);
}
pub fn draw(&self, d: &mut RaylibDrawHandle) {
if let Some(text) = self.text {
let font_size = 20;
let margin = 4;
let text_width = d.measure_text(text, font_size);
let x = self
.mouse_x
.min(self.screen_width - text_width - margin * 2);
let y = self
.mouse_y
.min(self.screen_height - font_size - margin * 2);
d.draw_rectangle(
x,
y,
text_width + margin * 2,
font_size + margin * 2,
BG_LIGHT,
);
d.draw_text(text, x + margin, y + margin, font_size, Color::WHITE);
}
}
}
pub fn simple_button(
(d, mouse): (&mut RaylibDrawHandle, &MouseInput),
x: i32,
y: i32,
width: i32,
height: i32,
) -> bool {
let mouse_pos = mouse.pos();
let bounds = Rectangle {
x: x as f32,
y: y as f32,
width: width as f32,
height: height as f32,
};
let hover = bounds.check_collision_point_rec(mouse_pos);
let pressed = hover && mouse.left_click();
d.draw_rectangle(x, y, width, height, widget_bg(hover));
pressed
}
pub fn text_button(
d: &mut RaylibDrawHandle,
mouse: &MouseInput,
x: i32,
y: i32,
width: i32,
text: &str,
) -> bool {
let font_size = 20;
let margin = font_size / 4;
let height = font_size + margin * 2;
let clicked = simple_button((d, mouse), x, y, width, height);
d.draw_text(text, x + margin, y + margin, font_size, Color::WHITE);
clicked
}
pub fn tex32_button(
(d, mouse): (&mut RaylibDrawHandle, &MouseInput),
(x, y): (i32, i32),
texture: &Texture2D,
(tooltip, text): (&mut Tooltip, &'static str),
) -> bool {
let size = 32;
let clicked = simple_button((d, mouse), x, y, 32, 32);
draw_scaled_texture(d, texture, x, y, 2.);
tooltip.add(x, y, size, size, text);
clicked
}
pub fn simple_option_button<T>(
(d, mouse): (&mut RaylibDrawHandle, &MouseInput),
bounds: Rectangle,
option: T,
current: &mut T,
) -> bool
where
T: PartialEq,
{
d.draw_rectangle_rec(bounds, widget_bg(&option == current));
let mut changed = false;
if mouse.left_click() && mouse.is_over(bounds) && current != &option {
*current = option;
changed = true;
}
changed
}
pub fn text_input(
d: &mut RaylibDrawHandle,
mouse: &MouseInput,
bounds: Rectangle,
text: &mut String,
is_selected: &mut bool,
max_len: usize,
editable: bool,
) -> bool {
let mut changed = false;
let font_size = 20;
d.draw_rectangle_rec(bounds, widget_bg(*is_selected));
d.draw_rectangle_rec(
Rectangle::new(
bounds.x + 2.,
bounds.y + bounds.height - 5.,
bounds.width - 4.,
3.,
),
BG_DARK,
);
d.draw_text(
text,
bounds.x as i32 + 4,
bounds.y as i32 + 4,
font_size,
Color::WHITE,
);
// blinking cursor
if *is_selected && d.get_time().fract() < 0.5 {
let width = d.measure_text(text, font_size);
d.draw_rectangle(
bounds.x as i32 + 6 + width,
bounds.y as i32 + 4,
2,
font_size,
Color::WHITE,
);
};
if editable && mouse.left_click() && (mouse.is_over(bounds) || *is_selected) {
*is_selected = !*is_selected;
}
if *is_selected {
if d.is_key_pressed(KeyboardKey::KEY_ESCAPE) {
*is_selected = false;
}
if d.is_key_pressed(KeyboardKey::KEY_BACKSPACE) && !text.is_empty() {
changed = true;
text.pop();
if d.is_key_down(KeyboardKey::KEY_LEFT_CONTROL) {
while let Some(c) = text.pop() {
if c == ' ' {
break;
}
}
}
}
if text.len() < max_len {
let char_code = unsafe { ffi::GetCharPressed() };
let c = if char_code > 0 {
char::from_u32(char_code as u32)
} else {
None
};
if let Some(c) = c {
changed = true;
text.push(c);
}
}
}
changed
}
pub fn scrollable_texture_option_button<T>(
(d, mouse): (&mut RaylibDrawHandle, &MouseInput),
pos: Vector2,
texture: &Texture2D,
option: T,
current: &mut T,
border: f32,
) -> Option<Scroll>
where
T: PartialEq,
{
let bounds = Rectangle {
x: pos.x,
y: pos.y,
width: 32. + border * 2.,
height: 32. + border * 2.,
};
d.draw_rectangle_rec(
bounds,
if &option == current {
BG_WIDGET_ACTIVE
} else {
gray(16)
},
);
d.draw_texture_ex(
texture,
pos + Vector2::new(border, border),
0.,
32. / texture.width as f32,
Color::WHITE,
);
if mouse.is_over(bounds) {
if mouse.left_click() {
*current = option;
}
return mouse.scroll();
}
None
}
pub fn draw_usize(
d: &mut RaylibDrawHandle,
textures: &Textures,
mut number: usize,
(x, y): (i32, i32),
digits: i32,
scale: i32,
) {
for i in 0..digits {
d.draw_rectangle(x + 10 * i * scale, y, 8 * scale, 16 * scale, BG_LIGHT);
}
let mut i = 0;
while (number != 0 || i == 0) && i < digits {
let texture = textures.get(&format!("digit_{}", number % 10));
let x = x + (digits - i - 1) * 10 * scale;
draw_scaled_texture(d, texture, x, y, scale as f32);
number /= 10;
i += 1;
}
}
pub fn draw_usize_small(
d: &mut RaylibDrawHandle,
textures: &Textures,
mut num: usize,
mut x: i32,
y: i32,
scale: f32,
) {
const MAX_DIGITS: usize = 8;
let mut digits = [0; MAX_DIGITS];
let mut i = 0;
while (num != 0 || i == 0) && i < MAX_DIGITS {
digits[MAX_DIGITS - i - 1] = num % 10;
num /= 10;
i += 1;
}
let texture = textures.get("digits_small");
for &digit in &digits[(MAX_DIGITS - i)..] {
let source = Rectangle::new(4. * digit as f32, 0., 4., 6.);
let dest = Rectangle::new(x as f32, y as f32, 4. * scale, 6. * scale);
d.draw_texture_pro(texture, source, dest, Vector2::zero(), 0., FG_MARBLE_VALUE);
x += 4 * scale as i32;
}
}
pub fn slider(
(d, mouse): (&mut RaylibDrawHandle, &MouseInput),
bounds: Rectangle,
value: &mut u8,
min: u8,
max: u8,
) -> bool {
// draw state
// the +1 makes the lowest state look slightly filled and the max state fully filled
let percent = (*value - min + 1) as f32 / (max - min + 1) as f32;
d.draw_rectangle_rec(bounds, BG_WIDGET);
let mut filled_bounds = bounds;
filled_bounds.width *= percent;
d.draw_rectangle_rec(filled_bounds, Color::CYAN);
// interaction
if mouse.is_over(bounds) {
if mouse.left_hold() {
let percent = (mouse.pos().x - bounds.x) / bounds.width;
let new_value = min + (percent * (max - min + 1) as f32) as u8;
if *value != new_value {
*value = new_value;
}
} else if mouse.scroll() == Some(Scroll::Up) && *value < max {
*value += 1;
} else if mouse.scroll() == Some(Scroll::Down) && *value > min {
*value -= 1;
}
}
false
}

View file

@ -0,0 +1,151 @@
use std::{collections::HashMap, fs::read_dir, path::PathBuf};
use raylib::prelude::*;
#[derive(Default)]
pub struct Textures {
map: HashMap<String, Texture2D>,
}
impl Textures {
pub fn load_dir(&mut self, folder: &str, rl: &mut RaylibHandle, thread: &RaylibThread) {
for d in read_dir(folder).unwrap().flatten() {
let path = d.path();
if path.is_file() {
let name = path.file_stem().unwrap().to_string_lossy();
let texture = rl
.load_texture(thread, &format!("{folder}/{name}.png"))
.unwrap();
self.map.insert(name.to_string(), texture);
}
}
}
pub fn get(&self, name: &str) -> &Texture2D {
self.map
.get(name)
.unwrap_or_else(|| self.map.get("missing").unwrap())
}
}
pub fn userdata_dir() -> PathBuf {
PathBuf::from("user")
}
pub fn draw_scaled_texture(
d: &mut RaylibDrawHandle,
texture: &Texture2D,
x: i32,
y: i32,
scale: f32,
) {
let pos = Vector2::new(x as f32, y as f32);
d.draw_texture_ex(texture, pos, 0., scale, Color::WHITE);
}
pub fn get_free_id<T>(items: &[T], id_fn: fn(&T) -> usize) -> usize {
let mut id = 0;
while items.iter().any(|i| id_fn(i) == id) {
id += 1;
}
id
}
pub fn screen_centered_rect(rl: &RaylibHandle, width: i32, height: i32) -> Rectangle {
let w = rl.get_screen_width();
let h = rl.get_screen_height();
Rectangle {
x: (w / 2 - width / 2) as f32,
y: (h / 2 - height / 2) as f32,
width: width as f32,
height: height as f32,
}
}
pub fn screen_centered_rect_dyn(rl: &RaylibHandle, margin_x: i32, margin_y: i32) -> Rectangle {
let w = rl.get_screen_width();
let h = rl.get_screen_height();
Rectangle {
x: margin_x as f32,
y: margin_y as f32,
width: (w - margin_x * 2) as f32,
height: (h - margin_y * 2) as f32,
}
}
#[derive(Debug, Default)]
pub struct MouseInput {
pos: Vector2,
left_click: bool,
left_hold: bool,
left_release: bool,
right_hold: bool,
scroll: Option<Scroll>,
}
impl MouseInput {
pub fn get(rl: &RaylibHandle) -> Self {
Self {
pos: rl.get_mouse_position(),
left_click: rl.is_mouse_button_pressed(MouseButton::MOUSE_BUTTON_LEFT),
left_hold: rl.is_mouse_button_down(MouseButton::MOUSE_BUTTON_LEFT),
left_release: rl.is_mouse_button_released(MouseButton::MOUSE_BUTTON_LEFT),
right_hold: rl.is_mouse_button_down(MouseButton::MOUSE_BUTTON_RIGHT),
scroll: get_scroll(rl),
}
}
pub fn is_over(&self, rect: Rectangle) -> bool {
rect.check_collision_point_rec(self.pos)
}
pub fn clear(&mut self) {
*self = Self::default();
}
pub fn pos(&self) -> Vector2 {
self.pos
}
pub fn left_click(&self) -> bool {
self.left_click
}
pub fn left_hold(&self) -> bool {
self.left_hold
}
pub fn left_release(&self) -> bool {
self.left_release
}
pub fn right_hold(&self) -> bool {
self.right_hold
}
pub fn scroll(&self) -> Option<Scroll> {
self.scroll
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Scroll {
Up,
Down,
}
pub fn get_scroll(rl: &RaylibHandle) -> Option<Scroll> {
const SCROLL_THRESHOLD: f32 = 0.5;
let value = rl.get_mouse_wheel_move();
if value > SCROLL_THRESHOLD {
Some(Scroll::Up)
} else if value < -SCROLL_THRESHOLD {
Some(Scroll::Down)
} else {
None
}
}
pub fn rect(x: i32, y: i32, width: i32, height: i32) -> Rectangle {
Rectangle::new(x as f32, y as f32, width as f32, height as f32)
}