flatland3 0.1.0

Flatland3 terminal play client and account CLI
use std::io;
use std::net::SocketAddr;
use std::time::Duration;

use clap::Parser;
use crossterm::event::{self, Event};
use flatland_client_lib::{connect, default_server_addr, ConnectOptions, GameClient};
use flatland_tui::{draw, InputAction, MovementState};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tokio::sync::mpsc;
use tracing::info;

#[derive(Debug, Parser)]
#[command(about = "Ratatui play client — connect to flatland-server")]
pub struct PlayArgs {
    /// Character name (created on first login if missing)
    #[arg(short, long, default_value = "traveler")]
    pub name: String,

    /// Game server address (flatland-server TCP)
    #[arg(long, default_value_t = default_server_addr())]
    pub server: SocketAddr,
}

pub async fn run_with_options(opts: ConnectOptions) -> anyhow::Result<()> {
    let session = connect(opts).await?;

    let mut client = GameClient::new(session);
    client.wait_until_ready().await?;

    info!(
        entity_id = client.entity_id(),
        "connected — use WASD to move, q to quit"
    );

    let (key_tx, mut key_rx) = mpsc::unbounded_channel::<Event>();
    std::thread::spawn(move || {
        loop {
            if event::poll(Duration::from_millis(50)).unwrap_or(false) {
                if let Ok(ev) = event::read() {
                    if key_tx.send(ev).is_err() {
                        break;
                    }
                }
            }
        }
    });

    crate::terminal::enter()?;
    let backend = CrosstermBackend::new(io::stdout());
    let mut ratatui = Terminal::new(backend)?;

    let mut movement_state = MovementState::default();
    let mut tick_interval = tokio::time::interval(Duration::from_millis(33));
    let mut intent_interval = tokio::time::interval(Duration::from_millis(100));
    intent_interval.tick().await;

    loop {
        let movement = movement_state.current();
        let movement_label = movement.label();
        ratatui.draw(|frame| draw(frame, &client.state, movement_label))?;

        tokio::select! {
            _ = tick_interval.tick() => {
                client.drain_events();
            }
            _ = intent_interval.tick() => {
                let (forward, strafe) = movement.components();
                if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
                    client.move_by(forward, strafe).await?;
                } else {
                    client.stop().await?;
                }
            }
            maybe_key = key_rx.recv() => {
                if let Some(Event::Key(key)) = maybe_key {
                    match movement_state.apply_key(key) {
                        InputAction::Quit => break,
                        InputAction::None => {}
                    }
                }
            }
        }
    }

    client.disconnect();
    ratatui.show_cursor()?;
    crate::terminal::leave()?;

    Ok(())
}