Synvael/crates/client/src/main.rs

300 lines
11 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
//! Main entry point for the Synvael client.
//!
//! This crate handles window creation, input processing, and drives the renderer to display the game world.
mod camera;
mod chunks;
mod meshing;
use std::time::Instant;
use anyhow::{Context, Result};
use camera::Camera;
use glam::Vec3;
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use tracing::{error, info, warn};
use winit::application::ApplicationHandler;
use winit::event::{DeviceEvent, DeviceId, ElementState, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{KeyCode, PhysicalKey};
use winit::window::{CursorGrabMode, Window, WindowId};
/// Transient per-frame input state sampled from window and device events.
///
/// Keyboard fields hold whether a movement key is currently pressed. `mouse_delta` accumulates raw pointer motion between frames and is consumed (reset to zero) once applied to the camera.
#[expect(
clippy::struct_excessive_bools,
reason = "per-key held states are independent; a flat bool struct is the clearest representation"
)]
#[derive(Default)]
struct InputState {
/// Whether the "move forward" key (W) is held.
forward: bool,
/// Whether the "move backward" key (S) is held.
backward: bool,
/// Whether the "strafe left" key (A) is held.
left: bool,
/// Whether the "strafe right" key (D) is held.
right: bool,
/// Whether the "move up" key (Space) is held.
up: bool,
/// Whether the "move down" key (Left Shift) is held.
down: bool,
/// Accumulated raw mouse motion (x, y) since the last frame, in device units.
mouse_delta: (f64, f64),
}
/// Top-level application state driving the window, renderer, and camera.
struct App {
/// The Vulkan renderer, initialised once the window exists.
renderer: Option<renderer::Renderer>,
/// The application window, created on resume.
window: Option<Window>,
/// The free-fly camera supplying the view matrix each frame.
camera: Camera,
/// The current keyboard and mouse input state.
input: InputState,
/// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame.
last_frame: Option<Instant>,
/// Receives the outcome of the background connect and handshake, drained non-blocking from the event loop. `None` before the connection is started and once the outcome has been observed.
handshake_rx: Option<net::ConnectOutcome>,
/// Streams chunk meshes in and out around the camera. `None` until the renderer and worldgen config are initialised on resume.
chunks: Option<chunks::ChunkManager>,
}
impl Default for App {
fn default() -> Self {
Self {
renderer: None,
window: None,
// Start above and behind the origin chunk, looking toward -Z and angled downward.
camera: Camera::new(
Vec3::new(16.0, 40.0, 60.0),
-std::f32::consts::FRAC_PI_2,
-0.5,
),
input: InputState::default(),
last_frame: None,
handshake_rx: None,
chunks: None,
}
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let attributes = Window::default_attributes().with_title("Synvael");
let window = match event_loop.create_window(attributes) {
Ok(w) => w,
Err(e) => {
error!("Failed to create window: {e}");
event_loop.exit();
return;
}
};
let display_handle = match event_loop.display_handle() {
Ok(h) => h.as_raw(),
Err(e) => {
error!("Failed to get display handle: {e}");
event_loop.exit();
return;
}
};
let window_handle = match window.window_handle() {
Ok(h) => h.as_raw(),
Err(e) => {
error!("Failed to get window handle: {e}");
event_loop.exit();
return;
}
};
let required_extensions = match ash_window::enumerate_required_extensions(display_handle) {
Ok(exts) => exts,
Err(e) => {
error!("Failed to enumerate required extensions: {e}");
event_loop.exit();
return;
}
};
let size = window.inner_size();
let renderer = match renderer::Renderer::new(
display_handle,
window_handle,
size.width,
size.height,
required_extensions,
) {
Ok(r) => r,
Err(e) => {
error!("Failed to initialize Vulkan renderer: {e}");
event_loop.exit();
return;
}
};
// Confine and hide the pointer so mouse motion drives the camera rather than moving a visible cursor. `Locked` is preferred; some platforms only support `Confined`.
if let Err(e) = window
.set_cursor_grab(CursorGrabMode::Locked)
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
{
warn!("Failed to grab cursor: {e}");
}
window.set_cursor_visible(false);
self.window = Some(window);
self.renderer = Some(renderer);
#[expect(
clippy::expect_used,
reason = "startup asset load; a missing worldgen config is unrecoverable at launch"
)]
let config_str = std::fs::read_to_string("assets/data/worldgen/default.json")
.expect("Failed to read worldgen config");
#[expect(
clippy::expect_used,
reason = "startup config parse; a malformed worldgen config is unrecoverable at launch"
)]
let worldgen_config: shared::generator::WorldGenConfig =
serde_json::from_str(&config_str).expect("Failed to parse worldgen config");
let seed = 4_813_530;
let generator = shared::generator::VoxelGenerator::new(worldgen_config, seed);
self.chunks = Some(chunks::ChunkManager::new(generator));
// Kick off a background connect + handshake to the local server.
let hello = shared::protocol::ClientHello {
protocol_version: shared::protocol::PROTOCOL_VERSION,
client_build: env!("CARGO_PKG_VERSION").to_owned(),
player_identity: shared::protocol::PlayerIdentity {
display_name: "Player".to_owned(),
},
installed_packs: Vec::new(),
requested_features: shared::protocol::FeatureFlags(0),
};
let server_addr =
std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, net::DEFAULT_PORT));
info!("Connecting to server at {server_addr}");
self.handshake_rx = Some(net::connect_in_background(server_addr, hello));
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => {
event_loop.exit();
}
WindowEvent::KeyboardInput { event, .. } => {
let pressed = event.state == ElementState::Pressed;
if let PhysicalKey::Code(code) = event.physical_key {
match code {
KeyCode::KeyW => self.input.forward = pressed,
KeyCode::KeyS => self.input.backward = pressed,
KeyCode::KeyA => self.input.left = pressed,
KeyCode::KeyD => self.input.right = pressed,
KeyCode::Space => self.input.up = pressed,
KeyCode::ShiftLeft => self.input.down = pressed,
KeyCode::Escape => event_loop.exit(),
_ => {}
}
}
}
WindowEvent::RedrawRequested => {
// Non-blocking check for the handshake outcome.
let mut handshake_done = false;
if let Some(rx) = self.handshake_rx.as_ref() {
match rx.try_recv() {
Ok(Ok(ack)) => {
info!(
protocol_version = ack.protocol_version,
"handshake complete"
);
handshake_done = true;
}
Ok(Err(reason)) => {
warn!("handshake failed: {reason}");
handshake_done = true;
}
// Empty: not ready yet. Disconnected: the network thread ended.
Err(_) => {}
}
}
if handshake_done {
self.handshake_rx = None;
}
// Derive delta-time from the previous frame so movement is framerate-independent. The first frame has no predecessor and therefore advances by zero seconds.
let now = Instant::now();
let dt = self
.last_frame
.map_or(0.0, |prev| now.duration_since(prev).as_secs_f32());
self.last_frame = Some(now);
self.camera.update(&self.input, dt);
// The accumulated motion has been applied; clear it so it is not counted twice.
self.input.mouse_delta = (0.0, 0.0);
// Reconcile streamed chunks toward the chunk the camera now occupies. `from_world` floors via `div_euclid`, so negative coordinates map to the correct chunk.
let pos = self.camera.position;
let center = shared::world::ChunkPos::from_world(
f64::from(pos.x),
f64::from(pos.y),
f64::from(pos.z),
);
if let (Some(chunks), Some(renderer)) =
(self.chunks.as_mut(), self.renderer.as_mut())
{
chunks.update(center, renderer);
}
let view = self.camera.view_matrix();
if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(view)) {
error!("Failed to draw frame: {e}");
event_loop.exit();
}
if let Some(window) = self.window.as_ref() {
window.request_redraw();
}
}
_ => (),
}
}
fn device_event(
&mut self,
_event_loop: &ActiveEventLoop,
_device_id: DeviceId,
event: DeviceEvent,
) {
// Raw mouse motion is used for look control; it is unaffected by pointer acceleration or the desktop cursor position, which absolute window coordinates would not guarantee.
if let DeviceEvent::MouseMotion { delta } = event {
self.input.mouse_delta.0 += delta.0;
self.input.mouse_delta.1 += delta.1;
}
}
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
info!("Starting Synvael client");
let event_loop = EventLoop::new().context("Failed to create event loop")?;
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = App::default();
event_loop
.run_app(&mut app)
.context("Failed to run event loop")?;
Ok(())
}