diff --git a/TODO.md b/TODO.md index 0072dee..340e2c0 100644 --- a/TODO.md +++ b/TODO.md @@ -1,25 +1,30 @@ # Specific projects ## Now: +- outfitter + - Fix input (turn and multipliers) + - Indicator for "can't buy / sell" + - Buy multiple outfits + - Show money + - Show owned outfits - init on resize - hide ui element - zoom limits -- text scrolling - scrollbox scroll limits -- clean up content - Clean up state api - Clean up & document UI api - Clean up scripting errors - Mouse colliders - Fade sprites and text in scrollbox - Selection while flying -- outfitter - fps textbox positioning - shield generation curve - clippy & rules - reject unknown toml +- styled text & better content formatting ## Small jobs +- Scroll direction config - Better planet icons in radar - Clean up sprite content (and content in general) - Check game version in config diff --git a/content/system.toml b/content/system.toml index 9307fdf..bd9cb75 100644 --- a/content/system.toml +++ b/content/system.toml @@ -26,6 +26,16 @@ of the human species. Earth is also the capital of the Republic. Representative government becomes complicated when one planet has a greater population than a hundred planets elsewhere. As a result, settlements of less than a million are grouped together into planetary districts that +elect a single representative between them - a source of much frustration in the frontier worlds. + The ancestral home world of humanity, Earth has a population twice that of any other inhabited planet. +Sprawling cities cover large portions of its surface, many of them overcrowded and dangerous. +Some people work to scrape together enough money to leave, while at the same time others, born +on distant worlds, make a pilgrimage of sorts to see this planet that once cradled the entirety +of the human species. +

+ Earth is also the capital of the Republic. Representative government becomes complicated when +one planet has a greater population than a hundred planets elsewhere. As a result, +settlements of less than a million are grouped together into planetary districts that elect a single representative between them - a source of much frustration in the frontier worlds. """ object.earth.landable.image = "ui::landscape::test" diff --git a/content/ui/landed.rhai b/content/ui/landed.rhai index d491fd8..eebc866 100644 --- a/content/ui/landed.rhai +++ b/content/ui/landed.rhai @@ -69,6 +69,7 @@ fn init(state) { ) ); textbox::font_sans("desc"); + textbox::enable_scroll("desc", true); if state.player_ship().is_landed() { textbox::set_text("desc", state.player_ship().landed_on().desc()); } diff --git a/content/ui/outfitter.rhai b/content/ui/outfitter.rhai index cfabd09..3ae9926 100644 --- a/content/ui/outfitter.rhai +++ b/content/ui/outfitter.rhai @@ -99,6 +99,8 @@ fn init(state) { ) ); textbox::font_mono("ship_stats"); + textbox::enable_scroll("ship_stats", true); + sprite::add( "outfit_bg", @@ -146,6 +148,8 @@ fn init(state) { ); textbox::font_serif("outfit_desc"); textbox::set_text("outfit_desc", ""); + textbox::enable_scroll("outfit_desc", true); + textbox::add( @@ -159,6 +163,7 @@ fn init(state) { ); textbox::font_mono("outfit_stats"); textbox::set_text("outfit_stats", ""); + textbox::enable_scroll("outfit_stats", true); sprite::add( @@ -348,7 +353,7 @@ fn init(state) { fn event(state, event) { // TODO: update on ship outfit change only - update_ship_info(state); + //update_ship_info(state); if type_of(event) == "MouseHoverEvent" { let element = event.element(); @@ -572,6 +577,9 @@ fn update_outfit_info(selected_outfit) { textbox::set_text("outfit_name", outfit.display_name()); textbox::set_text("outfit_desc", outfit.desc()); textbox::set_text("outfit_stats", stats); + + textbox::reset_scroll("outfit_stats"); + textbox::reset_scroll("outfit_name"); } } diff --git a/crates/render/src/ui/api/event.rs b/crates/render/src/ui/api/event.rs index ff65abb..eb8549f 100644 --- a/crates/render/src/ui/api/event.rs +++ b/crates/render/src/ui/api/event.rs @@ -1,5 +1,18 @@ use rhai::{CustomType, ImmutableString, TypeBuilder}; +/// "Do-nothing" event, used to stop processing input without triggering user code. +/// This allows us to scroll a textbox inside a scrollbox without moving the scrollbox, +/// for example. +#[derive(Debug, Clone)] +pub struct NullEvent {} + +impl CustomType for NullEvent { + // We implement build, but NullEvents do NOT trigger the event callback. + fn build(mut builder: TypeBuilder) { + builder.with_name("NullEvent"); + } +} + #[derive(Debug, Clone)] pub struct MouseClickEvent { pub down: bool, diff --git a/crates/render/src/ui/api/functions/textbox.rs b/crates/render/src/ui/api/functions/textbox.rs index 59131e1..1b7f94a 100644 --- a/crates/render/src/ui/api/functions/textbox.rs +++ b/crates/render/src/ui/api/functions/textbox.rs @@ -374,5 +374,53 @@ pub fn build_textbox_module( e.set_style(&mut font.borrow_mut(), Style::Italic); }); + let state = state_src.clone(); + let _ = FuncRegistration::new("enable_scroll") + .with_namespace(FnNamespace::Internal) + .set_into_module(&mut module, move |name: ImmutableString, enable: bool| { + let mut ui_state = state.borrow_mut(); + let e = match ui_state.get_mut_by_name(&name) { + Some(UiElement::SubElement { element, .. }) => match &mut **element { + UiElement::Text(x) => x, + _ => { + error!("called `textbox::enable_scroll` on an invalid name `{name}`"); + return; + } + }, + + Some(UiElement::Text(x)) => x, + _ => { + error!("called `textbox::enable_scroll` on an invalid name `{name}`"); + return; + } + }; + + e.set_scroll(enable); + }); + + let state = state_src.clone(); + let _ = FuncRegistration::new("reset_scroll") + .with_namespace(FnNamespace::Internal) + .set_into_module(&mut module, move |name: ImmutableString| { + let mut ui_state = state.borrow_mut(); + let e = match ui_state.get_mut_by_name(&name) { + Some(UiElement::SubElement { element, .. }) => match &mut **element { + UiElement::Text(x) => x, + _ => { + error!("called `textbox::reset_scroll` on an invalid name `{name}`"); + return; + } + }, + + Some(UiElement::Text(x)) => x, + _ => { + error!("called `textbox::reset_scroll` on an invalid name `{name}`"); + return; + } + }; + + e.reset_scroll(); + }); + return module; } diff --git a/crates/render/src/ui/elements/scrollbox.rs b/crates/render/src/ui/elements/scrollbox.rs index f2f4a10..a4c39dd 100644 --- a/crates/render/src/ui/elements/scrollbox.rs +++ b/crates/render/src/ui/elements/scrollbox.rs @@ -2,7 +2,10 @@ use nalgebra::Vector2; use rhai::{Dynamic, ImmutableString}; use super::super::api::Rect; -use crate::{InputEvent, RenderInput, RenderState}; +use crate::{ + ui::api::{MouseHoverEvent, NullEvent}, + InputEvent, RenderInput, RenderState, +}; #[derive(Debug, Clone)] pub struct UiScrollbox { @@ -52,16 +55,25 @@ impl UiScrollbox { InputEvent::MouseMove(pos) => { if r.contains_mouse(state, pos) && !self.has_mouse { self.has_mouse = true; + return Some(Dynamic::from(MouseHoverEvent { + enter: true, + element: self.name.clone(), + })); } if !r.contains_mouse(state, pos) && self.has_mouse { self.has_mouse = false; + return Some(Dynamic::from(MouseHoverEvent { + enter: false, + element: self.name.clone(), + })); } } InputEvent::Scroll(x) => { if self.has_mouse { self.offset.y -= x; + return Some(Dynamic::from(NullEvent {})); } } diff --git a/crates/render/src/ui/elements/textbox.rs b/crates/render/src/ui/elements/textbox.rs index e845c1b..fa05609 100644 --- a/crates/render/src/ui/elements/textbox.rs +++ b/crates/render/src/ui/elements/textbox.rs @@ -3,17 +3,28 @@ use glyphon::{ Shaping, Style, TextBounds, Weight, }; use nalgebra::Vector2; -use rhai::ImmutableString; +use rhai::{Dynamic, ImmutableString}; use std::rc::Rc; use winit::window::Window; use super::{super::api::Rect, OwnedTextArea}; -use crate::{ui::api, RenderInput}; +use crate::{ + ui::api::{self, MouseHoverEvent, NullEvent}, + InputEvent, RenderInput, RenderState, +}; #[derive(Debug, Clone)] pub struct UiTextBox { pub name: ImmutableString, + /// Vertical scroll. + /// We don't use native cosmic_text scroll, since that only allows us + /// to scroll by individual lines. + v_offset: f32, + + enable_scroll: bool, + has_mouse: bool, + text: String, justify: Align, rect: Rect, @@ -34,7 +45,7 @@ impl UiTextBox { let mut buffer = Buffer::new(font, Metrics::new(font_size, line_height)); // Do NOT apply UI scale here, that's only done when we make a TextArea - buffer.set_size(font, rect.dim.x, rect.dim.y); + buffer.set_size(font, rect.dim.x, rect.dim.y + 100.0); Self { name, @@ -44,6 +55,9 @@ impl UiTextBox { justify: Align::Left, attrs: AttrsOwned::new(Attrs::new()), text: String::new(), + v_offset: 0.0, + has_mouse: false, + enable_scroll: false, } } @@ -88,6 +102,83 @@ impl UiTextBox { self.attrs.style = style; self.reflow(font); } + + pub fn set_scroll(&mut self, scroll: bool) { + self.enable_scroll = scroll + } + + pub fn reset_scroll(&mut self) { + self.v_offset = 0.0; + } + + pub fn handle_event( + &mut self, + input: &RenderInput, + state: &mut RenderState, + event: &InputEvent, + ) -> Option { + self.handle_event_with_offset(input, state, event, Vector2::new(0.0, 0.0)) + } + + pub fn handle_event_with_offset( + &mut self, + input: &RenderInput, + state: &mut RenderState, + event: &InputEvent, + offset: Vector2, + ) -> Option { + let mut r = self + .rect + .to_centered(&state.window, input.ct.config.ui_scale); + r.pos += offset; + + match event { + InputEvent::MouseMove(pos) => { + if r.contains_mouse(state, pos) && !self.has_mouse { + self.has_mouse = true; + return Some(Dynamic::from(MouseHoverEvent { + enter: true, + element: self.name.clone(), + })); + } + + if !r.contains_mouse(state, pos) && self.has_mouse { + self.has_mouse = false; + return Some(Dynamic::from(MouseHoverEvent { + enter: false, + element: self.name.clone(), + })); + } + } + + InputEvent::Scroll(x) => { + if self.has_mouse && self.enable_scroll { + self.v_offset -= x; + + println!("{:?}", self.buffer.layout_runs().len()); + + let max_scroll = 0f32.max( + (self.buffer.metrics().line_height + * self.buffer.layout_runs().len() as f32) + - self.buffer.size().1, + ); + + // Top scroll bound + if self.v_offset <= 0.0 { + self.v_offset = 0.0; + } else if self.v_offset >= max_scroll { + self.v_offset = max_scroll; + } + + return Some(Dynamic::from(NullEvent {})); + } + } + + _ => return None, + } + + return None; + } } impl<'a, 'b: 'a> UiTextBox { @@ -106,6 +197,7 @@ impl<'a, 'b: 'a> UiTextBox { // Glypon works with physical pixels, so we must do some conversion let fac = window.scale_factor() as f32; + let scroll_offset = self.v_offset * fac; let corner_ne = Vector2::new( (rect.pos.x - rect.dim.x / 2.0) * fac + window.inner_size().width as f32 / 2.0, window.inner_size().height as f32 / 2.0 - (rect.pos.y * fac + rect.dim.y / 2.0), @@ -115,7 +207,7 @@ impl<'a, 'b: 'a> UiTextBox { OwnedTextArea { buffer: self.buffer.clone(), - top: corner_ne.y, + top: corner_ne.y - scroll_offset, left: corner_ne.x, scale: input.ct.config.ui_scale, bounds: TextBounds { diff --git a/crates/render/src/ui/executor.rs b/crates/render/src/ui/executor.rs index ba0a5dd..85282c5 100644 --- a/crates/render/src/ui/executor.rs +++ b/crates/render/src/ui/executor.rs @@ -8,7 +8,7 @@ use std::{cell::RefCell, num::NonZeroU32, rc::Rc, sync::Arc}; use winit::event::VirtualKeyCode; use super::{ - api::{self, KeyboardEvent, PlayerShipStateEvent, ScrollEvent}, + api::{self, KeyboardEvent, NullEvent, PlayerShipStateEvent, ScrollEvent}, UiConfig, UiElement, UiState, }; use crate::{ui::api::State, InputEvent, RenderInput, RenderState}; @@ -75,8 +75,9 @@ impl UiScriptExecutor { let arg = match ui_state.get_mut_by_idx(i).unwrap() { UiElement::Sprite(sprite) => sprite.handle_event(&input, state, &event), UiElement::Scrollbox(sbox) => sbox.handle_event(&input, state, &event), + UiElement::Text(text) => text.handle_event(&input, state, &event), - UiElement::RadialBar(_) | UiElement::Text(..) => None, + UiElement::RadialBar(_) => None, UiElement::SubElement { parent, element } => { // Be very careful here, to avoid // borrowing mutably twice... @@ -94,7 +95,10 @@ impl UiScriptExecutor { UiElement::Scrollbox(sbox) => { sbox.handle_event_with_offset(&input, state, &event, offset) } - UiElement::RadialBar(_) | UiElement::Text(..) => None, + UiElement::Text(text) => { + text.handle_event_with_offset(&input, state, &event, offset) + } + UiElement::RadialBar(_) => None, UiElement::SubElement { .. } => unreachable!(), }, _ => unreachable!(), @@ -109,6 +113,11 @@ impl UiScriptExecutor { // Return if event hook returns any PlayerDirective (including None), // continue to the next element if the hook returns (). if let Some(arg) = arg { + // Ignore NullEvents + if arg.clone().try_cast::().is_some() { + return Ok(PlayerDirective::None); + } + let result = self.run_event_callback(state, &input, arg)?; if let Some(result) = result {