Adapt Android mobile controls and native client UI

This commit is contained in:
shen
2026-09-28 19:39:13 -07:00
parent a1ae69aa7a
commit 3c52ced02e
180 changed files with 688 additions and 23782 deletions
+5 -14
View File
@@ -1,6 +1,6 @@
# Native Vulkan renderer prototype
# Native Vulkan client
This is the standalone renderer path for the 40250 `Render3DDraw` and `UIRenderCommand` command streams, plus direct `--live-client` execution of the ported 40250 client (`PythonBoot`). SDL3 owns the window and input forwarding; Vulkan owns the swapchain (`FIFO` or `IMMEDIATE`/`MAILBOX`), `VK_FORMAT_D32_SFLOAT` depth buffer, `VK_QUERY_TYPE_TIMESTAMP` hardware GPU timer, key-indexed persistent vertex/index buffers, GPU skeletal skinning bone palette SSBO (`set = 1, binding = 0`), DDS/TGA/`mem:` glyph-page texture sampler descriptors, fixed-function 3D + 2D UI state pipelines, and draw submission. On macOS, the Vulkan loader uses MoltenVK over Metal. The existing Godot client remains the default playable path.
This is the standalone renderer path for the 40250 `Render3DDraw` and `UIRenderCommand` command streams, plus direct `--live-client` execution of the ported 40250 client (`PythonBoot`). SDL3 owns the window and input forwarding; Vulkan owns the swapchain (`FIFO` or `IMMEDIATE`/`MAILBOX`), `VK_FORMAT_D32_SFLOAT` depth buffer, `VK_QUERY_TYPE_TIMESTAMP` hardware GPU timer, key-indexed persistent vertex/index buffers, GPU skeletal skinning bone palette SSBO (`set = 1, binding = 0`), DDS/TGA/`mem:` glyph-page texture sampler descriptors, fixed-function 3D + 2D UI state pipelines, and draw submission. On macOS, the Vulkan loader uses MoltenVK over Metal. This is the active macOS and Android client path.
## Build and run on macOS
@@ -9,18 +9,9 @@ Install Vulkan headers/loader, MoltenVK, SDL3 and `glslc` (shaderc). With Homebr
```sh
brew install vulkan-headers vulkan-loader molten-vk sdl3 shaderc
# 1. Standalone replay build (without port_platform)
cmake -S . -B build-native-render -DMTGODOT_BUILD_EXTENSION=OFF \
-DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
cmake --build build-native-render --target mt_native_render -j8
# 2. Full live-client build (links port_platform + mtpython + FakeLoginServer)
cmake -S . -B build -DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
cmake --build build --target mtgodot mt_native_render port_fake_login_server -j8
# 3. Optimized Release (-O3) live-client build
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DMT_BUILD_NATIVE_RENDER_PROTOTYPE=ON \
-DMTGODOT_EMBED_PYTHON=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
# Optimized Release build (includes port_platform, embedded Python and FakeLoginServer)
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -DMT_BUILD_NATIVE_RENDER=ON \
-DMT_EMBED_PYTHON=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
cmake --build build-release --target mt_native_render -j8
```
+147 -25
View File
@@ -9,6 +9,7 @@
#include "platform/PackBackend.h"
#include "platform/ScriptLib/PythonBoot.h"
#include "platform/UserInterface/ServerClock.h"
#include "platform/EterBase/TraceErrorObserver.h"
#include "../tests/port_login_flow_server.h"
#include <thread>
#endif
@@ -23,6 +24,7 @@
#ifdef __APPLE__
#include <AudioToolbox/AudioToolbox.h>
#include <TargetConditionals.h>
#endif
#include <algorithm>
@@ -230,8 +232,8 @@ std::uint64_t geometry_hash(const Render3DDraw& draw) {
return hash_bytes(hash, draw.indices.data(), draw.indices.size() * sizeof(std::uint32_t));
}
mtgodot::Image decode_tga(const std::uint8_t* data, std::size_t size) {
mtgodot::Image out;
mtimage::Image decode_tga(const std::uint8_t* data, std::size_t size) {
mtimage::Image out;
if (size < 18) return out;
const std::uint8_t id_len = data[0];
const std::uint8_t cmap_type = data[1];
@@ -306,7 +308,7 @@ mtgodot::Image decode_tga(const std::uint8_t* data, std::size_t size) {
return out;
}
mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size) {
mtimage::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size) {
if (!data || size < 12) return {};
if (data[0] == 'M' && data[1] == 'T' && data[2] == 'R' && data[3] == 'A') {
std::uint32_t w = 0, h = 0;
@@ -314,7 +316,7 @@ mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size)
std::memcpy(&h, data + 8, 4);
const std::size_t bytes = std::size_t(w) * std::size_t(h) * 4;
if (w > 0 && h > 0 && w <= 4096 && h <= 4096 && size == 12 + bytes) {
mtgodot::Image out;
mtimage::Image out;
out.w = static_cast<std::uint16_t>(w);
out.h = static_cast<std::uint16_t>(h);
out.rgba.assign(data + 12, data + 12 + bytes);
@@ -323,14 +325,14 @@ mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size)
return {};
}
if (data[0] == 'D' && data[1] == 'D' && data[2] == 'S' && data[3] == ' ')
return mtgodot::load_dds(data, size);
return mtimage::load_dds(data, size);
auto tga = decode_tga(data, size);
if (tga.ok()) return tga;
if (size > static_cast<std::size_t>(INT_MAX)) return {};
int w = 0, h = 0, channels = 0;
stbi_uc* pixels = stbi_load_from_memory(data, static_cast<int>(size), &w, &h, &channels, 4);
if (pixels && w > 0 && h > 0 && w <= 4096 && h <= 4096) {
mtgodot::Image out;
mtimage::Image out;
out.w = static_cast<std::uint16_t>(w);
out.h = static_cast<std::uint16_t>(h);
out.rgba.assign(pixels, pixels + std::size_t(w) * std::size_t(h) * 4);
@@ -847,6 +849,7 @@ public:
}
#endif
if (!SDL_Init(SDL_INIT_VIDEO)) throw std::runtime_error(SDL_GetError());
SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight");
window_ = SDL_CreateWindow(
"Metin2 Native Vulkan Client",
std::max(init_width, 320),
@@ -979,6 +982,7 @@ public:
screenshot_path_ = std::move(path);
screenshot_frame_ = frame;
}
std::uint64_t frame_number() const { return frame_number_; }
int msaa_samples() const { return int(samples_); }
std::uint32_t width() const { return extent_.width; }
@@ -986,11 +990,19 @@ public:
std::uint32_t logical_width() const {
int w = 0, h = 0;
if (window_) SDL_GetWindowSize(window_, &w, &h);
#ifdef __ANDROID__
// Keep the 40250 UI at the same height as the macOS 1280x800 window,
// while retaining the device aspect ratio on wider phone displays.
if (w > 0 && h > 0) return std::max(1, int(std::lround(double(w) * 800.0 / h)));
#endif
return w > 0 ? static_cast<std::uint32_t>(w) : extent_.width;
}
std::uint32_t logical_height() const {
int w = 0, h = 0;
if (window_) SDL_GetWindowSize(window_, &w, &h);
#ifdef __ANDROID__
if (w > 0 && h > 0) return 800;
#endif
return h > 0 ? static_cast<std::uint32_t>(h) : extent_.height;
}
@@ -1192,10 +1204,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(ew, eh);
touch_controller.update_screen_size(int(logical_width()), int(logical_height()));
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
if (forward_to_live_client && ew > 0 && eh > 0) {
PythonBoot::SetUISize(ew, eh);
PythonBoot::SetUISize(int(logical_width()), int(logical_height()));
}
#endif
recreate_swapchain();
@@ -1658,9 +1670,12 @@ public:
present.waitSemaphoreCount = 1; present.pWaitSemaphores = &rendered_;
present.swapchainCount = 1; present.pSwapchains = &swapchain_; present.pImageIndices = &image_index;
const auto presented = vkQueuePresentKHR(queue_, &present);
if (presented == VK_ERROR_OUT_OF_DATE_KHR || presented == VK_SUBOPTIMAL_KHR) {
// An Android surface can continuously report SUBOPTIMAL while the app deliberately
// uses the identity transform. The image is still presentable; rebuilding every frame
// stalls rendering without changing that status.
if (presented == VK_ERROR_OUT_OF_DATE_KHR) {
recreate_swapchain();
} else if (presented != VK_SUCCESS) {
} else if (presented != VK_SUCCESS && presented != VK_SUBOPTIMAL_KHR) {
check(presented, "vkQueuePresentKHR");
}
const auto presented_at = std::chrono::steady_clock::now();
@@ -2027,7 +2042,17 @@ private:
info.imageFormat = format.format; info.imageColorSpace = format.colorSpace; info.imageExtent = extent_;
info.imageArrayLayers = 1; info.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT |
(capabilities.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT);
info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; info.preTransform = capabilities.currentTransform;
info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
// Android surfaces can report a quarter-turn transform even when SDL's window is
// already landscape. Rendering in SDL window coordinates and requesting that transform
// again rotates the entire game image on presentation.
info.preTransform = (capabilities.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR)
? VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR : capabilities.currentTransform;
#ifdef __ANDROID__
SDL_Log("swapchain: window=%dx%d extent=%ux%u currentTransform=%u preTransform=%u",
width, height, extent_.width, extent_.height,
unsigned(capabilities.currentTransform), unsigned(info.preTransform));
#endif
info.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; info.presentMode = present_mode_;
info.clipped = VK_TRUE;
check(vkCreateSwapchainKHR(device_, &info, nullptr, &swapchain_), "vkCreateSwapchainKHR");
@@ -2617,7 +2642,7 @@ private:
}
it->second.last_decode_attempt_frame = frame_number_;
mtgodot::Image decoded;
mtimage::Image decoded;
if (auto raw = capture_textures.find(texture_name); raw != capture_textures.end() && !raw->second.empty()) {
decoded = decode_texture_bytes(raw->second.data(), raw->second.size());
}
@@ -3187,6 +3212,32 @@ bool py_eval_true(const char* expr) {
return PythonBoot::Evaluate(expr, &res, &err) && (res == "True" || res == "1");
}
#ifdef __ANDROID__
bool hide_mobile_taskbar_quickslots() {
return py_exec(
"_mobile_taskbar = _stream.curPhaseWindow.interface.wndTaskBar\n"
"for _mobile_quickslot in _mobile_taskbar.quickslot:\n"
" _mobile_quickslot.Hide()\n"
"_mobile_taskbar.GetChild('QuickSlotBoard').Hide()\n");
}
#endif
#if defined(__APPLE__) && TARGET_OS_OSX
bool configure_macos_numeric_quickslots() {
return py_exec(
"_mac_game = _stream.curPhaseWindow\n"
"_mac_game.onPressKeyDict[app.DIK_5] = lambda: _mac_game._GameWindow__PressQuickSlot(4)\n"
"_mac_game.onPressKeyDict[app.DIK_6] = lambda: _mac_game._GameWindow__PressQuickSlot(5)\n"
"_mac_game.onPressKeyDict[app.DIK_7] = lambda: _mac_game._GameWindow__PressQuickSlot(6)\n"
"_mac_game.onPressKeyDict[app.DIK_8] = lambda: _mac_game._GameWindow__PressQuickSlot(7)\n"
"for _mac_key in (app.DIK_F1, app.DIK_F2, app.DIK_F3, app.DIK_F4):\n"
" del _mac_game.onPressKeyDict[_mac_key]\n"
"_mac_taskbar = _mac_game.interface.wndTaskBar\n"
"for _mac_slot_number in range(5, 9):\n"
" _mac_taskbar.GetChild('slot_%d' % _mac_slot_number).LoadImage('d:/ymir work/ui/game/taskbar/%d.sub' % _mac_slot_number)\n");
}
#endif
bool parse_live_server_spec(const std::string& spec, std::string& host, int& auth_port, int& game_port) {
const auto p1 = spec.find(':');
if (p1 == std::string::npos) return false;
@@ -3206,15 +3257,25 @@ int run_live_client(
bool gpu_skinning,
bool native_terrain,
bool login_screen,
bool selection_screen,
const std::string& live_server_spec,
const std::string& capture_out) {
const std::string& capture_out,
const std::string& screenshot_out) {
if (fake_mobs > 0) {
const std::string mobs_str = std::to_string(fake_mobs);
setenv("MT_FAKE_MOB_COUNT", mobs_str.c_str(), 1);
}
#ifdef __ANDROID__
SetTraceErrorObserver([](const char* line) {
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "40250 SYSERR: %s", line);
});
#endif
char resolved_client_dir[4096] = {};
const std::string abs_client_dir = realpath(client_dir.c_str(), resolved_client_dir) ? std::string(resolved_client_dir) : client_dir;
setenv("MT_40250_CLIENT", abs_client_dir.c_str(), 1);
#ifdef __ANDROID__
SDL_Log("live stage: pack init %s", abs_client_dir.c_str());
#endif
if (chdir(abs_client_dir.c_str()) != 0 || !mtpack40250::initialize(".")) {
throw std::runtime_error("cannot initialize 40250 pack at: " + abs_client_dir);
}
@@ -3226,6 +3287,9 @@ int run_live_client(
SetNativeTerrainRenderEnabled(native_terrain);
SetGpuSkinningEnabled(gpu_skinning);
NativeAudioEngine audio;
#ifdef __ANDROID__
SDL_Log("live stage: audio initialized");
#endif
std::string server_host = "127.0.0.1";
int auth_port = 0;
@@ -3271,11 +3335,17 @@ int run_live_client(
#endif
if (!PythonBoot::Start(stdlib.c_str(), &error))
throw std::runtime_error("PythonBoot::Start: " + error);
#ifdef __ANDROID__
SDL_Log("live stage: PythonBoot started");
#endif
SetPlatformServerTime(123456789);
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);
#ifdef __ANDROID__
SDL_Log("live stage: main script started");
#endif
const std::unordered_map<std::string, std::vector<std::uint8_t>> empty_textures;
auto pump_until = [&](double max_seconds, int sleep_ms, const auto& cond) -> bool {
@@ -3301,6 +3371,9 @@ int run_live_client(
if (!pump_until(20.0, 5, [] { return py_eval_true("isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"); }))
throw std::runtime_error("timed out waiting for LoginWindow");
#ifdef __ANDROID__
SDL_Log("live stage: LoginWindow open");
#endif
if (login_screen) {
char conn_cmd[512];
@@ -3338,18 +3411,28 @@ int run_live_client(
auth_port,
FakeLoginServer::kLogin,
FakeLoginServer::kPassword);
#ifdef __ANDROID__
SDL_Log("live stage: submitting fake login");
#endif
if (!py_exec(login_cmd))
throw std::runtime_error("failed to submit login credentials");
#ifdef __ANDROID__
SDL_Log("live stage: fake login submitted");
#endif
if (!pump_until(20.0, 5, [&] {
return (use_external_server || server.Has("game1:login2")) &&
py_eval_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)");
}))
throw std::runtime_error("timed out waiting for SelectCharacterWindow");
#ifdef __ANDROID__
SDL_Log("live stage: SelectCharacterWindow open");
#endif
if (!py_exec("_stream.curPhaseWindow.SelectSlot(0)\n"
"_stream.curPhaseWindow.StartGame()"))
throw std::runtime_error("failed to start game from SelectCharacterWindow");
if (!selection_screen) {
if (!py_exec("_stream.curPhaseWindow.SelectSlot(0)\n"
"_stream.curPhaseWindow.StartGame()"))
throw std::runtime_error("failed to start game from SelectCharacterWindow");
if (!use_external_server) {
if (!pump_until(30.0, 5, [&] { return server.Has("game2:client_version"); }))
@@ -3362,12 +3445,22 @@ int run_live_client(
}))
throw std::runtime_error("timed out waiting for GameWindow (server error: " + server.Error() + ")");
const int warmup_frames = std::max(10, fake_mobs / 2 + 10);
for (int i = 0; i < warmup_frames; ++i) {
if (!renderer.poll(true) || !PythonBoot::IsAppLooping()) break;
PythonBoot::UIUpdate();
renderer.sync_game_cursor();
audio.pump();
#ifdef __ANDROID__
if (!hide_mobile_taskbar_quickslots())
throw std::runtime_error("failed to hide the desktop quick-slot bar on Android");
#endif
#if defined(__APPLE__) && TARGET_OS_OSX
if (!configure_macos_numeric_quickslots())
throw std::runtime_error("failed to configure numeric quick slots on macOS");
#endif
const int warmup_frames = std::max(10, fake_mobs / 2 + 10);
for (int i = 0; i < warmup_frames; ++i) {
if (!renderer.poll(true) || !PythonBoot::IsAppLooping()) break;
PythonBoot::UIUpdate();
renderer.sync_game_cursor();
audio.pump();
}
}
}
@@ -3417,6 +3510,10 @@ int run_live_client(
native_draw_capture::write(capture_out, Render3DDraws(), cap_textures, ui_w, ui_h, cap_ui);
}
if (!screenshot_out.empty() && frames > 0) {
renderer.request_screenshot(screenshot_out, renderer.frame_number() + static_cast<std::uint64_t>(frames));
}
renderer.reset_timings();
const auto start = std::chrono::steady_clock::now();
double update_ms = 0.0;
@@ -3442,10 +3539,16 @@ int run_live_client(
++completed;
}
renderer.finish_gpu_timings();
#ifdef __ANDROID__
SDL_Log("live stage: render loop complete (%d frames)", completed);
#endif
const auto wall_ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
print_summary(renderer, completed, wall_ms, update_ms, std::move(frame_ms));
PythonBoot::Stop();
#ifdef __ANDROID__
SDL_Log("live stage: PythonBoot stopped");
#endif
if (!use_external_server)
server.Stop();
return (frames == 0 || completed == frames) ? 0 : 2;
@@ -3461,6 +3564,7 @@ int main(int argc, char** argv) {
bool synthetic_specified = false;
bool interactive = false;
bool login_screen = false;
bool selection_screen = false;
int init_width = 1280;
int init_height = 800;
std::size_t draw_count = 64;
@@ -3489,6 +3593,9 @@ int main(int argc, char** argv) {
else if (arg == "--login-screen") {
login_screen = true;
interactive = true;
} else if (arg == "--selection-screen") {
selection_screen = true;
interactive = true;
} else if (arg == "--live-server" && i+1 < argc) {
live_server_spec = argv[++i];
interactive = true;
@@ -3525,7 +3632,7 @@ int main(int argc, char** argv) {
else if (arg == "--no-terrain") native_terrain = false;
else if (arg == "--mobile") mobile_mode = true;
else throw std::runtime_error(
"usage: mt_native_render [--frames N] [--interactive] [--login-screen] "
"usage: mt_native_render [--frames N] [--interactive] [--login-screen|--selection-screen] "
"[--live-server HOST:AUTH_PORT:GAME_PORT] [--width W] [--height H] "
"[--draws N] [--triangles-per-draw N] [--capture FILE] [--animate-first-draw] "
"[--animate-bones] [--no-vsync] [--live-client DIR] [--fake-mobs N] "
@@ -3558,6 +3665,12 @@ int main(int argc, char** argv) {
}
}
if (interactive && !frames_specified) frames = 0;
#ifdef __ANDROID__
SDL_Log("native startup: argc=%d live_client=%s selection=%d frames=%d", argc,
live_client_dir.c_str(), selection_screen, frames);
#endif
if (login_screen && selection_screen)
throw std::runtime_error("--login-screen and --selection-screen are mutually exclusive");
if (frames < 0 || (!interactive && frames == 0) || draw_count == 0 || draw_count > 4096 ||
triangles_per_draw == 0 || triangles_per_draw > 10000)
throw std::runtime_error("invalid frame/draw/triangle count");
@@ -3569,7 +3682,7 @@ int main(int argc, char** argv) {
}
if (!live_client_dir.empty()) {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
return run_live_client(
const int result = run_live_client(
renderer,
live_client_dir,
frames,
@@ -3577,8 +3690,14 @@ int main(int argc, char** argv) {
gpu_skinning,
native_terrain,
login_screen,
selection_screen,
live_server_spec,
capture_out);
capture_out,
screenshot_out);
#ifdef __ANDROID__
SDL_Log("native live client exit: %d", result);
#endif
return result;
#else
throw std::runtime_error("mt_native_render was built without port_platform (--live-client unavailable)");
#endif
@@ -3620,6 +3739,9 @@ int main(int argc, char** argv) {
return (frames == 0 || completed == frames) ? 0 : 2;
} catch (const std::exception& error) {
std::cerr << "native renderer: " << error.what() << '\n';
#ifdef __ANDROID__
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "native renderer: %s", error.what());
#endif
return 1;
}
}
+132 -211
View File
@@ -17,9 +17,7 @@
class TouchController {
public:
struct ButtonDef {
int id; // 1 = ATK, 2 = S1, 3 = S2, 4 = S3, 5 = POT, 6 = PICK
// 99 = MENU (toggle drawer)
// 10 = BAG (DIK_I), 11 = CHAR (DIK_C), 12 = SKILL (DIK_V), 13 = QUEST (DIK_N), 14 = COMM (DIK_M), 15 = SET (DIK_ESCAPE)
int id; // 1 = attack, 2..7 = quick slots 1..6
int dik;
float x, y, radius;
const char* label;
@@ -53,6 +51,14 @@ public:
// Called once per frame to maintain continuous analog movement
void update() {
if (!enabled_) return;
const bool controls_visible = gameplay_controls_visible();
if (!controls_visible) {
if (controls_were_visible_)
release_gameplay_controls();
controls_were_visible_ = false;
return;
}
controls_were_visible_ = true;
if (joystick_active_) {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
PythonBoot::SetMoveDirection(move_angle_, true);
@@ -65,23 +71,25 @@ public:
if (!enabled_) return false;
const float px = norm_x * float(screen_w_);
const float py = norm_y * float(screen_h_);
const bool controls_visible = gameplay_controls_visible();
// 1. Check HUD buttons (Action cluster & Drawer menu)
int btn_idx = find_button(px, py);
if (btn_idx >= 0) {
auto& btn = buttons_[btn_idx];
if (btn.id == 99) { // MENU toggle button
drawer_open_ = !drawer_open_;
layout_buttons();
// The mobile overlay owns only the movement and combat control regions.
if (controls_visible) {
const int btn_idx = find_button(px, py);
if (btn_idx >= 0) {
auto& btn = buttons_[btn_idx];
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
if (btn.id >= 2 && PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(btn.id - 2))
return true;
#endif
btn.pressed = true;
btn.finger_id = finger_id;
trigger_button(btn.dik, true);
return true;
}
btn.pressed = true;
btn.finger_id = finger_id;
trigger_button(btn.dik, true);
return true;
}
// 2. Intelligent UI Hit-Testing: check if touch lands inside an open in-game UI window
// Everything else uses the unchanged 40250 UI and its normal mouse path.
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
if (PythonBoot::IsPointInsideActiveUI(int(px), int(py))) {
ui_touch_finger_id_ = finger_id;
@@ -91,8 +99,8 @@ public:
}
#endif
// 3. Left side: virtual joystick (lower-left quadrant)
if (norm_x < 0.40f && norm_y > 0.35f && !joystick_active_) {
// Start movement only in the gameplay phase.
if (controls_visible && norm_x < 0.40f && norm_y > 0.35f && !joystick_active_) {
joystick_active_ = true;
joystick_finger_id_ = finger_id;
joystick_base_x_ = px;
@@ -103,7 +111,10 @@ public:
return true;
}
// 4. 3D Game World: camera rotation, pinch zoom, or tap to target
if (!controls_visible)
return false;
// The remaining gameplay area controls camera and world selection.
if (camera_finger_id_ < 0) {
camera_finger_id_ = finger_id;
camera_last_x_ = px;
@@ -265,80 +276,12 @@ public:
return false;
}
// Render virtual joystick, buttons, drawer menu, and player status bar
// Overlay only the controls that have no practical desktop-UI touch equivalent.
void append_ui_commands(std::vector<UIRenderCommand>& commands) const {
if (!enabled_) return;
if (!enabled_ || !gameplay_controls_visible()) return;
const float W = float(screen_w_);
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
// --- 1. Top-Left Player Status Bar (Mobile HUD) ---
const auto status = PythonBoot::GetPlayerStatusInfo();
if (status.max_hp > 0) {
const float bar_x = 20.0f;
const float bar_y = 16.0f;
const float bar_w = 205.0f;
const float bar_h = 44.0f;
// Background plate
draw_rect_bar(commands, bar_x, bar_y, bar_x + bar_w, bar_y + bar_h, 0x90101824);
draw_rect_lines(commands, bar_x, bar_y, bar_x + bar_w, bar_y + bar_h, 0x60405870);
// Avatar circle badge
const float av_cx = bar_x + 22.0f;
const float av_cy = bar_y + 22.0f;
const float av_r = 15.0f;
draw_filled_disc(commands, av_cx, av_cy, av_r, 0xB0203040);
draw_circle(commands, av_cx, av_cy, av_r, 0xE0FFD700, 16);
// "Lv" tick inside avatar
draw_line(commands, av_cx - 4.0f, av_cy - 4.0f, av_cx - 4.0f, av_cy + 3.0f, 0xFFFFFFFF);
draw_line(commands, av_cx - 4.0f, av_cy + 3.0f, av_cx - 1.0f, av_cy + 3.0f, 0xFFFFFFFF);
draw_line(commands, av_cx + 1.0f, av_cy - 4.0f, av_cx + 3.0f, av_cy + 3.0f, 0xFFFFFFFF);
draw_line(commands, av_cx + 5.0f, av_cy - 4.0f, av_cx + 3.0f, av_cy + 3.0f, 0xFFFFFFFF);
// Gauges
const float gx1 = bar_x + 44.0f;
const float gx2 = bar_x + bar_w - 10.0f;
const float gw = gx2 - gx1;
// HP Gauge
const float hp_y1 = bar_y + 10.0f;
const float hp_y2 = hp_y1 + 10.0f;
const float hp_ratio = std::clamp(float(status.hp) / float(status.max_hp), 0.0f, 1.0f);
draw_rect_bar(commands, gx1, hp_y1, gx2, hp_y2, 0x70301010);
draw_rect_bar(commands, gx1, hp_y1, gx1 + gw * hp_ratio, hp_y2, 0xE5D82424);
draw_line(commands, gx1, hp_y1, gx1 + gw * hp_ratio, hp_y1, 0x60FFFFFF); // Gloss line
draw_rect_lines(commands, gx1, hp_y1, gx2, hp_y2, 0x80502020);
// MP Gauge
const float mp_y1 = bar_y + 24.0f;
const float mp_y2 = mp_y1 + 8.0f;
const float mp_ratio = status.max_sp > 0 ? std::clamp(float(status.sp) / float(status.max_sp), 0.0f, 1.0f) : 0.0f;
draw_rect_bar(commands, gx1, mp_y1, gx2, mp_y2, 0x70102038);
draw_rect_bar(commands, gx1, mp_y1, gx1 + gw * mp_ratio, mp_y2, 0xE52068E0);
draw_line(commands, gx1, mp_y1, gx1 + gw * mp_ratio, mp_y1, 0x60FFFFFF); // Gloss line
draw_rect_lines(commands, gx1, mp_y1, gx2, mp_y2, 0x80204060);
// EXP bar (bottom trim line)
if (status.max_exp > 0) {
const float exp_ratio = std::clamp(float(status.exp) / float(status.max_exp), 0.0f, 1.0f);
draw_rect_bar(commands, bar_x, bar_y + bar_h - 2.0f, bar_x + bar_w * exp_ratio, bar_y + bar_h, 0xD0E0B020);
}
}
#endif
// --- 2. Top-Right Drawer Menu Bar ---
if (drawer_open_) {
// Background capsule tray behind drawer buttons
draw_rect_bar(commands, W - 468.0f, 16.0f, W - 198.0f, 54.0f, 0x90101824);
draw_rect_lines(commands, W - 468.0f, 16.0f, W - 198.0f, 54.0f, 0x60506880);
}
// --- 3. Buttons Rendering (Combat wheel & Drawer items) ---
// Right-side attack and quick-slot buttons.
for (const auto& btn : buttons_) {
if (btn.id >= 10 && btn.id <= 15 && !drawer_open_)
continue;
const uint32_t col = btn.pressed ? btn.color_pressed : btn.color_idle;
draw_filled_disc(commands, btn.x, btn.y, btn.radius, col);
draw_circle(commands, btn.x, btn.y, btn.radius * 0.85f, btn.pressed ? 0xFFFFFFFF : 0x70FFFFFF, 16);
@@ -346,6 +289,24 @@ public:
const uint32_t icon_col = btn.pressed ? 0xFFFFFFFF : 0xDDFFFFFF;
const float r = btn.radius;
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
if (btn.id >= 2) {
const std::string skill_icon = PythonBoot::LocalQuickSlotSkillIcon(btn.id - 2);
if (!skill_icon.empty()) {
UIRenderCommand icon{};
icon.kind = UIRenderCommand::Image;
icon.x1 = btn.x - r * 0.68f;
icon.y1 = btn.y - r * 0.68f;
icon.x2 = btn.x + r * 0.68f;
icon.y2 = btn.y + r * 0.68f;
icon.argb = icon_col;
icon.text = skill_icon;
commands.push_back(std::move(icon));
continue;
}
}
#endif
switch (btn.id) {
case 1: { // ATK: crossed swords
const float s = r * 0.35f;
@@ -376,69 +337,30 @@ public:
draw_line(commands, btn.x + 6.0f, btn.y - h, btn.x + 6.0f, btn.y + h, icon_col);
break;
}
case 5: { // POT: +
const float p = r * 0.4f;
draw_line(commands, btn.x - p, btn.y, btn.x + p, btn.y, icon_col);
draw_line(commands, btn.x, btn.y - p, btn.x, btn.y + p, icon_col);
case 5: { // S4: IV
const float h = r * 0.35f;
draw_line(commands, btn.x - 7.0f, btn.y - h, btn.x - 7.0f, btn.y + h, icon_col);
draw_line(commands, btn.x, btn.y - h, btn.x + 7.0f, btn.y + h, icon_col);
draw_line(commands, btn.x + 14.0f, btn.y - h, btn.x + 7.0f, btn.y + h, icon_col);
break;
}
case 6: { // PICK: Downward arrow
const float a = r * 0.35f;
draw_line(commands, btn.x - a, btn.y - a * 0.3f, btn.x, btn.y + a * 0.6f, icon_col);
draw_line(commands, btn.x + a, btn.y - a * 0.3f, btn.x, btn.y + a * 0.6f, icon_col);
draw_line(commands, btn.x, btn.y - a * 0.7f, btn.x, btn.y + a * 0.6f, icon_col);
case 6: { // S5: V
const float h = r * 0.35f;
draw_line(commands, btn.x - 8.0f, btn.y - h, btn.x, btn.y + h, icon_col);
draw_line(commands, btn.x + 8.0f, btn.y - h, btn.x, btn.y + h, icon_col);
break;
}
case 99: { // MENU: ☰ hamburger icon
draw_line(commands, btn.x - 7.0f, btn.y - 5.0f, btn.x + 7.0f, btn.y - 5.0f, icon_col);
draw_line(commands, btn.x - 7.0f, btn.y, btn.x + 7.0f, btn.y, icon_col);
draw_line(commands, btn.x - 7.0f, btn.y + 5.0f, btn.x + 7.0f, btn.y + 5.0f, icon_col);
break;
}
case 10: { // BAG: Backpack
draw_rect_lines(commands, btn.x - 6.0f, btn.y - 4.0f, btn.x + 6.0f, btn.y + 6.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y - 4.0f, btn.x, btn.y - 7.0f, icon_col);
draw_line(commands, btn.x, btn.y - 7.0f, btn.x + 3.0f, btn.y - 4.0f, icon_col);
draw_line(commands, btn.x - 6.0f, btn.y, btn.x + 6.0f, btn.y, icon_col);
break;
}
case 11: { // CHAR: Head + Shoulders
draw_circle(commands, btn.x, btn.y - 3.0f, 4.0f, icon_col, 12);
draw_line(commands, btn.x - 6.0f, btn.y + 6.0f, btn.x - 3.0f, btn.y + 2.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y + 2.0f, btn.x + 3.0f, btn.y + 2.0f, icon_col);
draw_line(commands, btn.x + 3.0f, btn.y + 2.0f, btn.x + 6.0f, btn.y + 6.0f, icon_col);
break;
}
case 12: { // SKILL: Lightning
draw_line(commands, btn.x + 2.0f, btn.y - 7.0f, btn.x - 3.0f, btn.y - 1.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y - 1.0f, btn.x + 1.0f, btn.y - 1.0f, icon_col);
draw_line(commands, btn.x + 1.0f, btn.y - 1.0f, btn.x - 2.0f, btn.y + 7.0f, icon_col);
break;
}
case 13: { // QUEST: Scroll
draw_rect_lines(commands, btn.x - 5.0f, btn.y - 6.0f, btn.x + 5.0f, btn.y + 6.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y - 2.0f, btn.x + 3.0f, btn.y - 2.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y + 2.0f, btn.x + 1.0f, btn.y + 2.0f, icon_col);
break;
}
case 14: { // COMM: Chat bubble
draw_rect_lines(commands, btn.x - 6.0f, btn.y - 5.0f, btn.x + 6.0f, btn.y + 3.0f, icon_col);
draw_line(commands, btn.x - 3.0f, btn.y + 3.0f, btn.x - 5.0f, btn.y + 6.0f, icon_col);
draw_line(commands, btn.x - 5.0f, btn.y + 6.0f, btn.x, btn.y + 3.0f, icon_col);
break;
}
case 15: { // SET: Gear / Close
draw_circle(commands, btn.x, btn.y, 4.0f, icon_col, 10);
draw_line(commands, btn.x - 7.0f, btn.y, btn.x + 7.0f, btn.y, icon_col);
draw_line(commands, btn.x, btn.y - 7.0f, btn.x, btn.y + 7.0f, icon_col);
draw_line(commands, btn.x - 5.0f, btn.y - 5.0f, btn.x + 5.0f, btn.y + 5.0f, icon_col);
draw_line(commands, btn.x - 5.0f, btn.y + 5.0f, btn.x + 5.0f, btn.y - 5.0f, icon_col);
case 7: { // S6: VI
const float h = r * 0.35f;
draw_line(commands, btn.x - 11.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col);
draw_line(commands, btn.x + 5.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col);
draw_line(commands, btn.x + 11.0f, btn.y - h, btn.x + 11.0f, btn.y + h, icon_col);
break;
}
}
}
// --- 4. Virtual Joystick ---
// Left-side movement joystick.
draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_, 0x8080C0FF, 24);
draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_ * 0.45f, 0x4080C0FF, 16);
draw_line(commands, joystick_base_x_ - joystick_radius_, joystick_base_y_,
@@ -494,26 +416,52 @@ public:
}
private:
bool gameplay_controls_visible() const {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
return !PythonBoot::CurrentMapName().empty();
#else
return false;
#endif
}
void release_gameplay_controls() {
if (joystick_active_) {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
PythonBoot::SetMoveDirection(0.0f, false);
#endif
}
joystick_active_ = false;
joystick_finger_id_ = -1;
joystick_knob_x_ = joystick_base_x_;
joystick_knob_y_ = joystick_base_y_;
for (auto& btn : buttons_) {
if (btn.pressed)
trigger_button(btn.dik, false);
btn.pressed = false;
btn.finger_id = -1;
}
if (camera_dragged_) {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
PythonBoot::CameraEndDrag();
#endif
}
camera_finger_id_ = -1;
camera_dragged_ = false;
pinch_finger1_ = -1;
pinch_finger2_ = -1;
pinch_last_dist_ = 0.0f;
}
void init_buttons() {
buttons_.clear();
// Combat Action Wheel (Lower-Right)
// Lower-right combat controls. All menus and status UI remain the original 40250 UI.
buttons_.push_back({1, 0x39, 0, 0, 42.0f, "ATK", 0x80D48820, 0xD0FFB040});
buttons_.push_back({2, 0x02, 0, 0, 26.0f, "S1", 0x803060C0, 0xD05080FF});
buttons_.push_back({3, 0x03, 0, 0, 26.0f, "S2", 0x80903090, 0xD0D050D0});
buttons_.push_back({4, 0x04, 0, 0, 26.0f, "S3", 0x80309060, 0xD050D080});
buttons_.push_back({5, 0x05, 0, 0, 22.0f, "POT", 0x90A03030, 0xD0FF5050});
buttons_.push_back({6, 0x2c, 0, 0, 22.0f, "PICK", 0x80208080, 0xD040B0B0});
// Top-Right Drawer Menu Toggle
buttons_.push_back({99, 0, 0, 0, 20.0f, "MENU", 0x90283848, 0xD0FFB040});
// Drawer Menu Buttons
buttons_.push_back({10, 0x17, 0, 0, 18.0f, "BAG", 0x90D09020, 0xD0FFB040}); // DIK_I
buttons_.push_back({11, 0x2e, 0, 0, 18.0f, "CHAR", 0x903060B0, 0xD05080FF}); // DIK_C
buttons_.push_back({12, 0x2f, 0, 0, 18.0f, "SKILL", 0x90803090, 0xD0D050D0}); // DIK_V
buttons_.push_back({13, 0x31, 0, 0, 18.0f, "QUEST", 0x90308050, 0xD050D070}); // DIK_N
buttons_.push_back({14, 0x32, 0, 0, 18.0f, "COMM", 0x90905020, 0xD0E07030}); // DIK_M
buttons_.push_back({15, 0x01, 0, 0, 18.0f, "SET", 0x90506070, 0xD08090A0}); // DIK_ESCAPE
buttons_.push_back({5, 0x05, 0, 0, 26.0f, "S4", 0x80906030, 0xD0D08050});
buttons_.push_back({6, 0x06, 0, 0, 26.0f, "S5", 0x80603090, 0xD08050D0});
buttons_.push_back({7, 0x07, 0, 0, 26.0f, "S6", 0x80308090, 0xD050C0D0});
}
void layout_buttons() {
@@ -522,70 +470,40 @@ private:
for (auto& btn : buttons_) {
switch (btn.id) {
case 1:
btn.x = W - 85.0f;
btn.x = W - 75.0f;
btn.y = H - 85.0f;
btn.radius = std::min(46.0f, H * 0.12f);
break;
case 2:
btn.x = W - 360.0f;
btn.y = H - 75.0f;
btn.radius = std::min(28.0f, H * 0.08f);
break;
case 3:
btn.x = W - 295.0f;
btn.y = H - 75.0f;
btn.radius = std::min(28.0f, H * 0.08f);
break;
case 4:
btn.x = W - 230.0f;
btn.y = H - 75.0f;
btn.radius = std::min(28.0f, H * 0.08f);
break;
case 5:
btn.x = W - 165.0f;
btn.y = H - 85.0f;
btn.radius = std::min(28.0f, H * 0.08f);
break;
case 3:
btn.x = W - 145.0f;
case 6:
btn.x = W - 125.0f;
btn.y = H - 155.0f;
btn.radius = std::min(28.0f, H * 0.08f);
break;
case 4:
btn.x = W - 85.0f;
btn.y = H - 175.0f;
case 7:
btn.x = W - 75.0f;
btn.y = H - 225.0f;
btn.radius = std::min(28.0f, H * 0.08f);
break;
case 5:
btn.x = W - 225.0f;
btn.y = H - 75.0f;
btn.radius = std::min(24.0f, H * 0.065f);
break;
case 6:
btn.x = W - 85.0f;
btn.y = H - 235.0f;
btn.radius = std::min(24.0f, H * 0.065f);
break;
case 99: // MENU toggle button (to the left of MiniMap)
btn.x = W - 170.0f;
btn.y = 35.0f;
btn.radius = 20.0f;
break;
case 10: // BAG
btn.x = W - 220.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
case 11: // CHAR
btn.x = W - 265.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
case 12: // SKILL
btn.x = W - 310.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
case 13: // QUEST
btn.x = W - 355.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
case 14: // COMM
btn.x = W - 400.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
case 15: // SET
btn.x = W - 445.0f;
btn.y = 35.0f;
btn.radius = 18.0f;
break;
}
}
}
@@ -593,8 +511,6 @@ private:
int find_button(float x, float y) {
for (size_t i = 0; i < buttons_.size(); ++i) {
const auto& btn = buttons_[i];
if (btn.id >= 10 && btn.id <= 15 && !drawer_open_)
continue;
const float dist = std::hypot(x - btn.x, y - btn.y);
if (dist <= btn.radius * 1.25f) {
return int(i);
@@ -608,6 +524,11 @@ private:
if (dik == 0x39) {
PythonBoot::SetAttackKey(pressed);
}
if (dik >= 0x02 && dik <= 0x07) {
if (pressed)
PythonBoot::UseLocalQuickSlot(dik - 0x02);
return;
}
if (dik != 0) {
PythonBoot::UIKey(dik, pressed);
}
@@ -703,7 +624,7 @@ private:
int screen_w_ = 1280;
int screen_h_ = 720;
bool drawer_open_ = false;
bool controls_were_visible_ = false;
int64_t ui_touch_finger_id_ = -1;
bool joystick_active_ = false;