synvael/crates/client/src/main.rs

315 lines
13 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 mesh_pool;
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>,
/// Handles onto the background network connection: the handshake outcome, the chunk-subscription sender, and the chunk-delivery receiver. `None` before the connection is started.
link: Option<net::ClientLink>,
/// Whether the handshake has completed successfully. Gates chunk subscription until the connection is usable.
connected: bool,
/// The chunk position the camera last subscribed around, so a new subscription is sent only when the center chunk changes.
last_center: Option<shared::world::ChunkPos>,
/// Streams chunk meshes in and out around the camera. `None` until the renderer is 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,
link: None,
connected: false,
last_center: 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);
// The client renders only server-streamed terrain and no longer generates chunks locally.
// TODO: offline/singleplayer via an in-process server would reintroduce a local world source here.
self.chunks = Some(chunks::ChunkManager::new());
// 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.link = 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::Resized(size) => {
// Rebuild the swapchain to match the new surface size. Without this the swapchain keeps its initial extent and the compositor stretches the fixed-size image to the window, distorting the aspect ratio.
if let Some(renderer) = self.renderer.as_mut()
&& let Err(e) = renderer.recreate_swapchain(size.width, size.height)
{
error!("Failed to recreate swapchain on resize: {e}");
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. The link is retained after success so its chunk channels can be used; only a failure discards it.
if !self.connected {
let outcome = self
.link
.as_ref()
.and_then(|link| link.handshake.try_recv().ok());
match outcome {
Some(Ok(ack)) => {
info!(
protocol_version = ack.protocol_version,
"handshake complete"
);
self.connected = true;
}
Some(Err(reason)) => {
warn!("handshake failed: {reason}");
self.link = None;
}
// No outcome yet (empty), or the network thread ended (disconnected).
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),
);
// Subscribe to the region around the camera whenever the center chunk changes, so the server streams the matching set. The client subscribes with its own load radius so the server's resident set aligns with what the client keeps.
if self.connected && self.last_center != Some(center) {
if let Some(link) = self.link.as_ref() {
let radius = u16::try_from(chunks::LOAD_RADIUS).unwrap_or(u16::MAX);
link.subscribe
.send(shared::protocol::chunk::ChunkSubscribe { center, radius });
}
self.last_center = Some(center);
}
// Apply queued server deliveries and reconcile the resident set against the camera.
if let (Some(chunks), Some(link), Some(renderer)) = (
self.chunks.as_mut(),
self.link.as_mut(),
self.renderer.as_mut(),
) {
chunks.update(center, &mut link.chunks, 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(())
}