299 lines
11 KiB
Rust
299 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 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>,
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
let chunk = generator.generate_chunk(shared::world::ChunkPos::new(0, 0, 0));
|
|
|
|
let (vertices, indices) = meshing::generate_mesh(&chunk);
|
|
tracing::info!(
|
|
"Generated Mesh with {} vertices and {} indices!",
|
|
vertices.len(),
|
|
indices.len()
|
|
);
|
|
|
|
#[expect(
|
|
clippy::expect_used,
|
|
reason = "the renderer is assigned earlier in this function"
|
|
)]
|
|
self.renderer
|
|
.as_mut()
|
|
.expect("Renderer initialized")
|
|
.insert_mesh((0, 0, 0), &vertices, &indices, [0.0, 0.0, 0.0])
|
|
.expect("Failed to upload terrain to GPU");
|
|
|
|
// 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);
|
|
|
|
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(())
|
|
}
|