codecraft 0.1.1

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, an immediate-mode UI, audio and gamepad haptics
Documentation
//! A small command port, so the running game can be driven from outside it.
//!
//! It exists for looking at the game while it runs: ask for a screenshot and
//! get back the path to a PNG of the actual window, rather than whatever a
//! desktop screen grab catches. Off unless `RENDERER_CONTROL_PORT` is set,
//! and bound to loopback either way.
//!
//! ```text
//! RENDERER_CONTROL_PORT=7878 cargo run -p chessrs
//! printf 'screenshot /tmp/board.png\n' | nc 127.0.0.1 7878
//! ok /tmp/board.png
//! printf 'key F12\n' | nc 127.0.0.1 7878
//! ok F12
//! printf 'down KeyW\n' | nc 127.0.0.1 7878
//! ok KeyW down
//! ```
use std::io::{BufRead, BufReader, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};

use crate::input::KeyCode;

/// The environment variable that turns the port on. `0` lets the OS choose,
/// and the port it picked is logged.
pub const PORT_VAR: &str = "RENDERER_CONTROL_PORT";

/// What the game has been asked to do.
#[derive(Clone, PartialEq, Debug)]
pub enum Command {
    /// Write a PNG of the next frame to this path.
    Screenshot { path: PathBuf },
    /// Press and release a key, as though somebody had.
    Key { code: KeyCode },
    /// Hold a key down until it is let go with `Up`.
    ///
    /// A `key` is a tap, and a tap cannot drive a tank: holding the throttle
    /// is a state, the way hovering is, so the port needs the two halves of
    /// a press apart -- for the same reason it has `press` and `release` for
    /// the mouse.
    Down { code: KeyCode },
    /// Let a held key go.
    Up { code: KeyCode },
    /// Move the pointer, in pixels from the top-left of the window.
    ///
    /// Hover is a state a game can be *in*, so a screenshot of a menu with a
    /// submenu open needs a way to say where the pointer is. Without this the
    /// port can open a menu and never see inside it.
    Cursor { x: f32, y: f32 },
    /// Click the left button, optionally moving the pointer there first.
    Click { at: Option<(f32, f32)> },
    /// Hold the left button down until a `release`, optionally moving the
    /// pointer there first.
    ///
    /// A `click` is a press and a release in one frame, which is a click and
    /// never a drag. Dragging a panel by its title bar needs the button held
    /// while the pointer moves, so it needs the two halves apart.
    Press { at: Option<(f32, f32)> },
    /// Let the left button go.
    Release,
    /// Shut the game down.
    Quit,
}

/// A command, and where its answer goes.
pub struct Request {
    pub command: Command,
    reply: Sender<Result<String, String>>,
}

impl Request {
    /// Answers the caller. Doing nothing with a request simply leaves the
    /// caller waiting until it gives up.
    pub fn answer(self, answer: Result<String, String>) {
        let _ = self.reply.send(answer);
    }

    pub fn command(&self) -> &Command {
        &self.command
    }
}

/// The command port.
pub struct Control {
    requests: Receiver<Request>,
    port: u16,
}

impl Control {
    /// Starts a port if [`PORT_VAR`] asks for one.
    pub fn from_env() -> Option<Self> {
        let value = std::env::var(PORT_VAR).ok()?;
        let port: u16 = match value.trim().parse() {
            Ok(port) => port,
            Err(_) => {
                log::error!("{PORT_VAR}={value:?} is not a port number");
                return None;
            }
        };
        match Self::listen(port) {
            Ok(control) => Some(control),
            Err(error) => {
                log::error!("could not open the control port: {error}");
                None
            }
        }
    }

    /// Listens on loopback. Port `0` lets the OS choose one.
    pub fn listen(port: u16) -> std::io::Result<Self> {
        let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port)))?;
        let port = listener.local_addr()?.port();
        let (sender, requests) = channel();

        std::thread::Builder::new()
            .name("control".into())
            .spawn(move || serve(listener, sender))?;

        log::info!("control port listening on 127.0.0.1:{port}");
        Ok(Self { requests, port })
    }

    pub fn port(&self) -> u16 {
        self.port
    }

    /// The next request, if one is waiting. Never blocks.
    pub fn poll(&self) -> Option<Request> {
        match self.requests.try_recv() {
            Ok(request) => Some(request),
            Err(TryRecvError::Empty | TryRecvError::Disconnected) => None,
        }
    }
}

/// Accepts callers and turns their lines into requests.
fn serve(listener: TcpListener, sender: Sender<Request>) {
    for stream in listener.incoming() {
        let Ok(mut stream) = stream else {
            continue;
        };
        if let Err(error) = handle(&mut stream, &sender) {
            log::debug!("control connection ended: {error}");
        }
    }
}

/// One connection: a line in, a line out, until the caller hangs up.
fn handle(stream: &mut TcpStream, sender: &Sender<Request>) -> std::io::Result<()> {
    let mut lines = BufReader::new(stream.try_clone()?).lines();
    while let Some(line) = lines.next().transpose()? {
        let answer = match parse(&line) {
            Err(problem) => Err(problem),
            Ok(command) => {
                let (reply, answer) = channel();
                if sender.send(Request { command, reply }).is_err() {
                    Err("the game is gone".to_string())
                } else {
                    // The game answers on its next frame; a frame that never
                    // comes should not hang the caller forever.
                    answer
                        .recv_timeout(std::time::Duration::from_secs(10))
                        .unwrap_or_else(|_| Err("the game did not answer".to_string()))
                }
            }
        };

        let line = match answer {
            Ok(message) => format!("ok {message}\n"),
            Err(problem) => format!("error {problem}\n"),
        };
        stream.write_all(line.as_bytes())?;
        stream.flush()?;
    }
    Ok(())
}

/// The key that goes by that name, as winit spells it.
///
/// Winit's names are what a caller is most likely to reach for — `F12`,
/// `KeyV`, `Escape` — and taking them verbatim means this list never has to
/// be kept in step with anything.
fn key_named(name: &str) -> Result<KeyCode, String> {
    let wanted = name.trim();
    KEYS.iter()
        .find(|(spelling, _)| spelling.eq_ignore_ascii_case(wanted))
        .map(|(_, code)| *code)
        .ok_or_else(|| format!("no such key: {wanted}"))
}

/// An `x y` pair, in pixels from the top-left of the window.
fn point(rest: &str) -> Result<(f32, f32), String> {
    let mut parts = rest.split_whitespace();
    let mut next = || {
        parts
            .next()
            .and_then(|p| p.parse::<f32>().ok())
            .ok_or_else(|| format!("expected `x y` in pixels, got {rest:?}"))
    };
    let (x, y) = (next()?, next()?);
    match parts.next() {
        Some(extra) => Err(format!("only x and y, but there is also {extra:?}")),
        None => Ok((x, y)),
    }
}

/// The keys that can be pressed from outside. Enough to drive a game being
/// looked at, rather than every key a keyboard has.
const KEYS: [(&str, KeyCode); 23] = [
    ("Escape", KeyCode::Escape),
    ("Enter", KeyCode::Enter),
    ("Space", KeyCode::Space),
    ("Tab", KeyCode::Tab),
    ("ShiftLeft", KeyCode::ShiftLeft),
    ("Left", KeyCode::ArrowLeft),
    ("Right", KeyCode::ArrowRight),
    ("Up", KeyCode::ArrowUp),
    ("Down", KeyCode::ArrowDown),
    ("KeyV", KeyCode::KeyV),
    ("KeyW", KeyCode::KeyW),
    ("KeyA", KeyCode::KeyA),
    ("KeyS", KeyCode::KeyS),
    ("KeyD", KeyCode::KeyD),
    ("KeyR", KeyCode::KeyR),
    ("F1", KeyCode::F1),
    ("F2", KeyCode::F2),
    ("F3", KeyCode::F3),
    ("F4", KeyCode::F4),
    ("F5", KeyCode::F5),
    ("F9", KeyCode::F9),
    ("F11", KeyCode::F11),
    ("F12", KeyCode::F12),
];

/// Reads one command line.
pub fn parse(line: &str) -> Result<Command, String> {
    let line = line.trim();
    let (name, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
    let rest = rest.trim();

    match name {
        "screenshot" if rest.is_empty() => Err("screenshot needs a path".to_string()),
        "screenshot" => Ok(Command::Screenshot {
            path: PathBuf::from(rest),
        }),
        "key" if rest.is_empty() => Err("key needs a key to press".to_string()),
        "key" => key_named(rest).map(|code| Command::Key { code }),
        "down" if rest.is_empty() => Err("down needs a key to hold".to_string()),
        "down" => key_named(rest).map(|code| Command::Down { code }),
        "up" if rest.is_empty() => Err("up needs a key to let go".to_string()),
        "up" => key_named(rest).map(|code| Command::Up { code }),
        "cursor" => point(rest).map(|(x, y)| Command::Cursor { x, y }),
        "click" if rest.is_empty() => Ok(Command::Click { at: None }),
        "click" => point(rest).map(|(x, y)| Command::Click { at: Some((x, y)) }),
        "press" if rest.is_empty() => Ok(Command::Press { at: None }),
        "press" => point(rest).map(|(x, y)| Command::Press { at: Some((x, y)) }),
        "release" => Ok(Command::Release),
        "quit" => Ok(Command::Quit),
        "" => Err("say something".to_string()),
        other => Err(format!("no such command: {other}")),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_key_is_named_the_way_winit_names_it() {
        assert_eq!(parse("key F12"), Ok(Command::Key { code: KeyCode::F12 }));
        assert_eq!(
            parse("key KeyV"),
            Ok(Command::Key {
                code: KeyCode::KeyV
            })
        );
        // Case is not worth being strict about at a command prompt.
        assert_eq!(
            parse("key escape"),
            Ok(Command::Key {
                code: KeyCode::Escape
            })
        );
    }

    /// A tap cannot hold a throttle open; a key that stays down can.
    #[test]
    fn a_key_can_be_held_and_let_go() {
        assert_eq!(parse("down KeyW"), Ok(Command::Down { code: KeyCode::KeyW }));
        assert_eq!(parse("up KeyW"), Ok(Command::Up { code: KeyCode::KeyW }));
        assert!(parse("down").is_err());
        assert!(parse("up Nope").is_err());
    }

    #[test]
    fn a_key_nobody_has_is_refused_by_name() {
        let refusal = parse("key Banana").unwrap_err();
        assert!(refusal.contains("Banana"), "{refusal}");
        assert!(parse("key").is_err(), "and a key command needs a key");
    }

    #[test]
    fn a_screenshot_command_carries_its_path() {
        assert_eq!(
            parse("screenshot /tmp/board.png"),
            Ok(Command::Screenshot {
                path: PathBuf::from("/tmp/board.png"),
            }),
        );
    }

    #[test]
    fn surrounding_whitespace_does_not_matter() {
        // Lines arrive with their newline, and callers are careless. A path
        // with a space in it is still one path.
        assert_eq!(
            parse("  screenshot   /tmp/a b.png \r\n"),
            Ok(Command::Screenshot {
                path: PathBuf::from("/tmp/a b.png"),
            }),
        );
    }

    #[test]
    fn a_screenshot_without_a_path_is_refused() {
        assert!(parse("screenshot").is_err());
        assert!(parse("screenshot   ").is_err());
    }

    #[test]
    fn quit_needs_nothing_else() {
        assert_eq!(parse("quit"), Ok(Command::Quit));
        assert_eq!(parse(" quit \n"), Ok(Command::Quit));
    }

    #[test]
    fn nonsense_is_refused_by_name() {
        assert_eq!(parse("wibble"), Err("no such command: wibble".to_string()));
        assert!(parse("").is_err());
    }

    #[test]
    fn a_request_can_be_answered_once() {
        let (reply, answer) = channel();
        let request = Request {
            command: Command::Screenshot {
                path: PathBuf::from("/tmp/x.png"),
            },
            reply,
        };
        request.answer(Ok("/tmp/x.png".into()));

        assert_eq!(answer.recv().unwrap(), Ok("/tmp/x.png".to_string()));
    }
}