Android: ландшафт, HUD над системными панелями, прыжок по тапу

- MainActivity фиксирует ландшафт (SDL перебивал манифест FULL_USER) и
  передаёт путь к ассетам через getExternalFilesDir;
- короткий тап по JUMP не теряется между кадрами (jump_pending);
- HUD сдвинут внутрь от статус-бара и нижней панели;
- логгер на Android собирается без неиспользуемой ветки.
This commit is contained in:
Niko Marmeladkov 2026-10-23 14:17:39 +03:00
parent e07f39d8dc
commit 8d572b8190
9 changed files with 180 additions and 23 deletions

View file

@ -31,9 +31,9 @@
android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
<activity
android:name="org.libsdl.app.SDLActivity"
android:name="org.gmodrs.client.MainActivity"
android:label="gmod-rs"
android:screenOrientation="sensorLandscape"
android:screenOrientation="landscape"
android:alwaysRetainTaskState="true"
android:launchMode="singleInstance"
android:configChanges="layoutDirection|locale|grammaticalGender|fontScale|fontWeightAdjustment|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"

View file

@ -33,16 +33,27 @@ adb install -r target/android/gmod-rs-debug.apk
```bash
adb shell mkdir -p /sdcard/Android/data/org.gmodrs.client/files/gmod_server
adb push ~/gmod_server/server /sdcard/Android/data/org.gmodrs.client/files/gmod_server/
# adb создаёт каталоги от shell, а приложению нужен доступ:
adb shell chmod -R 777 /sdcard/Android/data/org.gmodrs.client/files/gmod_server
```
Приложение по умолчанию открывает `gm_construct` и ищет ассеты в
`/sdcard/Android/data/org.gmodrs.client/files/gmod_server/server`. Другой путь
можно задать переменной `GMOD_RS_ASSETS` (например, через `adb shell setprop`
не сработает — переменные окружения Android-приложения задаются только из
Java/обвязки; для отладки удобнее положить ассеты по стандартному пути).
`/sdcard/Android/data/org.gmodrs.client/files/gmod_server/server` (точный путь
передаёт `MainActivity` через `getExternalFilesDir`). Другой путь можно задать
переменной `GMOD_RS_ASSETS` — но на телефоне переменные окружения задать
непросто, поэтому проще положить ассеты по стандартному пути.
Минимально для `gm_construct` достаточно `garrysmod/` (карты и контент игры) и
`sourceengine/` (текстуры/модели HL2); `platform/` нужен для части эффектов.
Проверено на усечённом наборе (garrysmod VPK + карта): карта грузится и
рисуется, часть материалов заменяется заглушкой.
## Запуск и логи
```bash
adb shell am start -n org.gmodrs.client/.MainActivity
adb logcat -s gmod-rs SDL
```
## Управление
@ -57,5 +68,9 @@ Java/обвязки; для отладки удобнее положить ас
команда `net` недоступна.
- Lua на Android — Lua 5.4 (vendored), а не LuaJIT: кросс-сборка LuaJIT под
Android в mlua не поддерживается. API GMod-скриптов тот же.
- Проверено только arm64-v8a; x86_64-эмулятор требует отдельной сборки
(см. `ANDROID_ABI`/`TARGET` в `build.sh`).
- Собран и проверен `arm64-v8a` (APK для телефона) и `x86_64` (эмулятор).
Проверка в эмуляторе API 35: карта грузится, кадры рисуются через
SwiftShader (11–14 fps на софтверном GPU), стик/обзор/прыжок работают.
На реальном arm64-устройстве не запускалось — проверьте `adb logcat`.
- Для эмулятора: `ANDROID_ABI=x86_64 ./android/build.sh`, результат —
`target/android/gmod-rs-debug-x86_64.apk`.

View file

@ -0,0 +1,35 @@
package org.gmodrs.client;
import java.io.File;
/**
* Активность клиента gmod-rs.
*
* Наследует штатную SDLActivity и передаёт нативной части путь к ассетам:
* внешний каталог приложения (`Android/data/org.gmodrs.client/files`) доступен
* без разрешений на хранилище, в отличие от прямого `/sdcard`.
*/
public class MainActivity extends org.libsdl.app.SDLActivity {
/**
* SDL при создании окна запрашивает ориентацию из размеров окна и флага
* resizable (FULL_USER), перебивая манифест. Для игры фиксируем ландшафт.
*/
@Override
public void setOrientationBis(int w, int h, boolean resizable, String hint) {
setRequestedOrientation(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
}
@Override
protected String[] getArguments() {
File assets = new File(getExternalFilesDir(null), "gmod_server/server");
android.util.Log.i("gmod-rs", "assets=" + assets.getAbsolutePath()
+ " exists=" + assets.isDirectory()
+ " external=" + getExternalFilesDir(null));
return new String[] {
"map",
"gm_construct",
"--assets",
assets.getAbsolutePath()
};
}
}

View file

@ -171,9 +171,14 @@ pub const ANDROID_DEFAULT_ASSETS: &str =
/// Java-обвязка SDL (`SDLActivity`) загружает `libSDL3.so` и `libmain.so` и
/// вызывает этот символ. По умолчанию открываем `gm_construct` с ассетами из
/// внешнего каталога приложения; аргументы можно передать из Java.
///
/// # Safety
///
/// Вызывается только из SDL: `argv` — массив из `argc` валидных
/// NUL-терминированных строк.
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "C" fn SDL_main(
pub unsafe extern "C" fn SDL_main(
argc: std::os::raw::c_int,
argv: *mut *mut std::os::raw::c_char,
) -> std::os::raw::c_int {

View file

@ -798,7 +798,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
if touch.duck() {
move_up -= 1.0;
}
let touch_jump = touch.jump();
let touch_jump = touch.take_jump();
if player.noclip {
let speed = if pressed.contains(&Keycode::LShift) {
args.speed * 3.0
@ -901,6 +901,20 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
// HUD (тень + текст, чтобы читалось на любом фоне).
let (width, height) = gfx.size();
// Android рисует поверх всего экрана, включая системные панели:
// сдвигаем HUD внутрь, иначе строки уезжают под статус-бар.
let hud_top = if cfg!(target_os = "android") {
96.0
} else {
8.0
};
let hud_bottom = if cfg!(target_os = "android") {
// Нижняя системная панель на Android выше, чем кажется: держим
// строку над ней, иначе подсказка уходит под чёрную полосу.
height as f32 - 110.0
} else {
height as f32 - 28.0
};
fps = fps * 0.9 + (1.0 / dt.max(1e-4)) * 0.1;
renderer.clear_text();
let mut hud = |x: f32, y: f32, scale: f32, color: [f32; 4], text: String| {
@ -909,14 +923,14 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
};
hud(
8.0,
8.0,
hud_top,
1.5,
[1.0, 1.0, 1.0, 1.0],
format!("FPS {:.0}", fps),
);
hud(
8.0,
28.0,
hud_top + 20.0,
1.5,
[0.85, 0.95, 1.0, 1.0],
format!(
@ -935,7 +949,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
);
hud(
8.0,
height as f32 - 28.0,
hud_bottom,
1.5,
[0.95, 0.95, 0.8, 1.0],
if touch_enabled {
@ -970,7 +984,7 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
);
}
if show_log {
let mut y = height as f32 - 60.0;
let mut y = hud_bottom - 32.0;
for line in log.iter().rev().take(4) {
hud(8.0, y, 1.2, [0.8, 1.0, 0.8, 0.95], line.clone());
y -= 16.0;

View file

@ -52,6 +52,9 @@ pub struct TouchControls {
look_finger: Option<u64>,
jump_finger: Option<u64>,
duck_finger: Option<u64>,
/// Короткое касание кнопки прыжка могло начаться и кончиться между
/// кадрами — запоминаем его до ближайшего кадра.
jump_pending: bool,
}
impl TouchControls {
@ -71,6 +74,7 @@ impl TouchControls {
if JUMP_BUTTON.contains(x, y) {
self.jump_finger = Some(finger_id);
self.jump_pending = true;
return true;
}
if DUCK_BUTTON.contains(x, y) {
@ -159,6 +163,7 @@ impl TouchControls {
self.look_finger = None;
self.jump_finger = None;
self.duck_finger = None;
self.jump_pending = false;
self.move_vector = (0.0, 0.0);
}
@ -170,8 +175,14 @@ impl TouchControls {
self.move_vector
}
pub fn jump(&self) -> bool {
self.enabled && self.jump_finger.is_some()
/// Прыжок на этот кадр: кнопка удерживается или была коротко нажата.
pub fn take_jump(&mut self) -> bool {
if !self.enabled {
return false;
}
let jump = self.jump_finger.is_some() || self.jump_pending;
self.jump_pending = false;
jump
}
pub fn duck(&self) -> bool {
@ -206,10 +217,10 @@ mod tests {
assert!(touch.finger_up(2));
assert!(touch.finger_down(3, 0.8, 0.6));
assert!(touch.jump());
assert!(touch.take_jump());
assert!(!touch.duck());
assert!(touch.finger_up(3));
assert!(!touch.jump());
assert!(!touch.take_jump());
}
#[test]
@ -217,6 +228,6 @@ mod tests {
let mut touch = TouchControls::new(false);
assert!(!touch.finger_down(1, 0.2, 0.8));
assert_eq!(touch.movement(), (0.0, 0.0));
assert!(!touch.jump());
assert!(!touch.take_jump());
}
}

View file

@ -1,6 +1,5 @@
//! Инициализация логирования (tracing).
use std::io::IsTerminal;
use std::sync::Once;
use tracing_subscriber::EnvFilter;
@ -25,13 +24,16 @@ pub fn init(verbosity: u8) {
EnvFilter::new(level)
});
let ansi = std::io::stdout().is_terminal();
let builder = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(true);
#[cfg(not(target_os = "android"))]
let _ = builder.with_ansi(ansi).try_init();
let _ = {
use std::io::IsTerminal;
let ansi = std::io::stdout().is_terminal();
builder.with_ansi(ansi).try_init()
};
// На Android stdout/stderr не видны: пишем строки в logcat
// (`adb logcat -s gmod-rs`), иначе диагностика запуска теряется.

61
docs/ANDROID.md Normal file
View file

@ -0,0 +1,61 @@
# Android-порт клиента
Цель — тот же клиент `gmod-rs` (SDL3 + wgpu), запускаемый как APK на Android
12+ (API 31+). Отдельного «мобильного движка» нет: карта, физика, Lua и рендер
общие с десктопом.
## Сборка
```bash
./android/build.sh # arm64-v8a, APK для телефона
ANDROID_ABI=x86_64 ./android/build.sh # x86_64, для эмулятора
```
Скрипт использует SDK/NDK, `cargo`, `aapt2`, `d8`, `zipalign`, `apksigner`
(Gradle не нужен). Результат — `target/android/gmod-rs-debug.apk`
(подписан debug-ключом `android/debug.keystore`). Подробности и команды
установки — в `android/README.md`.
## Крейты и target cfg
- `crates/client` теперь и библиотека (`src/lib.rs`), и бинарь (`src/main.rs`).
Бинарь вызывает `client::run(args)`, Android-сборка — `cargo build --lib`
с `crate-type = ["cdylib", "rlib"]`.
- Экспорт `SDL_main` (в `lib.rs` под `cfg(target_os = "android")`) — точка
входа для штатной Java-обвязки SDL3 (`SDLActivity`).
- `steamworks` подключён только для не-Android; команда `net` на Android
отсутствует.
- `mlua`: на десктопе LuaJIT + `vendored`, на Android — Lua 5.4 + `vendored`
(LuaJIT не собирается кросс-компилятором mlua). API GMod-скриптов сохраняется.
- `tracing` на Android пишет в logcat (`adb logcat -s gmod-rs`), а не в stdout.
## APK
- Java-классы SDL3 (`org.libsdl.app.*`) взяты из `sdl3-src` (zlib-лицензия) и
лежат в `android/app/src/main/java`.
- `MainActivity` наследует `SDLActivity`, фиксирует ландшафт и передаёт
нативной части аргументы `map gm_construct --assets <external files>/...`.
- Нативные библиотеки пакуются как `lib/arm64-v8a/libSDL3.so` и
`libmain.so` (внутри — `libclient.so` с `SDL_main`).
- Ассеты не входят в APK: их кладут во внешний каталог приложения
(`Android/data/org.gmodrs.client/files/gmod_server/server`), см. README.
## Управление
Обрабатываются `SDL_EVENT_FINGER_*`; синтетические события мыши от касаний
отключены (`SDL_TOUCH_MOUSE_EVENTS=0`):
- левая половина экрана — аналоговый стик (forward/right идут в тот же
`Player::update`, что WASD);
- правая половина — обзор свайпом (приращение углов камеры);
- кнопки `JUMP`/`DUCK` — прыжок (с защёлкой короткого тапа) и присед;
- на Android движение сразу бегом (отдельной кнопки Shift нет).
## Проверка
- `cargo clippy -p client --lib --target aarch64-linux-android -- -D warnings`.
- Эмулятор API 35 x86_64: APK запускается, `gm_construct` грузится, кадры
рисуются (SwiftShader, ~11–14 fps), стик/обзор/прыжок проверены через
`adb shell input`.
- Реальное arm64-устройство не проверялось (нет устройства); при отладке —
`adb logcat -s gmod-rs SDL`.

View file

@ -22,7 +22,8 @@ crates/
ui — нативный UI: меню, консоль, шрифты, HUD-рамка, загрузочные экраны
lua — mlua (vendored LuaJIT), sandbox, рельсы API, мосты к engine/render/studio
steam — фичи steamworks-rs (тикет, UGC/workshop), отдельный steam-поток
client — бинарь `gmod-rs`: сборка всего, главный цикл, конфиг, CLI
client — бинарь `gmod-rs` и библиотека: сборка всего, главный цикл,
конфиг, CLI; на Android — cdylib с `SDL_main` (см. `ANDROID.md`)
```
### Правило зависимостей
@ -158,6 +159,19 @@ texture streaming и VRAM-бюджет (с M9).
- Авто-детект ассетов: (1) явный путь; (2) `~/gmod_server/server`; (3) Steam common.
- Внутриигровые настройки — конвары (как в Source), сохраняются в `cfg/`.
### 3.10 Android
- Тот же клиент собирается как `cdylib` и грузится штатной `SDLActivity` из
SDL3; экспорт — `SDL_main`. Сборка APK — `android/build.sh` (aapt2 + d8,
без Gradle), debug-подпись.
- Целевые ABI: `arm64-v8a` (телефон) и `x86_64` (эмулятор). `minSdk 31`,
`targetSdk 35`, фиксированный ландшафт.
- `target.'cfg(target_os = "android")'`: без `steamworks`, Lua 5.4 вместо
LuaJIT; логирование в logcat.
- Ассеты — во внешнем каталоге приложения; касания (`FingerDown/Motion/Up`)
переведены в те же `wish_forward/right`, прыжок и присед, что клавиатура.
- Детали и ограничения — `docs/ANDROID.md`, команды установки — `android/README.md`.
## 4. Конвенции кода
1. `unwrap/expect/panic` запрещены в путях обработки внешних данных (сеть/диск/Lua).