android-native: auto hunt, in-client account registration, mobile UI polish

- Auto hunt (client-only): PythonPlayerAutoHunt.cpp + AutoHuntScript.inc as the
  embedded mt_autohunt module, AUTO touch button, F9/F8.
- Account registration: tools/40250/register_server.py (rc.d mt_register, :11080)
  inserts into account.account; net.RegisterAccount/GetRegisterAccountResult
  (PythonAccountRegister.cpp) and the mt_register login-window dialog
  (RegisterScript.inc).
- --py-exec also runs in --login-screen mode for scripted login-screen tests.
- Touch controller, window manager and render command updates for the
  Android native client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-29 16:15:04 +09:00
co-authored by Claude Opus 5.5
parent 53d06c3c26
commit 3e3708ef06
31 changed files with 2975 additions and 140 deletions
+120 -5
View File
@@ -19,6 +19,9 @@
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3/SDL_vulkan.h>
#ifdef __ANDROID__
#include <jni.h>
#endif
#include <vulkan/vulkan.h>
#include "stb_image.h"
@@ -714,6 +717,21 @@ std::string bundled_file_path(const char* name) {
return std::string(base ? base : "./") + name;
}
#ifdef __ANDROID__
// MainActivity.performHaptic(): the long-press vibration of the touch gestures.
void android_haptic() {
auto* env = static_cast<JNIEnv*>(SDL_GetAndroidJNIEnv());
auto activity = static_cast<jobject>(SDL_GetAndroidActivity());
if (!env || !activity) return;
jclass cls = env->GetObjectClass(activity);
if (jmethodID method = env->GetMethodID(cls, "performHaptic", "()V"))
env->CallVoidMethod(activity, method);
if (env->ExceptionCheck()) env->ExceptionClear();
env->DeleteLocalRef(cls);
env->DeleteLocalRef(activity);
}
#endif
std::vector<std::uint32_t> read_spirv(const char* name) {
std::size_t size = 0;
void* bytes = SDL_LoadFile(bundled_file_path(name).c_str(), &size);
@@ -848,6 +866,8 @@ public:
}
}
#endif
// Android's back key reaches the game as SDL_SCANCODE_AC_BACK (-> ESC) instead of finishing the activity.
SDL_SetHint(SDL_HINT_ANDROID_TRAP_BACK_BUTTON, "1");
if (!SDL_Init(SDL_INIT_VIDEO)) throw std::runtime_error(SDL_GetError());
SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight");
window_ = SDL_CreateWindow(
@@ -1010,12 +1030,24 @@ public:
return {0, 0, float(extent_.width), float(extent_.height), 0, 1};
}
// Window pixels kept clear at the left and right screen edges (rounded corners, cutouts);
// MainActivity measures them and passes --safe-inset-px.
void set_safe_inset_px(int px) { safe_inset_px_ = std::max(0, px); }
// Test builds: frames per second in the top-left corner (--show-fps).
void set_show_fps(bool show) { show_fps_ = show; }
// The same inset in logical UI pixels: the 40250 window layers are shifted and narrowed by it.
int ui_safe_inset() const {
int pw = 0, ph = 0;
if (safe_inset_px_ <= 0 || !window_ || !SDL_GetWindowSizeInPixels(window_, &pw, &ph) || ph <= 0) return 0;
return int(std::lround(double(safe_inset_px_) * logical_height() / ph));
}
// D3DVIEWPORT8 (in the game's logical screen pixels) scaled to the swapchain extent.
VkViewport draw_viewport(const Render3DDraw& draw) const {
if (draw.viewport[2] <= 0 || draw.viewport[3] <= 0) return full_viewport();
const float sx = float(extent_.width) / float(logical_width());
const float sy = float(extent_.height) / float(logical_height());
return {draw.viewport[0] * sx, draw.viewport[1] * sy, draw.viewport[2] * sx, draw.viewport[3] * sy,
return {(draw.viewport[0] + draw.ui_offset_x) * sx, draw.viewport[1] * sy, draw.viewport[2] * sx, draw.viewport[3] * sy,
draw.viewport_z[0], draw.viewport_z[1]};
}
@@ -1160,6 +1192,24 @@ public:
}
}
// Touch hosts: SDL text input (and with it the system keyboard) is on only while the player
// types into a tapped EditLine; desktop keeps the always-on text input from window creation.
void sync_screen_keyboard() {
const bool want = touch_controller.wants_screen_keyboard();
const unsigned serial = touch_controller.screen_keyboard_serial();
if (want && (!SDL_TextInputActive(window_) || serial != screen_keyboard_serial_)) {
SDL_PropertiesID props = SDL_CreateProperties();
SDL_SetNumberProperty(props, SDL_PROP_TEXTINPUT_TYPE_NUMBER, SDL_TEXTINPUT_TYPE_TEXT);
SDL_SetNumberProperty(props, SDL_PROP_TEXTINPUT_CAPITALIZATION_NUMBER, SDL_CAPITALIZE_NONE);
SDL_SetBooleanProperty(props, SDL_PROP_TEXTINPUT_AUTOCORRECT_BOOLEAN, false);
SDL_StartTextInputWithProperties(window_, props);
SDL_DestroyProperties(props);
} else if (!want && SDL_TextInputActive(window_)) {
SDL_StopTextInput(window_);
}
screen_keyboard_serial_ = serial;
}
bool poll(bool forward_to_live_client = false) {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
auto map_mouse = [&](float wx, float wy, int& ox, int& oy) {
@@ -1190,6 +1240,13 @@ public:
SDL_Event event;
while (SDL_PollEvent(&event)) {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
// Touches arrive as SDL_EVENT_FINGER_*; the touch-synthesized mouse copy
// would register as a second finger (101) and turn every drag into a pinch.
if (touch_controller.is_enabled() &&
((event.type == SDL_EVENT_MOUSE_MOTION && event.motion.which == SDL_TOUCH_MOUSEID) ||
((event.type == SDL_EVENT_MOUSE_BUTTON_DOWN || event.type == SDL_EVENT_MOUSE_BUTTON_UP) &&
event.button.which == SDL_TOUCH_MOUSEID)))
continue;
if (forward_to_live_client && event.type == SDL_EVENT_MOUSE_MOTION) {
int mx = 0, my = 0;
map_mouse(event.motion.x, event.motion.y, mx, my);
@@ -1204,9 +1261,10 @@ public:
if (event.type == SDL_EVENT_WINDOW_RESIZED || event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) {
int ew = 0, eh = 0;
SDL_GetWindowSize(window_, &ew, &eh);
touch_controller.update_screen_size(int(logical_width()), int(logical_height()));
touch_controller.update_screen_size(int(logical_width()), int(logical_height()), ui_safe_inset());
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
if (forward_to_live_client && ew > 0 && eh > 0) {
PythonBoot::SetUISafeInset(ui_safe_inset());
PythonBoot::SetUISize(int(logical_width()), int(logical_height()));
}
#endif
@@ -1259,6 +1317,8 @@ public:
PythonBoot::UIMouseWheel(int(event.wheel.y * 120.0f));
} else if (event.type == SDL_EVENT_KEY_DOWN || event.type == SDL_EVENT_KEY_UP) {
const bool pressed = event.type == SDL_EVENT_KEY_DOWN;
// Android back = ESC: closes the top window, or opens the system menu (game.py).
if (event.key.scancode == SDL_SCANCODE_AC_BACK) event.key.scancode = SDL_SCANCODE_ESCAPE;
if (pressed) {
if (const int vk = sdl_scancode_to_vk(event.key.scancode))
PythonBoot::UIIMEKeyDown(vk);
@@ -1304,6 +1364,8 @@ public:
flush_mouse_move();
#endif
touch_controller.update();
if (touch_controller.is_enabled())
sync_screen_keyboard();
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
if (forward_to_live_client && !hardware_cursor_enabled_ && !os_cursor_hidden_ && !touch_controller.is_enabled()) {
SDL_HideCursor();
@@ -1452,6 +1514,8 @@ public:
prepared_draw.fixed_state = make_fixed_function_state(draw);
// XYZRHW vertices are already in screen space and bypass the viewport transform.
prepared_draw.viewport = draw.pretransformed ? full_viewport() : draw_viewport(draw);
if (draw.pretransformed && draw.ui_offset_x != 0)
prepared_draw.viewport.x += draw.ui_offset_x * float(extent_.width) / float(logical_width());
prepared_draws.push_back(prepared_draw);
vertex_count += geometry.vertex_count;
index_count += geometry.index_count;
@@ -1470,10 +1534,19 @@ public:
if (ui_mapped_) {
const uint32_t target_ui_w = ui_width ? ui_width : 960;
const uint32_t target_ui_h = ui_height ? ui_height : 640;
if (touch_controller.is_enabled()) {
touch_controller.update_screen_size(int(target_ui_w), int(target_ui_h));
update_fps_counter();
if (touch_controller.is_enabled() || show_fps_) {
std::vector<UIRenderCommand> combined_ui = ui_commands;
touch_controller.append_ui_commands(combined_ui);
if (touch_controller.is_enabled()) {
touch_controller.update_screen_size(int(target_ui_w), int(target_ui_h), ui_safe_inset());
touch_controller.append_ui_commands(combined_ui);
}
if (show_fps_ && fps_ >= 0) {
// Top left, inside the safe area and clear of 40250's corner icon.
const float inset = float(std::min<int>(ui_safe_inset(), int(target_ui_w) / 8));
TouchController::draw_segment_text(combined_ui, "FPS " + std::to_string(fps_),
inset + 40.0f, 8.0f, 12.0f, 0xFF40FF40);
}
if (!combined_ui.empty()) {
build_ui_batches(target_ui_w, target_ui_h, combined_ui, capture_textures, ui_batches, ui_quad_count);
}
@@ -3055,6 +3128,27 @@ private:
float timestamp_period_ns_ = 1.0f;
float max_anisotropy_ = 1.0f;
SDL_Window* window_ = nullptr;
unsigned screen_keyboard_serial_ = 0;
int safe_inset_px_ = 0;
bool show_fps_ = false;
int fps_ = -1;
int fps_frames_ = 0;
std::chrono::steady_clock::time_point fps_window_start_{};
// Presented frames over the last half second.
void update_fps_counter() {
const auto now = std::chrono::steady_clock::now();
if (fps_frames_++ == 0) {
fps_window_start_ = now;
return;
}
const double elapsed = std::chrono::duration<double>(now - fps_window_start_).count();
if (elapsed >= 0.5) {
fps_ = int(std::lround((fps_frames_ - 1) / elapsed));
fps_frames_ = 1;
fps_window_start_ = now;
}
}
VkInstance instance_ = VK_NULL_HANDLE;
VkSurfaceKHR surface_ = VK_NULL_HANDLE;
VkPhysicalDevice physical_ = VK_NULL_HANDLE;
@@ -3198,6 +3292,10 @@ void print_summary(const VulkanWindow& renderer, int completed, double wall_ms,
}
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
// --py-exec: a test hook run once the auto-login run reaches the GameWindow (or, with --login-screen,
// once the LoginWindow is up).
std::string g_py_exec_after_login;
bool py_exec(const std::string& code) {
std::string err;
if (!PythonBoot::RunLine(code.c_str(), &err)) {
@@ -3342,6 +3440,7 @@ int run_live_client(
#endif
SetPlatformServerTime(123456789);
PythonBoot::SetUISafeInset(renderer.ui_safe_inset());
PythonBoot::SetUISize(static_cast<int>(renderer.logical_width()), static_cast<int>(renderer.logical_height()));
if (!PythonBoot::RunMainScript("", &error) || !PythonBoot::IsAppLooping())
throw std::runtime_error("PythonBoot::RunMainScript: " + error);
@@ -3396,6 +3495,8 @@ int run_live_client(
renderer.sync_game_cursor();
PythonBoot::UIRender();
}
if (!g_py_exec_after_login.empty() && !py_exec(g_py_exec_after_login))
throw std::runtime_error("--py-exec failed");
} else {
char login_cmd[512];
std::snprintf(
@@ -3455,6 +3556,8 @@ int run_live_client(
if (!configure_macos_numeric_quickslots())
throw std::runtime_error("failed to configure numeric quick slots on macOS");
#endif
if (!g_py_exec_after_login.empty() && !py_exec(g_py_exec_after_login))
throw std::runtime_error("--py-exec failed");
const int warmup_frames = std::max(10, fake_mobs / 2 + 10);
for (int i = 0; i < warmup_frames; ++i) {
@@ -3583,6 +3686,8 @@ int main(int argc, char** argv) {
bool gpu_skinning = true;
bool native_terrain = true;
bool mobile_mode = false;
int safe_inset_px = 0;
bool show_fps = false;
#ifdef __ANDROID__
mobile_mode = true;
#endif
@@ -3633,6 +3738,11 @@ int main(int argc, char** argv) {
else if (arg == "--no-gpu-skinning") gpu_skinning = false;
else if (arg == "--no-terrain") native_terrain = false;
else if (arg == "--mobile") mobile_mode = true;
else if (arg == "--safe-inset-px" && i+1 < argc) safe_inset_px = std::stoi(argv[++i]);
else if (arg == "--show-fps") show_fps = true;
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
else if (arg == "--py-exec" && i+1 < argc) g_py_exec_after_login = argv[++i];
#endif
else throw std::runtime_error(
"usage: mt_native_render [--frames N] [--interactive] [--login-screen|--selection-screen] "
"[--live-server HOST:AUTH_PORT:GAME_PORT] [--width W] [--height H] "
@@ -3677,9 +3787,14 @@ int main(int argc, char** argv) {
triangles_per_draw == 0 || triangles_per_draw > 10000)
throw std::runtime_error("invalid frame/draw/triangle count");
VulkanWindow renderer(vsync, init_width, init_height);
renderer.set_safe_inset_px(safe_inset_px);
renderer.set_show_fps(show_fps);
if (!screenshot_out.empty() && frames > 0) renderer.request_screenshot(screenshot_out, std::uint64_t(frames));
if (mobile_mode) {
renderer.touch_controller.set_enabled(true);
#ifdef __ANDROID__
renderer.touch_controller.set_haptic(android_haptic);
#endif
renderer.touch_controller.update_screen_size(init_width, init_height);
}
if (!live_client_dir.empty()) {