From 4bfe923577576fed877c26e4859804792cc23809 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Mon, 26 Oct 2026 23:30:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20Sandbox:=20=D0=BD=D0=B0=D1=81=D1=82=D0=BE=D1=8F?= =?UTF-8?q?=D1=89=D0=B8=D0=B9=20Q-=D0=BC=D0=B5=D0=BD=D1=8E,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D1=8B=D0=B9=20Lua=20=D0=B8=20=D1=81=D0=B5=D0=BD?= =?UTF-8?q?=D1=81=D0=BE=D1=80=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - APK: полные lua/ + gamemodes/ + settings + resource/language (раньше был только autorun, и Sandbox-меню не собиралось, показывалось резервное). ASSET_VERSION=3: при обновлении старые распакованные файлы удаляются, иначе остаётся полный битый spawnlist. - fs pack: список EXTRA_MODELS — 13 частых пропов и физган; спавнлист отфильтрован по моделям, которые реально есть в APK (31 запись в 7 категориях вместо 7473 белых иконок). - Сенсорное управление: Q, NOCLIP, LMB, RMB, JUMP, DUCK; полупрозрачные рамки с подписями и подсказка стика. LMB/RMB превращаются в настоящие события мыши (общий код физгана/оружия), noclip переключает полёт. Кнопка X рисуется поверх Q-меню, остальные под меню скрыты; свайп вверх/ вниз в меню листает панели колесом. - Lua-шим: list.Get для неизвестного списка возвращает пустую таблицу; panel:Call/SetHTML/AddFunction/QueueJavascript — заглушки; добавлены фразы vehicles/search/quick_filter и подсказки. - Проверки: clippy -D warnings и cargo test --workspace зелёные. --- .../java/org/gmodrs/client/MainActivity.java | 38 +- crates/client/src/map.rs | 141 +++++-- crates/client/src/pack.rs | 23 ++ crates/client/src/touch.rs | 344 ++++++++++++++---- crates/lua/src/stdlib.rs | 5 +- crates/lua/src/ui/bootstrap.lua | 11 + docs/DIARY.md | 30 ++ 7 files changed, 478 insertions(+), 114 deletions(-) diff --git a/android/app/src/main/java/org/gmodrs/client/MainActivity.java b/android/app/src/main/java/org/gmodrs/client/MainActivity.java index cb6e425..ce0db43 100644 --- a/android/app/src/main/java/org/gmodrs/client/MainActivity.java +++ b/android/app/src/main/java/org/gmodrs/client/MainActivity.java @@ -34,17 +34,40 @@ public class MainActivity extends org.libsdl.app.SDLActivity { super.onCreate(savedInstanceState); } + /** + * Версия встроенного набора ассетов. При обновлении APK с новыми Lua- + * скриптами версию надо поднять: тогда старые распакованные ассеты + * перезапишутся, и Q-меню не останется без gamemodes. + */ + private static final String ASSET_VERSION = "3"; + /** Распаковывает `assets/gmod` в каталог ассетов, если его ещё нет. */ private void unpackBundledAssets() { File target = new File(getExternalFilesDir(null), "gmod_server/server"); File marker = new File(target, "garrysmod/maps/gm_construct.bsp"); - if (marker.isFile()) { + File versionFile = new File(target, "garrysmod/.assets_version"); + String version = ""; + try { + if (versionFile.isFile()) { + version = java.nio.file.Files.readString(versionFile.toPath()).trim(); + } + } catch (IOException ignored) { + version = ""; + } + if (marker.isFile() && ASSET_VERSION.equals(version)) { return; } long started = System.currentTimeMillis(); android.util.Log.i("gmod-rs", "распаковка встроенных ассетов в " + target); try { + // Старые файлы могли остаться от предыдущей версии набора (например, + // полный spawnlist, который теперь отфильтрован). Чистим каталог, + // иначе клиент прочитает и их. + if (target.isDirectory()) { + deleteRecursive(target); + } copyAsset("gmod", target); + java.nio.file.Files.writeString(versionFile.toPath(), ASSET_VERSION); android.util.Log.i("gmod-rs", "ассеты распакованы за " + (System.currentTimeMillis() - started) + " мс"); } catch (IOException e) { @@ -52,6 +75,19 @@ public class MainActivity extends org.libsdl.app.SDLActivity { } } + /** Удаляет каталог со всем содержимым. */ + private void deleteRecursive(File file) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursive(child); + } + } + // Не удалось — не критично: copyAsset перезапишет файлы. + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + /** Рекурсивное копирование ассета: каталог или файл. */ private void copyAsset(String path, File outFile) throws IOException { AssetManager assets = getAssets(); diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index 9dbb6f3..225f23e 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -3,7 +3,7 @@ use crate::doctor; use crate::qmenu::{self, MenuAction, SpawnItem, SpawnMenu}; use crate::sandbox::{self, SandboxAction, UndoStack}; -use crate::touch::TouchControls; +use crate::touch::{TouchButton, TouchControls}; use crate::ui_overlay; use anyhow::Context; use engine::FlyCamera; @@ -994,6 +994,10 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let mut icon_renders_at_open: u64 = 0; // Палец, который сейчас нажимает в Lua-панель (остальные — камера/стик). let mut ui_touch_finger: Option = None; + // Накопленный вертикальный свайп в меню — превращается в колесо. + let mut ui_touch_drag = 0.0f32; + // Сенсорные LMB/RMB, удерживаемые пальцами: на отпускании шлём MouseUp. + let mut touch_mouse_fingers: Vec<(u64, MouseButton)> = Vec::new(); if std::env::var_os("SOURCE_RS_QMENU").is_some() { lua_ui_open = set_menu_open( true, @@ -1077,6 +1081,72 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { raw: 0, }); } + // Сенсорные LMB/RMB — обычные события мыши: обработка одна и та же. + // Пока меню открыто, кнопки не работают: касания уходят в Lua-панели. + if touch_enabled && !lua_ui_open && !spawn_menu.is_open() { + let (sw, sh) = gfx.size(); + let (sw, sh) = (sw as f32, sh as f32); + let mut extras: Vec<(usize, Event)> = Vec::new(); + for (index, event) in frame_events.iter().enumerate() { + match event { + Event::FingerDown { + finger_id, x, y, .. + } => { + let mouse = match touch.button_at(*x, *y) { + Some(TouchButton::Primary) => Some(MouseButton::Left), + Some(TouchButton::Secondary) => Some(MouseButton::Right), + _ => None, + }; + if let Some(mouse_btn) = mouse { + touch_mouse_fingers.push((*finger_id, mouse_btn)); + extras.push(( + index, + Event::MouseButtonDown { + timestamp: 0, + window_id: 0, + which: 0, + mouse_btn, + clicks: 1, + x: *x * sw, + y: *y * sh, + }, + )); + } + } + Event::FingerUp { + finger_id, x, y, .. + } + | Event::FingerCanceled { + finger_id, x, y, .. + } => { + if let Some(pos) = touch_mouse_fingers + .iter() + .position(|(id, _)| id == finger_id) + { + let (_, mouse_btn) = touch_mouse_fingers.remove(pos); + extras.push(( + index, + Event::MouseButtonUp { + timestamp: 0, + window_id: 0, + which: 0, + mouse_btn, + clicks: 1, + x: *x * sw, + y: *y * sh, + }, + )); + } + } + _ => {} + } + } + // Вставляем после исходного касания, с конца: индексы не съезжают. + for (index, event) in extras.into_iter().rev() { + frame_events.insert(index + 1, event); + } + } + for event in frame_events { match event { Event::Quit { .. } => break 'running, @@ -1726,6 +1796,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { menu_q_hold = false; if ui_touch_finger.is_none() { ui_touch_finger = Some(finger_id); + ui_touch_drag = 0.0; let (width, height) = gfx.size(); let (px, py) = (x * width as f32, y * height as f32); if let Some(lua) = &lua { @@ -1735,6 +1806,17 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } } else { touch.finger_down(finger_id, x, y); + if touch.take_noclip() { + player.set_noclip(!player.noclip); + log.push_back(if player.noclip { + "noclip: включён".to_string() + } else { + "noclip: выключен".to_string() + }); + while log.len() > 8 { + log.pop_front(); + } + } } } Event::FingerMotion { @@ -1751,6 +1833,18 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { let (px, py) = (x * width as f32, y * height as f32); if let Some(lua) = &lua { lua.ui_mouse("move", px, py, 0); + // Свайп вверх/вниз листает панели: на телефоне + // колеса нет. Шаг 48 px, как щелчок колеса. + ui_touch_drag += dy * height as f32; + const STEP: f32 = 48.0; + while ui_touch_drag <= -STEP { + lua.ui_mouse("wheel", px, py, -1); + ui_touch_drag += STEP; + } + while ui_touch_drag >= STEP { + lua.ui_mouse("wheel", px, py, 1); + ui_touch_drag -= STEP; + } } } } else if !spawn_menu.is_open() @@ -1767,6 +1861,7 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } => { if ui_touch_finger == Some(finger_id) { ui_touch_finger = None; + ui_touch_drag = 0.0; menu_pinned = true; menu_q_hold = false; let (width, height) = gfx.size(); @@ -2645,39 +2740,6 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { ); } } - if touch_enabled { - // Подписи зон: сами зоны заданы в `touch.rs`, здесь только - // ориентиры, чтобы на телефоне было понятно, куда нажимать. - hud( - width as f32 * 0.06, - height as f32 * 0.80, - 1.4, - [0.75, 0.9, 1.0, 0.75], - "MOVE".to_string(), - ); - // Четвёртая кнопка — меню (аналог Q), над прыжком. - hud( - width as f32 * 0.74, - height as f32 * 0.30, - 1.6, - [0.9, 0.85, 1.0, 0.85], - "MENU".to_string(), - ); - hud( - width as f32 * 0.74, - height as f32 * 0.60, - 1.6, - [0.8, 1.0, 0.8, 0.8], - "JUMP".to_string(), - ); - hud( - width as f32 * 0.74, - height as f32 * 0.86, - 1.6, - [1.0, 0.9, 0.7, 0.8], - "DUCK".to_string(), - ); - } if show_log { let mut y = hud_bottom - 48.0; for line in log.iter().rev().take(4) { @@ -2826,6 +2888,17 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { ui_commands.clear(); } + // Сенсорный слой — поверх всего, включая Q-меню: иначе кнопку + // закрытия меню не видно и не нажать. + if touch_enabled { + touch.draw( + &mut renderer, + width as f32, + height as f32, + lua_ui_open || spawn_menu.is_open(), + ); + } + if frames_drawn % 60 == 0 { tracing::debug!( z = player.position.z, diff --git a/crates/client/src/pack.rs b/crates/client/src/pack.rs index 6f50e76..800538d 100644 --- a/crates/client/src/pack.rs +++ b/crates/client/src/pack.rs @@ -43,6 +43,29 @@ pub fn pack(assets: Option, map: &str, out: &Path) -> anyhow::Result<() for model in ["models/player/police.mdl", "models/weapons/w_pistol.mdl"] { let _ = engine::model::load_model(&vfs, model, &mut budget); } + // Пропы для Q-меню мобильной сборки: без них в APK остаются только + // модели карты, и настоящий Sandbox-список пуст. Список намеренно + // короткий — частые пропы, которые есть в spawnlist GMod. + const EXTRA_MODELS: &[&str] = &[ + "models/weapons/c_superphyscannon.mdl", + "models/props_c17/oildrum001.mdl", + "models/props_c17/woodbarrel001.mdl", + "models/props_borealis/bluebarrel001.mdl", + "models/props_junk/wood_crate001a.mdl", + "models/props_junk/wood_crate002a.mdl", + "models/props_c17/furniturechair001a.mdl", + "models/props_c17/furnituretable001a.mdl", + "models/props_c17/furnituredrawer001a.mdl", + "models/props_c17/consolebox01a.mdl", + "models/props_c17/furniturebed001a.mdl", + "models/props_lab/cactus.mdl", + "models/props_phx/construct/metal_plate1.mdl", + ]; + for model in EXTRA_MODELS { + if let Err(err) = engine::model::load_model(&vfs, model, &mut budget) { + tracing::warn!(model, "pack: модель не загружена: {err}"); + } + } for path in vfs.list("lua/autorun/") { let _ = vfs.read(&path); } diff --git a/crates/client/src/touch.rs b/crates/client/src/touch.rs index 3f4d8b5..6fd5934 100644 --- a/crates/client/src/touch.rs +++ b/crates/client/src/touch.rs @@ -1,54 +1,83 @@ //! Виртуальные органы управления для сенсорного экрана. //! //! Левый стик — ходьба (те же `wish_forward`/`wish_right`, что у WASD), -//! правая половина — обзор (свайп = мышь), кнопки справа — меню, прыжок и -//! присед. Касания обрабатываются в общем цикле `map`, поэтому на телефоне -//! работают те же правила движения, коллизий и физики, что и с клавиатуры. +//! остальной экран — обзор (свайп = мышь), справа снизу — кнопки действий. +//! LMB/RMB превращаются в обычные события мыши, поэтому на телефоне работает +//! ровно тот же код физгана, оружия и меню, что и на десктопе. -/// Координаты касаний SDL нормализованы в 0..1 по окну. +use render::WorldRenderer; + +/// Радиус стика и мёртвая зона в нормализованных координатах SDL (0..1). const STICK_RADIUS: f32 = 0.16; const STICK_DEADZONE: f32 = 0.10; /// Полный свайп по ширине экрана поворачивает камеру на столько градусов. const LOOK_DEGREES_PER_SCREEN: f32 = 180.0; -/// Прямоугольник кнопки в нормализованных координатах. +/// Кнопка сенсорного управления. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TouchButton { + /// Q-меню (настоящий Sandbox из Lua). В открытом меню — закрыть. + Menu, + /// V — полёт без коллизий. + Noclip, + Primary, + Secondary, + Jump, + Duck, +} + +/// Прямоугольник кнопки в нормализованных координатах (0..1, y вниз). #[derive(Clone, Copy, Debug)] -struct ButtonRect { - min_x: f32, - max_x: f32, - min_y: f32, - max_y: f32, +pub struct ButtonRect { + pub min_x: f32, + pub max_x: f32, + pub min_y: f32, + pub max_y: f32, } impl ButtonRect { - fn contains(&self, x: f32, y: f32) -> bool { + pub fn contains(&self, x: f32, y: f32) -> bool { (self.min_x..=self.max_x).contains(&x) && (self.min_y..=self.max_y).contains(&y) } } -/// Прыжок: широкая кнопка справа снизу. -const JUMP_BUTTON: ButtonRect = ButtonRect { - min_x: 0.70, - max_x: 0.93, - min_y: 0.50, - max_y: 0.75, -}; +/// Описание кнопки для отрисовки и попаданий. +pub struct TouchZone { + pub button: TouchButton, + pub label: &'static str, + pub rect: ButtonRect, +} -/// Присед: под прыжком. -const DUCK_BUTTON: ButtonRect = ButtonRect { - min_x: 0.70, - max_x: 0.93, - min_y: 0.78, - max_y: 0.97, -}; +const fn zone( + button: TouchButton, + label: &'static str, + min_x: f32, + min_y: f32, + max_x: f32, + max_y: f32, +) -> TouchZone { + TouchZone { + button, + label, + rect: ButtonRect { + min_x, + max_x, + min_y, + max_y, + }, + } +} -/// Меню (аналог Q): над прыжком, чтобы не задевать стик и обзор. -const MENU_BUTTON: ButtonRect = ButtonRect { - min_x: 0.70, - max_x: 0.93, - min_y: 0.22, - max_y: 0.47, -}; +/// Раскладка: Q и noclip сверху справа (не мешают обзору), действия — +/// правый нижний угол под большой палец. +pub const ZONES: &[TouchZone] = &[ + zone(TouchButton::Menu, "Q", 0.86, 0.03, 0.97, 0.12), + zone(TouchButton::Noclip, "NOCLIP", 0.84, 0.145, 0.97, 0.235), + zone(TouchButton::Jump, "JUMP", 0.80, 0.43, 0.95, 0.58), + zone(TouchButton::Duck, "DUCK", 0.62, 0.58, 0.76, 0.72), + zone(TouchButton::Primary, "LMB", 0.79, 0.65, 0.97, 0.89), + zone(TouchButton::Secondary, "RMB", 0.61, 0.78, 0.77, 0.94), +]; /// Состояние сенсорного управления. #[derive(Debug, Default)] @@ -56,13 +85,18 @@ pub struct TouchControls { enabled: bool, move_finger: Option, move_origin: (f32, f32), + /// `(forward, right)` в диапазоне -1..1. move_vector: (f32, f32), look_finger: Option, jump_finger: Option, duck_finger: Option, - /// Короткое касание кнопки прыжка могло начаться и кончиться между - /// кадрами — запоминаем его до ближайшего кадра. + primary_finger: Option, + secondary_finger: Option, + noclip_finger: Option, + /// Короткое касание прыжка могло начаться и кончиться между кадрами. jump_pending: bool, + /// Нажатие noclip: переключаем один раз на касание. + noclip_pending: bool, } impl TouchControls { @@ -74,13 +108,23 @@ impl TouchControls { } } + /// Кнопка под точкой касания, если она есть. + pub fn button_at(&self, x: f32, y: f32) -> Option { + if !self.enabled { + return None; + } + ZONES + .iter() + .find(|zone| zone.rect.contains(x, y)) + .map(|zone| zone.button) + } + /// Возвращает true, если касание пришлось на кнопку меню. /// - /// Кнопка проверяется и при открытом меню: ею же меню закрывается - /// (на Android нет Esc), а остальные касания в этот момент уходят в - /// Lua-панели. + /// Проверяется и при открытом меню: на Android нет Esc, этой же кнопкой + /// меню закрывается. pub fn menu_button(&self, x: f32, y: f32) -> bool { - self.enabled && MENU_BUTTON.contains(x, y) + self.button_at(x, y) == Some(TouchButton::Menu) } /// Возвращает true, если касание было поглощено сенсорным управлением. @@ -89,22 +133,27 @@ impl TouchControls { return false; } - if MENU_BUTTON.contains(x, y) { - return true; - } - if JUMP_BUTTON.contains(x, y) { - self.jump_finger = Some(finger_id); - self.jump_pending = true; - return true; - } - if DUCK_BUTTON.contains(x, y) { - self.duck_finger = Some(finger_id); + if let Some(button) = self.button_at(x, y) { + match button { + TouchButton::Menu => {} + TouchButton::Noclip => { + self.noclip_finger = Some(finger_id); + self.noclip_pending = true; + } + TouchButton::Jump => { + self.jump_finger = Some(finger_id); + self.jump_pending = true; + } + TouchButton::Duck => self.duck_finger = Some(finger_id), + TouchButton::Primary => self.primary_finger = Some(finger_id), + TouchButton::Secondary => self.secondary_finger = Some(finger_id), + } return true; } - // Левая половина — стик; правая — обзор. Второй палец на левой - // половине тоже может крутить обзор. - if x < 0.45 && self.move_finger.is_none() { + // Левая половина — стик; остальное — обзор. Второй палец тоже + // может крутить обзор, если стик уже занят. + if x < 0.45 && y > 0.35 && self.move_finger.is_none() { self.move_finger = Some(finger_id); self.move_origin = (x, y); self.move_vector = (0.0, 0.0); @@ -142,8 +191,8 @@ impl TouchControls { } else { (0.0, 0.0) }; - // Вверх по экрану — вперёд (Source: +Y смотрит влево, поэтому - // движение вправо уменьшает yaw, а стик — обычный экранный X). + // Вверх по экрану — вперёд. Source: +Y смотрит влево, поэтому + // экранный X превращается в `right` без инверсии. self.move_vector = (-ny * scale, nx * scale); return None; } @@ -157,23 +206,24 @@ impl TouchControls { /// Отпускание пальца. Возвращает true, если палец был занят управлением. pub fn finger_up(&mut self, finger_id: u64) -> bool { let mut handled = false; - if self.move_finger == Some(finger_id) { - self.move_finger = None; + macro_rules! clear { + ($field:ident) => { + if self.$field == Some(finger_id) { + self.$field = None; + handled = true; + } + }; + } + clear!(move_finger); + if self.move_finger.is_none() { self.move_vector = (0.0, 0.0); - handled = true; - } - if self.look_finger == Some(finger_id) { - self.look_finger = None; - handled = true; - } - if self.jump_finger == Some(finger_id) { - self.jump_finger = None; - handled = true; - } - if self.duck_finger == Some(finger_id) { - self.duck_finger = None; - handled = true; } + clear!(look_finger); + clear!(jump_finger); + clear!(duck_finger); + clear!(primary_finger); + clear!(secondary_finger); + clear!(noclip_finger); handled } @@ -183,7 +233,11 @@ impl TouchControls { self.look_finger = None; self.jump_finger = None; self.duck_finger = None; + self.primary_finger = None; + self.secondary_finger = None; + self.noclip_finger = None; self.jump_pending = false; + self.noclip_pending = false; self.move_vector = (0.0, 0.0); } @@ -208,6 +262,118 @@ impl TouchControls { pub fn duck(&self) -> bool { self.enabled && self.duck_finger.is_some() } + + /// Одноразовое переключение noclip (нажали кнопку в этом кадре). + pub fn take_noclip(&mut self) -> bool { + if !self.enabled { + return false; + } + std::mem::take(&mut self.noclip_pending) + } + + /// Нажата ли кнопка (для подсветки). + pub fn is_pressed(&self, button: TouchButton) -> bool { + match button { + TouchButton::Menu => false, + TouchButton::Noclip => self.noclip_finger.is_some(), + TouchButton::Primary => self.primary_finger.is_some(), + TouchButton::Secondary => self.secondary_finger.is_some(), + TouchButton::Jump => self.jump_finger.is_some(), + TouchButton::Duck => self.duck_finger.is_some(), + } + } + + /// Стик для отрисовки: `(origin, move_vector)` в нормализованных + /// координатах. + pub fn stick(&self) -> Option<((f32, f32), (f32, f32))> { + self.move_finger + .map(|_| (self.move_origin, self.move_vector)) + } + + /// Рисует кнопки и стик поверх кадра. + /// + /// `menu_open` оставляет только кнопку закрытия меню: игровые кнопки под + /// открытым Q-меню не нужны и перекрывали бы панели. + pub fn draw(&self, renderer: &mut WorldRenderer, width: f32, height: f32, menu_open: bool) { + if !self.enabled || width <= 0.0 || height <= 0.0 { + return; + } + for zone in ZONES { + if menu_open && zone.button != TouchButton::Menu { + continue; + } + let rect = &zone.rect; + let x = rect.min_x * width; + let y = rect.min_y * height; + let w = (rect.max_x - rect.min_x) * width; + let h = (rect.max_y - rect.min_y) * height; + let pressed = self.is_pressed(zone.button); + let border = if pressed { + [0.50, 0.88, 1.0, 0.9] + } else { + [0.72, 0.82, 1.0, 0.42] + }; + let fill = if pressed { + [0.18, 0.48, 0.75, 0.5] + } else { + [0.03, 0.06, 0.10, 0.18] + }; + // Рамка и заливка: два прямоугольника примитивами оверлея. + renderer.push_rect(x, y, w, h, border); + renderer.push_rect( + x + 2.0, + y + 2.0, + (w - 4.0).max(1.0), + (h - 4.0).max(1.0), + fill, + ); + let label = if menu_open && zone.button == TouchButton::Menu { + "X" + } else { + zone.label + }; + // Шрифт 8×8 с шагом 8·scale — центрируем вручную. + let scale = if label.len() > 4 { 0.8 } else { 1.4 }; + let text_w = label.len() as f32 * 8.0 * scale; + renderer.push_text( + x + (w - text_w) * 0.5, + y + (h - 8.0 * scale) * 0.5, + scale, + [1.0, 1.0, 1.0, 0.95], + label, + ); + } + + if !menu_open && let Some((origin, vector)) = self.stick() { + let radius = STICK_RADIUS * width.min(height); + let base_x = origin.0 * width - radius; + let base_y = origin.1 * height - radius; + renderer.push_rect( + base_x, + base_y, + radius * 2.0, + radius * 2.0, + [0.7, 0.8, 1.0, 0.35], + ); + renderer.push_rect( + base_x + 2.0, + base_y + 2.0, + radius * 2.0 - 4.0, + radius * 2.0 - 4.0, + [0.03, 0.06, 0.10, 0.25], + ); + let knob = radius * 0.55; + let knob_x = origin.0 * width + vector.1 * radius - knob; + let knob_y = origin.1 * height - vector.0 * radius - knob; + renderer.push_rect( + knob_x, + knob_y, + knob * 2.0, + knob * 2.0, + [0.75, 0.9, 1.0, 0.65], + ); + } + } } #[cfg(test)] @@ -236,7 +402,7 @@ mod tests { assert_eq!(pitch, 0.0); assert!(touch.finger_up(2)); - assert!(touch.finger_down(3, 0.8, 0.6)); + assert!(touch.finger_down(3, 0.86, 0.5)); assert!(touch.take_jump()); assert!(!touch.duck()); assert!(touch.finger_up(3)); @@ -249,22 +415,44 @@ mod tests { assert!(!touch.finger_down(1, 0.2, 0.8)); assert_eq!(touch.movement(), (0.0, 0.0)); assert!(!touch.take_jump()); + assert!(touch.button_at(0.85, 0.75).is_none()); } #[test] - fn menu_button_is_above_jump() { + fn action_buttons_are_where_drawn() { let touch = TouchControls::new(true); - assert!(touch.menu_button(0.8, 0.3)); - assert!(!touch.menu_button(0.8, 0.6)); - assert!(!touch.menu_button(0.2, 0.3)); - let disabled = TouchControls::new(false); - assert!(!disabled.menu_button(0.8, 0.3)); + assert_eq!(touch.button_at(0.9, 0.08), Some(TouchButton::Menu)); + assert_eq!(touch.button_at(0.9, 0.19), Some(TouchButton::Noclip)); + assert_eq!(touch.button_at(0.87, 0.5), Some(TouchButton::Jump)); + assert_eq!(touch.button_at(0.68, 0.64), Some(TouchButton::Duck)); + assert_eq!(touch.button_at(0.87, 0.77), Some(TouchButton::Primary)); + assert_eq!(touch.button_at(0.68, 0.86), Some(TouchButton::Secondary)); + assert_eq!(touch.button_at(0.3, 0.5), None); } #[test] - fn menu_button_does_not_start_look() { + fn noclip_is_one_shot_and_buttons_do_not_start_look() { let mut touch = TouchControls::new(true); - assert!(touch.finger_down(5, 0.8, 0.3)); - assert!(touch.finger_motion(5, 0.9, 0.3, 0.1, 0.0).is_none()); + assert!(touch.finger_down(5, 0.9, 0.19)); + assert!(touch.take_noclip()); + assert!(!touch.take_noclip()); + assert!(touch.finger_up(5)); + // Кнопка — не обзор: движение пальца ничего не крутит. + assert!(touch.finger_down(6, 0.87, 0.77)); + assert!(touch.finger_motion(6, 0.9, 0.7, 0.0, -0.1).is_none()); + assert!(touch.finger_up(6)); + } + + #[test] + fn primary_and_secondary_are_held() { + let mut touch = TouchControls::new(true); + assert!(touch.finger_down(7, 0.87, 0.77)); + assert!(touch.is_pressed(TouchButton::Primary)); + assert!(!touch.is_pressed(TouchButton::Secondary)); + assert!(touch.finger_down(8, 0.68, 0.86)); + assert!(touch.is_pressed(TouchButton::Secondary)); + assert!(touch.finger_up(7)); + assert!(!touch.is_pressed(TouchButton::Primary)); + assert!(touch.is_pressed(TouchButton::Secondary)); } } diff --git a/crates/lua/src/stdlib.rs b/crates/lua/src/stdlib.rs index 88f5afe..05707fb 100644 --- a/crates/lua/src/stdlib.rs +++ b/crates/lua/src/stdlib.rs @@ -74,9 +74,12 @@ fn install_list(lua: &Lua) -> Result<(), mlua::Error> { list.set("Add", set)?; list.set( "Get", + // Неизвестный список — пустая таблица, как в GMod: меню Sandbox + // индексирует `list.Get("NPC")` напрямую, и nil ронял Init + // SpawnmenuNPCSidebarToolbox («table index is nil»). lua.create_function(|lua, kind: String| match list_entries(lua, &kind)? { Some(entries) => Ok(Value::Table(entries)), - None => Ok(Value::Nil), + None => Ok(Value::Table(lua.create_table()?)), })?, )?; list.set( diff --git a/crates/lua/src/ui/bootstrap.lua b/crates/lua/src/ui/bootstrap.lua index 8ed2c36..5cdf742 100644 --- a/crates/lua/src/ui/bootstrap.lua +++ b/crates/lua/src/ui/bootstrap.lua @@ -890,6 +890,13 @@ local builtin_phrases = { ["spawnmenu.category.weapons"] = "Weapons", ["spawnmenu.category.npcs"] = "NPCs", ["spawnmenu.category.postprocess"] = "Post Process", + ["spawnmenu.category.vehicles"] = "Vehicles", + ["spawnmenu.search"] = "Search", + ["spawnmenu.quick_filter"] = "Quick Filter...", + ["spawnmenu.listname_tooltip"] = "List name", + ["spawnmenu.header_tooltip"] = "Header", + ["menubar.npcs.defaultweapon"] = "Default weapon", + ["menubar.npcs.noweapon"] = "No weapon", ["spawnmenu.category.your_spawnlists"] = "Your Spawnlists", ["spawnmenu.category.addon_spawnlists"] = "Addon Spawnlists", ["spawnmenu.tools.constraints"] = "Constraints", @@ -2011,6 +2018,10 @@ function panel_meta:IsMultiline() return false end function panel_meta:GetMultiline() return false end function panel_meta:GetEnterAllowed() return true end function panel_meta:OpenURL() end +function panel_meta:Call() end +function panel_meta:SetHTML() end +function panel_meta:AddFunction() end +function panel_meta:QueueJavascript() end function panel_meta:SetDragParent(parent) self.__drag_parent = parent end function panel_meta:GetDragParent() return self.__drag_parent end diff --git a/docs/DIARY.md b/docs/DIARY.md index 5f1782d..ddce621 100644 --- a/docs/DIARY.md +++ b/docs/DIARY.md @@ -8,6 +8,36 @@ --- +## 0.16. Мобильное управление и настоящий Q-меню в APK (2026-10-26) + +- **Почему на телефоне было «непонятное меню».** В APK лежали только 35 + Lua-файлов (`lua/autorun`), без `gamemodes/` — Sandbox-меню собрать было + не из чего, и клиент показывал резервное меню. В `android/assets` добавлены + полные `lua/` (277 файлов), `gamemodes/` (320), `settings/` и + `resource/language`; `ASSET_VERSION` в `MainActivity` поднят до 3, чтобы + при обновлении APK старые распакованные ассеты перезаписались. +- **Спавнлист под мобильный набор.** В APK теперь 21 модель: пропы карты, + оружие, физган и 13 частых пропов (`oildrum001`, `woodbarrel001`, + `bluebarrel001`, `wood_crate001a/002a`, `furniturechair001a`, + `furnituretable001a`, `furnituredrawer001a`, `consolebox01a`, + `furniturebed001a`, `cactus`, `metal_plate1`). `fs pack` расширен списком + `EXTRA_MODELS` (записывает модели и их материалы), а + `settings/spawnlist_default` отфильтрован по существующим моделям: 31 + запись в 7 категориях вместо 7473 битых иконок. +- **Сенсорные кнопки.** Полноценный слой: Q (открыть/закрыть настоящий + Sandbox), NOCLIP, LMB, RMB, JUMP, DUCK; полупрозрачные рамки с подписями, + стик с подсказкой базы. LMB/RMB превращаются в настоящие + `MouseButtonDown/Up` (тот же код физгана/оружия), noclip — в переключение + `player.set_noclip`. Кнопка закрытия Q (`X`) рисуется поверх меню, остальные + под меню скрыты. В меню свайп вверх/вниз листает панели (колесо). +- **Lua-шим.** `list.Get` для неизвестного списка возвращает пустую таблицу + (падал `Init SpawnmenuNPCSidebarToolbox: table index is nil`); добавлены + `panel:Call/SetHTML/AddFunction/QueueJavascript` (таймеры Dupes/Saves больше + не сыпят ошибками). +- **Проверки.** Кадры `/tmp/opencode/touch_ui.png` (кнопки) и + `/tmp/opencode/mobile_qmenu2.png` (меню из APK-ассетов); юнит-тесты + раскладки кнопок и noclip. + ## 0.15. Физган: чистая пружина, угол от камеры, модель; игрок толкает пропы (2026-10-25) - **Пружина без feed-forward.** Скорость цели больше не прибавляется к