diff --git a/Cargo.lock b/Cargo.lock index db4b8b7b..40293335 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2625,7 +2625,7 @@ dependencies = [ [[package]] name = "playdate-sys" -version = "0.2.6" +version = "0.2.7" dependencies = [ "arrayvec 0.7.4", "playdate-bindgen", @@ -2656,6 +2656,19 @@ dependencies = [ "usb-ids", ] +[[package]] +name = "playdate-ui-crank-indicator" +version = "0.1.0" +dependencies = [ + "playdate-controls", + "playdate-display", + "playdate-graphics", + "playdate-menu", + "playdate-sprite", + "playdate-sys", + "playdate-system", +] + [[package]] name = "plist" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 9b33bdda..d2a46ff2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = [ "cargo", "api/*", "support/*", - # "components/*" + "components/*" ] default-members = ["cargo", "support/tool", "support/bindgen"] exclude = ["cargo/tests/crates/**/*"] diff --git a/README.md b/README.md index 40a65b2f..0198e830 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,17 @@ This project allows you to create games for the [Playdate handheld gaming system * [Modular low- & high- level API][api-dir] - with [examples][ctrl-examples-dir] * __All the parts of API are accumulated in [One Crate][playdate-crate]__ ([git][playdate-crate-git]) +* UI components + - [crank-indicator][crank-indicator-gh] (port from [lua version][crank-indicator-lua]), requires SDK 2.1 Welcome to [discussions][] and [issues][] for any questions and suggestions. Take a look at [videos](#demo) or [do something great](#usage). +[crank-indicator-gh]: https://github.com/boozook/playdate/tree/main/components/crank-indicator +[crank-indicator-lua]: https://sdk.play.date/Inside%20Playdate.html#C-ui.crankIndicator + + ## Prerequisites Follow the instructions for: diff --git a/api/sys/Cargo.toml b/api/sys/Cargo.toml index fa7dc5e7..baf2d0a7 100644 --- a/api/sys/Cargo.toml +++ b/api/sys/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "playdate-sys" -version = "0.2.6" +version = "0.2.7" build = "src/build.rs" readme = "README.md" description = "Low-level Playdate API bindings" diff --git a/components/crank-indicator/Cargo.toml b/components/crank-indicator/Cargo.toml new file mode 100644 index 00000000..2d0560bd --- /dev/null +++ b/components/crank-indicator/Cargo.toml @@ -0,0 +1,99 @@ +[package] +name = "playdate-ui-crank-indicator" +version = "0.1.0" +readme = "README.md" +description = "Crank Indicator UI component." +edition.workspace = true +license.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true + + +[features] +default = [ + "sys/default", + "gfx/default", + "display/default", + "sprite/default", + "system/default", +] + +# playdate-sys features, should be shared because it's build configuration: + +bindgen-runtime = [ + "sys/bindgen-runtime", + "gfx/bindgen-runtime", + "display/bindgen-runtime", + "sprite/bindgen-runtime", + "system/bindgen-runtime", +] +bindgen-static = [ + "sys/bindgen-static", + "gfx/bindgen-static", + "display/bindgen-static", + "sprite/bindgen-static", + "system/bindgen-static", +] + +bindings-derive-debug = [ + "sys/bindings-derive-debug", + "gfx/bindings-derive-debug", + "display/bindings-derive-debug", + "sprite/bindings-derive-debug", + "system/bindings-derive-debug", +] + + +[dependencies] +sys = { workspace = true, default-features = false } +system = { workspace = true, default-features = false } +display = { workspace = true, default-features = false } +# feature `sdk_2_1` not used really, it's just because linked assets exists only in SDK starting from 2.1. +gfx = { workspace = true, default-features = false, features = ["sdk_2_1"] } +sprite = { workspace = true, default-features = false, features = ["sdk_2_1"] } + + +[dev-dependencies] +ctrl = { workspace = true, default-features = false } +menu = { workspace = true, default-features = false } + + +[[example]] +name = "example" +crate-type = ["dylib", "staticlib"] +path = "examples/example.rs" +required-features = [ + "sys/lang-items", + "sys/entry-point", + "sys/try-trait-v2", + "system/try-trait-v2", +] + + +[package.metadata.playdate] +bundle-id = "rs.playdate.ui.crank.indicator" + +[package.metadata.playdate.assets] +"ui/crank-ind/" = "${PLAYDATE_SDK_PATH}/CoreLibs/assets/crank/*.png" + + +[package.metadata.docs.rs] +all-features = false +features = [ + "sys/bindings-derive-default", + "sys/bindings-derive-eq", + "sys/bindings-derive-copy", + "bindings-derive-debug", + "sys/bindings-derive-hash", + "sys/bindings-derive-ord", + "sys/bindings-derive-partialeq", + "sys/bindings-derive-partialord", +] +rustdoc-args = ["--cfg", "docsrs", "--show-type-layout"] +default-target = "thumbv7em-none-eabihf" +cargo-args = [ + "-Zunstable-options", + "-Zrustdoc-scrape-examples", + "-Zbuild-std=core,alloc", +] diff --git a/components/crank-indicator/README.md b/components/crank-indicator/README.md new file mode 100644 index 00000000..273aa867 --- /dev/null +++ b/components/crank-indicator/README.md @@ -0,0 +1,30 @@ +# Playdate Crank-Indicator Alert + +Requires SDK 2.1. + +Optimized port of [official lua version][crank-indicator-lua], implemented as sprite. + +> Small system-styled indicator, alerting the player that this game will use the crank. + + + +See [examples][crank-indicator-examples] to learn how to use. +```rust +use playdate_ui_crank_indicator::CrankIndicator; +use playdate_display::DisplayScale; +use playdate_sprite::add_sprite; + +let crank = CrankIndicator::new(DisplayScale::Normal)?; +add_sprite(&crank); +``` + + +[crank-indicator-gh]: https://github.com/boozook/playdate/tree/main/components/crank-indicator +[crank-indicator-examples]: https://github.com/boozook/playdate/tree/main/components/crank-indicator/examples +[crank-indicator-lua]: https://sdk.play.date/Inside%20Playdate.html#C-ui.crankIndicator + + + +- - - + +This software is not sponsored or supported by Panic. diff --git a/components/crank-indicator/examples/README.md b/components/crank-indicator/examples/README.md new file mode 100644 index 00000000..62ce6ba0 --- /dev/null +++ b/components/crank-indicator/examples/README.md @@ -0,0 +1,25 @@ +# Examples + + +There is one example that demonstrates `CrankIndicator` in various modes and environments. + +Use controls to change: +- `<-` & `->` arrows to change global render offset +- `^` & `⌄` arrows to change global render scale factor +- use system menu to change framerate + + +![example](https://github.com/boozook/playdate/assets/888526/70fe1d74-4cea-4fd4-ab2b-8e56d178c3b7) + + +# How to run + +```bash +cargo playdate run -p=playdate-ui-crank-indicator --example=example --features=sys/lang-items,sys/entry-point,sys/try-trait-v2,system/try-trait-v2 +``` + +More information how to use [cargo-playdate][] in help: `cargo playdate --help`. + + + +[cargo-playdate]: https://crates.io/crates/cargo-playdate diff --git a/components/crank-indicator/examples/example.rs b/components/crank-indicator/examples/example.rs new file mode 100644 index 00000000..354aa59a --- /dev/null +++ b/components/crank-indicator/examples/example.rs @@ -0,0 +1,149 @@ +#![no_std] +#[macro_use] +extern crate alloc; + +#[macro_use] +extern crate sys; +extern crate playdate_ui_crank_indicator as ui; + +use core::ffi::*; +use core::ptr::NonNull; + +use sys::EventLoopCtrl; +use sys::ffi::PlaydateAPI; +use ctrl::buttons::PDButtonsExt; +use display::DisplayScale; +use menu::OptionsMenuItem; +use ui::CrankIndicator; +use system::prelude::*; + + +/// App state +struct State { + /// system draw offset + offset: (c_int, c_int), + /// system scale mode + scale: DisplayScale, + /// custom system menu + fps: OptionsMenuItem, + + /// our neat indicator + crank: Option, + + // cached endpoints + system: system::System, + display: display::Display, + gfx: gfx::Graphics, + btns: ctrl::peripherals::Buttons, +} + +impl State { + fn new() -> Result { + fn fps_changed(value: &mut bool) { *value = true } + + let fps = OptionsMenuItem::new("FPS", ["20", "50", "Inf"], Some(fps_changed), false).unwrap(); + + let crank = CrankIndicator::new(DisplayScale::Normal)?; + sprite::add_sprite(&crank); + + Ok(Self { offset: (0, 0), + scale: DisplayScale::Normal, + crank: crank.into(), + fps, + gfx: gfx::Graphics::new(), + system: system::System::new(), + display: display::Display::new(), + btns: ctrl::peripherals::Buttons::new() }) + } + + + /// Updates the state + fn update(&mut self) -> UpdateCtrl { + sprite::update_and_draw_sprites(); + + // save current scale to compare + let old_scale = self.scale; + + // react to input + let buttons = self.btns.get(); + if buttons.released.right() { + self.offset.0 += 6; + self.offset.1 += 2; + self.gfx.set_draw_offset(self.offset.0, self.offset.1); + } else if buttons.released.left() { + self.offset.0 -= 6; + self.offset.1 -= 2; + self.gfx.set_draw_offset(self.offset.0, self.offset.1); + } else if buttons.released.up() { + self.scale = match self.scale { + DisplayScale::Normal => DisplayScale::Double, + DisplayScale::Double => DisplayScale::Quad, + DisplayScale::Quad => DisplayScale::Eight, + DisplayScale::Eight => DisplayScale::Eight, + }; + self.display.set_scale(self.scale); + } else if buttons.released.down() { + self.scale = match self.scale { + DisplayScale::Normal => DisplayScale::Normal, + DisplayScale::Double => DisplayScale::Normal, + DisplayScale::Quad => DisplayScale::Double, + DisplayScale::Eight => DisplayScale::Quad, + }; + self.display.set_scale(self.scale); + } + + // create or remove indicator + if buttons.released.a() { + self.crank.take(); + let crank = CrankIndicator::new(self.scale)?; + sprite::add_sprite(&crank); + self.crank = crank.into(); + } else if buttons.released.b() { + self.crank.take(); + } + + // update changed scale for indicator + if old_scale != self.scale { + self.crank.as_mut().map(|crank| crank.set_scale(self.scale)); + } + + // update frame rate if changed + self.fps.get_userdata().filter(|v| **v).map(|value| { + let fps = match self.fps.selected_option() { + 0 => 20., + 1 => 50., + _ => 0., + }; + self.display.set_refresh_rate(fps); + *value = false; + }); + + // draw state (offset, scale, fps) + self.gfx + .draw_text(format!("{}x, {:?}", self.scale, self.offset), 18, 0)?; + self.system.draw_fps(0, 0); + + UpdateCtrl::Continue + } +} + + +#[no_mangle] +fn event_handler(_: NonNull, event: SystemEvent, _: u32) -> EventLoopCtrl { + // Ignore any other events, just for this minimalistic example + if !matches!(event, SystemEvent::Init) { + return EventLoopCtrl::Continue; + } + + let state = State::new()?; + state.display.set_refresh_rate(20.); + + let system = state.system; + system.set_update_callback_boxed(State::update, state); + + EventLoopCtrl::Continue +} + + +// Needed for debug build +ll_symbols!(); diff --git a/components/crank-indicator/src/lib.rs b/components/crank-indicator/src/lib.rs new file mode 100644 index 00000000..b66fe64b --- /dev/null +++ b/components/crank-indicator/src/lib.rs @@ -0,0 +1,435 @@ +#![cfg_attr(not(test), no_std)] + +#[macro_use] +extern crate alloc; +extern crate sys; + +use core::ffi::c_int; +use core::ffi::c_uint; + +use display::DisplayScale; +use gfx::BitmapFlip; +use gfx::BitmapFlipExt; +use gfx::bitmap; +use gfx::bitmap::Bitmap; +use gfx::bitmap::table::BitmapTable; +use sprite::Sprite; +use sys::ffi::PDRect; +use sys::traits::AsRaw; + +use sprite::AnySprite; +use sprite::prelude::*; +use sprite::callback::update::SpriteUpdate; +use sprite::callback::update; +use sprite::callback::draw::SpriteDraw; +use sprite::callback::draw; + + +const CRANK_FRAME_COUNT: c_int = 12; +const TEXT_FRAME_COUNT: c_int = 14; + +type MySprite = Sprite; +type UpdHandle = update::Handle; +type DrwHandle = draw::l2::Handle; + + +pub struct CrankIndicator { + sprite: DrwHandle, +} + +impl CrankIndicator { + pub fn new(scale: DisplayScale) -> Result { + let state = State::new(scale)?; + + let sprite = Sprite::<_, sprite::api::Default>::new().into_update_handler::(); + sprite.set_ignores_draw_offset(true); + sprite.set_bounds(state.bounds()); + + sprite.set_userdata(state); + Ok(Self { sprite: sprite.into_draw_handler::() }) + } + + + pub fn set_scale(&self, scale: DisplayScale) { self.sprite.userdata().map(|state| state.set_scale(scale)); } + + pub fn set_offset(&self, x: i8, y: i8) { + self.sprite + .userdata() + .map(|state| state.set_offset(Point::new(x, y))); + } +} + + +impl AsRaw for CrankIndicator { + type Type = sys::ffi::LCDSprite; + unsafe fn as_raw(&self) -> *mut Self::Type { self.sprite.as_raw() } +} +impl SpriteApi for CrankIndicator { + type Api = sprite::api::Default; + + fn api(&self) -> Self::Api + where Self::Api: Copy { + self.sprite.api() + } + + fn api_ref(&self) -> &Self::Api { self.sprite.api_ref() } +} +impl AnySprite for CrankIndicator {} + + +pub struct UpdateDraw(Sprite); + +impl SpriteUpdate for UpdateDraw where Self: From { + fn on_update(s: &update::Handle, Self>) { + if let Some(state) = s.userdata() { + if state.update() { + s.set_bounds(state.bounds()); + s.mark_dirty(); + } else { + // skip draw, not dirty + } + } + } +} + +impl SpriteDraw for UpdateDraw where Self: From { + fn on_draw(s: &draw::Handle, Self>, bounds: PDRect, _: PDRect) { + if let Some(state) = s.userdata() { + let gfx = state.gfx; + gfx.draw(&state.bubble, bounds.x as _, bounds.y as _, state.bubble_flip); + + const NORM: BitmapFlip = BitmapFlip::Unflipped; + + if let Some(crank) = state.crank_current.as_ref() { + gfx.draw(&crank, state.crank_pos.x as _, state.crank_pos.y as _, NORM); + } else if let Some(text) = state.text.as_ref() { + gfx.draw( + &text, + state.text_position.x as _, + state.text_position.y as _, + NORM, + ); + } + } + } +} + + +pub struct State { + // background + bubble: Bitmap, + bubble_pos: Point, + bubble_size: Size, + bubble_flip: BitmapFlip, + + /// Crank animation frames + crank: BitmapTable, + /// Position of current frame of the crank for render + crank_pos: Point, + /// Current frame of the crank animation + crank_current: Option, + + // frames of sequence + frame: c_int, // TODO: u8 + frame_count: c_int, // TODO: u8 + + // text + text: Option>, + text_frame_count: c_int, // TODO: u8 + text_offset: i16, + text_position: Point, + + /// User set option clockwise + clockwise: bool, + /// User set option offset + offset: Point, + /// User set option scale + scale: DisplayScale, + + /// Last draw moment + last_time: c_uint, + /// Need to reload bitmaps + dirty: bool, + + // cached endpoints + system: system::System, + display: display::Display, + gfx: gfx::Graphics, +} + +impl State { + fn new(scale: DisplayScale) -> Result { + let bubble = load_bubble_for_scale(scale)?; + let crank = load_crank_for_scale(scale)?; + + let bubble_size = bubble.size(); + let bubble_size = Size::new(bubble_size.0 as _, bubble_size.1 as _); + + let mut this = Self { bubble, + bubble_pos: Point::new(0, 0), + bubble_size, + bubble_flip: BitmapFlip::Unflipped, + crank, + crank_current: None, + crank_pos: Point::new(0, 0), + frame: 1, + frame_count: CRANK_FRAME_COUNT as c_int * 3, + text: None, + text_frame_count: 0, + text_position: Point::new(0, 0), + text_offset: 0, + offset: Point::new(0, 0), + clockwise: true, + scale, + last_time: 0, + dirty: false, + system: system::System::new(), + display: display::Display::new(), + gfx: gfx::Graphics::new() }; + + this.load_text_if_needed()?; + this.calc_positions(); + + Ok(this) + } + + + fn calc_positions(&mut self) { + let crank_indicator_y = 210 / self.scale.as_u8(); + + if self.system.flipped() { + let y = self.display.height() as i16 - (crank_indicator_y - self.bubble_size.h / 2) as i16; + self.bubble_pos = Point::new(0, y); + self.bubble_flip = BitmapFlip::FlippedXY; + self.text_offset = 100 / self.scale.as_u8() as i16; + } else { + self.bubble_pos.x = self.display.width() as i16 - self.bubble_size.w as i16; + self.bubble_pos.y = crank_indicator_y as i16 - self.bubble_size.h as i16 / 2; + self.bubble_flip = BitmapFlip::Unflipped; + self.text_offset = 76 / self.scale.as_u8() as i16; + } + + self.frame = 1; + self.frame_count = CRANK_FRAME_COUNT; + + if let Some(text_frame_image) = &self.text { + self.text_frame_count = TEXT_FRAME_COUNT; + self.frame_count = CRANK_FRAME_COUNT + TEXT_FRAME_COUNT; + + let x_offset = self.offset_correction_x(); + + let (tw, th) = text_frame_image.size(); + let x = self.bubble_pos.x + x_offset + (self.text_offset - tw as i16) / 2; + let y = self.bubble_pos.y + self.offset.y as i16 + (self.bubble_size.h as i16 - th as i16) / 2; + self.text_position.x = x; + self.text_position.y = y; + } else { + self.text_frame_count = 0; + self.frame_count = CRANK_FRAME_COUNT; + } + } + + + fn load_text_if_needed(&mut self) -> Result<(), gfx::error::ApiError> { + if matches!(self.scale, DisplayScale::Normal | DisplayScale::Double) { + self.text = load_text_for_scale(self.scale)?.into(); + } else { + self.text.take(); + } + Ok(()) + } + + + fn reload_bitmaps(&mut self) -> Result<(), gfx::error::ApiError> { + let bubble = load_bubble_for_scale(self.scale)?; + self.crank = load_crank_for_scale(self.scale)?; + + let bubble_size = bubble.size(); + self.bubble_size = Size::new(bubble_size.0 as _, bubble_size.1 as _); + + self.bubble = bubble; + + self.load_text_if_needed()?; + + self.calc_positions(); + self.dirty = false; + + Ok(()) + } + + fn offset_correction_x(&self) -> i16 { + // if matches!(self.scale, DisplayScale::Double) { + // self.offset.x - 1 + // } else { + // self.offset.x + // } + + // this is better: + self.offset.x as i16 + } + + fn offset_correction_y(&self) -> i16 { + if matches!(self.scale, DisplayScale::Double | DisplayScale::Quad) { + self.offset.y as i16 + 1 + } else { + self.offset.y as i16 + } + } + + + fn set_scale(&mut self, scale: DisplayScale) { + self.scale = scale; + self.dirty = true; + } + + fn set_offset(&mut self, offset: Point) { + self.offset = offset; + self.calc_positions(); + } + + fn update(&mut self) -> bool { + let mut dirty = self.dirty; + let last_frame = self.frame; + let crank_drawn = self.crank_current.is_some(); + + + if self.dirty { + self.reload_bitmaps().ok(); + } + + + let current_time = self.system.current_time_milliseconds_raw(); + let mut delta = current_time - self.last_time; + + + // reset to start frame if `draw` hasn't been called in more than a second + if delta > 1000 { + self.frame = 1; + } + + // normalized steps by delta + while delta >= 50 { + self.last_time += 50; + delta -= 50; + self.frame += 1; + if self.frame > self.frame_count { + self.frame = 1; + } + } + + // prepare next frame of the crank + if self.scale.as_u8() > 2 || self.frame > self.text_frame_count { + let index = if self.clockwise { + ((self.frame - self.text_frame_count - 1) % CRANK_FRAME_COUNT) + 1 + } else { + ((CRANK_FRAME_COUNT - (self.frame - self.text_frame_count - 1)) % CRANK_FRAME_COUNT) + 1 + } - 1; + + if index < 0 { + self.crank_current = None; + return true; + } + + if dirty || self.frame != last_frame { + dirty = true; + + let frame = self.crank + .get::(index) + .expect("missed frame"); + let (fw, fh) = frame.size(); + + let x = self.bubble_pos.x + self.offset.x as i16 + (self.text_offset - fw as i16) / 2; + let y = self.bubble_pos.y + self.offset_correction_y() + (self.bubble_size.h as i16 - fh as i16) / 2; + self.crank_pos = Point::new(x, y); + self.crank_current = frame.into(); + } + } else { + self.crank_current = None; + } + + // is dirty: + // 0. if bitmaps just reloaded, + // 1. if frame changed, + // 2. if self.crank_current was None, but now is Some, and otherwise. + dirty || (crank_drawn != self.crank_current.is_some()) + } + + + fn bounds(&self) -> PDRect { + PDRect { x: (self.bubble_pos.x + self.offset.x as i16) as _, + y: (self.bubble_pos.y + self.offset.y as i16) as _, + width: self.bubble_size.w as _, + height: self.bubble_size.h as _ } + } +} + + +fn load_bubble_for_scale(scale: DisplayScale) -> Result, gfx::error::ApiError> { + let path = format!("ui/crank-ind/crank-notice-bubble-{}x", scale.as_u8()); + Bitmap::load(path) +} + +fn load_text_for_scale(scale: DisplayScale) -> Result, gfx::error::ApiError> { + let path = format!("ui/crank-ind/crank-notice-text-{}x", scale.as_u8()); + Bitmap::load(path) +} + +fn load_crank_for_scale(scale: DisplayScale) + -> Result, gfx::error::ApiError> { + let path = format!("ui/crank-ind/crank-frames-{}x", scale.as_u8()); + BitmapTable::load(path) +} + + +/// 2D point +struct Point { + x: T, + y: T, +} + +impl Point { + const fn new(x: T, y: T) -> Point { Self { x, y } } +} + +/// 2D size +struct Size { + w: T, + h: T, +} + +impl Size { + const fn new(w: T, h: T) -> Size { Self { w, h } } +} + + +mod impls_combine_handlers { + use sys::traits::AsRaw; + use super::*; + + impl AsRef> for UpdateDraw { + fn as_ref(&self) -> &Sprite { &self.0 } + } + + impl From for UpdateDraw where Sprite: From { + fn from(ptr: T) -> Self { Self(Sprite::from(ptr)) } + } + + impl TypedSprite for UpdateDraw { + type Userdata = State; + const FREE_ON_DROP: bool = false; + } + impl AsRaw for UpdateDraw { + type Type = ::Type; + unsafe fn as_raw(&self) -> *mut Self::Type { self.0.as_raw() } + } + impl SpriteApi for UpdateDraw { + type Api = ::Api; + + fn api(&self) -> Self::Api + where Self::Api: Copy { + self.0.api() + } + + fn api_ref(&self) -> &Self::Api { self.0.api_ref() } + } +}