client: главное меню (выбор карты, запуск), команда Run = меню
- menu: список карт из VFS, навигация Up/Down, Enter — загрузка карты, Esc — выход, скриншот для тестов; пустой мир + 2D-текст - create_buffer: пустые буферы wgpu запрещены — минимальный размер - run.rs удалён (дублировал окно без меню)
This commit is contained in:
parent
2c13062b03
commit
b7a63d9a83
5 changed files with 279 additions and 158 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
236
crates/client/src/menu.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue