Мобильный Sandbox: настоящий Q-меню, полный Lua и сенсорные кнопки
Some checks are pending
CI / checks (push) Waiting to run

- 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 зелёные.
This commit is contained in:
Niko Marmeladkov 2026-10-26 23:30:43 +03:00
parent 6570598f78
commit 4bfe923577
7 changed files with 478 additions and 114 deletions

View file

@ -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();

View file

@ -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<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
let mut icon_renders_at_open: u64 = 0;
// Палец, который сейчас нажимает в Lua-панель (остальные — камера/стик).
let mut ui_touch_finger: Option<u64> = 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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<PathBuf>, 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,

View file

@ -43,6 +43,29 @@ pub fn pack(assets: Option<PathBuf>, 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);
}

View file

@ -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<u64>,
move_origin: (f32, f32),
/// `(forward, right)` в диапазоне -1..1.
move_vector: (f32, f32),
look_finger: Option<u64>,
jump_finger: Option<u64>,
duck_finger: Option<u64>,
/// Короткое касание кнопки прыжка могло начаться и кончиться между
/// кадрами — запоминаем его до ближайшего кадра.
primary_finger: Option<u64>,
secondary_finger: Option<u64>,
noclip_finger: Option<u64>,
/// Короткое касание прыжка могло начаться и кончиться между кадрами.
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<TouchButton> {
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));
}
}

View file

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

View file

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

View file

@ -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.** Скорость цели больше не прибавляется к