diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 7b453dc..4f991da 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -4,8 +4,8 @@ mod convars; mod doctor; mod lua_cmd; mod map; +mod menu; mod net_cmd; -mod run; use clap::{Parser, Subcommand}; use std::path::PathBuf; @@ -31,8 +31,11 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { - /// Запустить окно клиента (действие по умолчанию) - Run(run::RunArgs), + /// Главное меню: выбор карты (действие по умолчанию) + Run(menu::MenuArgs), + + /// Меню выбора карты + Menu(menu::MenuArgs), /// Загрузить карту и отрисовать её (полёт камеры) Map(map::MapArgs), @@ -117,8 +120,11 @@ fn main() -> anyhow::Result<()> { tier::logging::init(cli.verbose); convars::register_all(); - match cli.command.unwrap_or(Command::Run(run::RunArgs::default())) { - Command::Run(args) => run::run(cli.assets, args), + match cli + .command + .unwrap_or(Command::Run(menu::MenuArgs::default())) + { + Command::Run(args) | Command::Menu(args) => menu::menu(cli.assets, args), Command::Map(args) => map::map(cli.assets, args), Command::Net { cmd } => match cmd { NetCmd::Info { addr } => net_cmd::info(&addr), diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index 47e4578..d097e27 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -75,6 +75,29 @@ pub struct MapArgs { pub noclip: bool, } +impl MapArgs { + /// Аргументы для запуска только карты (для меню). + pub fn map_only(map: &str) -> Self { + Self { + map: map.to_string(), + frames: None, + hidden: false, + screenshot: None, + width: 1280, + height: 720, + no_mouse: false, + speed: 400.0, + pos: None, + player_pos: None, + angles: None, + debug_materials: false, + only_material: None, + dump_textures: None, + noclip: false, + } + } +} + /// Запускает рендер карты. pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let config = tier::config::Config::load(assets, None); diff --git a/crates/client/src/menu.rs b/crates/client/src/menu.rs new file mode 100644 index 0000000..d9679fe --- /dev/null +++ b/crates/client/src/menu.rs @@ -0,0 +1,236 @@ +//! Главное меню: выбор карты, запуск, выход. +//! +//! Простой GUI на SDL3 + wgpu: пустой мир + 2D-текст. + +use crate::map::{self, MapArgs}; +use anyhow::Context; +use engine::World; +use math::Vec3; +use render::{Gfx, WorldRenderer}; +use sdl3::event::{Event, WindowEvent}; +use sdl3::keyboard::Keycode; +use std::path::PathBuf; +use tracing::{info, warn}; + +/// Аргументы команды `menu`. +#[derive(Debug, Default, clap::Args)] +pub struct MenuArgs { + /// Создать скрытое окно (для автотестов) + #[arg(long)] + pub hidden: bool, + + /// Автоматически выбрать карту по имени и запустить её + #[arg(long, value_name = "MAP")] + pub map: Option, + + /// Выйти после N кадров (для тестов) + #[arg(long)] + pub frames: Option, + + /// Сохранить PNG после завершения цикла + #[arg(long, value_name = "PATH")] + pub screenshot: Option, +} + +/// Пустой мир (нужен WorldRenderer'у для меню). +fn empty_world() -> World { + World { + vertices: Vec::new(), + indices: Vec::new(), + batches: Vec::new(), + materials: Vec::new(), + spawn: Vec3::ZERO, + spawn_yaw: 0.0, + bounds_min: Vec3::splat(-1.0), + bounds_max: Vec3::splat(1.0), + stats: Default::default(), + skybox: None, + disp_tris: Vec::new(), + sky_mesh: None, + } +} + +/// Открывает меню; при выборе карты запускает её. +pub fn menu(assets: Option, args: MenuArgs) -> anyhow::Result<()> { + let config = tier::config::Config::load(assets.clone(), None); + let layout = tier::paths::detect(config.assets.as_deref()); + + // Список карт. + let mut maps: Vec = Vec::new(); + if let Some(layout) = &layout { + let mut vfs = fs::VirtualFs::new(); + for vpk in crate::doctor::collect_dir_vpks(layout) { + let _ = vfs.mount_vpk(&vpk); + } + vfs.mount_dir(layout.game.clone()); + for path in vfs.list("maps/") { + if let Some(name) = path.strip_prefix("maps/") { + if let Some(name) = name.strip_suffix(".bsp") { + maps.push(name.to_string()); + } + } + } + } + maps.sort(); + maps.dedup(); + if maps.is_empty() { + maps.push("gm_construct".to_string()); + } + info!(count = maps.len(), "карт найдено"); + + if let Some(wanted) = &args.map { + // Немедленный запуск без GUI. + return map::map(assets, MapArgs::map_only(wanted)); + } + let mut selected = 0usize; + + let sdl = sdl3::init().map_err(|e| anyhow::anyhow!("SDL3 init: {e}"))?; + let video = sdl + .video() + .map_err(|e| anyhow::anyhow!("SDL3 video: {e}"))?; + let mut builder = video.window("Source RS — меню", 1280, 720); + builder.position_centered().resizable(); + if args.hidden { + builder.hidden(); + } + let window = builder + .build() + .map_err(|e| anyhow::anyhow!("SDL3 window: {e}"))?; + let mut gfx = Gfx::new(&window).context("инициализация wgpu")?; + + let world = empty_world(); + let mut renderer = WorldRenderer::new(&gfx, &world, false)?; + let camera = engine::FlyCamera::new(Vec3::ZERO, 0.0, 0.0); + + let mut events = sdl + .event_pump() + .map_err(|e| anyhow::anyhow!("SDL3 event pump: {e}"))?; + + let mut chosen: Option = None; + let mut frames: u64 = 0; + let mut last = std::time::Instant::now(); + let mut fps = 0.0f32; + + 'menu: loop { + for event in events.poll_iter() { + match event { + Event::Quit { .. } => break 'menu, + Event::KeyDown { + keycode: Some(code), + repeat: false, + .. + } => match code { + Keycode::Escape => break 'menu, + Keycode::Up => { + selected = selected.saturating_sub(1); + } + Keycode::Down => { + if selected + 1 < maps.len() { + selected += 1; + } + } + Keycode::Return | Keycode::KpEnter => { + chosen = Some(maps[selected].clone()); + break 'menu; + } + _ => {} + }, + Event::Window { + win_event: WindowEvent::PixelSizeChanged(w, h) | WindowEvent::Resized(w, h), + .. + } => { + gfx.resize(w.max(1) as u32, h.max(1) as u32); + renderer.resize(&gfx, w.max(1) as u32, h.max(1) as u32); + } + _ => {} + } + } + + let dt = last.elapsed().as_secs_f32().max(1e-4); + last = std::time::Instant::now(); + fps = fps * 0.9 + (1.0 / dt) * 0.1; + + let (width, height) = gfx.size(); + renderer.clear_text(); + renderer.push_text( + width as f32 * 0.5 - 140.0, + 40.0, + 3.0, + [1.0, 1.0, 1.0, 1.0], + "SOURCE RS", + ); + renderer.push_text( + width as f32 * 0.5 - 140.0, + 72.0, + 1.5, + [0.7, 0.8, 0.9, 1.0], + format!("map {}/{} fps {:.0}", selected + 1, maps.len(), fps), + ); + + // Видимый список (окно из 16 строк вокруг выбранной). + let window_size = 16usize; + let start = selected.saturating_sub(window_size / 2); + let end = (start + window_size).min(maps.len()); + let mut y = 120.0; + for (index, name) in maps.iter().enumerate().take(end).skip(start) { + let is_selected = index == selected; + let color = if is_selected { + [1.0, 0.9, 0.4, 1.0] + } else { + [0.8, 0.8, 0.8, 1.0] + }; + let prefix = if is_selected { "> " } else { " " }; + renderer.push_text( + width as f32 * 0.5 - 160.0, + y, + 1.6, + [0.0, 0.0, 0.0, 0.7], + format!("{prefix}{name}"), + ); + renderer.push_text( + width as f32 * 0.5 - 161.0, + y - 1.0, + 1.6, + color, + format!("{prefix}{name}"), + ); + y += 22.0; + } + renderer.push_text( + 16.0, + height as f32 - 28.0, + 1.5, + [0.9, 0.9, 0.9, 1.0], + "Up/Down select | Enter load map | Esc quit", + ); + + if let Err(err) = renderer.render_to_surface(&gfx, &camera) { + warn!("кадр меню не отрисован: {err:#}"); + } + + frames += 1; + if let Some(max) = args.frames { + if frames >= max { + break 'menu; + } + } + } + + if let Some(path) = &args.screenshot { + let (width, height) = gfx.size(); + let pixels = renderer.capture(&gfx, &camera, width, height)?; + let file = std::fs::File::create(path)?; + let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header()?.write_image_data(&pixels)?; + info!(path = %path.display(), "скриншот меню сохранён"); + } + + if let Some(map_name) = chosen { + info!(map = %map_name, "запуск карты из меню"); + return map::map(assets, MapArgs::map_only(&map_name)); + } + info!("меню закрыто"); + Ok(()) +} diff --git a/crates/client/src/run.rs b/crates/client/src/run.rs deleted file mode 100644 index 582e8a7..0000000 --- a/crates/client/src/run.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! Запуск окна клиента: SDL3 + wgpu, замер FPS, аккуратное завершение. - -use crate::convars; -use anyhow::Context; -use render::Gfx; -use sdl3::event::{Event, WindowEvent}; -use sdl3::keyboard::Keycode; -use std::path::PathBuf; -use std::time::{Duration, Instant}; -use tracing::{info, warn}; - -/// Аргументы команды `run`. -#[derive(Debug, Default, clap::Args)] -pub struct RunArgs { - /// Выйти после N кадров (для тестов и CI) - #[arg(long)] - pub frames: Option, - - /// Создать скрытое окно (удобно для автотестов) - #[arg(long)] - pub hidden: bool, -} - -/// Открывает окно и крутит цикл кадров до выхода пользователя -/// (или до `--frames N` кадров). -pub fn run(assets: Option, args: RunArgs) -> anyhow::Result<()> { - info!("Source RS {} — запуск", tier::VERSION); - - let config = tier::config::Config::load(assets, None); - info!("{}", config.describe()); - match tier::paths::detect(config.assets.as_deref()) { - Some(layout) => info!("ассеты: {}", layout.describe()), - None => warn!("ассеты Garry's Mod не найдены — окно всё равно откроется"), - } - - let sdl = sdl3::init().map_err(|e| anyhow::anyhow!("SDL3 init: {e}"))?; - let video = sdl - .video() - .map_err(|e| anyhow::anyhow!("SDL3 video: {e}"))?; - - let mut builder = video.window("Source RS", 1280, 720); - builder.position_centered().resizable(); - if args.hidden { - builder.hidden(); - } - let window = builder - .build() - .map_err(|e| anyhow::anyhow!("SDL3 window: {e}"))?; - - let (width, height) = window.size(); - info!("окно SDL3: {width}x{height}"); - - let mut gfx = Gfx::new(&window).context("инициализация wgpu")?; - let (surface_w, surface_h) = gfx.size(); - info!("поверхность wgpu: {surface_w}x{surface_h}"); - let adapter = gfx.adapter_info(); - info!( - "GPU: {} ({:?}, драйвер {}, тип {:?})", - adapter.name, adapter.backend, adapter.driver, adapter.device_type - ); - - let mut events = sdl - .event_pump() - .map_err(|e| anyhow::anyhow!("SDL3 event pump: {e}"))?; - - let start = Instant::now(); - let mut last_frame = Instant::now(); - let mut frames_drawn: u64 = 0; - let mut fps_window_start = Instant::now(); - let mut frames_in_window: u64 = 0; - let mut fps: f32 = 0.0; - - 'running: loop { - for event in events.poll_iter() { - match event { - Event::Quit { .. } => break 'running, - Event::KeyDown { - keycode: Some(Keycode::Escape), - .. - } => break 'running, - Event::Window { - win_event: WindowEvent::PixelSizeChanged(w, h) | WindowEvent::Resized(w, h), - .. - } => { - gfx.resize(w.max(1) as u32, h.max(1) as u32); - } - _ => {} - } - } - - if let Some(max_frames) = args.frames { - if frames_drawn >= max_frames { - break 'running; - } - } - - // Пульсирующий фон — просто чтобы видеть, что кадры идут. - let t = start.elapsed().as_secs_f64(); - let pulse = 0.5 + 0.5 * (t * 0.8).sin(); - let color = wgpu::Color { - r: (convars::R_CLEAR_R.get_float() + 0.02 * pulse).clamp(0.0, 1.0), - g: convars::R_CLEAR_G.get_float().clamp(0.0, 1.0), - b: (convars::R_CLEAR_B.get_float() + 0.05 * pulse).clamp(0.0, 1.0), - a: 1.0, - }; - if let Err(err) = gfx.render_clear(color) { - warn!("кадр не отрисован: {err:#}"); - } - - frames_drawn += 1; - frames_in_window += 1; - - // Ограничение FPS (fps_max), если задано. - let fps_cap = convars::FPS_MAX.get_int(); - if fps_cap > 0 { - let target = Duration::from_secs_f64(1.0 / fps_cap as f64); - if let Some(remaining) = target.checked_sub(last_frame.elapsed()) { - std::thread::sleep(remaining); - } - } - last_frame = Instant::now(); - - // Раз в секунду — FPS в заголовок окна и в лог. - let elapsed = fps_window_start.elapsed(); - if elapsed >= Duration::from_secs(1) { - fps = frames_in_window as f32 / elapsed.as_secs_f32(); - frames_in_window = 0; - fps_window_start = Instant::now(); - if !args.hidden { - set_window_title(&window, &format!("Source RS — {fps:.0} FPS")); - } - info!(frames = frames_drawn, fps = format!("{fps:.1}"), "кадры"); - } - } - - info!( - "завершение: {frames_drawn} кадров за {:.2} с (последний FPS {fps:.1})", - start.elapsed().as_secs_f32() - ); - Ok(()) -} - -/// Устанавливает заголовок окна через C-API: метод `Window::set_title` требует -/// `&mut Window`, а окно уже занято неизменяемой ссылкой внутри поверхности wgpu. -fn set_window_title(window: &sdl3::video::Window, title: &str) { - let Ok(cstr) = std::ffi::CString::new(title) else { - return; - }; - // Безопасность: raw-указатель валиден, пока живо окно; вызов на главном потоке. - unsafe { - sdl3::sys::video::SDL_SetWindowTitle(window.raw(), cstr.as_ptr()); - } -} diff --git a/crates/render/src/world_renderer.rs b/crates/render/src/world_renderer.rs index 6227d1a..e0257ed 100644 --- a/crates/render/src/world_renderer.rs +++ b/crates/render/src/world_renderer.rs @@ -1042,6 +1042,15 @@ fn create_buffer( usage: wgpu::BufferUsages, ) -> wgpu::Buffer { use wgpu::util::DeviceExt; + if data.is_empty() { + // Пустые буферы wgpu запрещает: создаём минимальный. + return device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size: 4, + usage: usage | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: data,