Skip to main content

repame_view2d/
actors.rs

1//! Live vector actors: `.ren` rigs played through `renamite-player`.
2//! Timelines and state machines drive playback; the Canvas bridge paints.
3//! [`actors_view`] is presentational, [`actors_view_with`] takes opts.
4//! Rigs drawn many times under one world camera are the [`draw`](crate::draw)
5//! module instead.
6//!
7//! ```no_run
8//! use repame_view2d::{actors_view, host_from_str};
9//! use repose_core::RenderContext;
10//!
11//! let host = host_from_str(repame_view2d::MINIMAL_REN).unwrap();
12//! let ctx = RenderContext::new();
13//! let _view = actors_view(host, ctx);
14//! ```
15
16use std::cell::RefCell;
17use std::rc::Rc;
18
19use glam::DVec2;
20use renamite_render_bridge::SceneRenderer;
21use repose_canvas::{Canvas, DrawScope};
22use repose_core::input::PointerEvent;
23use repose_core::{Modifier, RenderContext, Vec2, View, request_frame, theme};
24
25pub use renamite_player::{Player, PlayerError};
26pub use renamite_player_ui::{PlayerHost, PlayerHostRef, ViewTransform};
27
28/// Minimal `.ren` document (empty 64x64 composition, no nodes).
29/// Fixture helper for hosts built without asset files.
30pub const MINIMAL_REN: &str = "RenFile(format_version: 1, meta: Meta(name: \"\", author: \"\", generator: \"\"), document: Document(format_version: 1, compositions: [SerdeSlot(value: None, version: 0), SerdeSlot(value: Some(Composition(name: \"\", size: (64, 64), rate: FrameRate(num: 60, den: 1), range: (Frame(0), Frame(1)), children: [])), version: 1)], nodes: [SerdeSlot(value: None, version: 0)], assets: [SerdeSlot(value: None, version: 0)], main: SerKey(idx: 1, version: 1)))";
31
32/// Host a `.ren` rig from source text. The game ticks the handle per frame
33/// and paints it via [`actors_view`] or [`actors_view_with`].
34/// Hosts start playing; sim-driven games call `pause` to hold.
35pub fn host_from_str(source: &str) -> Result<PlayerHostRef, PlayerError> {
36    Ok(Rc::new(RefCell::new(PlayerHost::from_ren_str(source)?)))
37}
38
39/// Host a packed `.renb` rig. Same rig, a fraction of the bytes: use this for
40/// shipped art and keep the `.ren` as the source of truth.
41pub fn host_from_bytes(bytes: &[u8]) -> Result<PlayerHostRef, PlayerError> {
42    Ok(Rc::new(RefCell::new(PlayerHost::from_ren_bytes(bytes)?)))
43}
44
45/// How the artboard maps onto the surface.
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub enum ActorFit {
48    /// Exact artboard fit each draw: scale from live surface size.
49    /// Letterboxes when aspects differ; margins paint no art.
50    #[default]
51    Exact,
52    /// Margin fit (56 px), re-applied on resize so zoom survives redraws.
53    Margin,
54}
55
56/// Full-control options for [`actors_view_with`].
57#[derive(Clone, Copy, Debug)]
58pub struct ActorViewOpts {
59    /// Artboard mapping (default [`ActorFit::Exact`]).
60    pub fit: ActorFit,
61    /// Checkerboard backplate behind the art (default off).
62    pub chrome: bool,
63    /// Scroll-zoom plus pointer forwarding in world coords (default off).
64    pub interactive: bool,
65    /// Tick playback each draw while playing (default off).
66    pub auto_tick: bool,
67}
68
69impl Default for ActorViewOpts {
70    /// Presentational defaults.
71    fn default() -> Self {
72        Self {
73            fit: ActorFit::Exact,
74            chrome: false,
75            interactive: false,
76            auto_tick: false,
77        }
78    }
79}
80
81impl ActorViewOpts {
82    /// Editor defaults: margin fit, chrome, interaction, auto-tick.
83    pub fn editor() -> Self {
84        Self {
85            fit: ActorFit::Margin,
86            chrome: true,
87            interactive: true,
88            auto_tick: true,
89        }
90    }
91}
92
93/// Presentational rig surface with default opts: transparent, exact fit,
94/// no input, no auto-tick. The game applies machine inputs and ticks.
95pub fn actors_view(host: PlayerHostRef, ctx: RenderContext) -> View {
96    actors_view_with(host, ctx, ActorViewOpts::default())
97}
98
99/// Rig surface with full control. Canvas size comes from layout
100/// (`fill_max_size`); the game sizes and layers the surface.
101pub fn actors_view_with(host: PlayerHostRef, ctx: RenderContext, opts: ActorViewOpts) -> View {
102    let draw = host.clone();
103
104    let mut modifier = Modifier::new().fill_max_size();
105    if opts.chrome {
106        modifier = modifier.background(theme().surface_container_lowest);
107    }
108    if opts.interactive {
109        modifier = modifier
110            .on_scroll({
111                let host = host.clone();
112                move |delta: Vec2| {
113                    let mut h = host.borrow_mut();
114                    let factor = (1.0 + (-delta.y as f64) * 0.002).clamp(0.5, 2.0);
115                    let anchor = h.last_pointer;
116                    h.zoom_at(anchor, factor);
117                    request_frame();
118                    Vec2::ZERO
119                }
120            })
121            .on_pointer_down({
122                let host = host.clone();
123                move |pe: PointerEvent| {
124                    let mut h = host.borrow_mut();
125                    let p = pe_position(&pe);
126                    h.last_pointer = p;
127                    let world = h.view.screen_to_world(p);
128                    h.player.pointer_down(world);
129                    request_frame();
130                }
131            })
132            .on_pointer_up({
133                let host = host.clone();
134                move |pe: PointerEvent| {
135                    let mut h = host.borrow_mut();
136                    let world = h.view.screen_to_world(pe_position(&pe));
137                    h.player.pointer_up(world);
138                    request_frame();
139                }
140            })
141            .on_pointer_move({
142                let host = host.clone();
143                move |pe: PointerEvent| {
144                    let mut h = host.borrow_mut();
145                    let p = pe_position(&pe);
146                    h.last_pointer = p;
147                    let world = h.view.screen_to_world(p);
148                    h.player.pointer_move(world);
149                    request_frame();
150                }
151            })
152            .on_pointer_leave({
153                let host = host.clone();
154                move |_pe: PointerEvent| {
155                    host.borrow_mut().player.pointer_leave();
156                    request_frame();
157                }
158            });
159    } else {
160        modifier = modifier.hit_passthrough();
161    }
162
163    Canvas(modifier, move |scope| {
164        let mut h = draw.borrow_mut();
165
166        if opts.auto_tick && h.tick_playback() {
167            request_frame();
168        }
169
170        let sw = scope.size.width as f64;
171        let sh = scope.size.height as f64;
172        if sw <= 1.0 || sh <= 1.0 {
173            return;
174        }
175        // One artboard lookup serves both fit modes and the chrome backplate.
176        let art = h.artboard();
177        let art_valid = art.x > 0.0 && art.y > 0.0;
178        let surface = DVec2::new(sw, sh);
179        match opts.fit {
180            ActorFit::Exact => {
181                if !art_valid {
182                    return;
183                }
184                // Per-draw map with no zoom state; shared upstream helper.
185                h.fit_exact(surface);
186            }
187            ActorFit::Margin => {
188                // Margin fit tracks resizes so zoom survives redraws.
189                h.fit(surface);
190            }
191        }
192        if h.dirty_images {
193            let host = &mut *h;
194            host.renderer
195                .sync_document_images(&host.player.project.document, &ctx);
196            host.dirty_images = false;
197        }
198        if opts.chrome && art_valid {
199            paint_chrome(scope, art, h.view);
200        }
201        let scene = h.player.scene().clone();
202        let view = h.view;
203        let prepared = h.renderer.prepare(&scene, &view);
204        h.renderer.paint_prepared(&prepared, scope);
205    })
206}
207
208fn pe_position(pe: &PointerEvent) -> DVec2 {
209    DVec2::new(pe.position.x as f64, pe.position.y as f64)
210}
211
212/// Checkerboard backplate plus border behind the artboard.
213fn paint_chrome(scope: &mut DrawScope, artboard: DVec2, view: ViewTransform) {
214    SceneRenderer::paint_artboard_chrome(scope, artboard, &view);
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn opts_default_is_presentational() {
223        let opts = ActorViewOpts::default();
224        assert_eq!(opts.fit, ActorFit::Exact);
225        assert!(!opts.chrome && !opts.interactive && !opts.auto_tick);
226    }
227
228    #[test]
229    fn opts_editor_is_full_control() {
230        let opts = ActorViewOpts::editor();
231        assert_eq!(opts.fit, ActorFit::Margin);
232        assert!(opts.chrome && opts.interactive && opts.auto_tick);
233    }
234
235    #[test]
236    fn host_parses_minimal_document() {
237        let host = host_from_str(MINIMAL_REN).expect("minimal doc must parse");
238        // New hosts upload images on first draw.
239        assert!(host.borrow().dirty_images);
240    }
241
242    #[test]
243    fn views_construct_without_a_backend() {
244        // Construction is data only; paint closures run later on the render thread.
245        let ctx = RenderContext::new();
246        let _presentational = actors_view(host_from_str(MINIMAL_REN).unwrap(), ctx.clone());
247        let _full = actors_view_with(
248            host_from_str(MINIMAL_REN).unwrap(),
249            ctx.clone(),
250            ActorViewOpts::editor(),
251        );
252        let _mixed = actors_view_with(
253            host_from_str(MINIMAL_REN).unwrap(),
254            ctx,
255            ActorViewOpts {
256                interactive: true,
257                ..ActorViewOpts::default()
258            },
259        );
260    }
261}