client: главное меню (выбор карты, запуск), команда Run = меню

- menu: список карт из VFS, навигация Up/Down, Enter — загрузка карты,
  Esc — выход, скриншот для тестов; пустой мир + 2D-текст
- create_buffer: пустые буферы wgpu запрещены — минимальный размер
- run.rs удалён (дублировал окно без меню)
This commit is contained in:
Niko Marmeladkov 2026-10-12 04:33:00 +03:00
parent 2c13062b03
commit b7a63d9a83
5 changed files with 279 additions and 158 deletions

View file

@ -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),

View file

@ -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<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
let config = tier::config::Config::load(assets, None);

236
crates/client/src/menu.rs Normal file
View file

@ -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<String>,
/// Выйти после N кадров (для тестов)
#[arg(long)]
pub frames: Option<u64>,
/// Сохранить PNG после завершения цикла
#[arg(long, value_name = "PATH")]
pub screenshot: Option<PathBuf>,
}
/// Пустой мир (нужен 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<PathBuf>, 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<String> = 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<String> = 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(())
}

View file

@ -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<u64>,
/// Создать скрытое окно (удобно для автотестов)
#[arg(long)]
pub hidden: bool,
}
/// Открывает окно и крутит цикл кадров до выхода пользователя
/// (или до `--frames N` кадров).
pub fn run(assets: Option<PathBuf>, 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());
}
}

View file

@ -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,