Skip to main content

repame_ui/
touch.rs

1//! The virtual stick and tap buttons, as kit controls.
2//!
3//! Eight games ship the same `ui/touch.rs`: a left-side pointer zone that
4//! becomes a stick origin on press, a drag that moves a knob, and a release
5//! that zeroes it — plus, in the games that have them, round tap buttons built
6//! from `RBox` and `on_click` that push a game-local action. They are all wired
7//! to the same pointer path (repose's `on_pointer_*` callbacks on a hit region)
8//! and all gated on `paused || modal`, each with its own copy of that condition
9//! and of the stick arithmetic.
10//!
11//! [`TouchPad`] is that once. Two differences from the copies:
12//!
13//! * The gate is [`FlowState::game_has_input`], the same predicate the sim's
14//!   gameplay systems read, so the overlay cannot be live while the sim thinks
15//!   the game is frozen (or the reverse).
16//! * The stick's output runs through `repame_input::apply_stick`, the dead zone
17//!   the pads use, so a touch stick and a gamepad stick mean the same thing to
18//!   gameplay. The copies wrote a raw `delta / radius`, which drifts at rest.
19//!
20//! Tap buttons push into the kit's action sink, so they arrive in the same
21//! queue as every other control. The stick is a *polled* value
22//! ([`TouchPad::stick`]): movement is continuous, not an edge, and every copy
23//! polls it per tick.
24
25use std::cell::RefCell;
26use std::rc::Rc;
27
28use glam::Vec2;
29use repose_core::prelude::{Modifier, View};
30use repose_core::input::{PointerEvent, PointerKind};
31use repose_core::{AlignItems, Dp, JustifyContent};
32use repose_ui::{Box as RBox, Spacer, ViewExt as _, ZStack};
33
34use crate::flow::FlowState;
35use crate::kit::Kit;
36use crate::nav::NavDriver;
37use crate::palette::Tokens;
38use crate::queue::UiAction;
39
40/// Radius of the stick's travel, in design units.
41pub const STICK_RADIUS: f32 = 46.0;
42
43/// Fraction of the viewport width the stick's touch zone covers.
44pub const STICK_ZONE: f32 = 0.45;
45
46/// Knob radius in design units.
47const KNOB_RADIUS: f32 = 16.0;
48
49/// Why a touch pad setting was rejected.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum TouchError {
52    /// The stick zone must cover a positive fraction of the viewport width, and
53    /// no more than all of it.
54    ZoneOutOfRange,
55}
56
57impl std::fmt::Display for TouchError {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            TouchError::ZoneOutOfRange => {
61                f.write_str("stick zone must be a fraction in 0..=1 of the viewport width")
62            }
63        }
64    }
65}
66
67impl std::error::Error for TouchError {}
68
69/// Re-anchor a drag so the knob stays within `radius` of the stick's origin.
70///
71/// Returns the origin to use and the knob's offset from it. Moving past the
72/// radius drags the origin along, which is what the copies do: the stick follows
73/// the finger instead of pinning to where the touch started.
74pub fn drag(origin: Vec2, here: Vec2, radius: f32) -> (Vec2, Vec2) {
75    if !radius.is_finite() || radius <= 0.0 {
76        return (origin, Vec2::ZERO);
77    }
78    let delta = here - origin;
79    if delta.length() <= radius {
80        return (origin, delta);
81    }
82    let direction = delta.normalize_or_zero();
83    (here - direction * radius, direction * radius)
84}
85
86/// The stick's output for a knob offset: dead-zoned through the pads' curve and
87/// clamped to the unit disc.
88///
89/// A non-positive radius reads as rest rather than scaling to infinity.
90pub fn axis(delta: Vec2, radius: f32) -> Vec2 {
91    if !radius.is_finite() || radius <= 0.0 {
92        return Vec2::ZERO;
93    }
94    repame_input::apply_stick(delta / radius)
95}
96
97#[derive(Clone, Copy, PartialEq, Eq)]
98struct Owner {
99    kind: PointerKind,
100    id: u64,
101}
102
103fn owner_of(event: &PointerEvent) -> Owner {
104    Owner {
105        kind: event.kind,
106        id: event.id.0,
107    }
108}
109
110struct PadState {
111    enabled: bool,
112    scale: f32,
113    zone: f32,
114    owner: Option<Owner>,
115    active: bool,
116    origin: Vec2,
117    raw: Vec2,
118}
119
120impl PadState {
121    fn release(&mut self) {
122        self.active = false;
123        self.owner = None;
124        self.origin = Vec2::ZERO;
125        self.raw = Vec2::ZERO;
126    }
127}
128
129/// The virtual stick and its tap buttons, pushing into the kit's action sink.
130///
131/// Owned by the app for the life of the game, like the games' own touch state:
132/// the pad holds the finger that is down, its origin and the gate.
133#[derive(Clone)]
134pub struct TouchPad<A: UiAction> {
135    kit: Kit<A>,
136    inner: Rc<RefCell<PadState>>,
137}
138
139impl<A: UiAction> TouchPad<A> {
140    /// A pad sharing `kit`'s sink, palette and scale.
141    pub fn new(kit: Kit<A>) -> Self {
142        Self {
143            kit,
144            inner: Rc::new(RefCell::new(PadState {
145                enabled: false,
146                scale: 1.0,
147                zone: STICK_ZONE,
148                owner: None,
149                active: false,
150                origin: Vec2::ZERO,
151                raw: Vec2::ZERO,
152            })),
153        }
154    }
155
156    /// Cover a different fraction of the viewport width with the stick's zone.
157    pub fn with_zone(self, fraction: f32) -> Result<Self, TouchError> {
158        if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 {
159            return Err(TouchError::ZoneOutOfRange);
160        }
161        self.inner.borrow_mut().zone = fraction;
162        Ok(self)
163    }
164
165    pub fn kit(&self) -> &Kit<A> {
166        &self.kit
167    }
168
169    /// Whether the pad is taking pointers this frame.
170    pub fn enabled(&self) -> bool {
171        self.inner.borrow().enabled
172    }
173
174    /// Whether a finger is on the stick.
175    pub fn active(&self) -> bool {
176        self.inner.borrow().active
177    }
178
179    /// The stick's value: dead-zoned, clamped to the unit disc, y down.
180    ///
181    /// Poll it where the game already combines its move axes; it is not an
182    /// action because movement is a level, not an edge.
183    pub fn stick(&self) -> Vec2 {
184        let inner = self.inner.borrow();
185        axis(inner.raw, STICK_RADIUS)
186    }
187
188    /// Per-frame gate: the pad takes pointers only while the game owns input.
189    ///
190    /// Call before composing (or before the sim step) with the same
191    /// [`FlowState`] the gameplay systems read. Closing the gate releases a
192    /// finger that is still down, so the game cannot keep moving under a modal.
193    pub fn begin_frame(&self, flow: FlowState) {
194        let mut inner = self.inner.borrow_mut();
195        inner.enabled = flow.game_has_input();
196        if !inner.enabled {
197            inner.release();
198        }
199    }
200
201    /// Per-frame gate read straight from the sim world.
202    ///
203    /// A world where the flow was never installed reads as
204    /// [`FlowState::CLOSED`], so the overlay is closed rather than live.
205    pub fn begin_frame_in(&self, world: &repame_sim::bevy_ecs::prelude::World) {
206        self.begin_frame(FlowState::from_world(world).unwrap_or(FlowState::CLOSED));
207    }
208
209    /// Forget the stick without touching the gate.
210    pub fn release(&self) {
211        self.inner.borrow_mut().release();
212    }
213
214    /// The stick's touch zone and ring, to be composed over the game.
215    ///
216    /// Nothing is composed while the gate is closed.
217    pub fn view(&self, viewport: [f32; 2]) -> View {
218        {
219            let mut inner = self.inner.borrow_mut();
220            if !inner.enabled {
221                return Spacer();
222            }
223            // The pointer callbacks convert physical pixels with this scale;
224            // layout reports the frame's fit scale.
225            inner.scale = self.kit.scale().fit();
226        }
227
228        let tokens = self.kit.tokens();
229        let zone_fraction = self.inner.borrow().zone;
230        let zone = RBox(
231            self.kit
232                .place(viewport, [0.0, 0.0, zone_fraction, 1.0], [0.0; 4])
233                .on_pointer_down(self.on_down())
234                .on_pointer_move(self.on_move())
235                .on_pointer_up(self.on_release())
236                .on_pointer_cancel(self.on_release()),
237        )
238        .child(Spacer());
239        ZStack(Modifier::new().fill_max_size()).child((zone, self.ring(viewport, &tokens)))
240    }
241
242    /// A round tap button at a design-space point, pushing `action` on release.
243    ///
244    /// Sized like the glyph buttons and placed absolutely, so it can sit under
245    /// the thumb. Touch buttons stay out of keyboard traversal: a finger is not
246    /// a focus.
247    pub fn tap(
248        &self,
249        viewport: [f32; 2],
250        glyph: &str,
251        at: [f32; 2],
252        radius: f32,
253        action: A,
254    ) -> View {
255        let scale = self.kit.scale().fit();
256        let tokens = self.kit.tokens();
257        let enabled = self.inner.borrow().enabled;
258        let modifier = self
259            .kit
260            .place(
261                viewport,
262                [0.0; 4],
263                [
264                    at[0] - radius,
265                    at[1] - radius,
266                    at[0] + radius,
267                    at[1] + radius,
268                ],
269            )
270            .background(tokens.cream)
271            .border(Dp(3.0 * scale), tokens.ink, Dp(radius * scale))
272            .clip_rounded(Dp(radius * scale))
273            .shadow_with_color(Dp(0.0), Dp(4.0 * scale), tokens.button_shadow)
274            .align_items(AlignItems::CENTER)
275            .justify_content(JustifyContent::CENTER)
276            .focusable(false);
277        let modifier = if enabled {
278            let sink = self.kit.sink().clone();
279            modifier.on_click(move || sink.push(action.clone()))
280        } else {
281            modifier.disabled()
282        };
283        RBox(modifier).child(self.kit.icon(glyph, 26.0, tokens.ink))
284    }
285
286    /// The traversal driver for the pad's controls, when a game wants a panel's
287    /// buttons reachable from a stick as well as from a finger: a touch stick is
288    /// a pointer, so this is only for a game that also reads it as a
289    /// directional axis.
290    pub fn driver(&self) -> NavDriver {
291        self.kit.driver()
292    }
293
294    fn on_down(&self) -> impl Fn(PointerEvent) + 'static {
295        let inner = self.inner.clone();
296        move |event: PointerEvent| {
297            let mut state = inner.borrow_mut();
298            if !state.enabled || state.owner.is_some() {
299                return;
300            }
301            state.owner = Some(owner_of(&event));
302            state.active = true;
303            state.raw = Vec2::ZERO;
304            state.origin = design_units(event.window_position, state.scale);
305            event.consume();
306        }
307    }
308
309    fn on_move(&self) -> impl Fn(PointerEvent) + 'static {
310        let inner = self.inner.clone();
311        move |event: PointerEvent| {
312            let mut state = inner.borrow_mut();
313            if !state.enabled || state.owner != Some(owner_of(&event)) {
314                return;
315            }
316            let here = design_units(event.window_position, state.scale);
317            let (origin, delta) = drag(state.origin, here, STICK_RADIUS);
318            state.origin = origin;
319            state.raw = delta;
320            event.consume();
321        }
322    }
323
324    fn on_release(&self) -> impl Fn(PointerEvent) + 'static {
325        let inner = self.inner.clone();
326        move |event: PointerEvent| {
327            let mut state = inner.borrow_mut();
328            if state.owner != Some(owner_of(&event)) {
329                return;
330            }
331            state.release();
332        }
333    }
334
335    fn ring(&self, viewport: [f32; 2], tokens: &Tokens) -> View {
336        let inner = self.inner.borrow();
337        if !inner.active {
338            return Spacer();
339        }
340        let origin = inner.origin.to_array();
341        let knob = (inner.origin + inner.raw).to_array();
342        let vouchers = [
343            self.kit.disc(viewport, origin, STICK_RADIUS + 13.0, tokens.touch_shadow),
344            self.kit.disc(viewport, origin, STICK_RADIUS + 3.0, tokens.touch_bg),
345            self.kit.ring(viewport, origin, STICK_RADIUS + 3.0, 3.0, tokens.touch_ring),
346            self.kit.disc(viewport, origin, 4.0, tokens.touch_center),
347            self.kit.disc(viewport, knob, KNOB_RADIUS - 3.0, tokens.touch_knob_dark),
348            self.kit.disc(viewport, knob, KNOB_RADIUS - 6.0, tokens.touch_knob),
349        ];
350        ZStack(Modifier::new().fill_max_size()).child(vouchers.to_vec())
351    }
352}
353
354/// Pointer position in physical pixels to design units.
355fn design_units(pixels: repose_core::Vec2, scale: f32) -> Vec2 {
356    crate::layout::design_units(pixels, scale)
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::flow::{Flow, Suspension, Transition};
363    use crate::layout::Scale;
364    use crate::nav::Wrap;
365    use crate::palette::Tokens;
366    use crate::queue::ActionQueue;
367    use repame_input::{GamepadState, TouchContact};
368
369    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
370    enum Act {
371        Jump,
372        Dive,
373    }
374
375    fn pad() -> (TouchPad<Act>, ActionQueue<Act>) {
376        let (queue, sink) = ActionQueue::pair();
377        let kit = Kit::new(Scale::unit(), Tokens::default(), sink);
378        (TouchPad::new(kit), queue)
379    }
380
381    fn playing() -> FlowState {
382        FlowState::new(Flow::Playing, Transition::Idle, Suspension::Live)
383    }
384
385    fn paused() -> FlowState {
386        FlowState::new(Flow::Paused, Transition::Idle, Suspension::Live)
387    }
388
389    #[test]
390    fn drag_within_the_radius_keeps_the_origin() {
391        let origin = Vec2::new(100.0, 100.0);
392        let (kept, delta) = drag(origin, Vec2::new(120.0, 100.0), 46.0);
393        assert_eq!(kept, origin);
394        assert_eq!(delta, Vec2::new(20.0, 0.0));
395    }
396
397    #[test]
398    fn drag_past_the_radius_pulls_the_origin_along() {
399        let origin = Vec2::new(100.0, 100.0);
400        let (moved, delta) = drag(origin, Vec2::new(200.0, 100.0), 46.0);
401        assert_eq!(moved, Vec2::new(154.0, 100.0));
402        assert_eq!(delta, Vec2::new(46.0, 0.0));
403        assert!((delta.length() - 46.0).abs() < 1e-6);
404    }
405
406    #[test]
407    fn a_degenerate_radius_reads_as_rest() {
408        let origin = Vec2::new(10.0, 10.0);
409        assert_eq!(drag(origin, Vec2::new(50.0, 10.0), 0.0), (origin, Vec2::ZERO));
410        assert_eq!(axis(Vec2::new(46.0, 0.0), 0.0), Vec2::ZERO);
411    }
412
413    #[test]
414    fn axis_kills_the_dead_zone_and_reaches_full_deflection() {
415        assert_eq!(axis(Vec2::ZERO, STICK_RADIUS), Vec2::ZERO);
416        assert_eq!(
417            axis(Vec2::new(8.0, 0.0), STICK_RADIUS),
418            Vec2::ZERO,
419            "0.17 of the radius is inside repame-input's 0.22 dead zone"
420        );
421        let half = axis(Vec2::new(23.0, 0.0), STICK_RADIUS);
422        assert!(half.x > 0.0 && half.x < 0.5, "half deflection is partial, got {half:?}");
423        let full = axis(Vec2::new(46.0, 0.0), STICK_RADIUS);
424        assert!((full.x - 1.0).abs() < 1e-6, "full deflection is unit, got {full:?}");
425        let beyond = axis(Vec2::new(200.0, 0.0), STICK_RADIUS);
426        assert!((beyond.length() - 1.0).abs() < 1e-6, "clamped to the unit disc");
427        assert_eq!(beyond.y, 0.0);
428    }
429
430    #[test]
431    fn axis_keeps_the_direction() {
432        let up_left = axis(Vec2::new(-46.0, -46.0), STICK_RADIUS);
433        assert!(up_left.x < 0.0 && up_left.y < 0.0);
434        assert!((up_left.length() - 1.0).abs() < 1e-6);
435        assert!((up_left.x + up_left.y).abs() < 1e-6, "the diagonal stays symmetric");
436    }
437
438    #[test]
439    fn the_gate_follows_the_flow_predicates() {
440        let (pad, _queue) = pad();
441        assert!(!pad.enabled(), "closed until a frame says otherwise");
442        pad.begin_frame(playing());
443        assert!(pad.enabled());
444        pad.begin_frame(paused());
445        assert!(!pad.enabled(), "a pause panel closes the gate");
446        pad.begin_frame(FlowState::new(Flow::Playing, Transition::Running, Suspension::Live));
447        assert!(!pad.enabled(), "a transition closes the gate");
448        pad.begin_frame(FlowState::new(Flow::Playing, Transition::Idle, Suspension::Suspended));
449        assert!(!pad.enabled(), "a suspend closes the gate");
450        pad.begin_frame(playing());
451        assert!(pad.enabled());
452    }
453
454    #[test]
455    fn the_gate_can_be_read_from_a_sim_world() {
456        use repame_sim::Sim;
457
458        let (pad, _queue) = pad();
459        let mut sim = Sim::with_default_step();
460        // No flow installed: the pad stays closed rather than live.
461        pad.begin_frame_in(&sim.world);
462        assert!(!pad.enabled());
463
464        crate::flow::install(&mut sim, Flow::Playing);
465        pad.begin_frame_in(&sim.world);
466        assert!(pad.enabled());
467
468        sim.set_state(Flow::Modal);
469        sim.tick();
470        pad.begin_frame_in(&sim.world);
471        assert!(!pad.enabled(), "a modal freezes the overlay");
472    }
473
474    #[test]
475    fn the_closed_flow_state_runs_nothing() {
476        assert!(!FlowState::CLOSED.run_live());
477        assert!(FlowState::CLOSED.menu());
478        assert!(FlowState::CLOSED.panel_owns_input());
479    }
480
481    #[test]
482    fn closing_the_gate_releases_the_stick() {
483        let (pad, _queue) = pad();
484        pad.begin_frame(playing());
485        {
486            let mut inner = pad.inner.borrow_mut();
487            inner.active = true;
488            inner.raw = Vec2::new(46.0, 0.0);
489        }
490        assert!(pad.active());
491        assert!((pad.stick().length() - 1.0).abs() < 1e-6);
492        pad.begin_frame(paused());
493        assert!(!pad.active());
494        assert_eq!(pad.stick(), Vec2::ZERO);
495    }
496
497    #[test]
498    fn the_stick_is_dead_zoned_in_design_units() {
499        let (pad, _queue) = pad();
500        pad.begin_frame(playing());
501        {
502            let mut inner = pad.inner.borrow_mut();
503            inner.active = true;
504            inner.raw = Vec2::new(46.0, 0.0);
505        }
506        let stick = pad.stick();
507        assert!((stick.x - 1.0).abs() < 1e-6);
508        {
509            let mut inner = pad.inner.borrow_mut();
510            inner.raw = Vec2::new(5.0, 0.0);
511        }
512        assert_eq!(pad.stick(), Vec2::ZERO, "inside the dead zone");
513    }
514
515    #[test]
516    fn the_view_is_empty_while_the_gate_is_closed() {
517        let (pad, _queue) = pad();
518        pad.begin_frame(paused());
519        let view = pad.view([960.0, 540.0]);
520        assert!(view.children.is_empty(), "no stick under a modal");
521        pad.begin_frame(playing());
522        let view = pad.view([960.0, 540.0]);
523        assert_eq!(view.children.len(), 2, "the touch zone and the ring layer");
524    }
525
526    #[test]
527    fn a_tap_button_pushes_into_the_shared_queue() {
528        let (pad, queue) = pad();
529        pad.begin_frame(playing());
530        let tap = pad.tap([960.0, 540.0], "arrow_upward", [820.0, 400.0], 48.0, Act::Jump);
531        let on_click = tap.modifier.on_click.clone().expect("enabled taps click");
532        on_click();
533        let dive = pad.tap([960.0, 540.0], "arrow_downward", [820.0, 480.0], 48.0, Act::Dive);
534        dive.modifier.on_click.clone().expect("enabled taps click")();
535        assert_eq!(queue.drain(), vec![Act::Jump, Act::Dive]);
536        assert!(!tap.modifier.focusable.unwrap_or(true), "taps stay out of keyboard traversal");
537    }
538
539    #[test]
540    fn a_tap_button_is_disabled_with_the_gate() {
541        let (pad, queue) = pad();
542        pad.begin_frame(paused());
543        let tap = pad.tap([960.0, 540.0], "arrow_upward", [820.0, 400.0], 48.0, Act::Jump);
544        assert!(tap.modifier.on_click.is_none());
545        assert!(tap.modifier.disabled);
546        assert!(queue.is_empty());
547    }
548
549    #[test]
550    fn a_zone_fraction_outside_the_unit_interval_is_rejected() {
551        let (pad, _queue) = pad();
552        assert_eq!(TouchPad::new(pad.kit().clone()).with_zone(0.0).err(), Some(TouchError::ZoneOutOfRange));
553        assert_eq!(TouchPad::new(pad.kit().clone()).with_zone(1.5).err(), Some(TouchError::ZoneOutOfRange));
554        assert_eq!(TouchPad::new(pad.kit().clone()).with_zone(f32::NAN).err(), Some(TouchError::ZoneOutOfRange));
555        let half = TouchPad::new(pad.kit().clone()).with_zone(0.5).expect("half the width");
556        assert!(!half.enabled(), "a fresh pad is closed until a frame opens it");
557    }
558
559    #[test]
560    fn the_pads_own_driver_is_the_kits_driver() {
561        let (pad, _queue) = pad();
562        assert_eq!(pad.driver().wrap(), Wrap::Cycle);
563    }
564
565    #[test]
566    fn a_contact_snapshot_can_seed_the_stick_on_the_sim_side() {
567        // The pad itself reads attacks through repose's pointer path; the sim
568        // side can still take a contact and ask for the same stick curve.
569        let contact = TouchContact {
570            id: 3,
571            start: Vec2::new(80.0, 400.0),
572            pos: Vec2::new(126.0, 400.0),
573            just_pressed: true,
574        };
575        let (_, delta) = drag(contact.start, contact.pos, STICK_RADIUS);
576        let value = axis(delta, STICK_RADIUS);
577        assert!((value.x - 1.0).abs() < 1e-6, "a full drag reads as full deflection");
578        assert_eq!(value.y, 0.0);
579    }
580
581    #[test]
582    fn pad_state_does_not_move_the_stick_by_itself() {
583        // A reminder encoded as a test: the touch pad is the only thing that
584        // writes the stick, so a gamepad snapshot cannot leak into it.
585        let pad_state = GamepadState::default();
586        let (pad, _queue) = pad();
587        pad.begin_frame(playing());
588        assert_eq!(pad.stick(), Vec2::ZERO);
589        assert_eq!(pad_state.left_stick, Vec2::ZERO);
590    }
591}