Skip to main content

repame_shell/
lib.rs

1//! App wiring: Repose platform runners + sim/gamepad helpers.
2//!
3//! A game provides a root view; [`ShellHooks`] is the game-side per-frame
4//! contract (snapshot production + variable-rate hook) that the game's own
5//! root closure drives. The `run_*` runners below are thin mounts only:
6//! they do not call `ShellHooks` or step the sim — stepping stays game-side
7//! via [`Sim::step`] (see the rozvp pilot runner for the reference wiring).
8//! Per-game UI stays in the game crate as Repose views.
9
10use std::collections::HashSet;
11use web_time::Duration;
12
13use anyhow::Result;
14pub use repame_sim::{Sim, SimTime};
15pub use repame_sprite::{Camera2d, FrameInput, PickEvent, SpriteInstance};
16pub use repose_core::input::{GamepadEvent, GamepadId};
17use repose_platform::gamepad::{GamepadBackend, create_backend};
18
19/// Per-frame hooks the game implements. Snapshot production (`frame_input`)
20/// is the only required render coupling: plain data out, no renderer types.
21pub trait ShellHooks {
22    /// Build this frame's viewport snapshot from sim + UI state.
23    fn frame_input(&mut self, sim: &Sim) -> FrameInput;
24    /// Fixed-step systems run inside [`Sim`]; this is the per-frame
25    /// variable-rate hook (tweens, audio triggers, autosave timers).
26    fn on_frame(&mut self, _sim: &mut Sim, _dt: Duration) {}
27}
28
29/// Desktop entry point. Mounts `root` on the repose-platform runner with
30/// the given window title/size. The root closure owns stepping (sim,
31/// rigs) and must call `request_frame()` for continuous frames; see
32/// the rozvp pilot runner for the reference wiring.
33#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
34pub fn run_desktop(
35    title: &str,
36    size: (u32, u32),
37    root: impl FnMut(
38        &mut repose_core::runtime::Scheduler,
39        &repose_core::RenderContext,
40    ) -> repose_core::View
41    + 'static,
42) -> Result<()> {
43    let config = repose_app::AppConfig {
44        window_title: title.to_string(),
45        window_size: size,
46        ..Default::default()
47    };
48    repose_platform::run_desktop_app_with_config(root, config)?;
49    Ok(())
50}
51
52/// Web entry point. Mounts `root` on the repose-platform web runner with
53/// default options (prevent-default on, default canvas).
54#[cfg(target_arch = "wasm32")]
55pub fn run_web(
56    root: impl FnMut(
57        &mut repose_core::runtime::Scheduler,
58        &repose_core::RenderContext,
59    ) -> repose_core::View
60    + 'static,
61) -> Result<(), wasm_bindgen::JsValue> {
62    let mut options = repose_platform::web::WebOptions::new(None);
63    options.set_prevent_default(true);
64    repose_platform::web::run_web_app(root, options)
65}
66
67/// Android entry point. Mounts `root` on the repose-platform android
68/// runner with default options. Logging + window insets stay app-side.
69#[cfg(target_os = "android")]
70pub fn run_android(
71    app: winit::platform::android::activity::AndroidApp,
72    root: impl FnMut(
73        &mut repose_core::runtime::Scheduler,
74        &repose_core::RenderContext,
75    ) -> repose_core::View
76    + 'static,
77) -> Result<()> {
78    repose_platform::android::run_android_app_with_options(
79        app,
80        root,
81        repose_app::AndroidOptions::default(),
82    )
83}
84
85/// Gamepad polling unified on the shared `repose-platform` backend: one
86/// button layout, one deadzone, one mapping policy for repose apps and
87/// repame games alike. Events feed straight into
88/// `ReposeRuntime::handle_gamepad` (UI nav) and `rt.gamepads` (gameplay).
89pub struct GamepadPoller {
90    backend: Option<Box<dyn GamepadBackend>>,
91    connected: HashSet<u32>,
92}
93
94impl GamepadPoller {
95    pub fn new() -> Self {
96        Self {
97            backend: create_backend().map(|b| Box::new(b) as Box<dyn GamepadBackend>),
98            // Pads already plugged in report `Connected` on the first
99            // `poll()` (backend synthesizes the boot list), so the set is
100            // correct from the first frame with no button press required.
101            connected: HashSet::new(),
102        }
103    }
104
105    /// Drain hardware events since the last call. Tracks connection state
106    /// so [`GamepadPoller::connected_ids`] stays live: boot-plugged pads
107    /// arrive as synthesized `Connected` events on the first `poll`,
108    /// hotplug `Connected` / `Disconnected` update the set, and any
109    /// button/axis event from an unknown id implicitly marks that pad
110    /// connected (covers a hotplug racing the first `poll`).
111    pub fn poll(&mut self) -> Vec<GamepadEvent> {
112        let Some(backend) = &mut self.backend else {
113            return Vec::new();
114        };
115        let events = backend.poll();
116        for ev in &events {
117            match ev {
118                GamepadEvent::Connected { id, .. } => {
119                    self.connected.insert(id.0);
120                }
121                GamepadEvent::Disconnected { id } => {
122                    self.connected.remove(&id.0);
123                }
124                GamepadEvent::Button { id, .. } | GamepadEvent::Axis { id, .. } => {
125                    self.connected.insert(id.0);
126                }
127            }
128        }
129        events
130    }
131
132    pub fn connected_ids(&self) -> Vec<GamepadId> {
133        let mut ids: Vec<GamepadId> = self.connected.iter().copied().map(GamepadId).collect();
134        ids.sort_by_key(|id| id.0);
135        ids
136    }
137
138    pub fn connected_count(&self) -> usize {
139        self.connected.len()
140    }
141}
142
143impl Default for GamepadPoller {
144    fn default() -> Self {
145        Self::new()
146    }
147}