use std::collections::{HashMap, HashSet};
use std::sync::mpsc;
use std::time::Duration;
use all_is_cubes::camera::{
FogOption, GraphicsOptions, LightingOption, TransparencyOption, Viewport,
};
use all_is_cubes::cgmath::{EuclideanSpace as _, Point2, Vector2, Vector3, Zero as _};
use all_is_cubes::character::Character;
use all_is_cubes::listen::{ListenableCell, ListenableSource};
use all_is_cubes::math::FreeCoordinate;
use all_is_cubes::notnan;
use all_is_cubes::time::Tick;
use all_is_cubes::universe::{URef, Universe};
use crate::apps::ControlMessage;
/// Parse input events, particularly key-down/up pairs, into character control and such.
///
/// This is designed to be a leaf of the dependency graph: it does not own or send
/// messages to any other elements of the application. Instead, the following steps
/// must occur in the given order.
///
/// 1. The platform-specific code should call [`InputProcessor::key_down`] and such to
/// to provide input information.
/// 2. The game loop should call `InputProcessor::apply_input` to apply the effects
/// of input on the relevant [`Character`]. (This is currently only possible via
/// [`Session`].)
/// 3. The game loop should call `InputProcessor::step` to apply the effects of time
/// on the input processor. (This is currently only possible via [`Session`].)
///
/// TODO: Refactor APIs till this can be explained more cleanly without reference to
/// private items.
///
/// [`Session`]: super::Session
#[derive(Debug)]
pub struct InputProcessor {
/// All [`Key`]s currently pressed.
keys_held: HashSet<Key>,
/// As a special feature for supporting input without key-up events, stores all
/// keypresses arriving through [`Self::key_momentary`] and virtually holds them
/// for a short time. The value is the remaining time.
momentary_timeout: HashMap<Key, Duration>,
/// [`Key`]s with one-shot effects when pressed which need to be applied
/// once per press rather than while held.
command_buffer: Vec<Key>,
/// Do we *want* pointer lock for mouselook?
///
/// This is listenable so that the UI can react to this state.
mouselook_mode: ListenableCell<bool>,
/// Do we *have* pointer lock for mouselook? Reported by calling input implementation.
has_pointer_lock: bool,
/// Net mouse movement since the last [`Self::apply_input`].
mouselook_buffer: Vector2<FreeCoordinate>,
/// Mouse position in NDC. None if out of bounds/lost focus.
mouse_ndc_position: Option<Point2<FreeCoordinate>>,
/// Mouse position used for generating mouselook deltas.
/// [`None`] if games.
mouse_previous_pixel_position: Option<Point2<f64>>,
}
impl InputProcessor {
/// Constructs a new [`InputProcessor`].
///
/// Consider using [`Session`](crate::apps::Session) instead of directly calling this.
#[allow(clippy::new_without_default)] // I expect it'll grow some parameters
pub fn new() -> Self {
Self {
keys_held: HashSet::new(),
momentary_timeout: HashMap::new(),
command_buffer: Vec::new(),
mouselook_mode: ListenableCell::new(false), // TODO: might want a parameter
has_pointer_lock: false,
mouselook_buffer: Vector2::zero(),
mouse_ndc_position: Some(Point2::origin()),
mouse_previous_pixel_position: None,
}
}
fn is_bound(key: Key) -> bool {
// Eventually we'll have actual configurable keybindings...
match key {
// Used in `InputProcessor::movement()`.
Key::Character('w') => true,
Key::Character('a') => true,
Key::Character('s') => true,
Key::Character('d') => true,
Key::Character('e') => true,
Key::Character('c') => true,
// Used in `InputProcessor::apply_input()`.
Key::Escape => true,
Key::Left => true,
Key::Right => true,
Key::Up => true,
Key::Down => true,
Key::Character(' ') => true,
Key::Character(d) if d.is_ascii_digit() => true,
Key::Character('i') => true,
Key::Character('l') => true,
Key::Character('o') => true,
Key::Character('p') => true,
Key::Character('u') => true,
_ => false,
}
}
/// Returns true if the key should go in `command_buffer`.
fn is_command(key: Key) -> bool {
#[allow(clippy::match_like_matches_macro)]
match key {
Key::Escape => true,
Key::Character(d) if d.is_ascii_digit() => true,
Key::Character('i') => true,
Key::Character('l') => true,
Key::Character('o') => true,
Key::Character('p') => true,
Key::Character('u') => true,
// TODO: move slot selection commands here
_ => false,
}
}
/// Handles incoming key-down events. Returns whether the key was unbound.
pub fn key_down(&mut self, key: Key) -> bool {
let bound = Self::is_bound(key);
if bound {
self.keys_held.insert(key);
if Self::is_command(key) {
self.command_buffer.push(key);
}
}
bound
}
/// Handles incoming key-up events.
pub fn key_up(&mut self, key: Key) {
self.keys_held.remove(&key);
}
/// Handles incoming key events in the case where key-up events are not available,
/// such that an assumption about equivalent press duration must be made.
pub fn key_momentary(&mut self, key: Key) -> bool {
self.momentary_timeout
.insert(key, Duration::from_millis(200));
self.key_up(key);
self.key_down(key)
}
/// Handles the keyboard focus being gained or lost. If the platform does not have
/// a concept of focus, you need not call this method, but may call it with `true`.
///
/// `InputProcessor` will assume that if focus is lost, key-up events may be lost and
/// so currently held keys should stop taking effect.
pub fn key_focus(&mut self, has_focus: bool) {
if has_focus {
// Nothing to do.
} else {
self.keys_held.clear();
self.momentary_timeout.clear();
self.mouselook_mode.set(false);
}
}
/// True when the UI is in a state which _should_ have mouse pointer
/// lock/capture/disable. This is not the same as actually having it since the window
/// may lack focus, the application may lack permission, etc.; use
/// [`InputProcessor::has_pointer_lock`] to report that state.
pub fn wants_pointer_lock(&self) -> bool {
*self.mouselook_mode.get()
}
/// Use this method to report whether mouse mouse pointer lock/capture/disable is
/// known to be successfully enabled, after [`InputProcessor::wants_pointer_lock`]
/// requests it or it is disabled for any reason.
pub fn has_pointer_lock(&mut self, value: bool) {
self.has_pointer_lock = value;
}
/// Provide relative movement information for mouselook.
///
/// This value is an accumulated displacement, not an angular velocity, so it is not
/// suitable for joystick-type input.
///
/// Note that absolute cursor positions must be provided separately.
pub fn mouselook_delta(&mut self, delta: Vector2<FreeCoordinate>) {
// TODO: sensitivity option
if self.has_pointer_lock {
self.mouselook_buffer += delta * 0.2;
}
}
/// Provide position of mouse pointer or other input device in normalized device
/// coordinates (range -1 to 1 upward and rightward).
/// [`None`] denotes the cursor being outside the viewport, and out-of-range
/// coordinates will be treated the same.
///
/// Pixel coordinates may be converted to NDC using [`Viewport::normalize_nominal_point`]
/// or by using [`InputProcessor::mouse_pixel_position`].
///
/// If this is never called, the default value is (0, 0) which corresponds to the
/// center of the screen.
pub fn mouse_ndc_position(&mut self, position: Option<Point2<FreeCoordinate>>) {
self.mouse_ndc_position = position.filter(|p| p.x.abs() <= 1. && p.y.abs() <= 1.);
}
/// Provide position of mouse pointer or other input device in pixel coordinates
/// framed by the given [`Viewport`].
/// [`None`] denotes the cursor being outside the viewport, and out-of-range
/// coordinates will be treated the same.
///
/// This is equivalent to converting the coordinates and calling
/// [`InputProcessor::mouse_ndc_position`].
///
/// If this is never called, the default value is (0, 0) which corresponds to the
/// center of the screen.
///
/// TODO: this should take float input, probably
pub fn mouse_pixel_position(
&mut self,
viewport: Viewport,
position: Option<Point2<f64>>,
derive_movement: bool,
) {
self.mouse_ndc_position(
position.map(|p| Point2::from_vec(viewport.normalize_nominal_point(p))),
);
if derive_movement {
if let (Some(p1), Some(p2)) = (self.mouse_previous_pixel_position, position) {
self.mouselook_delta((p2 - p1).map(FreeCoordinate::from));
}
self.mouse_previous_pixel_position = position;
} else {
self.mouse_previous_pixel_position = None;
}
}
/// Returns the character movement velocity that input is currently requesting.
pub fn movement(&self) -> Vector3<FreeCoordinate> {
Vector3::new(
self.net_movement(Key::Character('a'), Key::Character('d')),
self.net_movement(Key::Character('c'), Key::Character('e')),
self.net_movement(Key::Character('w'), Key::Character('s')),
)
}
/// Advance time insofar as input interpretation is affected by time.
///
/// This method should be called *after* [`apply_input`](Self::apply_input), when
/// applicable.
pub(crate) fn step(&mut self, tick: Tick) {
let mut to_drop = Vec::new();
for (key, duration) in self.momentary_timeout.iter_mut() {
if let Some(reduced) = duration.checked_sub(tick.delta_t()) {
*duration = reduced;
} else {
to_drop.push(*key);
}
}
for key in to_drop.drain(..) {
self.momentary_timeout.remove(&key);
self.key_up(key);
}
self.mouselook_buffer = Vector2::zero();
}
/// Applies the accumulated input from previous events.
/// `targets` specifies the objects it should be applied to.
pub(crate) fn apply_input(&mut self, targets: InputTargets<'_>, tick: Tick) {
let InputTargets {
universe,
character: character_opt,
paused: paused_opt,
graphics_options,
control_channel,
} = targets;
// TODO: universe input is not yet used but it will be, as we start having inputs that trigger transactions
let _ = universe;
let dt = tick.delta_t().as_secs_f64();
let key_turning_step = 80.0 * dt;
// Direct character controls
if let Some(character_ref) = character_opt {
character_ref
.try_modify(|character| {
let movement = self.movement();
character.set_velocity_input(movement);
let turning = Vector2::new(
key_turning_step * self.net_movement(Key::Left, Key::Right)
+ self.mouselook_buffer.x,
key_turning_step * self.net_movement(Key::Up, Key::Down)
+ self.mouselook_buffer.y,
);
character.body.yaw = (character.body.yaw + turning.x).rem_euclid(360.0);
character.body.pitch = (character.body.pitch + turning.y).clamp(-90.0, 90.0);
if self.keys_held.contains(&Key::Character(' ')) {
character.jump_if_able();
}
})
.expect("character was borrowed during apply_input()");
}
for key in self.command_buffer.drain(..) {
match key {
Key::Escape => {
if let Some(ch) = control_channel {
let _ = ch.try_send(ControlMessage::Back);
}
}
Key::Character('i') => {
if let Some(cell) = graphics_options {
cell.update_mut(|options| {
options.lighting_display = match options.lighting_display {
LightingOption::None => LightingOption::Flat,
LightingOption::Flat => LightingOption::Smooth,
LightingOption::Smooth => LightingOption::None,
_ => LightingOption::None, // TODO: either stop doing cycle-commands or put it on the enum so it can be exhaustive
};
});
}
}
Key::Character('l') => {
// TODO: duplicated with fn toggle_mouselook_mode() because of borrow conflicts
let new_state = !*self.mouselook_mode.get();
self.mouselook_mode.set(new_state);
if new_state {
// Clear delta tracking just in case
self.mouse_previous_pixel_position = None;
}
}
Key::Character('o') => {
if let Some(cell) = graphics_options {
cell.update_mut(|options| {
options.transparency = match options.transparency {
TransparencyOption::Surface => TransparencyOption::Volumetric,
TransparencyOption::Volumetric => {
TransparencyOption::Threshold(notnan!(0.5))
}
TransparencyOption::Threshold(_) => TransparencyOption::Surface,
_ => TransparencyOption::Surface, // TODO: either stop doing cycle-commands or put it on the enum so it can be exhaustive
};
});
}
}
Key::Character('p') => {
// TODO: eliminate this weird binding once escape-based pausing is working well
if let Some(paused) = paused_opt {
paused.update_mut(|p| *p = !*p);
}
}
Key::Character('u') => {
if let Some(cell) = graphics_options {
cell.update_mut(|options| {
options.fog = match options.fog {
FogOption::None => FogOption::Abrupt,
FogOption::Abrupt => FogOption::Compromise,
FogOption::Compromise => FogOption::Physical,
FogOption::Physical => FogOption::None,
_ => FogOption::None, // TODO: either stop doing cycle-commands or put it on the enum so it can be exhaustive
};
});
}
}
Key::Character(numeral) if numeral.is_ascii_digit() => {
let digit = numeral.to_digit(10).unwrap() as usize;
let slot = (digit + 9).rem_euclid(10); // wrap 0 to 9
if let Some(character_ref) = character_opt {
character_ref
.try_modify(|c| c.set_selected_slot(1, slot))
.expect("character was borrowed during apply_input()");
}
}
_ => {}
}
}
}
pub fn mouselook_mode(&self) -> ListenableSource<bool> {
self.mouselook_mode.as_source()
}
// TODO: duplicated with the keybinding impl because of borrow conflicts
pub(crate) fn toggle_mouselook_mode(&mut self) {
let new_state = !*self.mouselook_mode.get();
self.mouselook_mode.set(new_state);
if new_state {
// Clear delta tracking just in case
self.mouse_previous_pixel_position = None;
}
}
/// Returns the position which should be used for click/cursor raycasting.
/// This is not necessarily equal to the tracked mouse position.
///
/// Returns [`None`] if the mouse position is out of bounds, the window has lost
/// focus, or similar conditions under which no cursor should be shown.
pub fn cursor_ndc_position(&self) -> Option<Point2<FreeCoordinate>> {
if *self.mouselook_mode.get() {
Some(Point2::origin())
} else {
self.mouse_ndc_position
}
}
/// Computes the net effect of a pair of opposed inputs (e.g. "forward" and "back").
fn net_movement(&self, negative: Key, positive: Key) -> FreeCoordinate {
match (
self.keys_held.contains(&negative),
self.keys_held.contains(&positive),
) {
(true, false) => -1.0,
(false, true) => 1.0,
_ => 0.0,
}
}
}
/// Things needed to apply input.
///
/// Missing inputs will cause input to be ignored.
/// TODO: Specify a warning reporting scheme.
#[derive(Debug, Default)]
#[non_exhaustive]
pub(crate) struct InputTargets<'a> {
pub universe: Option<&'a mut Universe>,
pub character: Option<&'a URef<Character>>,
pub paused: Option<&'a ListenableCell<bool>>,
pub graphics_options: Option<&'a ListenableCell<GraphicsOptions>>,
// TODO: replace cells with control channel?
// TODO: make the control channel a type alias?
pub control_channel: Option<&'a mpsc::SyncSender<ControlMessage>>,
}
/// A platform-neutral representation of keyboard keys for [`InputProcessor`].
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum Key {
/// Letters should be lowercase.
Character(char),
/// Escape key (or controller ‘start’ or mobile ‘back’).
Escape,
/// Left arrow key.
Left,
/// Right arrow key.
Right,
/// Up arrow key.
Up,
/// Down arrow key.
Down,
}
#[cfg(test)]
mod tests {
use super::*;
use all_is_cubes::space::Space;
use all_is_cubes::universe::{URef, Universe};
fn apply_input_helper(
input: &mut InputProcessor,
universe: &mut Universe,
character: &URef<Character>,
) {
input.apply_input(
InputTargets {
universe: Some(universe),
character: Some(character),
paused: None,
graphics_options: None,
control_channel: None,
},
Tick::arbitrary(),
);
}
#[test]
fn movement() {
let mut input = InputProcessor::new();
assert_eq!(input.movement(), Vector3::new(0.0, 0.0, 0.0));
input.key_down(Key::Character('d'));
assert_eq!(input.movement(), Vector3::new(1.0, 0.0, 0.0));
input.key_down(Key::Character('a'));
assert_eq!(input.movement(), Vector3::new(0.0, 0.0, 0.0));
input.key_up(Key::Character('d'));
assert_eq!(input.movement(), Vector3::new(-1.0, 0.0, 0.0));
}
#[test]
fn focus_lost_cancels_keys() {
let mut input = InputProcessor::new();
assert_eq!(input.movement(), Vector3::zero());
input.key_down(Key::Character('d'));
assert_eq!(input.movement(), Vector3::unit_x());
input.key_focus(false);
assert_eq!(input.movement(), Vector3::zero()); // Lost focus, no movement.
// Confirm that keys work again afterward.
input.key_focus(true);
assert_eq!(input.movement(), Vector3::zero());
input.key_down(Key::Character('d'));
assert_eq!(input.movement(), Vector3::unit_x());
// TODO: test (and handle) key events arriving while focus is lost, just in case.
}
#[test]
fn slot_selection() {
// TODO: Awful lot of setup boilerplate...
let u = &mut Universe::new();
let space = u.insert_anonymous(Space::empty_positive(1, 1, 1));
let character = u.insert_anonymous(Character::spawn_default(space.clone()));
let mut input = InputProcessor::new();
input.key_down(Key::Character('5'));
input.key_up(Key::Character('5'));
apply_input_helper(&mut input, u, &character);
assert_eq!(character.read().unwrap().selected_slots()[1], 4);
// Tenth slot
input.key_down(Key::Character('0'));
input.key_up(Key::Character('0'));
apply_input_helper(&mut input, u, &character);
assert_eq!(character.read().unwrap().selected_slots()[1], 9);
}
// TODO: test jump and flying logic
}