From 3e3708ef06463ce4bf3efe1735efd9bfa628f5b2 Mon Sep 17 00:00:00 2001 From: shenlei Date: Tue, 29 Sep 2026 16:15:04 +0900 Subject: [PATCH] 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 --- .../org/metin2port/client/MainActivity.java | 45 + .../src/platform/EterLib/RecordingDevice.cpp | 1 + .../src/platform/EterLib/RenderCommands3D.h | 3 + .../src/platform/EterLib/UIRenderCommands.cpp | 14 +- .../src/platform/EterLib/UIRenderCommands.h | 5 + .../src/platform/ScriptLib/AutoHuntScript.inc | 385 ++++++++ .../src/platform/ScriptLib/PythonBoot.cpp | 225 ++++- extension/src/platform/ScriptLib/PythonBoot.h | 33 +- .../src/platform/ScriptLib/RegisterScript.inc | 328 +++++++ .../UserInterface/PythonApplication.cpp | 7 +- extension/src/port/CMakeLists.txt | 4 + .../src/port/EterPythonLib/PythonWindow.cpp | 9 + .../src/port/EterPythonLib/PythonWindow.h | 4 + .../EterPythonLib/PythonWindowManager.cpp | 33 +- .../port/EterPythonLib/PythonWindowManager.h | 7 + .../UserInterface/PythonAccountRegister.cpp | 198 ++++ .../src/port/UserInterface/PythonItem.cpp | 7 + extension/src/port/UserInterface/PythonItem.h | 2 + .../PythonNetworkStreamModule.cpp | 6 + .../src/port/UserInterface/PythonPlayer.cpp | 38 + .../src/port/UserInterface/PythonPlayer.h | 103 ++- .../UserInterface/PythonPlayerAutoHunt.cpp | 875 ++++++++++++++++++ .../port/UserInterface/PythonPlayerInput.cpp | 7 +- .../PythonPlayerInputKeyboard.cpp | 1 + .../UserInterface/PythonPlayerInputMouse.cpp | 4 + .../port/UserInterface/PythonPlayerModule.cpp | 82 ++ .../UserInterface/PythonTextTailModule.cpp | 3 + native_render/main.cpp | 125 ++- native_render/touch_controller.h | 407 ++++++-- tools/40250/mt_register.rc | 24 + tools/40250/register_server.py | 130 +++ 31 files changed, 2975 insertions(+), 140 deletions(-) create mode 100644 extension/src/platform/ScriptLib/AutoHuntScript.inc create mode 100644 extension/src/platform/ScriptLib/RegisterScript.inc create mode 100644 extension/src/port/UserInterface/PythonAccountRegister.cpp create mode 100644 extension/src/port/UserInterface/PythonPlayerAutoHunt.cpp create mode 100644 tools/40250/mt_register.rc create mode 100644 tools/40250/register_server.py diff --git a/android-native/app/src/main/java/org/metin2port/client/MainActivity.java b/android-native/app/src/main/java/org/metin2port/client/MainActivity.java index 4b7a6f8f..42860f8a 100644 --- a/android-native/app/src/main/java/org/metin2port/client/MainActivity.java +++ b/android-native/app/src/main/java/org/metin2port/client/MainActivity.java @@ -5,6 +5,10 @@ import android.content.pm.ActivityInfo; import android.os.Build; import android.os.Bundle; import android.util.Log; +import android.view.Display; +import android.view.DisplayCutout; +import android.view.HapticFeedbackConstants; +import android.view.RoundedCorner; import android.view.View; import android.view.WindowInsets; import android.view.WindowInsetsController; @@ -35,6 +39,11 @@ public class MainActivity extends SDLActivity { applyImmersiveMode(); } + // Called from native code (touch_controller long press). + public void performHaptic() { + runOnUiThread(() -> getWindow().getDecorView().performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)); + } + @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); @@ -75,9 +84,45 @@ public class MainActivity extends SDLActivity { Log.i("Metin2Args", "native args: " + args); if (args == null || args.trim().isEmpty()) args = DEFAULT_ARGS; + if (!args.contains("--safe-inset-px")) + args = args.trim() + " --safe-inset-px " + safeInsetPx(); + // Test phase: FPS readout in the top-left corner. Drop this line for release builds. + if (!args.contains("--show-fps")) + args = args.trim() + " --show-fps"; return args.trim().split("\\s+"); } + /** + * Pixels the game UI keeps clear at the left and right edges in landscape: the largest rounded + * screen corner (a window hugging a corner is clipped until it is a full radius in) and any + * display cutout. The 3D view still fills the screen. + */ + private int safeInsetPx() { + int inset = 0; + final Display display = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? getDisplay() + : getWindowManager().getDefaultDisplay(); + if (display == null) + return 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + final int[] positions = { RoundedCorner.POSITION_TOP_LEFT, RoundedCorner.POSITION_TOP_RIGHT, + RoundedCorner.POSITION_BOTTOM_LEFT, RoundedCorner.POSITION_BOTTOM_RIGHT }; + for (int position : positions) { + final RoundedCorner corner = display.getRoundedCorner(position); + if (corner != null) + inset = Math.max(inset, corner.getRadius()); + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // The activity may not be landscape yet: any side's cutout can end up left or right. + final DisplayCutout cutout = display.getCutout(); + if (cutout != null) + inset = Math.max(inset, Math.max(Math.max(cutout.getSafeInsetLeft(), cutout.getSafeInsetRight()), + Math.max(cutout.getSafeInsetTop(), cutout.getSafeInsetBottom()))); + } + Log.i("Metin2Args", "safe inset px: " + inset); + return inset; + } + @Override public void setOrientationBis(int w, int h, boolean resizable, String hint) { // Enforce landscape-only on mobile devices (sensorLandscape allows flipping 180 deg but never portrait). diff --git a/extension/src/platform/EterLib/RecordingDevice.cpp b/extension/src/platform/EterLib/RecordingDevice.cpp index 97a30b64..32f752e8 100644 --- a/extension/src/platform/EterLib/RecordingDevice.cpp +++ b/extension/src/platform/EterLib/RecordingDevice.cpp @@ -816,6 +816,7 @@ private: draw.viewport[3] = float(m_viewport.Height); draw.viewport_z[0] = m_viewport.MinZ; draw.viewport_z[1] = m_viewport.MaxZ; + draw.ui_offset_x = UIRenderOffsetX(); draw.pretransformed = layout.rhw; draw.lines = type == D3DPT_LINELIST || type == D3DPT_LINESTRIP; diff --git a/extension/src/platform/EterLib/RenderCommands3D.h b/extension/src/platform/EterLib/RenderCommands3D.h index ab3ddf96..5d809af2 100644 --- a/extension/src/platform/EterLib/RenderCommands3D.h +++ b/extension/src/platform/EterLib/RenderCommands3D.h @@ -19,6 +19,9 @@ struct Render3DDraw { float proj[16]; float viewport[4] = {}; // x, y, width, height float viewport_z[2] = {0, 1}; // MinZ, MaxZ + // PORT: UIRenderOffsetX() when recorded (a draw from a shifted window layer); the host moves the + // viewport (and a pretransformed draw's screen positions) right by it. + float ui_offset_x = 0; // IDirect3DDevice8::Clear on the back buffer, kept in draw order. A clear entry has no geometry; // clear_flags holds D3DCLEAR_TARGET / D3DCLEAR_ZBUFFER / D3DCLEAR_STENCIL. diff --git a/extension/src/platform/EterLib/UIRenderCommands.cpp b/extension/src/platform/EterLib/UIRenderCommands.cpp index 2bfa0318..2859b67b 100644 --- a/extension/src/platform/EterLib/UIRenderCommands.cpp +++ b/extension/src/platform/EterLib/UIRenderCommands.cpp @@ -34,8 +34,12 @@ int canvas_width = 0; int canvas_height = 0; float clip_x1 = 0, clip_y1 = 0, clip_x2 = 0, clip_y2 = 0; float saved_x1 = 0, saved_y1 = 0, saved_x2 = 0, saved_y2 = 0; +float offset_x = 0; } +void UIRenderSetOffsetX(float x) { offset_x = x; } +float UIRenderOffsetX() { return offset_x; } + void UIRenderSetSize(int width, int height) { canvas_width = width; canvas_height = height; } void UIRenderGetSize(unsigned* width, unsigned* height) { if (width) *width = canvas_width; @@ -50,6 +54,12 @@ void UIRenderBeginFrame() { } std::uint64_t UIRenderFrameId() { return frame_id; } void UIRenderAdd(UIRenderCommand command) { + if (offset_x != 0) { + command.x1 += offset_x; + command.x2 += offset_x; + if (command.quad) + for (float& x : command.qx) x += offset_x; + } command.clip_x1 = clip_x1; command.clip_y1 = clip_y1; command.clip_x2 = clip_x2; command.clip_y2 = clip_y2; command.behind_3d = Render3DDraws().empty(); @@ -84,8 +94,8 @@ void UIRenderAdd(UIRenderCommand command) { const std::vector& UIRenderCommands() { return commands; } void UIRenderSetClip(float x, float y, float width, float height) { saved_x1 = clip_x1; saved_y1 = clip_y1; saved_x2 = clip_x2; saved_y2 = clip_y2; - clip_x1 = x; clip_y1 = y; - clip_x2 = x + width; clip_y2 = y + height; + clip_x1 = x + offset_x; clip_y1 = y; + clip_x2 = x + offset_x + width; clip_y2 = y + height; } void UIRenderRestoreClip() { clip_x1 = saved_x1; clip_y1 = saved_y1; diff --git a/extension/src/platform/EterLib/UIRenderCommands.h b/extension/src/platform/EterLib/UIRenderCommands.h index fa1d245c..976f9feb 100644 --- a/extension/src/platform/EterLib/UIRenderCommands.h +++ b/extension/src/platform/EterLib/UIRenderCommands.h @@ -71,6 +71,11 @@ std::uint64_t UIRenderFrameId(); void UIRenderAdd(UIRenderCommand command); const std::vector& UIRenderCommands(); void UIRenderSetClip(float x, float y, float width, float height); +// PORT (mobile safe area): shifts the commands (and clips) recorded after it right by x canvas +// pixels, so the 40250 window layers can sit inside the rounded screen corners while the GAME layer +// (3D world, text tails) keeps the full canvas. 0 is the verbatim 40250 path. +void UIRenderSetOffsetX(float x); +float UIRenderOffsetX(); void UIRenderRestoreClip(); // HiDPI text: CGraphicFontTexture rasterises its glyph pages at this multiple of the logical font diff --git a/extension/src/platform/ScriptLib/AutoHuntScript.inc b/extension/src/platform/ScriptLib/AutoHuntScript.inc new file mode 100644 index 00000000..c79f8e5a --- /dev/null +++ b/extension/src/platform/ScriptLib/AutoHuntScript.inc @@ -0,0 +1,385 @@ +// The auto hunt window (PORT; 40250 has no counterpart), run by PythonBoot::RunMainScript as module +// mt_autohunt. It hooks game.GameWindow.Open/Close so the window lives exactly as long as the game phase, +// whichever way the host reached it. The logic is CPythonPlayer's (UserInterface/PythonPlayerAutoHunt.cpp); +// this is only the player.AutoHunt* front end. Strings are UTF-8 here and go through +// player.AutoHuntLocalize into the locale's code page, as the packed locale strings are. +static const char c_szAutoHuntScript[] = R"PY(# -*- coding: utf-8 -*- +import app +import chat +import game +import player +import ui +import wndMgr + +L = player.AutoHuntLocalize + +STATE_OFF, STATE_SEARCH, STATE_ENGAGE, STATE_LOOT, STATE_RETURN, STATE_PAUSED = range(6) +STOP_NONE, STOP_USER, STOP_DEAD, STOP_MAP, STOP_INVENTORY_FULL = range(5) +SKILL_SLOT_NUM = 8 + +STATE_TEXT = { + STATE_SEARCH: "寻找目标", + STATE_ENGAGE: "战斗中", + STATE_LOOT: "拾取中", + STATE_RETURN: "返回挂机点", + STATE_PAUSED: "手动操作,3秒后继续", +} +STOP_TEXT = { + STOP_USER: "自动狩猎已停止", + STOP_DEAD: "角色死亡,自动狩猎已停止", + STOP_MAP: "切换地图,自动狩猎已停止", + STOP_INVENTORY_FULL: "背包已满,自动狩猎已停止", +} + +RANGE_VALUES = (1000, 2000, 3500) +RANGE_TEXTS = ("近", "中", "远") +HP_VALUES = (0, 30, 50, 70) +SP_VALUES = (0, 20, 40, 60) +PICKUP_TEXTS = ("不拾取", "全部", "金币装备") +GATHER_VALUES = (0, 3, 5, 8) +GATHER_TEXTS = ("关", "3只", "5只", "8只") + +SMALL_BUTTON = "d:/ymir work/ui/public/small_button_%02d.sub" +MIDDLE_BUTTON = "d:/ymir work/ui/public/middle_button_%02d.sub" +XSMALL_BUTTON = "d:/ymir work/ui/public/xsmall_button_%02d.sub" + +COLOR_ON = 0xff7fff7f +COLOR_OFF = 0xffffcf7f +COLOR_SELECTED = 0xffffd24a +COLOR_UNSELECTED = 0xffc8c8c8 + + +def _MarkButton(button, isSelected): + "Down and gold text while selected: the down visual alone is hard to tell apart on a phone." + if isSelected: + button.Down() + else: + button.SetUp() + if button.ButtonText: + button.ButtonText.SetPackedFontColor(isSelected and COLOR_SELECTED or COLOR_UNSELECTED) + + +def _Nearest(values, value): + best = 0 + for i in xrange(len(values)): + if abs(values[i] - value) < abs(values[best] - value): + best = i + return best + + +class ChoiceRow: + "One setting: a label and a row of buttons of which exactly one stays down." + + def __init__(self, parent, y, label, texts, image, step, event): + self.label = ui.TextLine() + self.label.SetParent(parent) + self.label.SetPosition(18, y + 4) + self.label.SetText(L(label)) + self.label.Show() + + self.buttons = [] + self.event = event + self.index = -1 + x = 70 + for i in xrange(len(texts)): + button = ui.RadioButton() + button.SetParent(parent) + button.SetUpVisual(image % 1) + button.SetOverVisual(image % 2) + button.SetDownVisual(image % 3) + button.SetPosition(x, y) + button.SetText(L(texts[i])) + button.SetEvent(lambda i=i: self.Select(i, True)) + button.Show() + self.buttons.append(button) + x += step + + def Select(self, index, notify): + self.index = index + for i in xrange(len(self.buttons)): + _MarkButton(self.buttons[i], i == index) + if notify and self.event: + self.event() + + def Destroy(self): + self.event = None + self.buttons = [] + self.label = None + + +class FlagButton(ui.ToggleButton): + "A button that stays down while its flag is on." + + def __init__(self, parent, x, y, image, text, event): + ui.ToggleButton.__init__(self) + self.SetParent(parent) + self.SetUpVisual(image % 1) + self.SetOverVisual(image % 2) + self.SetDownVisual(image % 3) + self.SetPosition(x, y) + self.SetText(L(text)) + self.flag = False + self.flagEvent = event + self.SetToggleDownEvent(lambda: self.__OnToggle(True)) + self.SetToggleUpEvent(lambda: self.__OnToggle(False)) + self.Show() + + def __OnToggle(self, flag): + self.SetFlag(flag) + if self.flagEvent: + self.flagEvent() + + def SetFlag(self, flag): + self.flag = flag + _MarkButton(self, flag) + + def Destroy(self): + self.flagEvent = None + self.eventUp = None + self.eventDown = None + + +class SettingDialog(ui.BoardWithTitleBar): + WIDTH = 330 + HEIGHT = 300 + + def __init__(self): + ui.BoardWithTitleBar.__init__(self) + self.AddFlag("movable") + self.AddFlag("float") + self.SetSize(self.WIDTH, self.HEIGHT) + self.SetTitleName(L("自动狩猎")) + self.SetCloseEvent(self.Close) + + y = 36 + self.rangeRow = ChoiceRow(self, y, "范围", RANGE_TEXTS, SMALL_BUTTON, 48, self.__Save) + y += 28 + self.hpRow = ChoiceRow(self, y, "红药", ["关"] + ["%d%%" % v for v in HP_VALUES[1:]], SMALL_BUTTON, 48, self.__Save) + y += 28 + self.spRow = ChoiceRow(self, y, "蓝药", ["关"] + ["%d%%" % v for v in SP_VALUES[1:]], SMALL_BUTTON, 48, self.__Save) + y += 28 + self.pickupRow = ChoiceRow(self, y, "拾取", PICKUP_TEXTS, MIDDLE_BUTTON, 66, self.__Save) + y += 28 + self.gatherRow = ChoiceRow(self, y, "聚怪", GATHER_TEXTS, SMALL_BUTTON, 48, self.__Save) + y += 28 + + self.skillLabel = ui.TextLine() + self.skillLabel.SetParent(self) + self.skillLabel.SetPosition(18, y + 4) + self.skillLabel.SetText(L("技能栏")) + self.skillLabel.Show() + self.skillButtons = [] + for i in xrange(SKILL_SLOT_NUM): + self.skillButtons.append(FlagButton(self, 70 + i * 31, y, XSMALL_BUTTON, str(i + 1), self.__Save)) + y += 28 + + self.targetLabel = ui.TextLine() + self.targetLabel.SetParent(self) + self.targetLabel.SetPosition(18, y + 4) + self.targetLabel.SetText(L("攻击")) + self.targetLabel.Show() + self.stoneButton = FlagButton(self, 70, y, MIDDLE_BUTTON, "石头", self.__Save) + self.bossButton = FlagButton(self, 136, y, MIDDLE_BUTTON, "首领", self.__Save) + y += 30 + + self.hintLine = ui.TextLine() + self.hintLine.SetParent(self) + self.hintLine.SetPosition(0, y + 2) + self.hintLine.SetWindowHorizontalAlignCenter() + self.hintLine.SetHorizontalAlignCenter() + self.hintLine.SetPackedFontColor(0xffa0a0a0) + self.hintLine.SetText(L("手动操作时暂停,3秒后继续")) + self.hintLine.Show() + y += 26 + + self.startButton = ui.Button() + self.startButton.SetParent(self) + self.startButton.SetUpVisual("d:/ymir work/ui/public/large_button_01.sub") + self.startButton.SetOverVisual("d:/ymir work/ui/public/large_button_02.sub") + self.startButton.SetDownVisual("d:/ymir work/ui/public/large_button_03.sub") + self.startButton.SetPosition(0, y) + self.startButton.SetWindowHorizontalAlignCenter() + self.startButton.SetEvent(self.__OnClickStart) + self.startButton.Show() + + self.SetSize(self.WIDTH, y + 36) + self.SetCenterPosition() + self.isStarted = None + + def Destroy(self): + for row in (self.rangeRow, self.hpRow, self.spRow, self.pickupRow, self.gatherRow): + row.Destroy() + for button in self.skillButtons + [self.stoneButton, self.bossButton]: + button.Destroy() + self.skillButtons = [] + self.startButton = None + self.Hide() + + def Open(self): + (range_, hp, sp, pickup, stone, boss, mask, gather) = player.AutoHuntGetConfig() + self.rangeRow.Select(_Nearest(RANGE_VALUES, range_), False) + self.hpRow.Select(_Nearest(HP_VALUES, hp), False) + self.spRow.Select(_Nearest(SP_VALUES, sp), False) + self.pickupRow.Select(pickup, False) + self.gatherRow.Select(_Nearest(GATHER_VALUES, gather), False) + for i in xrange(SKILL_SLOT_NUM): + self.skillButtons[i].SetFlag(bool(mask & (1 << i))) + self.stoneButton.SetFlag(bool(stone)) + self.bossButton.SetFlag(bool(boss)) + self.isStarted = None + self.Show() + self.SetTop() + + def Close(self): + self.Hide() + + def OnPressEscapeKey(self): + self.Close() + return True + + def OnUpdate(self): + isStarted = bool(player.AutoHuntIsEnabled()) + if isStarted != self.isStarted: + self.isStarted = isStarted + self.startButton.SetText(L(isStarted and "停止狩猎" or "开始狩猎")) + + def __Save(self): + mask = 0 + for i in xrange(SKILL_SLOT_NUM): + if self.skillButtons[i].flag: + mask |= 1 << i + player.AutoHuntSetConfig(RANGE_VALUES[self.rangeRow.index], HP_VALUES[self.hpRow.index], + SP_VALUES[self.spRow.index], self.pickupRow.index, int(self.stoneButton.flag), int(self.bossButton.flag), mask, + GATHER_VALUES[self.gatherRow.index]) + + def __OnClickStart(self): + Toggle() + if player.AutoHuntIsEnabled(): + self.Close() + + +class StatusBoard(ui.ThinBoard): + def __init__(self): + ui.ThinBoard.__init__(self) + self.AddFlag("not_pick") + self.SetSize(260, 28) + self.SetWindowHorizontalAlignCenter() + self.SetPosition(0, 64) + self.textLine = ui.TextLine() + self.textLine.SetParent(self) + self.textLine.SetPosition(0, 7) + self.textLine.SetWindowHorizontalAlignCenter() + self.textLine.SetHorizontalAlignCenter() + self.textLine.Show() + + def SetStatus(self, text, color): + self.textLine.SetText(text) + self.textLine.SetPackedFontColor(color) + self.Show() + + +class Ticker(ui.Window): + "Always shown and empty: polls the hunt state for the status board, which is hidden while off." + + def __init__(self, status): + ui.Window.__init__(self) + self.AddFlag("not_pick") + self.status = status + self.last = None + self.stopShowTime = 0.0 + + def OnUpdate(self): + state = player.AutoHuntGetState() + if state != self.last: + (stateIndex, reason, noHP, noSP, gathering, gathered) = state + wasOn = self.last and self.last[0] != STATE_OFF + self.last = state + if stateIndex != STATE_OFF: + text = L("自动狩猎 · ") + L(STATE_TEXT.get(stateIndex, "")) + if gathering: + text = L("自动狩猎 · 聚怪中 %d/%d" % (gathered, player.AutoHuntGetConfig()[7])) + if noHP: + text += L(" · 无红药") + if noSP: + text += L(" · 无蓝药") + self.status.SetStatus(text, COLOR_ON) + elif wasOn and reason in STOP_TEXT: + self.status.SetStatus(L(STOP_TEXT[reason]), COLOR_OFF) + self.stopShowTime = app.GetTime() + 5.0 + if reason != STOP_USER: + chat.AppendChat(chat.CHAT_TYPE_INFO, L(STOP_TEXT[reason])) + else: + self.status.Hide() + elif state[0] == STATE_OFF and self.stopShowTime and app.GetTime() > self.stopShowTime: + self.stopShowTime = 0.0 + self.status.Hide() + + +g_status = None +g_ticker = None +g_dialog = None + + +def Toggle(): + if player.AutoHuntIsEnabled(): + player.AutoHuntSetEnabled(0) + return + player.AutoHuntSetEnabled(1) + if player.AutoHuntIsEnabled(): + chat.AppendChat(chat.CHAT_TYPE_INFO, L("自动狩猎已开启(再次点击AUTO或F9停止)")) + + +def OpenSettings(): + if g_dialog: + if g_dialog.IsShow(): + g_dialog.Close() + else: + g_dialog.Open() + + +def IsSettingsOpen(): + return g_dialog and g_dialog.IsShow() + + +def Attach(gameWindow): + global g_status, g_ticker, g_dialog + Detach() + g_status = StatusBoard() + g_ticker = Ticker(g_status) + g_ticker.Show() + g_dialog = SettingDialog() + if gameWindow.onPressKeyDict is not None: + gameWindow.onPressKeyDict[app.DIK_F9] = Toggle + gameWindow.onPressKeyDict[app.DIK_F8] = OpenSettings + + +def Detach(): + global g_status, g_ticker, g_dialog + player.AutoHuntSetEnabled(0) + if g_dialog: + g_dialog.Destroy() + if g_ticker: + g_ticker.Hide() + g_ticker.status = None + if g_status: + g_status.Hide() + g_status = g_ticker = g_dialog = None + + +def __Install(): + openGame = game.GameWindow.Open + closeGame = game.GameWindow.Close + + def Open(self): + openGame(self) + Attach(self) + + def Close(self): + Detach() + closeGame(self) + + game.GameWindow.Open = Open + game.GameWindow.Close = Close + +__Install() +)PY"; diff --git a/extension/src/platform/ScriptLib/PythonBoot.cpp b/extension/src/platform/ScriptLib/PythonBoot.cpp index 3d7d99c1..8c4b83dd 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.cpp +++ b/extension/src/platform/ScriptLib/PythonBoot.cpp @@ -5,6 +5,7 @@ #include "ScriptLib/Resource.h" #include "UserInterface/StdAfx.h" // initpack and the rest of the module initializer list #include "EterPythonLib/StdAfx.h" +#include "EterPythonLib/PythonWindow.h" #include "EterBase/Timer.h" #include "EterLib/Util.h" #include "EterLib/GrpBase.h" @@ -151,6 +152,7 @@ struct GameSingletons }; std::unique_ptr g_game_singletons; bool g_host_hardware_cursor_enabled = false; +bool g_touch_input = false; bool fail(std::string* error, const std::string& text) { @@ -268,11 +270,20 @@ bool IsRunning() return g_launcher != nullptr; } +static int g_ui_safe_inset = 0; + +void SetUISafeInset(int inset) +{ + g_ui_safe_inset = std::max(0, inset); +} + void SetUISize(int width, int height) { if (!g_window_manager || width <= 0 || height <= 0) return; - g_window_manager->SetScreenSize(width, height); + const int inset = std::min(g_ui_safe_inset, width / 8); + g_window_manager->SetScreenSize(width - 2 * inset, height); g_window_manager->SetResolution(width, height); + g_window_manager->SetScreenOffsetX(inset); UIRenderSetSize(width, height); CGraphicBase::SetBackBufferSize(width, height); if (g_game_singletons) @@ -290,8 +301,10 @@ void update_3d_pick_ray() return; long lx = 0, ly = 0; g_window_manager->GetMousePosition(lx, ly); - const long sw = g_window_manager->GetScreenWidth(); - const long sh = g_window_manager->GetScreenHeight(); + // The pick ray goes through the full-screen 3D view: screen coordinates and resolution. + lx += g_window_manager->GetScreenOffsetX(); + long sw = 0, sh = 0; + g_window_manager->GetResolution(sw, sh); if (sw <= 0 || sh <= 0) return; CScreen s; @@ -330,6 +343,11 @@ void UIMouseButton(int button, bool pressed, int x, int y) CPythonApplication::Instance().OnMouseMove(x, y); else g_window_manager->RunMouseMove(x, y); + { + long lx = x, ly = y; + g_window_manager->GetMousePosition(lx, ly); + MtHostSetCursor(static_cast(lx), static_cast(ly)); + } update_3d_pick_ray(); switch (button) { @@ -398,7 +416,8 @@ void SetMoveDirection(float angleDeg, bool moving) { if (!g_game_singletons) return; if (moving) - CPythonPlayer::Instance().NEW_MoveToDirection(angleDeg); + // The touch stick only steers the player; the camera turns by the right-hand drag. + CPythonPlayer::Instance().NEW_MoveToDirection(angleDeg, false); else CPythonPlayer::Instance().NEW_Stop(); } @@ -415,6 +434,30 @@ void UseLocalQuickSlot(int localSlotIndex) CPythonPlayer::Instance().RequestUseLocalQuickSlot(static_cast(localSlotIndex)); } +void AutoHuntToggle() +{ + std::string error; + if (g_game_singletons) + RunLine("__import__('mt_autohunt').Toggle()", &error); +} + +void AutoHuntOpenSettings() +{ + std::string error; + if (g_game_singletons) + RunLine("__import__('mt_autohunt').OpenSettings()", &error); +} + +bool AutoHuntIsEnabled() +{ + return g_game_singletons && CPythonPlayer::Instance().AutoHunt_IsEnabled(); +} + +bool AutoHuntAvailable() +{ + return g_game_singletons && CPythonPlayer::Instance().NEW_GetMainActorPtr() != nullptr; +} + bool TryAssignAttachedObjectToLocalQuickSlot(int localSlotIndex) { if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return false; @@ -429,55 +472,130 @@ bool TryAssignAttachedObjectToLocalQuickSlot(int localSlotIndex) return RunLine(command.c_str(), &error); } -std::string LocalQuickSlotSkillIcon(int localSlotIndex) +namespace { - if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return {}; +// The icons are .sub images (a rect of a shared texture). A resource only loads while referenced, and +// on touch hosts the taskbar slots that would reference it are hidden, so the overlay holds one. +bool quick_slot_image_uv(CGraphicImage* image, std::string* texture, float uv[4]) +{ + if (!image) return false; + static std::vector s_heldIcons; + if (std::none_of(s_heldIcons.begin(), s_heldIcons.end(), + [image](const CGraphicImage::TRef& ref) { return ref.GetPointer() == image; })) + s_heldIcons.emplace_back(image); + + CGraphicTexture* tex = image->GetTexturePointer(); + if (image->IsEmpty() || !tex || tex->GetWidth() <= 0 || tex->GetHeight() <= 0) return false; + *texture = UIRenderTextureName(tex); + if (texture->empty()) return false; + // CGraphicImageInstance::OnRender's texture coordinates. + const RECT& rc = image->GetRectReference(); + uv[0] = float(rc.left) / float(tex->GetWidth()); + uv[1] = float(rc.top) / float(tex->GetHeight()); + uv[2] = float(rc.right) / float(tex->GetWidth()); + uv[3] = float(rc.bottom) / float(tex->GetHeight()); + return true; +} +} + +bool LocalQuickSlotIcon(int localSlotIndex, std::string* texture, float uv[4], int* count) +{ + if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return false; DWORD type = SLOT_TYPE_NONE; DWORD position = 0; CPythonPlayer& player = CPythonPlayer::Instance(); player.GetLocalQuickSlotData(static_cast(localSlotIndex), &type, &position); - if (type != SLOT_TYPE_SKILL) return {}; + *count = 0; - CPythonSkill::TSkillData* skillData = nullptr; - if (!CPythonSkill::Instance().GetSkillData(player.GetSkillIndex(position), &skillData) || !skillData) - return {}; - const int grade = std::clamp(player.GetSkillGrade(position), 0, CPythonSkill::SKILL_GRADE_COUNT - 1); - CGraphicImage* image = skillData->GradeData[grade].pImage; - if (!image) image = skillData->pImage; - return image ? image->GetFileName() : std::string{}; -} - -void CameraBeginDrag(int x, int y) -{ - CCameraManager& rkCmrMgr = CCameraManager::Instance(); - CCamera* pkCmrCur = rkCmrMgr.GetCurrentCamera(); - if (pkCmrCur) - pkCmrCur->BeginDrag(x, y); -} - -void CameraDrag(int x, int y) -{ - CCameraManager& rkCmrMgr = CCameraManager::Instance(); - CCamera* pkCmrCur = rkCmrMgr.GetCurrentCamera(); - if (pkCmrCur) - { - POINT pt; - pkCmrCur->Drag(x, y, &pt); + if (type == SLOT_TYPE_SKILL) { + CPythonSkill::TSkillData* skillData = nullptr; + if (!CPythonSkill::Instance().GetSkillData(player.GetSkillIndex(position), &skillData) || !skillData) + return false; + const int grade = std::clamp(player.GetSkillGrade(position), 0, CPythonSkill::SKILL_GRADE_COUNT - 1); + CGraphicImage* image = skillData->GradeData[grade].pImage; + if (!image) image = skillData->pImage; + return quick_slot_image_uv(image, texture, uv); } + if (type == SLOT_TYPE_INVENTORY) { + // uitaskbar.RefreshQuickSlot: the item's icon and, above one, its count. + const TItemPos cell(INVENTORY, static_cast(position)); + CItemData* itemData = nullptr; + if (!CItemManager::Instance().GetItemDataPointer(player.GetItemIndex(cell), &itemData) || !itemData) + return false; + const DWORD itemCount = player.GetItemCount(cell); + *count = itemCount > 1 ? static_cast(itemCount) : 0; + return quick_slot_image_uv(itemData->GetIconImage(), texture, uv); + } + return false; } -void CameraEndDrag() +void UIMouseDoubleClick(int x, int y) { - CCameraManager& rkCmrMgr = CCameraManager::Instance(); - CCamera* pkCmrCur = rkCmrMgr.GetCurrentCamera(); - if (pkCmrCur) - pkCmrCur->EndDrag(); + MtHostSetCursor(x, y); + if (!g_window_manager) return; + if (g_game_singletons) + CPythonApplication::Instance().OnMouseMove(x, y); + else + g_window_manager->RunMouseMove(x, y); + update_3d_pick_ray(); + g_window_manager->RunMouseLeftButtonDoubleClick(x, y); +} + +bool UIIsAttaching() +{ + return g_window_manager && g_window_manager->IsAttaching(); +} + +void UIDeattachObject() +{ + std::string error; + RunLine("__import__('mouseModule').mouseController.DeattachObject()", &error); +} + +void CameraRotateBy(float rollDeg, float pitchDeg) +{ + CCamera* pkCmrCur = CCameraManager::Instance().GetCurrentCamera(); + if (!pkCmrCur || pkCmrCur->IsLock()) + return; + + // Same collision limits CCamera::Drag applies to its velocities. + switch (pkCmrCur->GetCameraState()) + { + case CAMERA_STATE_CANTGOLEFT: rollDeg = std::max(0.0f, rollDeg); break; + case CAMERA_STATE_CANTGORIGHT: rollDeg = std::min(0.0f, rollDeg); break; + case CAMERA_STATE_CANTGODOWN: pitchDeg = std::max(0.0f, pitchDeg); break; + default: break; + } + pkCmrCur->RotateEyeAroundTarget(pitchDeg, rollDeg); +} + +void SetTouchInput(bool touch) +{ + g_touch_input = touch; + UI::CWindow::SetHideVirtualKeyboard(touch); +} + +bool TouchInput() +{ + return g_touch_input; +} + +bool TextInputFocused() +{ + return g_window_manager && CIME::ms_bCaptureInput; +} + +bool IsPointInsideFocusedWindow(int x, int y) +{ + if (!g_window_manager) return false; + UI::CWindow* pWin = g_window_manager->GetActivateWindow(); + return pWin && pWin->IsShow() && pWin->IsIn(x - g_window_manager->GetScreenOffsetX(), y); } bool IsPointInsideActiveUI(int x, int y) { if (!g_window_manager) return false; - return g_window_manager->IsPointInsideActiveUI(x, y); + return g_window_manager->IsPointInsideActiveUI(x - g_window_manager->GetScreenOffsetX(), y); } PlayerStatusInfo GetPlayerStatusInfo() @@ -745,6 +863,32 @@ bool run_main_script_body(const char* lpCmdLine, std::string* error) namespace PythonBoot { +// PORT: the auto hunt window (AutoHuntScript.inc) as module mt_autohunt and the account registration +// window (RegisterScript.inc) as mt_register. They hook game.GameWindow / introLogin.LoginWindow, so they +// have to run once the root scripts are importable; a failure leaves the game without them, not broken. +#include "AutoHuntScript.inc" +#include "RegisterScript.inc" + +static void install_script_module(const char* c_szModule, const char* c_szScript) +{ + PyObject* module = PyImport_AddModule(c_szModule); + PyObject* dict = module ? PyModule_GetDict(module) : nullptr; + if (!dict) + { + PyErr_Clear(); + return; + } + if (!PyDict_GetItemString(dict, "__builtins__")) + PyDict_SetItemString(dict, "__builtins__", PyEval_GetBuiltins()); + PyObject* result = PyRun_String(c_szScript, Py_file_input, dict, dict); + if (!result) + { + TraceError("%s: install failed", c_szModule); + PyErr_Print(); + } + Py_XDECREF(result); +} + bool RunMainScript(const char* lpCmdLine, std::string* error) { if (!g_launcher) @@ -775,6 +919,11 @@ bool RunMainScript(const char* lpCmdLine, std::string* error) if (!g_fiber.ok) return fail(error, g_fiber.error); } + if (IsAppLooping()) + { + install_script_module("mt_autohunt", c_szAutoHuntScript); + install_script_module("mt_register", c_szRegisterScript); + } return true; } diff --git a/extension/src/platform/ScriptLib/PythonBoot.h b/extension/src/platform/ScriptLib/PythonBoot.h index 008f295c..c9b5273d 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.h +++ b/extension/src/platform/ScriptLib/PythonBoot.h @@ -59,6 +59,10 @@ bool Evaluate(const char* expression, std::string* result, std::string* error); // Forward physical Godot input to the original 40250 window manager. Coordinates // are viewport pixels; SetUISize also establishes its logical resolution. void SetUISize(int width, int height); +// Mobile safe area: the 40250 windows are laid out width - 2 * inset wide and drawn inset pixels +// right of the screen edge (clear of rounded corners and cutouts) while the 3D world keeps the whole +// screen. Input coordinates stay screen pixels. Takes effect at the next SetUISize; 0 = off. +void SetUISafeInset(int inset); void UIMouseMove(int x, int y); void UIMouseButton(int button, bool pressed, int x, int y); void UIMouseWheel(int nLen); @@ -68,11 +72,32 @@ void SetMoveDirection(float angleDeg, bool moving); void SetAttackKey(bool pressed); void UseLocalQuickSlot(int localSlotIndex); bool TryAssignAttachedObjectToLocalQuickSlot(int localSlotIndex); -std::string LocalQuickSlotSkillIcon(int localSlotIndex); -void CameraBeginDrag(int x, int y); -void CameraDrag(int x, int y); -void CameraEndDrag(); +// PORT: auto hunt (CPythonPlayer::AutoHunt_*, window in AutoHuntScript.inc): start/stop, the settings +// window, and whether it runs / a character is in the world to run it. +void AutoHuntToggle(); +void AutoHuntOpenSettings(); +bool AutoHuntIsEnabled(); +bool AutoHuntAvailable(); +// The skill or inventory item in the local quick slot: its UI texture name, the icon's (su, sv, eu, ev) +// inside it and the item count (0 for a skill). +bool LocalQuickSlotIcon(int localSlotIndex, std::string* texture, float uv[4], int* count); +// Touch gestures: a Win32 WM_LBUTTONDBLCLK (40250 CPythonApplication::OnMouseLeftButtonDoubleClick), +// whether an icon hangs on the cursor (CWindowManager::IsAttaching) and mouseController.DeattachObject(). +void UIMouseDoubleClick(int x, int y); +bool UIIsAttaching(); +void UIDeattachObject(); +// Turns the camera around the player immediately, without CCamera::Drag's decaying velocity. +void CameraRotateBy(float rollDeg, float pitchDeg); bool IsPointInsideActiveUI(int x, int y); +// Touch hosts: hide 40250's on-screen login keypad (the system keyboard types instead). +// Also drops the mouse arrow: touch hosts run 40250's hardware-cursor mode with no OS cursor, so +// mouseModule draws only the attached icon. +void SetTouchInput(bool touch); +bool TouchInput(); +// An EditLine has focus (40250 ui.EditLine.OnSetFocus -> ime.EnableCaptureInput). +bool TextInputFocused(); +// (x, y) hits the focused window, i.e. the tap landed on the focused EditLine. +bool IsPointInsideFocusedWindow(int x, int y); struct PlayerStatusInfo { int level = 1; diff --git a/extension/src/platform/ScriptLib/RegisterScript.inc b/extension/src/platform/ScriptLib/RegisterScript.inc new file mode 100644 index 00000000..6c9468be --- /dev/null +++ b/extension/src/platform/ScriptLib/RegisterScript.inc @@ -0,0 +1,328 @@ +// The account registration window (PORT; 40250 has no counterpart, its accounts were made on the web), +// run by PythonBoot::RunMainScript as module mt_register. It hooks introLogin.LoginWindow.Open/Close and +// puts a register button under the login board; the dialog posts to the server's registration service +// (tools/40250/register_server.py) through net.RegisterAccount. Strings are UTF-8 here and go through +// player.AutoHuntLocalize into the locale's code page. +static const char c_szRegisterScript[] = R"PY(# -*- coding: utf-8 -*- +import introLogin +import net +import player +import ui +import wndMgr + +L = player.AutoHuntLocalize + +REGISTER_PORT = 11080 +LOGIN_MIN, LOGIN_MAX = 4, 16 +PASSWORD_MIN, PASSWORD_MAX = 4, 16 +SOCIAL_ID_LEN = 7 + +LARGE_BUTTON = "d:/ymir work/ui/public/large_button_%02d.sub" +MIDDLE_BUTTON = "d:/ymir work/ui/public/middle_button_%02d.sub" + +COLOR_ERROR = 0xffff7f7f +COLOR_INFO = 0xffc8c8c8 + +ERROR_TEXT = { + "BAD_LOGIN": "账号需为4-16位英文字母或数字", + "BAD_PASSWORD": "密码需为4-16位字母、数字或符号", + "BAD_SOCIAL_ID": "删除码需为7位数字", + "EXISTS": "该账号已存在", + "RATE_LIMIT": "注册过于频繁,请稍后再试", + "CONNECT": "无法连接注册服务器", + "TIMEOUT": "注册服务器无响应", + "SERVER": "注册服务器出错,请稍后再试", +} + + +def _IsAlnum(text): + for c in text: + if not (("a" <= c <= "z") or ("A" <= c <= "Z") or ("0" <= c <= "9")): + return False + return True + + +def _IsPrintable(text): + for c in text: + if not ("!" <= c <= "~"): + return False + return True + + +def _Validate(login, password, confirm, socialID): + if not (LOGIN_MIN <= len(login) <= LOGIN_MAX) or not _IsAlnum(login): + return ERROR_TEXT["BAD_LOGIN"] + if not (PASSWORD_MIN <= len(password) <= PASSWORD_MAX) or not _IsPrintable(password): + return ERROR_TEXT["BAD_PASSWORD"] + if password != confirm: + return "两次输入的密码不一致" + if len(socialID) != SOCIAL_ID_LEN or not socialID.isdigit(): + return ERROR_TEXT["BAD_SOCIAL_ID"] + return None + + +def _MakeButton(parent, x, y, image, text, event): + button = ui.Button() + button.SetParent(parent) + button.SetPosition(x, y) + button.SetUpVisual(image % 1) + button.SetOverVisual(image % 2) + button.SetDownVisual(image % 3) + button.SetText(L(text)) + button.SetEvent(event) + button.Show() + return button + + +class InputRow: + LABEL_X = 18 + SLOT_X = 96 + SLOT_WIDTH = 160 + + def __init__(self, parent, y, label, maxLen): + self.label = ui.TextLine() + self.label.SetParent(parent) + self.label.SetPosition(self.LABEL_X, y + 2) + self.label.SetText(L(label)) + self.label.Show() + + self.slot = ui.SlotBar() + self.slot.SetParent(parent) + self.slot.SetPosition(self.SLOT_X, y) + self.slot.SetSize(self.SLOT_WIDTH, 18) + self.slot.Show() + + self.edit = ui.EditLine() + self.edit.SetParent(self.slot) + self.edit.SetPosition(4, 2) + self.edit.SetSize(self.SLOT_WIDTH - 8, 16) + self.edit.SetMax(maxLen) + self.edit.SetIMEFlag(0) + self.edit.Show() + + def GetText(self): + return self.edit.GetText() + + def Destroy(self): + self.edit.SetReturnEvent(0) + self.edit.SetTabEvent(0) + self.edit = None + self.slot = None + self.label = None + + +class RegisterDialog(ui.BoardWithTitleBar): + WIDTH = 280 + HEIGHT = 222 + + def __init__(self, loginWindow): + ui.BoardWithTitleBar.__init__(self) + self.loginWindow = loginWindow + self.pending = False + self.pendingLogin = "" + self.pendingPassword = "" + + self.AddFlag("movable") + self.AddFlag("float") + self.SetSize(self.WIDTH, self.HEIGHT) + self.SetTitleName(L("注册账号")) + self.SetCloseEvent(ui.__mem_func__(self.Close)) + + self.loginRow = InputRow(self, 38, "账号", LOGIN_MAX) + self.passwordRow = InputRow(self, 66, "密码", PASSWORD_MAX) + self.passwordRow.edit.SetSecret(True) + self.confirmRow = InputRow(self, 94, "确认密码", PASSWORD_MAX) + self.confirmRow.edit.SetSecret(True) + self.socialRow = InputRow(self, 122, "删除码", SOCIAL_ID_LEN) + self.socialRow.edit.SetNumberMode() + + rows = (self.loginRow, self.passwordRow, self.confirmRow, self.socialRow) + for i in xrange(len(rows)): + nextEdit = rows[(i + 1) % len(rows)].edit + rows[i].edit.SetTabEvent(ui.__mem_func__(nextEdit.SetFocus)) + if i + 1 < len(rows): + rows[i].edit.SetReturnEvent(ui.__mem_func__(nextEdit.SetFocus)) + self.socialRow.edit.SetReturnEvent(ui.__mem_func__(self.__OnClickRegister)) + for row in rows: + row.edit.SetEscapeEvent(ui.__mem_func__(self.Close)) + + self.hintLine = ui.TextLine() + self.hintLine.SetParent(self) + self.hintLine.SetPosition(0, 150) + self.hintLine.SetWindowHorizontalAlignCenter() + self.hintLine.SetHorizontalAlignCenter() + self.hintLine.Show() + + self.registerButton = _MakeButton(self, self.WIDTH / 2 - 66, 180, MIDDLE_BUTTON, "注册", + ui.__mem_func__(self.__OnClickRegister)) + self.cancelButton = _MakeButton(self, self.WIDTH / 2 + 5, 180, MIDDLE_BUTTON, "取消", + ui.__mem_func__(self.Close)) + + def Destroy(self): + self.Hide() + for row in (self.loginRow, self.passwordRow, self.confirmRow, self.socialRow): + row.Destroy() + self.loginRow = self.passwordRow = self.confirmRow = self.socialRow = None + self.registerButton = None + self.cancelButton = None + self.hintLine = None + self.loginWindow = None + + def Open(self): + for row in (self.loginRow, self.passwordRow, self.confirmRow, self.socialRow): + row.edit.SetText("") + self.__SetHint("账号仅限英文字母和数字,删除码用于删除角色", COLOR_INFO) + self.SetPosition((wndMgr.GetScreenWidth() - self.WIDTH) / 2, (wndMgr.GetScreenHeight() - self.HEIGHT) / 2) + self.Show() + self.SetTop() + self.loginRow.edit.SetFocus() + + def Close(self): + for row in (self.loginRow, self.passwordRow, self.confirmRow, self.socialRow): + row.edit.KillFocus() + self.Hide() + if self.loginWindow and self.loginWindow.idEditLine: + self.loginWindow.idEditLine.SetFocus() + return True + + def OnPressEscapeKey(self): + return self.Close() + + def __SetHint(self, text, color): + self.hintLine.SetText(L(text)) + self.hintLine.SetPackedFontColor(color) + + def __GetHost(self): + stream = self.loginWindow.stream + if stream.account_addr: + return stream.account_addr + return stream.addr + + def __OnClickRegister(self): + if self.pending: + return + login = self.loginRow.GetText() + password = self.passwordRow.GetText() + error = _Validate(login, password, self.confirmRow.GetText(), self.socialRow.GetText()) + if error: + self.__SetHint(error, COLOR_ERROR) + return + host = self.__GetHost() + if not host: + self.__SetHint("请先选择服务器", COLOR_ERROR) + return + if not net.RegisterAccount(host, REGISTER_PORT, login, password, self.socialRow.GetText()): + return + self.pending = True + self.pendingLogin = login.lower() + self.pendingPassword = password + self.registerButton.Disable() + self.__SetHint("正在注册...", COLOR_INFO) + + def OnUpdate(self): + if not self.pending: + return + result = net.GetRegisterAccountResult() + if not result: + return + self.pending = False + self.registerButton.Enable() + if result == "OK": + self.__OnRegistered() + return + code = result[4:] if result.startswith("ERR ") else "SERVER" + self.__SetHint(ERROR_TEXT.get(code, ERROR_TEXT["SERVER"]), COLOR_ERROR) + + def __OnRegistered(self): + loginWindow = self.loginWindow + login, password = self.pendingLogin, self.pendingPassword + self.pendingPassword = "" + self.Close() + if loginWindow.idEditLine: + loginWindow.idEditLine.SetText(login) + if loginWindow.pwdEditLine: + loginWindow.pwdEditLine.SetText(password) + loginWindow.PopupNotifyMessage(L("账号 %s 注册成功,请点击登录") % login) + + +class RegisterEntry(ui.Window): + "The button under the login board; it follows the board's position and visibility every frame." + + def __init__(self, loginWindow): + ui.Window.__init__(self) + self.loginWindow = loginWindow + self.dialog = RegisterDialog(loginWindow) + self.button = ui.Button() + self.button.SetUpVisual(LARGE_BUTTON % 1) + self.button.SetOverVisual(LARGE_BUTTON % 2) + self.button.SetDownVisual(LARGE_BUTTON % 3) + self.button.SetText(L("注册账号")) + self.button.SetEvent(ui.__mem_func__(self.dialog.Open)) + self.SetSize(0, 0) + self.Show() + + def Destroy(self): + self.Hide() + self.button.Hide() + self.button = None + self.dialog.Destroy() + self.dialog = None + self.loginWindow = None + + def OnUpdate(self): + board = self.loginWindow.loginBoard if self.loginWindow else None + if not board or not self.loginWindow.IsShow() or not board.IsShow() or self.dialog.IsShow(): + if self.button.IsShow(): + self.button.Hide() + return + x, y = board.GetGlobalPosition() + self.button.SetPosition(x + (board.GetWidth() - self.button.GetWidth()) / 2, y + board.GetHeight() + 6) + if not self.button.IsShow(): + self.button.Show() + self.button.SetTop() + + +_entries = {} + + +def Attach(loginWindow): + key = id(loginWindow) + if key not in _entries: + _entries[key] = RegisterEntry(loginWindow) + + +def Detach(loginWindow): + entry = _entries.pop(id(loginWindow), None) + if entry: + entry.Destroy() + + +def __Install(): + openLogin = introLogin.LoginWindow.Open + closeLogin = introLogin.LoginWindow.Close + + def Open(self): + result = openLogin(self) + try: + Attach(self) + except: + import dbg + dbg.TraceError("mt_register: attach failed") + return result + + def Close(self): + Detach(self) + return closeLogin(self) + + introLogin.LoginWindow.Open = Open + introLogin.LoginWindow.Close = Close + + # system.py reaches app.Loop() before this runs, so a login window may already be open. + import gc + for obj in gc.get_objects(): + if isinstance(obj, introLogin.LoginWindow) and getattr(obj, "loginBoard", None): + Attach(obj) + + +__Install() +)PY"; diff --git a/extension/src/platform/UserInterface/PythonApplication.cpp b/extension/src/platform/UserInterface/PythonApplication.cpp index b38cc335..561c8699 100644 --- a/extension/src/platform/UserInterface/PythonApplication.cpp +++ b/extension/src/platform/UserInterface/PythonApplication.cpp @@ -176,7 +176,7 @@ bool CPythonApplication::Create(PyObject*, const char*, int, int, int) if (IsNativeTerrainRenderEnabled()) { - const bool use_hardware_cursor = PythonBoot::HostHardwareCursorEnabled(); + const bool use_hardware_cursor = PythonBoot::HostHardwareCursorEnabled() || PythonBoot::TouchInput(); if (CPythonSystem::InstancePtr() && CPythonSystem::Instance().GetConfig()) CPythonSystem::Instance().GetConfig()->is_software_cursor = !use_hardware_cursor; SetCursorMode(use_hardware_cursor ? CURSOR_MODE_HARDWARE : CURSOR_MODE_SOFTWARE); @@ -214,6 +214,8 @@ void CPythonApplication::RenderGame() { long lx, ly; UI::CWindowManager::Instance().GetMousePosition(lx, ly); + // PORT: window coordinates -> the screen coordinates of the full-width 3D view. + lx += UI::CWindowManager::Instance().GetScreenOffsetX(); CPythonGraphic::Instance().SetCursorPosition(lx, ly); } @@ -453,7 +455,8 @@ int s_iCursorMode = CPythonApplication::CURSOR_MODE_HARDWARE; // m_iCurs void CPythonApplication::SetCursorVisible(BOOL bFlag, bool bLiarCursorOn) { s_bCursorVisible = bFlag; - s_bLiarCursorOn = bLiarCursorOn; + // PORT: a touch screen has no cursor, not even the stand-in 40250 draws while the real one is hidden. + s_bLiarCursorOn = bLiarCursorOn && !PythonBoot::TouchInput(); // PORT: 40250 also counts ShowCursor up or down in hardware mode; the host has no OS cursor to hide. } diff --git a/extension/src/port/CMakeLists.txt b/extension/src/port/CMakeLists.txt index 2c8005bf..89ba7367 100644 --- a/extension/src/port/CMakeLists.txt +++ b/extension/src/port/CMakeLists.txt @@ -92,6 +92,10 @@ target_include_directories(port_logic PRIVATE ${MT_PORT_GENERATED}) if(WIN32) target_link_libraries(port_logic PUBLIC winmm) endif() +if(ANDROID) + # PythonPlayerAutoHunt.cpp traces to logcat. + target_link_libraries(port_logic PUBLIC log) +endif() # 40250 Distribute|Win32 preprocessor definitions per library (vs_files//.vcxproj), # minus WIN32/_WINDOWS/_LIB/_CRT_SECURE_NO_WARNINGS: Windows-only branches go through platform/, diff --git a/extension/src/port/EterPythonLib/PythonWindow.cpp b/extension/src/port/EterPythonLib/PythonWindow.cpp index 49dfcde7..86f2ba68 100644 --- a/extension/src/port/EterPythonLib/PythonWindow.cpp +++ b/extension/src/port/EterPythonLib/PythonWindow.cpp @@ -85,8 +85,17 @@ namespace UI m_poHandler = NULL; } + static bool s_bHideVirtualKeyboard = false; + + void CWindow::SetHideVirtualKeyboard(bool bHide) + { + s_bHideVirtualKeyboard = bHide; + } + void CWindow::Show() { + if (s_bHideVirtualKeyboard && m_strName == "VirtualKeyboard") + return; m_bShow = true; } diff --git a/extension/src/port/EterPythonLib/PythonWindow.h b/extension/src/port/EterPythonLib/PythonWindow.h index a90b1f3b..4830b99e 100644 --- a/extension/src/port/EterPythonLib/PythonWindow.h +++ b/extension/src/port/EterPythonLib/PythonWindow.h @@ -12,6 +12,10 @@ namespace UI static DWORD Type(); BOOL IsType(DWORD dwType); + // PORT: touch hosts type with the system keyboard, so windows named "VirtualKeyboard" + // (intrologin.py's on-screen keypad) never show. + static void SetHideVirtualKeyboard(bool bHide); + enum EHorizontalAlign { HORIZONTAL_ALIGN_LEFT = 0, diff --git a/extension/src/port/EterPythonLib/PythonWindowManager.cpp b/extension/src/port/EterPythonLib/PythonWindowManager.cpp index 2d76203f..7863c17f 100644 --- a/extension/src/port/EterPythonLib/PythonWindowManager.cpp +++ b/extension/src/port/EterPythonLib/PythonWindowManager.cpp @@ -4,6 +4,9 @@ #include "PythonGridSlotWindow.h" #include "PythonWindowManager.h" +// PORT: platform EterLib/UIRenderCommands.cpp (the UI command recorder); see SetScreenOffsetX. +void UIRenderSetOffsetX(float x); + //#define __WINDOW_LEAK_CHECK__ BOOL g_bShowOverInWindowName = FALSE; @@ -700,7 +703,18 @@ namespace UI void CWindowManager::Render() { - m_pRootWindow->Render(); + if (!m_lScreenOffsetX) + { + m_pRootWindow->Render(); + return; + } + // PORT: the root has no content of its own; its children are the layers, in this order. + for (CWindow * pLayer : m_LayerWindowList) + { + UIRenderSetOffsetX(0 == strcmp(pLayer->GetName(), "GAME") ? 0.0f : float(m_lScreenOffsetX)); + pLayer->Render(); + } + UIRenderSetOffsetX(0.0f); } CWindow * CWindowManager::__PickWindow(long x, long y) @@ -728,6 +742,16 @@ namespace UI } } + // PORT: the GAME layer's window is laid out GetScreenWidth() wide but its world fills the + // screen; the strips beside the shifted layers still belong to it. + if (m_lScreenOffsetX && (x < 0 || x >= m_lWidth)) + { + CWindow * pGameLayer = m_LayerWindowMap["GAME"]; + CWindow * pPickedWindow = pGameLayer->PickWindow(x < 0 ? 0 : m_lWidth - 1, y); + if (pPickedWindow != pGameLayer) + return pPickedWindow; + } + return NULL; } @@ -757,7 +781,12 @@ namespace UI if (m_iVres==0) return; - m_lMouseX = m_lWidth * x / m_iHres; + // PORT: x is a screen position; the windows live m_lScreenOffsetX to the right of it. + const long lScreenWidth = m_iHres - 2 * m_lScreenOffsetX; + if (m_lScreenOffsetX && lScreenWidth > 0) + m_lMouseX = m_lWidth * (x - m_lScreenOffsetX) / lScreenWidth; + else + m_lMouseX = m_lWidth * x / m_iHres; m_lMouseY = m_lHeight * y / m_iVres; } diff --git a/extension/src/port/EterPythonLib/PythonWindowManager.h b/extension/src/port/EterPythonLib/PythonWindowManager.h index 7dad56a7..eedc6546 100644 --- a/extension/src/port/EterPythonLib/PythonWindowManager.h +++ b/extension/src/port/EterPythonLib/PythonWindowManager.h @@ -29,6 +29,12 @@ namespace UI void SetMouseHandler(PyObject * poMouseHandler); long GetScreenWidth() { return m_lWidth; } + // PORT (mobile safe area): the window layers other than GAME sit this many pixels right of + // the screen's left edge, inside a resolution m_iHres = m_lWidth + 2 * offset wide. The GAME + // layer (3D world, text tails) keeps screen coordinates; window/mouse coordinates are the + // shifted ones. 0 is the verbatim 40250 layout. + void SetScreenOffsetX(long lOffsetX) { m_lScreenOffsetX = lOffsetX; } + long GetScreenOffsetX() { return m_lScreenOffsetX; } long GetScreenHeight() { return m_lHeight; } void GetMousePosition(long & rx, long & ry); BOOL IsDragging(); @@ -151,6 +157,7 @@ namespace UI int m_iVres; int m_iHres; + long m_lScreenOffsetX = 0; long m_lMouseX, m_lMouseY; long m_lDragX, m_lDragY; diff --git a/extension/src/port/UserInterface/PythonAccountRegister.cpp b/extension/src/port/UserInterface/PythonAccountRegister.cpp new file mode 100644 index 00000000..93a604f8 --- /dev/null +++ b/extension/src/port/UserInterface/PythonAccountRegister.cpp @@ -0,0 +1,198 @@ +// PORT: in-client account registration. 40250 has no counterpart: its accounts were made on the web. +// +// net.RegisterAccount(host, port, login, password, socialId) starts one HTTP POST /register to the +// server's registration service (tools/40250/register_server.py) on a worker thread; +// net.GetRegisterAccountResult() polls it from the login window's OnUpdate. The reply is a single +// line, "OK" or "ERR ", returned to Python as-is. +#include "StdAfx.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +enum ERegisterState +{ + REGISTER_IDLE, + REGISTER_PENDING, + REGISTER_DONE, +}; + +const int c_iRegisterTimeoutMs = 8000; + +std::mutex gs_kRegisterMutex; +std::atomic gs_iRegisterState(REGISTER_IDLE); +std::string gs_strRegisterResult; + +std::string __UrlEncode(const std::string& c_rstText) +{ + static const char c_szHex[] = "0123456789ABCDEF"; + std::string stOut; + for (unsigned char c : c_rstText) + { + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') + { + stOut += char(c); + } + else + { + stOut += '%'; + stOut += c_szHex[c >> 4]; + stOut += c_szHex[c & 15]; + } + } + return stOut; +} + +bool __WaitFor(int iSocket, short sEvents) +{ + pollfd kPoll = { iSocket, sEvents, 0 }; + return poll(&kPoll, 1, c_iRegisterTimeoutMs) == 1 && (kPoll.revents & sEvents); +} + +std::string __Post(const std::string& c_rstHost, int iPort, const std::string& c_rstBody) +{ + addrinfo kHints; + memset(&kHints, 0, sizeof(kHints)); + kHints.ai_family = AF_INET; + kHints.ai_socktype = SOCK_STREAM; + addrinfo* pkAddr = NULL; + if (getaddrinfo(c_rstHost.c_str(), std::to_string(iPort).c_str(), &kHints, &pkAddr) != 0 || !pkAddr) + return "ERR CONNECT"; + + const int iSocket = socket(pkAddr->ai_family, pkAddr->ai_socktype, pkAddr->ai_protocol); + if (iSocket < 0) + { + freeaddrinfo(pkAddr); + return "ERR CONNECT"; + } +#ifdef SO_NOSIGPIPE + const int iOne = 1; + setsockopt(iSocket, SOL_SOCKET, SO_NOSIGPIPE, &iOne, sizeof(iOne)); +#endif + fcntl(iSocket, F_SETFL, fcntl(iSocket, F_GETFL, 0) | O_NONBLOCK); + + std::string stResult = "ERR CONNECT"; + int iError = 0; + socklen_t iErrorLen = sizeof(iError); + if (connect(iSocket, pkAddr->ai_addr, pkAddr->ai_addrlen) == 0 || + (errno == EINPROGRESS && __WaitFor(iSocket, POLLOUT) && + getsockopt(iSocket, SOL_SOCKET, SO_ERROR, &iError, &iErrorLen) == 0 && iError == 0)) + { + const std::string stRequest = + "POST /register HTTP/1.0\r\n" + "Host: " + c_rstHost + "\r\n" + "Content-Type: application/x-www-form-urlencoded\r\n" + "Content-Length: " + std::to_string(c_rstBody.size()) + "\r\n" + "Connection: close\r\n\r\n" + c_rstBody; + + size_t uSent = 0; + while (uSent < stRequest.size() && __WaitFor(iSocket, POLLOUT)) + { +#ifdef MSG_NOSIGNAL + const ssize_t n = send(iSocket, stRequest.data() + uSent, stRequest.size() - uSent, MSG_NOSIGNAL); +#else + const ssize_t n = send(iSocket, stRequest.data() + uSent, stRequest.size() - uSent, 0); +#endif + if (n <= 0) + break; + uSent += size_t(n); + } + + std::string stResponse; + if (uSent == stRequest.size()) + { + char acBuf[512]; + while (stResponse.size() < 4096 && __WaitFor(iSocket, POLLIN)) + { + const ssize_t n = recv(iSocket, acBuf, sizeof(acBuf), 0); + if (n <= 0) + break; + stResponse.append(acBuf, size_t(n)); + } + } + + const size_t uBody = stResponse.find("\r\n\r\n"); + if (uBody == std::string::npos) + { + stResult = uSent == stRequest.size() ? "ERR TIMEOUT" : "ERR CONNECT"; + } + else + { + stResult = stResponse.substr(uBody + 4); + const size_t uEnd = stResult.find_first_of("\r\n"); + if (uEnd != std::string::npos) + stResult.resize(uEnd); + if (stResult.empty()) + stResult = "ERR SERVER"; + } + } + + close(iSocket); + freeaddrinfo(pkAddr); + return stResult; +} +} + +PyObject* netRegisterAccount(PyObject* poSelf, PyObject* poArgs) +{ + char* szHost; + int iPort; + char* szLogin; + char* szPassword; + char* szSocialID; + if (!PyTuple_GetString(poArgs, 0, &szHost)) + return Py_BuildException(); + if (!PyTuple_GetInteger(poArgs, 1, &iPort)) + return Py_BuildException(); + if (!PyTuple_GetString(poArgs, 2, &szLogin)) + return Py_BuildException(); + if (!PyTuple_GetString(poArgs, 3, &szPassword)) + return Py_BuildException(); + if (!PyTuple_GetString(poArgs, 4, &szSocialID)) + return Py_BuildException(); + + int iExpected = REGISTER_IDLE; + if (!gs_iRegisterState.compare_exchange_strong(iExpected, REGISTER_PENDING)) + { + iExpected = REGISTER_DONE; + if (!gs_iRegisterState.compare_exchange_strong(iExpected, REGISTER_PENDING)) + return Py_BuildValue("i", 0); // one request at a time + } + + const std::string stHost = szHost; + const std::string stBody = "login=" + __UrlEncode(szLogin) + "&password=" + __UrlEncode(szPassword) + + "&social_id=" + __UrlEncode(szSocialID); + std::thread([stHost, iPort, stBody]() + { + const std::string stResult = __Post(stHost, iPort, stBody); + { + std::lock_guard kLock(gs_kRegisterMutex); + gs_strRegisterResult = stResult; + } + gs_iRegisterState = REGISTER_DONE; + }).detach(); + return Py_BuildValue("i", 1); +} + +// Returns "" while the request runs (or none was started), else its result once. +PyObject* netGetRegisterAccountResult(PyObject* poSelf, PyObject* poArgs) +{ + int iExpected = REGISTER_DONE; + if (!gs_iRegisterState.compare_exchange_strong(iExpected, REGISTER_IDLE)) + return Py_BuildValue("s", ""); + std::lock_guard kLock(gs_kRegisterMutex); + return Py_BuildValue("s", gs_strRegisterResult.c_str()); +} diff --git a/extension/src/port/UserInterface/PythonItem.cpp b/extension/src/port/UserInterface/PythonItem.cpp index 6b85b84e..6b08799a 100644 --- a/extension/src/port/UserInterface/PythonItem.cpp +++ b/extension/src/port/UserInterface/PythonItem.cpp @@ -672,6 +672,13 @@ BOOL CPythonItem::GetGroundItemPosition(DWORD dwVirtualID, TPixelPosition * pPos return TRUE; } +void CPythonItem::GetGroundItemVIDs(std::vector* pkVecVID) +{ + pkVecVID->clear(); + for (TGroundItemInstanceMap::iterator itor = m_GroundItemInstanceMap.begin(); itor != m_GroundItemInstanceMap.end(); ++itor) + pkVecVID->push_back(itor->first); +} + DWORD CPythonItem::__Pick(const POINT& c_rkPtMouse) { float fu, fv, ft; diff --git a/extension/src/port/UserInterface/PythonItem.h b/extension/src/port/UserInterface/PythonItem.h index 9824758d..fa5b8772 100644 --- a/extension/src/port/UserInterface/PythonItem.h +++ b/extension/src/port/UserInterface/PythonItem.h @@ -109,6 +109,8 @@ class CPythonItem : public CSingleton bool GetCloseMoney(const TPixelPosition & c_rPixelPosition, DWORD* dwItemID, DWORD dwDistance=300); DWORD GetVirtualNumberOfGroundItem(DWORD dwVID); + // PORT: auto hunt (CPythonPlayer::__AutoHunt_FindItem) walks the ground items. + void GetGroundItemVIDs(std::vector* pkVecVID); void BuildNoGradeNameData(int iType); DWORD GetNoGradeNameDataCount(); diff --git a/extension/src/port/UserInterface/PythonNetworkStreamModule.cpp b/extension/src/port/UserInterface/PythonNetworkStreamModule.cpp index e9b58d93..0becb158 100644 --- a/extension/src/port/UserInterface/PythonNetworkStreamModule.cpp +++ b/extension/src/port/UserInterface/PythonNetworkStreamModule.cpp @@ -11,6 +11,10 @@ static std::string gs_stServerInfo; extern BOOL gs_bEmpireLanuageEnable; std::list g_kList_strCommand; +// PORT: PythonAccountRegister.cpp +PyObject* netRegisterAccount(PyObject* poSelf, PyObject* poArgs); +PyObject* netGetRegisterAccountResult(PyObject* poSelf, PyObject* poArgs); + PyObject* netGetBettingGuildWarValue(PyObject* poSelf, PyObject* poArgs) { char* szName; @@ -1769,6 +1773,8 @@ void initnet() { "SendSelectCharacterPacket", netSendSelectCharacterPacket, METH_VARARGS }, { "SendChangeNamePacket", netSendChangeNamePacket, METH_VARARGS }, { "SendCreateCharacterPacket", netSendCreateCharacterPacket, METH_VARARGS }, + { "RegisterAccount", netRegisterAccount, METH_VARARGS }, + { "GetRegisterAccountResult", netGetRegisterAccountResult, METH_VARARGS }, { "SendDestroyCharacterPacket", netSendDestroyCharacterPacket, METH_VARARGS }, { "SendEnterGamePacket", netSendEnterGamePacket, METH_VARARGS }, diff --git a/extension/src/port/UserInterface/PythonPlayer.cpp b/extension/src/port/UserInterface/PythonPlayer.cpp index 6f900171..adbc22a4 100644 --- a/extension/src/port/UserInterface/PythonPlayer.cpp +++ b/extension/src/port/UserInterface/PythonPlayer.cpp @@ -176,6 +176,7 @@ void CPythonPlayer::Update() PyCallClassMemberFunc(m_ppyGameWindow, "RefreshStamina", Py_BuildValue("()")); } + __Update_AutoHunt(); // PORT: before __Update_AutoAttack, which acts on the target it sets __Update_AutoAttack(); __Update_NotifyGuildAreaEvent(); } @@ -525,6 +526,9 @@ void CPythonPlayer::NotifyCharacterUpdate(DWORD dwVID) void CPythonPlayer::NotifyDeadMainCharacter() { __ClearAutoAttackTargetActorID(); + // PORT: auto hunt stops with the character. + if (AutoHunt_IsEnabled()) + __AutoHunt_Stop(AUTO_HUNT_STOP_DEAD); } void CPythonPlayer::NotifyChangePKMode() @@ -1698,6 +1702,12 @@ void CPythonPlayer::Clear() m_bMobileFlag = FALSE; + // PORT: leaving the game (logout, character select) ends auto hunt without a stop notice. + m_eAutoHuntState = AUTO_HUNT_OFF; + m_eAutoHuntStopReason = AUTO_HUNT_STOP_NONE; + m_dwAutoHuntTargetVID = 0; + m_dwAutoHuntItemID = 0; + __ClearAutoAttackTargetActorID(); } @@ -1731,6 +1741,34 @@ CPythonPlayer::CPythonPlayer(void) m_sysIsLevelLimit = TRUE; m_dwPlayTime = 0; + // PORT: auto hunt. + m_isAutoHuntConfigLoaded = false; + m_eAutoHuntState = AUTO_HUNT_OFF; + m_eAutoHuntStopReason = AUTO_HUNT_STOP_NONE; + m_kAutoHuntConfig.iRange = 2000; + m_kAutoHuntConfig.iHPPercent = 40; + m_kAutoHuntConfig.iSPPercent = 20; + m_kAutoHuntConfig.iPickup = AUTO_HUNT_PICKUP_ALL; + m_kAutoHuntConfig.bAttackStone = false; + m_kAutoHuntConfig.bAttackBoss = false; + m_kAutoHuntConfig.dwSkillSlotMask = (1 << AUTO_HUNT_SKILL_SLOT_NUM) - 1; + m_kAutoHuntConfig.iGatherCount = 0; + m_isAutoHuntGathering = false; + m_fAutoHuntGatherStartTime = 0.0f; + m_fAutoHuntHitTime = 0.0f; + m_fAutoHuntLastUpdateTime = 0.0f; + m_fAutoHuntResumeTime = 0.0f; + m_dwAutoHuntTargetVID = 0; + m_fAutoHuntStateTime = 0.0f; + m_fAutoHuntProgressTime = 0.0f; + m_fAutoHuntBestDistance = 0.0f; + m_fAutoHuntNextActionTime = 0.0f; + m_fAutoHuntNextSkillTime = 0.0f; + m_fAutoHuntNextPotionTime = 0.0f; + m_dwAutoHuntItemID = 0; + m_isAutoHuntNoHPPotion = false; + m_isAutoHuntNoSPPotion = false; + m_aeMBFButton[MBT_LEFT]=CPythonPlayer::MBF_SMART; m_aeMBFButton[MBT_RIGHT]=CPythonPlayer::MBF_CAMERA; m_aeMBFButton[MBT_MIDDLE]=CPythonPlayer::MBF_CAMERA; diff --git a/extension/src/port/UserInterface/PythonPlayer.h b/extension/src/port/UserInterface/PythonPlayer.h index 51acda5b..8e4c91f3 100644 --- a/extension/src/port/UserInterface/PythonPlayer.h +++ b/extension/src/port/UserInterface/PythonPlayer.h @@ -222,7 +222,8 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer bool NEW_MoveToDestPixelPositionDirection(const TPixelPosition& c_rkPPosDst); bool NEW_MoveToMousePickedDirection(); bool NEW_MoveToMouseScreenDirection(); - bool NEW_MoveToDirection(float fDirRot); + // PORT: isAutoCameraRotation=false skips 40250's keyboard camera roll (mobile joystick). + bool NEW_MoveToDirection(float fDirRot, bool isAutoCameraRotation = true); void NEW_Stop(); @@ -674,6 +675,106 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer protected: float MOVABLE_GROUND_DISTANCE; + // PORT: auto hunt (PythonPlayerAutoHunt.cpp). 40250 has no counterpart; it only drives the existing + // player paths (auto attack, __UseSkill, item reserve), so the server sees ordinary play. + public: + enum EAutoHuntState + { + AUTO_HUNT_OFF, + AUTO_HUNT_SEARCH, + AUTO_HUNT_ENGAGE, + AUTO_HUNT_LOOT, + AUTO_HUNT_RETURN, + AUTO_HUNT_PAUSED, + }; + + enum EAutoHuntStopReason + { + AUTO_HUNT_STOP_NONE, + AUTO_HUNT_STOP_USER, + AUTO_HUNT_STOP_DEAD, + AUTO_HUNT_STOP_MAP, + AUTO_HUNT_STOP_INVENTORY_FULL, + }; + + enum EAutoHuntPickup + { + AUTO_HUNT_PICKUP_NONE, + AUTO_HUNT_PICKUP_ALL, + AUTO_HUNT_PICKUP_GOLD_EQUIP, + }; + + enum + { + AUTO_HUNT_SKILL_SLOT_NUM = 8, // the local quick slots on the taskbar's first page + }; + + struct SAutoHuntConfig + { + int iRange; // cm (100 = 1 m) around the anchor + int iHPPercent; // drink an HP potion below this, 0 = never + int iSPPercent; + int iPickup; // EAutoHuntPickup + bool bAttackStone; + bool bAttackBoss; + DWORD dwSkillSlotMask; // bit i = local quick slot i may be cast + int iGatherCount; // gather mode: hit this many mobs once each, then fight them together; 0 = off + }; + + void AutoHunt_SetEnabled(bool isEnable); + bool AutoHunt_IsEnabled(); + int AutoHunt_GetState(); + int AutoHunt_GetStopReason(); + // Gather mode: still pulling (vs. fighting the pack), and the pulled mobs still alive. + bool AutoHunt_IsGathering(); + int AutoHunt_GetGatheredCount(); + bool AutoHunt_HasNoPotion(bool isHP); + const SAutoHuntConfig& AutoHunt_GetConfig(); + void AutoHunt_SetConfig(const SAutoHuntConfig& c_rkConfig); + + protected: + void __AutoHunt_OnManualInput(); + void __AutoHunt_Stop(int eReason); + void __Update_AutoHunt(); + void __AutoHunt_UpdatePotion(CInstanceBase& rkInstMain, float fNow); + bool __AutoHunt_UpdateBuff(CInstanceBase& rkInstMain, float fNow); + void __AutoHunt_UpdateAttackSkill(CInstanceBase& rkInstMain, CInstanceBase& rkInstVictim, float fNow); + bool __AutoHunt_IsSkillReady(DWORD dwSkillSlot, CPythonSkill::TSkillData** ppSkillData); + DWORD __AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow, bool isExceptGathered = false); + DWORD __AutoHunt_FindGatheredTarget(CInstanceBase& rkInstMain); + void __AutoHunt_PurgeGathered(); + bool __AutoHunt_IsGatherDone(float fNow); + DWORD __AutoHunt_FindItem(CInstanceBase& rkInstMain, float fNow); + bool __AutoHunt_IsInventoryFull(); + void __AutoHunt_Engage(DWORD dwVID, float fNow); + void __AutoHunt_LoadConfig(); + void __AutoHunt_SaveConfig(); + + SAutoHuntConfig m_kAutoHuntConfig; + bool m_isAutoHuntConfigLoaded; + int m_eAutoHuntState; + int m_eAutoHuntStopReason; + TPixelPosition m_kPPosAutoHuntAnchor; + std::string m_stAutoHuntMapName; + float m_fAutoHuntResumeTime; + DWORD m_dwAutoHuntTargetVID; + float m_fAutoHuntStateTime; // when the current target / item / state began + float m_fAutoHuntProgressTime; // last time the target got closer or was hit + float m_fAutoHuntBestDistance; + float m_fAutoHuntNextActionTime; + float m_fAutoHuntNextSkillTime; + float m_fAutoHuntNextPotionTime; + DWORD m_dwAutoHuntItemID; + bool m_isAutoHuntNoHPPotion; + bool m_isAutoHuntNoSPPotion; + std::map m_kMap_dwAutoHuntSkipVID; // actor vid -> ignored until + std::map m_kMap_dwAutoHuntSkipItem; // ground item vid -> ignored until + bool m_isAutoHuntGathering; + float m_fAutoHuntGatherStartTime; + float m_fAutoHuntHitTime; // attacking the current target this long (gather tagging) + float m_fAutoHuntLastUpdateTime; + std::set m_kSet_dwAutoHuntGathered; // mobs hit while gathering, until they die + private: std::map m_kMap_dwAffectIndexToSkillIndex; }; diff --git a/extension/src/port/UserInterface/PythonPlayerAutoHunt.cpp b/extension/src/port/UserInterface/PythonPlayerAutoHunt.cpp new file mode 100644 index 00000000..5783df1e --- /dev/null +++ b/extension/src/port/UserInterface/PythonPlayerAutoHunt.cpp @@ -0,0 +1,875 @@ +// PORT: auto hunt. 40250 has no counterpart; see PythonPlayer.h. +// +// Everything here goes through the paths a player's own input takes: the auto-attack target +// (__SetAutoAttackTargetActorID -> __Update_AutoAttack -> MODE_CLICK_ACTOR walks up and attacks), +// __UseSkill for the quick-slot skills, the MODE_CLICK_ITEM reserve that walks to a ground item and +// sends the pick-up, and CG_ITEM_USE for potions. No packet is sent that manual play would not send. +#include "StdAfx.h" +#include "PythonPlayer.h" +#include "PythonItem.h" +#include "PythonNonPlayer.h" +#include "PythonBackground.h" +#include "PythonCharacterManager.h" +#include "PythonNetworkStream.h" +#include "InstanceBase.h" +#include "../GameLib/ItemManager.h" +#include "../EterBase/Timer.h" + +#include +#include +#include +#include +#include + +#ifdef __ANDROID__ +#include +#endif + +namespace +{ +const char c_szAutoHuntConfigFileName[] = "autohunt.cfg"; + +const float c_fAutoHuntResumeDelay = 3.0f; // after the player's own input +const float c_fAutoHuntNoProgressTime = 4.0f; // neither closer to the target nor hitting it +const float c_fAutoHuntTargetTimeout = 60.0f; +const float c_fAutoHuntSkipTime = 30.0f; // an unreachable target is left alone this long +const float c_fAutoHuntLootDelay = 0.6f; // drops land a moment after the kill +const float c_fAutoHuntLootTimeout = 15.0f; +const float c_fAutoHuntItemTimeout = 5.0f; // one ground item (someone else's, blocked path) +const float c_fAutoHuntItemSkipTime = 60.0f; +const float c_fAutoHuntItemRange = 1500.0f; +const float c_fAutoHuntReturnDistance = 300.0f; +const float c_fAutoHuntReturnTimeout = 20.0f; +const float c_fAutoHuntSkillDistance = 400.0f; // melee skills are cast once this close +const BYTE c_byMobRankBoss = 4; // PAWN, S_PAWN, KNIGHT, S_KNIGHT, BOSS, KING +const DWORD c_dwGoldVnum = 1; +const float c_fAutoHuntTagHitTime = 0.35f; // gather: attacking a mob this long has pulled it +const float c_fAutoHuntGatherTimeout = 25.0f; +const float c_fAutoHuntGatherRange = 1500.0f; // gather: the next mob this close to the player +const int c_iAutoHuntGatherHPPercent = 50; // gather: stop pulling below this + +// Trace: stderr with MT_AUTOHUNT_TRACE=1, logcat (tag mt_autohunt) on Android. +void AutoHuntTrace(const char* c_szFormat, ...) +{ + static const bool s_isTrace = getenv("MT_AUTOHUNT_TRACE") != NULL; + va_list args; +#ifdef __ANDROID__ + va_start(args, c_szFormat); + __android_log_vprint(ANDROID_LOG_INFO, "mt_autohunt", c_szFormat, args); + va_end(args); +#endif + if (!s_isTrace) + return; + va_start(args, c_szFormat); + fprintf(stderr, "autohunt: "); + vfprintf(stderr, c_szFormat, args); + fprintf(stderr, "\n"); + va_end(args); +} + +float PixelDistance(const TPixelPosition& a, const TPixelPosition& b) +{ + const float dx = a.x - b.x; + const float dy = a.y - b.y; + return sqrtf(dx * dx + dy * dy); +} +} + +void CPythonPlayer::AutoHunt_SetEnabled(bool isEnable) +{ + if (!isEnable) + { + if (AutoHunt_IsEnabled()) + __AutoHunt_Stop(AUTO_HUNT_STOP_USER); + return; + } + + CInstanceBase* pkInstMain = NEW_GetMainActorPtr(); + if (!pkInstMain || pkInstMain->IsDead()) + return; + + __AutoHunt_LoadConfig(); + pkInstMain->NEW_GetPixelPosition(&m_kPPosAutoHuntAnchor); + m_stAutoHuntMapName = CPythonBackground::Instance().GetWarpMapName(); + m_eAutoHuntState = AUTO_HUNT_SEARCH; + m_eAutoHuntStopReason = AUTO_HUNT_STOP_NONE; + m_dwAutoHuntTargetVID = 0; + m_dwAutoHuntItemID = 0; + m_fAutoHuntStateTime = CTimer::Instance().GetCurrentSecond(); + m_fAutoHuntNextActionTime = 0.0f; + m_fAutoHuntNextSkillTime = 0.0f; + m_fAutoHuntNextPotionTime = 0.0f; + m_isAutoHuntNoHPPotion = false; + m_isAutoHuntNoSPPotion = false; + m_kMap_dwAutoHuntSkipVID.clear(); + m_kMap_dwAutoHuntSkipItem.clear(); + m_kSet_dwAutoHuntGathered.clear(); + m_isAutoHuntGathering = false; + m_fAutoHuntHitTime = 0.0f; + m_fAutoHuntLastUpdateTime = m_fAutoHuntStateTime; +} + +bool CPythonPlayer::AutoHunt_IsEnabled() +{ + return AUTO_HUNT_OFF != m_eAutoHuntState; +} + +int CPythonPlayer::AutoHunt_GetState() +{ + return m_eAutoHuntState; +} + +int CPythonPlayer::AutoHunt_GetStopReason() +{ + return m_eAutoHuntStopReason; +} + +bool CPythonPlayer::AutoHunt_IsGathering() +{ + return AutoHunt_IsEnabled() && m_isAutoHuntGathering; +} + +int CPythonPlayer::AutoHunt_GetGatheredCount() +{ + int iCount = 0; + for (DWORD dwVID : m_kSet_dwAutoHuntGathered) + { + CInstanceBase* pkInst = NEW_FindActorPtr(dwVID); + if (pkInst && !pkInst->IsDead()) + ++iCount; + } + return iCount; +} + +bool CPythonPlayer::AutoHunt_HasNoPotion(bool isHP) +{ + return isHP ? m_isAutoHuntNoHPPotion : m_isAutoHuntNoSPPotion; +} + +const CPythonPlayer::SAutoHuntConfig& CPythonPlayer::AutoHunt_GetConfig() +{ + __AutoHunt_LoadConfig(); + return m_kAutoHuntConfig; +} + +void CPythonPlayer::AutoHunt_SetConfig(const SAutoHuntConfig& c_rkConfig) +{ + m_isAutoHuntConfigLoaded = true; + m_kAutoHuntConfig = c_rkConfig; + m_kAutoHuntConfig.iRange = MINMAX(500, m_kAutoHuntConfig.iRange, 5000); + m_kAutoHuntConfig.iHPPercent = MINMAX(0, m_kAutoHuntConfig.iHPPercent, 95); + m_kAutoHuntConfig.iSPPercent = MINMAX(0, m_kAutoHuntConfig.iSPPercent, 95); + m_kAutoHuntConfig.iPickup = MINMAX(0, m_kAutoHuntConfig.iPickup, int(AUTO_HUNT_PICKUP_GOLD_EQUIP)); + m_kAutoHuntConfig.dwSkillSlotMask &= (1 << AUTO_HUNT_SKILL_SLOT_NUM) - 1; + m_kAutoHuntConfig.iGatherCount = MINMAX(0, m_kAutoHuntConfig.iGatherCount, 10); + __AutoHunt_SaveConfig(); +} + +void CPythonPlayer::__AutoHunt_LoadConfig() +{ + if (m_isAutoHuntConfigLoaded) + return; + m_isAutoHuntConfigLoaded = true; + + FILE* fp = fopen(c_szAutoHuntConfigFileName, "rt"); + if (!fp) + return; + + SAutoHuntConfig kConfig = m_kAutoHuntConfig; + int iStone = 0, iBoss = 0, iGather = 0; + unsigned int uMask = 0; + // 7 fields before gather mode. + const int iRead = fscanf(fp, "%d %d %d %d %d %d %u %d", &kConfig.iRange, &kConfig.iHPPercent, &kConfig.iSPPercent, + &kConfig.iPickup, &iStone, &iBoss, &uMask, &iGather); + if (iRead >= 7) + { + kConfig.iGatherCount = 8 == iRead ? iGather : 0; + kConfig.bAttackStone = iStone != 0; + kConfig.bAttackBoss = iBoss != 0; + kConfig.dwSkillSlotMask = uMask; + fclose(fp); + AutoHunt_SetConfig(kConfig); + return; + } + fclose(fp); +} + +void CPythonPlayer::__AutoHunt_SaveConfig() +{ + FILE* fp = fopen(c_szAutoHuntConfigFileName, "wt"); + if (!fp) + return; + fprintf(fp, "%d %d %d %d %d %d %u %d\n", m_kAutoHuntConfig.iRange, m_kAutoHuntConfig.iHPPercent, + m_kAutoHuntConfig.iSPPercent, m_kAutoHuntConfig.iPickup, m_kAutoHuntConfig.bAttackStone ? 1 : 0, + m_kAutoHuntConfig.bAttackBoss ? 1 : 0, (unsigned int)m_kAutoHuntConfig.dwSkillSlotMask, + m_kAutoHuntConfig.iGatherCount); + fclose(fp); +} + +void CPythonPlayer::__AutoHunt_Stop(int eReason) +{ + if (AUTO_HUNT_ENGAGE == m_eAutoHuntState || AUTO_HUNT_LOOT == m_eAutoHuntState || AUTO_HUNT_RETURN == m_eAutoHuntState) + { + __ClearAutoAttackTargetActorID(); + __ClearReservedAction(); + if (AUTO_HUNT_RETURN == m_eAutoHuntState) + if (CInstanceBase* pkInstMain = NEW_GetMainActorPtr()) + pkInstMain->NEW_Stop(); + } + m_eAutoHuntState = AUTO_HUNT_OFF; + m_eAutoHuntStopReason = eReason; + m_dwAutoHuntTargetVID = 0; + m_dwAutoHuntItemID = 0; + m_isAutoHuntGathering = false; + m_kSet_dwAutoHuntGathered.clear(); +} + +// Direction keys, the touch stick, a world click or the attack key: the player takes over, and the +// hunt picks up again from wherever they leave the character once they stop for a moment. +void CPythonPlayer::__AutoHunt_OnManualInput() +{ + if (!AutoHunt_IsEnabled()) + return; + + if (AUTO_HUNT_PAUSED != m_eAutoHuntState) + { + __ClearAutoAttackTargetActorID(); + __ClearReservedAction(); + m_dwAutoHuntTargetVID = 0; + m_dwAutoHuntItemID = 0; + m_eAutoHuntState = AUTO_HUNT_PAUSED; + } + m_fAutoHuntResumeTime = CTimer::Instance().GetCurrentSecond() + c_fAutoHuntResumeDelay; +} + +void CPythonPlayer::__AutoHunt_Engage(DWORD dwVID, float fNow) +{ + m_dwAutoHuntTargetVID = dwVID; + m_eAutoHuntState = AUTO_HUNT_ENGAGE; + m_fAutoHuntStateTime = fNow; + m_fAutoHuntProgressTime = fNow; + m_fAutoHuntBestDistance = 1e9f; + m_fAutoHuntNextActionTime = fNow + 0.5f; + m_fAutoHuntHitTime = 0.0f; + + SetTarget(dwVID); + __ClearReservedAction(); + __SetAutoAttackTargetActorID(dwVID); +} + +DWORD CPythonPlayer::__AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow, bool isExceptGathered) +{ + CPythonCharacterManager& rkChrMgr = CPythonCharacterManager::Instance(); + CPythonNonPlayer& rkNonPlayer = CPythonNonPlayer::Instance(); + + DWORD dwBestVID = 0; + float fBestDistance = 1e9f; + for (CPythonCharacterManager::CharacterIterator i = rkChrMgr.CharacterInstanceBegin(); i != rkChrMgr.CharacterInstanceEnd(); ++i) + { + CInstanceBase* pkInst = *i; + if (!pkInst || pkInst == &rkInstMain || pkInst->IsDead()) + continue; + + if (!pkInst->IsEnemy() && !(m_kAutoHuntConfig.bAttackStone && pkInst->IsStone())) + continue; + + if (!rkInstMain.IsAttackableInstance(*pkInst) || pkInst->IsInSafe()) + continue; + + if (!m_kAutoHuntConfig.bAttackBoss) + { + const CPythonNonPlayer::TMobTable* pMobTable = rkNonPlayer.GetTable(pkInst->GetRace()); + if (pMobTable && pMobTable->bRank >= c_byMobRankBoss) + continue; + } + + std::map::iterator f = m_kMap_dwAutoHuntSkipVID.find(pkInst->GetVirtualID()); + if (f != m_kMap_dwAutoHuntSkipVID.end() && fNow < f->second) + continue; + + if (isExceptGathered && m_kSet_dwAutoHuntGathered.count(pkInst->GetVirtualID())) + continue; + + TPixelPosition kPPos; + pkInst->NEW_GetPixelPosition(&kPPos); + if (PixelDistance(kPPos, m_kPPosAutoHuntAnchor) > float(m_kAutoHuntConfig.iRange)) + continue; + + const float fDistance = rkInstMain.GetDistance(pkInst); + // The first pull may be anywhere in range; the rest near the pack that follows the character. + if (isExceptGathered && !m_kSet_dwAutoHuntGathered.empty() && fDistance > c_fAutoHuntGatherRange) + continue; + if (fDistance < fBestDistance) + { + fBestDistance = fDistance; + dwBestVID = pkInst->GetVirtualID(); + } + } + return dwBestVID; +} + +// Gather mode: the nearest pulled mob still alive. +DWORD CPythonPlayer::__AutoHunt_FindGatheredTarget(CInstanceBase& rkInstMain) +{ + DWORD dwBestVID = 0; + float fBestDistance = 1e9f; + for (DWORD dwVID : m_kSet_dwAutoHuntGathered) + { + CInstanceBase* pkInst = NEW_FindActorPtr(dwVID); + if (!pkInst || pkInst->IsDead()) + continue; + const float fDistance = rkInstMain.GetDistance(pkInst); + if (fDistance < fBestDistance) + { + fBestDistance = fDistance; + dwBestVID = dwVID; + } + } + return dwBestVID; +} + +// Dead or out of sight. +void CPythonPlayer::__AutoHunt_PurgeGathered() +{ + for (std::set::iterator i = m_kSet_dwAutoHuntGathered.begin(); i != m_kSet_dwAutoHuntGathered.end();) + { + CInstanceBase* pkInst = NEW_FindActorPtr(*i); + if (!pkInst || pkInst->IsDead()) + i = m_kSet_dwAutoHuntGathered.erase(i); + else + ++i; + } +} + +bool CPythonPlayer::__AutoHunt_IsGatherDone(float fNow) +{ + if (int(m_kSet_dwAutoHuntGathered.size()) >= m_kAutoHuntConfig.iGatherCount) + return true; + if (fNow - m_fAutoHuntGatherStartTime > c_fAutoHuntGatherTimeout) + return true; + const int iMaxHP = GetStatus(POINT_MAX_HP); + if (iMaxHP > 0 && GetStatus(POINT_HP) * 100 < iMaxHP * c_iAutoHuntGatherHPPercent) + { + AutoHuntTrace("gather stop: hp %d/%d", GetStatus(POINT_HP), iMaxHP); + return true; + } + return false; +} + +DWORD CPythonPlayer::__AutoHunt_FindItem(CInstanceBase& rkInstMain, float fNow) +{ + CPythonItem& rkItem = CPythonItem::Instance(); + std::vector kVecVID; + rkItem.GetGroundItemVIDs(&kVecVID); + + TPixelPosition kPPosMain; + rkInstMain.NEW_GetPixelPosition(&kPPosMain); + + const float fRange = MIN(float(m_kAutoHuntConfig.iRange), c_fAutoHuntItemRange); + DWORD dwBestID = 0; + float fBestDistance = 1e9f; + for (DWORD dwID : kVecVID) + { + std::map::iterator f = m_kMap_dwAutoHuntSkipItem.find(dwID); + if (f != m_kMap_dwAutoHuntSkipItem.end() && fNow < f->second) + continue; + + // Someone else's drop: the server refuses it until the ownership runs out. + const char* c_szOwner = NULL; + if (rkItem.GetOwnership(dwID, &c_szOwner) && c_szOwner && *c_szOwner && m_stName != c_szOwner) + continue; + + if (AUTO_HUNT_PICKUP_GOLD_EQUIP == m_kAutoHuntConfig.iPickup) + { + const DWORD dwVnum = rkItem.GetVirtualNumberOfGroundItem(dwID); + CItemData* pItemData = NULL; + const bool isGold = c_dwGoldVnum == dwVnum; + const bool isEquip = CItemManager::Instance().GetItemDataPointer(dwVnum, &pItemData) && pItemData && + (CItemData::ITEM_TYPE_WEAPON == pItemData->GetType() || CItemData::ITEM_TYPE_ARMOR == pItemData->GetType() || + CItemData::ITEM_TYPE_ELK == pItemData->GetType()); + if (!isGold && !isEquip) + continue; + } + + TPixelPosition kPPosItem; + if (!rkItem.GetGroundItemPosition(dwID, &kPPosItem)) + continue; + const float fDistance = PixelDistance(kPPosItem, kPPosMain); + if (fDistance > fRange) + continue; + if (fDistance < fBestDistance) + { + fBestDistance = fDistance; + dwBestID = dwID; + } + } + return dwBestID; +} + +// uiinventory's cells: a big item covers the cells below its own, page by page. +bool CPythonPlayer::__AutoHunt_IsInventoryFull() +{ + const DWORD c_dwColumns = 5; + bool abCovered[c_ItemSlot_Count] = {}; + for (DWORD i = 0; i < c_ItemSlot_Count; ++i) + { + const DWORD dwVnum = GetItemIndex(TItemPos(INVENTORY, WORD(i))); + if (!dwVnum) + continue; + DWORD dwSize = 1; + CItemData* pItemData = NULL; + if (CItemManager::Instance().GetItemDataPointer(dwVnum, &pItemData) && pItemData) + dwSize = MAX(1, pItemData->GetSize()); + const DWORD dwPageEnd = (i / c_Inventory_Page_Size + 1) * c_Inventory_Page_Size; + for (DWORD k = 0; k < dwSize && i + k * c_dwColumns < dwPageEnd; ++k) + abCovered[i + k * c_dwColumns] = true; + } + for (DWORD i = 0; i < c_ItemSlot_Count; ++i) + if (!abCovered[i]) + return false; + return true; +} + +// __CheckSkillUsable without its "cannot use" chat lines: the hunt only casts what will go through. +bool CPythonPlayer::__AutoHunt_IsSkillReady(DWORD dwSkillSlot, CPythonSkill::TSkillData** ppSkillData) +{ + if (dwSkillSlot >= SKILL_MAX_NUM) + return false; + + CInstanceBase* pkInstMain = NEW_GetMainActorPtr(); + if (!pkInstMain) + return false; + + TSkillInstance& rkSkillInst = m_playerStatus.aSkill[dwSkillSlot]; + if (!rkSkillInst.dwIndex || rkSkillInst.iLevel <= 0) + return false; + + CPythonSkill::TSkillData* pSkillData; + if (!CPythonSkill::Instance().GetSkillData(rkSkillInst.dwIndex, &pSkillData)) + return false; + + if (CPythonSkill::SKILL_TYPE_ACTIVE != pSkillData->byType && CPythonSkill::SKILL_TYPE_HORSE != pSkillData->byType) + return false; + if (!pSkillData->IsCanUseSkill()) + return false; + if ((pkInstMain->IsMountingHorse() ? true : false) != (pSkillData->IsHorseSkill() ? true : false)) + return false; + if (!pSkillData->CanUseWeaponType(pkInstMain->GetWeaponType())) + return false; + if (pSkillData->IsNeedBow() && !__HasEnoughArrow()) + return false; + if (pSkillData->IsNeedEmptyBottle() && !__HasItem(27995)) + return false; + if (pSkillData->IsNeedPoisonBottle() && !__HasItem(27996)) + return false; + if (__CheckRestSkillCoolTime(dwSkillSlot)) + return false; + if (__CheckShortLife(rkSkillInst, *pSkillData)) + return false; + + const int iNeedSP = pSkillData->GetNeedSP(rkSkillInst.fcurEfficientPercentage); + const int iCurSP = GetStatus(POINT_SP); + if (pSkillData->CanUseIfNotEnough() ? iCurSP <= 0 : iCurSP < iNeedSP) + return false; + + *ppSkillData = pSkillData; + return true; +} + +// Self buffs from the enabled quick slots whose effect is not on the character. +bool CPythonPlayer::__AutoHunt_UpdateBuff(CInstanceBase& rkInstMain, float fNow) +{ + if (fNow < m_fAutoHuntNextSkillTime || rkInstMain.IsUsingSkill() || rkInstMain.IsInSafe()) + return false; + + for (DWORD i = 0; i < AUTO_HUNT_SKILL_SLOT_NUM; ++i) + { + if (!(m_kAutoHuntConfig.dwSkillSlotMask & (1 << i))) + continue; + + DWORD dwType = SLOT_TYPE_NONE, dwSkillSlot = 0; + GetLocalQuickSlotData(i, &dwType, &dwSkillSlot); + if (SLOT_TYPE_SKILL != dwType) + continue; + + CPythonSkill::TSkillData* pSkillData; + if (!__AutoHunt_IsSkillReady(dwSkillSlot, &pSkillData)) + continue; + if (pSkillData->IsAttackSkill()) + continue; + if (!pSkillData->IsStandingSkill() && !pSkillData->CanUseForMe()) + continue; + + if (pSkillData->IsToggleSkill()) + { + if (IsSkillActive(dwSkillSlot)) + continue; + } + else + { + bool isActive = false; + const DWORD dwSkillIndex = m_playerStatus.aSkill[dwSkillSlot].dwIndex; + for (std::map::iterator f = m_kMap_dwAffectIndexToSkillIndex.begin(); f != m_kMap_dwAffectIndexToSkillIndex.end(); ++f) + if (f->second == dwSkillIndex && rkInstMain.IsAffect(f->first)) + isActive = true; + if (isActive) + continue; + } + + m_fAutoHuntNextSkillTime = fNow + 0.5f; + if (__UseSkill(dwSkillSlot)) + return true; + } + return false; +} + +void CPythonPlayer::__AutoHunt_UpdateAttackSkill(CInstanceBase& rkInstMain, CInstanceBase& rkInstVictim, float fNow) +{ + if (fNow < m_fAutoHuntNextSkillTime || rkInstMain.IsUsingSkill()) + return; + m_fAutoHuntNextSkillTime = fNow + 0.3f; + + const float fDistance = rkInstMain.GetDistance(&rkInstVictim); + for (DWORD i = 0; i < AUTO_HUNT_SKILL_SLOT_NUM; ++i) + { + if (!(m_kAutoHuntConfig.dwSkillSlotMask & (1 << i))) + continue; + + DWORD dwType = SLOT_TYPE_NONE, dwSkillSlot = 0; + GetLocalQuickSlotData(i, &dwType, &dwSkillSlot); + if (SLOT_TYPE_SKILL != dwType) + continue; + + CPythonSkill::TSkillData* pSkillData; + if (!__AutoHunt_IsSkillReady(dwSkillSlot, &pSkillData) || !pSkillData->IsAttackSkill()) + continue; + + // Ranged skills fire from their range; the rest once the auto attack has walked up. + const float fSkillRange = MAX(float(pSkillData->GetTargetRange()), c_fAutoHuntSkillDistance); + if (fDistance > fSkillRange) + continue; + + SetTarget(rkInstVictim.GetVirtualID()); + if (__UseSkill(dwSkillSlot)) + { + m_fAutoHuntNextSkillTime = fNow + 0.5f; + return; + } + } +} + +// uitaskbar's potions: the first USE_POTION stack in the inventory that restores what is low. +void CPythonPlayer::__AutoHunt_UpdatePotion(CInstanceBase& rkInstMain, float fNow) +{ + if (fNow < m_fAutoHuntNextPotionTime) + return; + m_fAutoHuntNextPotionTime = fNow + 0.5f; + + for (int iValue = 0; iValue < 2; ++iValue) + { + const bool isHP = 0 == iValue; + const int iPercent = isHP ? m_kAutoHuntConfig.iHPPercent : m_kAutoHuntConfig.iSPPercent; + const int iMax = GetStatus(isHP ? POINT_MAX_HP : POINT_MAX_SP); + const int iCur = GetStatus(isHP ? POINT_HP : POINT_SP); + bool& isNoPotion = isHP ? m_isAutoHuntNoHPPotion : m_isAutoHuntNoSPPotion; + if (iPercent <= 0 || iMax <= 0 || iCur * 100 >= iMax * iPercent) + continue; + + isNoPotion = true; + for (DWORD i = 0; i < c_ItemSlot_Count; ++i) + { + const TItemPos kCell(INVENTORY, WORD(i)); + CItemData* pItemData = NULL; + if (!CItemManager::Instance().GetItemDataPointer(GetItemIndex(kCell), &pItemData) || !pItemData) + continue; + if (CItemData::ITEM_TYPE_USE != pItemData->GetType() || CItemData::USE_POTION != pItemData->GetSubType()) + continue; + if (pItemData->GetValue(iValue) <= 0) + continue; + + isNoPotion = false; + CPythonNetworkStream::Instance().SendItemUsePacket(kCell); + m_fAutoHuntNextPotionTime = fNow + 1.0f; + return; + } + } +} + +void CPythonPlayer::__Update_AutoHunt() +{ + static int s_iLastState = AUTO_HUNT_OFF; + if (s_iLastState != m_eAutoHuntState) + { + AutoHuntTrace("state %d -> %d (reason %d, target %u, item %u, gathering %d, gathered %d)", s_iLastState, + m_eAutoHuntState, m_eAutoHuntStopReason, (unsigned)m_dwAutoHuntTargetVID, (unsigned)m_dwAutoHuntItemID, + m_isAutoHuntGathering ? 1 : 0, int(m_kSet_dwAutoHuntGathered.size())); + s_iLastState = m_eAutoHuntState; + } + + if (!AutoHunt_IsEnabled()) + return; + + CInstanceBase* pkInstMain = NEW_GetMainActorPtr(); + if (!pkInstMain) + return; + CInstanceBase& rkInstMain = *pkInstMain; + + if (rkInstMain.IsDead()) + { + __AutoHunt_Stop(AUTO_HUNT_STOP_DEAD); + return; + } + if (m_stAutoHuntMapName != CPythonBackground::Instance().GetWarpMapName()) + { + __AutoHunt_Stop(AUTO_HUNT_STOP_MAP); + return; + } + + const float fNow = CTimer::Instance().GetCurrentSecond(); + const float fElapsed = fMINMAX(0.0f, fNow - m_fAutoHuntLastUpdateTime, 0.1f); + m_fAutoHuntLastUpdateTime = fNow; + __AutoHunt_UpdatePotion(rkInstMain, fNow); + + if (IsOpenPrivateShop() || IsObserverMode() || rkInstMain.IsFishingMode()) + return; + + switch (m_eAutoHuntState) + { + case AUTO_HUNT_PAUSED: + { + if (fNow < m_fAutoHuntResumeTime) + return; + rkInstMain.NEW_GetPixelPosition(&m_kPPosAutoHuntAnchor); + m_eAutoHuntState = AUTO_HUNT_SEARCH; + m_fAutoHuntStateTime = fNow; + break; + } + + case AUTO_HUNT_SEARCH: + { + if (__AutoHunt_UpdateBuff(rkInstMain, fNow)) + return; + if (fNow < m_fAutoHuntNextActionTime || rkInstMain.IsUsingSkill()) + return; + m_fAutoHuntNextActionTime = fNow + 0.25f; + + // Gather mode: hit up to N mobs once each so they follow, then fight them where they stand. + __AutoHunt_PurgeGathered(); + if (m_kAutoHuntConfig.iGatherCount > 0 && !m_isAutoHuntGathering && m_kSet_dwAutoHuntGathered.empty()) + { + m_isAutoHuntGathering = true; + m_fAutoHuntGatherStartTime = fNow; + } + if (m_isAutoHuntGathering) + { + DWORD dwVID = __AutoHunt_IsGatherDone(fNow) ? 0 : __AutoHunt_FindTarget(rkInstMain, fNow, true); + if (dwVID) + { + __AutoHunt_Engage(dwVID, fNow); + return; + } + m_isAutoHuntGathering = false; + if (!m_kSet_dwAutoHuntGathered.empty()) + AutoHuntTrace("gather done: %d mobs, %.1fs", int(m_kSet_dwAutoHuntGathered.size()), + fNow - m_fAutoHuntGatherStartTime); + } + if (DWORD dwVID = __AutoHunt_FindGatheredTarget(rkInstMain)) + { + __AutoHunt_Engage(dwVID, fNow); + return; + } + + if (DWORD dwVID = __AutoHunt_FindTarget(rkInstMain, fNow)) + { + __AutoHunt_Engage(dwVID, fNow); + return; + } + + // Nothing to fight: drops the post-kill window missed (a kill farther than the loot range). + if (AUTO_HUNT_PICKUP_NONE != m_kAutoHuntConfig.iPickup && __AutoHunt_FindItem(rkInstMain, fNow)) + { + m_dwAutoHuntItemID = 0; + m_eAutoHuntState = AUTO_HUNT_LOOT; + m_fAutoHuntStateTime = fNow - c_fAutoHuntLootDelay; + m_fAutoHuntNextActionTime = 0.0f; + return; + } + + TPixelPosition kPPosMain; + rkInstMain.NEW_GetPixelPosition(&kPPosMain); + if (PixelDistance(kPPosMain, m_kPPosAutoHuntAnchor) > c_fAutoHuntReturnDistance) + { + m_eAutoHuntState = AUTO_HUNT_RETURN; + m_fAutoHuntStateTime = fNow; + m_fAutoHuntNextActionTime = 0.0f; + } + break; + } + + case AUTO_HUNT_RETURN: + { + if (fNow < m_fAutoHuntNextActionTime) + return; + m_fAutoHuntNextActionTime = fNow + 0.5f; + + if (DWORD dwVID = __AutoHunt_FindTarget(rkInstMain, fNow)) + { + __AutoHunt_Engage(dwVID, fNow); + return; + } + + TPixelPosition kPPosMain; + rkInstMain.NEW_GetPixelPosition(&kPPosMain); + if (PixelDistance(kPPosMain, m_kPPosAutoHuntAnchor) < c_fAutoHuntReturnDistance * 0.5f || + fNow - m_fAutoHuntStateTime > c_fAutoHuntReturnTimeout) + { + rkInstMain.NEW_Stop(); + m_eAutoHuntState = AUTO_HUNT_SEARCH; + m_fAutoHuntStateTime = fNow; + return; + } + if (!rkInstMain.IsWalking()) + rkInstMain.NEW_MoveToDestPixelPositionDirection(m_kPPosAutoHuntAnchor); + break; + } + + case AUTO_HUNT_ENGAGE: + { + CInstanceBase* pkInstVictim = NEW_FindActorPtr(m_dwAutoHuntTargetVID); + if (!pkInstVictim || pkInstVictim->IsDead()) + { + __ClearAutoAttackTargetActorID(); + m_dwAutoHuntTargetVID = 0; + m_dwAutoHuntItemID = 0; + // The rest of the pack is still on the character: loot once it is down. + __AutoHunt_PurgeGathered(); + const bool isPackAlive = m_isAutoHuntGathering || !m_kSet_dwAutoHuntGathered.empty(); + m_eAutoHuntState = AUTO_HUNT_PICKUP_NONE == m_kAutoHuntConfig.iPickup || isPackAlive ? AUTO_HUNT_SEARCH : AUTO_HUNT_LOOT; + m_fAutoHuntStateTime = fNow; + m_fAutoHuntNextActionTime = 0.0f; + return; + } + + const float fDistance = rkInstMain.GetDistance(pkInstVictim); + if (fDistance < m_fAutoHuntBestDistance - 30.0f) + { + m_fAutoHuntBestDistance = fDistance; + m_fAutoHuntProgressTime = fNow; + } + if (rkInstMain.IsAttacking() || rkInstMain.IsUsingSkill()) + { + m_fAutoHuntProgressTime = fNow; + m_fAutoHuntHitTime += fElapsed; + } + + // Gather: this one is hit and follows now; go and hit the next. + if (m_isAutoHuntGathering && m_fAutoHuntHitTime >= c_fAutoHuntTagHitTime && + !m_kSet_dwAutoHuntGathered.count(m_dwAutoHuntTargetVID)) + { + m_kSet_dwAutoHuntGathered.insert(m_dwAutoHuntTargetVID); + __AutoHunt_PurgeGathered(); + AutoHuntTrace("gather tag %u (%d/%d)", (unsigned)m_dwAutoHuntTargetVID, int(m_kSet_dwAutoHuntGathered.size()), + m_kAutoHuntConfig.iGatherCount); + } + if (m_isAutoHuntGathering && m_kSet_dwAutoHuntGathered.count(m_dwAutoHuntTargetVID) && !rkInstMain.IsUsingSkill()) + { + DWORD dwNextVID = __AutoHunt_IsGatherDone(fNow) ? 0 : __AutoHunt_FindTarget(rkInstMain, fNow, true); + if (dwNextVID) + { + __AutoHunt_Engage(dwNextVID, fNow); + return; + } + m_isAutoHuntGathering = false; + AutoHuntTrace("gather done: %d mobs, %.1fs", int(m_kSet_dwAutoHuntGathered.size()), + fNow - m_fAutoHuntGatherStartTime); + } + + // Blocked path, a target out of reach, or a safe zone that refuses the attack. + const bool isBlockedBySafeZone = rkInstMain.IsInSafe() && rkInstMain.NEW_IsClickableDistanceDestInstance(*pkInstVictim); + if (isBlockedBySafeZone || fNow - m_fAutoHuntProgressTime > c_fAutoHuntNoProgressTime || + fNow - m_fAutoHuntStateTime > c_fAutoHuntTargetTimeout) + { + m_kMap_dwAutoHuntSkipVID[m_dwAutoHuntTargetVID] = fNow + c_fAutoHuntSkipTime; + __ClearAutoAttackTargetActorID(); + __ClearReservedAction(); + m_dwAutoHuntTargetVID = 0; + m_eAutoHuntState = AUTO_HUNT_SEARCH; + m_fAutoHuntStateTime = fNow; + return; + } + + __AutoHunt_UpdateBuff(rkInstMain, fNow); + if (!m_isAutoHuntGathering) + __AutoHunt_UpdateAttackSkill(rkInstMain, *pkInstVictim, fNow); + + // A cast or an interrupted approach drops the auto-attack target; put it back. + if (m_dwAutoAttackTargetVID != m_dwAutoHuntTargetVID && fNow >= m_fAutoHuntNextActionTime && + !rkInstMain.IsUsingSkill() && MODE_USE_SKILL != m_eReservedMode) + { + m_fAutoHuntNextActionTime = fNow + 0.5f; + SetTarget(m_dwAutoHuntTargetVID); + __SetAutoAttackTargetActorID(m_dwAutoHuntTargetVID); + } + break; + } + + case AUTO_HUNT_LOOT: + { + if (fNow - m_fAutoHuntStateTime < c_fAutoHuntLootDelay || fNow < m_fAutoHuntNextActionTime) + return; + m_fAutoHuntNextActionTime = fNow + 0.2f; + + CPythonItem& rkItem = CPythonItem::Instance(); + TPixelPosition kPPosItem; + if (m_dwAutoHuntItemID) + { + if (!rkItem.GetGroundItemPosition(m_dwAutoHuntItemID, &kPPosItem)) + { + m_dwAutoHuntItemID = 0; // picked up (or gone) + } + else if (fNow - m_fAutoHuntProgressTime > c_fAutoHuntItemTimeout) + { + m_kMap_dwAutoHuntSkipItem[m_dwAutoHuntItemID] = fNow + c_fAutoHuntItemSkipTime; + m_dwAutoHuntItemID = 0; + __ClearReservedAction(); + } + else + { + if (MODE_CLICK_ITEM != m_eReservedMode && !rkInstMain.IsWalking()) + __ReserveClickItem(m_dwAutoHuntItemID); + return; + } + } + + const DWORD dwItemID = fNow - m_fAutoHuntStateTime < c_fAutoHuntLootTimeout ? __AutoHunt_FindItem(rkInstMain, fNow) : 0; + if (!dwItemID || !rkItem.GetGroundItemPosition(dwItemID, &kPPosItem)) + { + m_eAutoHuntState = AUTO_HUNT_SEARCH; + m_fAutoHuntStateTime = fNow; + m_fAutoHuntNextActionTime = 0.0f; + return; + } + + if (c_dwGoldVnum != rkItem.GetVirtualNumberOfGroundItem(dwItemID) && __AutoHunt_IsInventoryFull()) + { + __AutoHunt_Stop(AUTO_HUNT_STOP_INVENTORY_FULL); + return; + } + + // __OnPressItem without its once-per-item guard. + m_dwAutoHuntItemID = dwItemID; + m_fAutoHuntProgressTime = fNow; + __ClearReservedAction(); + if (rkInstMain.NEW_IsClickableDistanceDestPixelPosition(kPPosItem)) + { + rkInstMain.NEW_Stop(); + SendClickItemPacket(dwItemID); + } + else + { + __ReserveClickItem(dwItemID); + } + break; + } + } +} diff --git a/extension/src/port/UserInterface/PythonPlayerInput.cpp b/extension/src/port/UserInterface/PythonPlayerInput.cpp index 515cfda7..dc24db02 100644 --- a/extension/src/port/UserInterface/PythonPlayerInput.cpp +++ b/extension/src/port/UserInterface/PythonPlayerInput.cpp @@ -442,8 +442,11 @@ void CPythonPlayer::__OnPressScreen(CInstanceBase& rkInstMain) } -bool CPythonPlayer::NEW_MoveToDirection(float fDirRot) +bool CPythonPlayer::NEW_MoveToDirection(float fDirRot, bool isAutoCameraRotation) { + // PORT: direction keys and the touch stick pause auto hunt. + __AutoHunt_OnManualInput(); + // PrivateShop if (IsOpenPrivateShop()) return true; @@ -464,7 +467,7 @@ bool CPythonPlayer::NEW_MoveToDirection(float fDirRot) float fCmrCurRot=CameraRotationToCharacterRotation(pkCmrCur->GetRoll()); // 현재 - if (m_isCmrRot) + if (m_isCmrRot && isAutoCameraRotation) { float fSigDirRot=fDirRot; if (fSigDirRot>180.0f) diff --git a/extension/src/port/UserInterface/PythonPlayerInputKeyboard.cpp b/extension/src/port/UserInterface/PythonPlayerInputKeyboard.cpp index 2ba1fb42..bfed0c1b 100644 --- a/extension/src/port/UserInterface/PythonPlayerInputKeyboard.cpp +++ b/extension/src/port/UserInterface/PythonPlayerInputKeyboard.cpp @@ -7,6 +7,7 @@ void CPythonPlayer::SetAttackKeyState(bool isPress) { if (isPress) { + __AutoHunt_OnManualInput(); // PORT: auto hunt CInstanceBase* pkInstMain = NEW_GetMainActorPtr(); if (pkInstMain) if (pkInstMain->IsFishingMode()) diff --git a/extension/src/port/UserInterface/PythonPlayerInputMouse.cpp b/extension/src/port/UserInterface/PythonPlayerInputMouse.cpp index d748370f..17052752 100644 --- a/extension/src/port/UserInterface/PythonPlayerInputMouse.cpp +++ b/extension/src/port/UserInterface/PythonPlayerInputMouse.cpp @@ -97,6 +97,10 @@ void CPythonPlayer::NEW_SetMouseSmartState(int eMBS, bool isAuto) CInstanceBase* pkInstMain=NEW_GetMainActorPtr(); if (!pkInstMain) return; + // PORT: a click in the world hands control back to the player for a moment (auto hunt). + if (MBS_PRESS == eMBS) + __AutoHunt_OnManualInput(); + // PrivateShop if (IsOpenPrivateShop()) { diff --git a/extension/src/port/UserInterface/PythonPlayerModule.cpp b/extension/src/port/UserInterface/PythonPlayerModule.cpp index dbdf0822..993c89cb 100644 --- a/extension/src/port/UserInterface/PythonPlayerModule.cpp +++ b/extension/src/port/UserInterface/PythonPlayerModule.cpp @@ -2166,6 +2166,81 @@ PyObject* playerSendDragonSoulRefine(PyObject* poSelf, PyObject* poArgs) return Py_BuildNone(); } +// PORT: auto hunt (PythonPlayerAutoHunt.cpp); 40250 has no counterpart. +PyObject * playerAutoHuntSetEnabled(PyObject* poSelf, PyObject* poArgs) +{ + int isEnable; + if (!PyTuple_GetInteger(poArgs, 0, &isEnable)) + return Py_BuildException(); + + CPythonPlayer::Instance().AutoHunt_SetEnabled(isEnable ? true : false); + return Py_BuildNone(); +} + +PyObject * playerAutoHuntIsEnabled(PyObject* poSelf, PyObject* poArgs) +{ + return Py_BuildValue("i", CPythonPlayer::Instance().AutoHunt_IsEnabled()); +} + +// (state, stop reason, out of HP potions, out of SP potions, gathering, gathered mobs alive) +PyObject * playerAutoHuntGetState(PyObject* poSelf, PyObject* poArgs) +{ + CPythonPlayer& rkPlayer = CPythonPlayer::Instance(); + return Py_BuildValue("iiiiii", rkPlayer.AutoHunt_GetState(), rkPlayer.AutoHunt_GetStopReason(), + rkPlayer.AutoHunt_HasNoPotion(true), rkPlayer.AutoHunt_HasNoPotion(false), + rkPlayer.AutoHunt_IsGathering(), rkPlayer.AutoHunt_GetGatheredCount()); +} + +// (range, hp %, sp %, pickup, stone, boss, quick-slot skill mask, gather count) +PyObject * playerAutoHuntGetConfig(PyObject* poSelf, PyObject* poArgs) +{ + const CPythonPlayer::SAutoHuntConfig& c_rkConfig = CPythonPlayer::Instance().AutoHunt_GetConfig(); + return Py_BuildValue("iiiiiiii", c_rkConfig.iRange, c_rkConfig.iHPPercent, c_rkConfig.iSPPercent, c_rkConfig.iPickup, + c_rkConfig.bAttackStone, c_rkConfig.bAttackBoss, int(c_rkConfig.dwSkillSlotMask), c_rkConfig.iGatherCount); +} + +PyObject * playerAutoHuntSetConfig(PyObject* poSelf, PyObject* poArgs) +{ + int aiValue[8]; + for (int i = 0; i < 8; ++i) + if (!PyTuple_GetInteger(poArgs, i, &aiValue[i])) + return Py_BuildException(); + + CPythonPlayer::SAutoHuntConfig kConfig; + kConfig.iRange = aiValue[0]; + kConfig.iHPPercent = aiValue[1]; + kConfig.iSPPercent = aiValue[2]; + kConfig.iPickup = aiValue[3]; + kConfig.bAttackStone = aiValue[4] != 0; + kConfig.bAttackBoss = aiValue[5] != 0; + kConfig.dwSkillSlotMask = DWORD(aiValue[6]); + kConfig.iGatherCount = aiValue[7]; + CPythonPlayer::Instance().AutoHunt_SetConfig(kConfig); + return Py_BuildNone(); +} + +// The auto hunt window's UTF-8 source strings in the locale's code page (locale.cfg), as the packed +// locale_game.txt strings are. +PyObject * playerAutoHuntLocalize(PyObject* poSelf, PyObject* poArgs) +{ + char* szText; + if (!PyTuple_GetString(poArgs, 0, &szText)) + return Py_BuildException(); + + const int iWide = MultiByteToWideChar(CP_UTF8, 0, szText, -1, NULL, 0); + if (iWide <= 0) + return Py_BuildValue("s", szText); + std::vector kVecWide(iWide); + MultiByteToWideChar(CP_UTF8, 0, szText, -1, &kVecWide[0], iWide); + + const int iBytes = WideCharToMultiByte(GetDefaultCodePage(), 0, &kVecWide[0], -1, NULL, 0, NULL, NULL); + if (iBytes <= 0) + return Py_BuildValue("s", szText); + std::vector kVecText(iBytes); + WideCharToMultiByte(GetDefaultCodePage(), 0, &kVecWide[0], -1, &kVecText[0], iBytes, NULL, NULL); + return Py_BuildValue("s", &kVecText[0]); +} + void initPlayer() { static PyMethodDef s_methods[] = @@ -2337,6 +2412,13 @@ void initPlayer() { "SlotTypeToInvenType", playerSlotTypeToInvenType, METH_VARARGS }, { "SendDragonSoulRefine", playerSendDragonSoulRefine, METH_VARARGS }, + { "AutoHuntSetEnabled", playerAutoHuntSetEnabled, METH_VARARGS }, + { "AutoHuntIsEnabled", playerAutoHuntIsEnabled, METH_VARARGS }, + { "AutoHuntGetState", playerAutoHuntGetState, METH_VARARGS }, + { "AutoHuntGetConfig", playerAutoHuntGetConfig, METH_VARARGS }, + { "AutoHuntSetConfig", playerAutoHuntSetConfig, METH_VARARGS }, + { "AutoHuntLocalize", playerAutoHuntLocalize, METH_VARARGS }, + { NULL, NULL, NULL }, }; diff --git a/extension/src/port/UserInterface/PythonTextTailModule.cpp b/extension/src/port/UserInterface/PythonTextTailModule.cpp index 81af20a6..3ce83de9 100644 --- a/extension/src/port/UserInterface/PythonTextTailModule.cpp +++ b/extension/src/port/UserInterface/PythonTextTailModule.cpp @@ -1,6 +1,7 @@ #include "StdAfx.h" #include "PythonTextTail.h" #include "PythonCharacterManager.h" +#include "../EterPythonLib/PythonWindowManager.h" PyObject * textTailClear(PyObject * poSelf, PyObject * poArgs) { @@ -173,6 +174,8 @@ PyObject * textTailPick(PyObject * poSelf, PyObject * poArgs) if (!PyTuple_GetInteger(poArgs, 1, &iy)) return Py_BuildException(); + // PORT: game.py passes window coordinates; the text tails sit at screen coordinates. + ix += UI::CWindowManager::Instance().GetScreenOffsetX(); int iValue = CPythonTextTail::Instance().Pick(ix, iy); return Py_BuildValue("i", iValue); } diff --git a/native_render/main.cpp b/native_render/main.cpp index 611661a1..7cbf7e6a 100644 --- a/native_render/main.cpp +++ b/native_render/main.cpp @@ -19,6 +19,9 @@ #include #include #include +#ifdef __ANDROID__ +#include +#endif #include #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(SDL_GetAndroidJNIEnv()); + auto activity = static_cast(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 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 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(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(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(renderer.logical_width()), static_cast(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()) { diff --git a/native_render/touch_controller.h b/native_render/touch_controller.h index 79608940..a4615a88 100644 --- a/native_render/touch_controller.h +++ b/native_render/touch_controller.h @@ -17,7 +17,7 @@ class TouchController { public: struct ButtonDef { - int id; // 1 = attack, 2..7 = quick slots 1..6 + int id; // 1 = attack, 2..7 = quick slots 1..6, 8 = auto hunt int dik; float x, y, radius; const char* label; @@ -31,15 +31,30 @@ public: init_buttons(); } - void set_enabled(bool enabled) { enabled_ = enabled; } + void set_enabled(bool enabled) { + enabled_ = enabled; +#ifdef MT_NATIVE_HAS_LIVE_CLIENT + PythonBoot::SetTouchInput(enabled); +#endif + } bool is_enabled() const { return enabled_; } + // Short vibration when a long press fires (the host wires it to the platform). + void set_haptic(void (*haptic)()) { haptic_ = haptic; } - void update_screen_size(int width, int height) { + // The system keyboard is up only after the player taps the focused EditLine, not when a + // script focuses one on its own (intrologin.py focuses the ID field as the board opens). + // The serial changes on every such tap so the host can re-show a keyboard the user dismissed. + bool wants_screen_keyboard() const { return keyboard_requested_; } + unsigned screen_keyboard_serial() const { return keyboard_serial_; } + + // safe_inset: logical pixels kept clear at the left and right edges (rounded corners, cutouts). + void update_screen_size(int width, int height, int safe_inset = 0) { if (width <= 0 || height <= 0) return; screen_w_ = width; screen_h_ = height; + safe_inset_ = float(std::max(0, safe_inset)); // Position joystick on lower-left - joystick_base_x_ = std::max(90.0f, float(width) * 0.12f); + joystick_base_x_ = safe_inset_ + std::max(90.0f, float(width) * 0.12f); joystick_base_y_ = float(height) - std::max(90.0f, float(height) * 0.22f); if (!joystick_active_) { joystick_knob_x_ = joystick_base_x_; @@ -51,6 +66,37 @@ public: // Called once per frame to maintain continuous analog movement void update() { if (!enabled_) return; +#ifdef MT_NATIVE_HAS_LIVE_CLIENT + if (keyboard_requested_ && !PythonBoot::TextInputFocused()) + keyboard_requested_ = false; + // A world tap presses and releases on later frames than its mouse move so + // 40250's per-frame actor picking has already hit the target under the finger. + if (tap_stage_ == 1) { + PythonBoot::UIMouseMove(int(tap_x_), int(tap_y_)); + PythonBoot::UIMouseButton(1, true, int(tap_x_), int(tap_y_)); + tap_stage_ = 2; + } else if (tap_stage_ == 2) { + PythonBoot::UIMouseButton(1, false, int(tap_x_), int(tap_y_)); + tap_stage_ = 0; + } + // A UI finger held still turns into a right click (use item, equip, skill up...). + if (ui_touch_finger_id_ >= 0 && ui_gesture_ == UiGesture::Pending && + get_time_sec() - ui_down_time_ >= kLongPressSec) { + ui_gesture_ = UiGesture::LongPressed; + last_tap_time_ = -1.0; + PythonBoot::UIMouseButton(2, true, int(ui_start_x_), int(ui_start_y_)); + PythonBoot::UIMouseButton(2, false, int(ui_start_x_), int(ui_start_y_)); + if (haptic_) haptic_(); + } + for (auto& btn : buttons_) { + if (btn.id == kAutoHuntButton && btn.pressed && !auto_hunt_long_pressed_ && + get_time_sec() - auto_hunt_down_time_ >= kLongPressSec) { + auto_hunt_long_pressed_ = true; + PythonBoot::AutoHuntOpenSettings(); + if (haptic_) haptic_(); + } + } +#endif const bool controls_visible = gameplay_controls_visible(); if (!controls_visible) { if (controls_were_visible_) @@ -79,11 +125,17 @@ public: if (btn_idx >= 0) { auto& btn = buttons_[btn_idx]; #ifdef MT_NATIVE_HAS_LIVE_CLIENT - if (btn.id >= 2 && PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(btn.id - 2)) + if (is_quick_slot_button(btn.id) && PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(btn.id - 2)) return true; #endif btn.pressed = true; btn.finger_id = finger_id; + if (btn.id == kAutoHuntButton) { + // Tap toggles; holding opens the settings window (update()). + auto_hunt_down_time_ = get_time_sec(); + auto_hunt_long_pressed_ = false; + return true; + } trigger_button(btn.dik, true); return true; } @@ -91,16 +143,21 @@ public: // Everything else uses the unchanged 40250 UI and its normal mouse path. #ifdef MT_NATIVE_HAS_LIVE_CLIENT + // Only hover here (the tooltip shows while the finger rests); what the finger does next picks + // the gesture: lift = click, slide = press + drag, hold = right click, second tap = double click. if (PythonBoot::IsPointInsideActiveUI(int(px), int(py))) { ui_touch_finger_id_ = finger_id; + ui_gesture_ = UiGesture::Pending; + ui_start_x_ = px; + ui_start_y_ = py; + ui_down_time_ = get_time_sec(); PythonBoot::UIMouseMove(int(px), int(py)); - PythonBoot::UIMouseButton(1, true, int(px), int(py)); return true; } #endif - // Start movement only in the gameplay phase. - if (controls_visible && norm_x < 0.40f && norm_y > 0.35f && !joystick_active_) { + // Mobile-game layout: the left half moves (floating stick), the right half looks. + if (controls_visible && norm_x < kMoveZoneRight && norm_y > kMoveZoneTop && !joystick_active_) { joystick_active_ = true; joystick_finger_id_ = finger_id; joystick_base_x_ = px; @@ -114,20 +171,28 @@ public: 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; - camera_last_y_ = py; - camera_start_x_ = px; - camera_start_y_ = py; - camera_dragged_ = false; - camera_down_time_ = get_time_sec(); + const bool in_look_zone = norm_x >= kMoveZoneRight; + if (look_finger_id_ < 0) { + // Outside the look zone the finger can only tap-select. + look_finger_id_ = finger_id; + look_can_rotate_ = in_look_zone; + look_rotating_ = false; + look_moved_ = false; + look_start_x_ = look_last_x_ = px; + look_start_y_ = look_last_y_ = py; + look_down_time_ = get_time_sec(); +#ifdef MT_NATIVE_HAS_LIVE_CLIENT + PythonBoot::UIMouseMove(int(px), int(py)); // start picking under the finger +#endif return true; - } else if (pinch_finger2_ < 0) { - pinch_finger1_ = camera_finger_id_; - pinch_finger2_ = finger_id; - pinch_last_dist_ = std::hypot(px - camera_last_x_, py - camera_last_y_); + } + if (pinch_finger_id_ < 0 && look_can_rotate_ && in_look_zone) { + pinch_finger_id_ = finger_id; + pinch_x_ = px; + pinch_y_ = py; + pinch_last_dist_ = std::hypot(px - look_last_x_, py - look_last_y_); + look_rotating_ = false; + look_moved_ = true; // a pinch never ends as a tap return true; } @@ -143,6 +208,14 @@ public: // 1. UI Touch dragging (e.g. dragging item in inventory or scrollbar) if (ui_touch_finger_id_ == finger_id) { #ifdef MT_NATIVE_HAS_LIVE_CLIENT + if (ui_gesture_ == UiGesture::Pending && + std::hypot(px - ui_start_x_, py - ui_start_y_) > kUiDragSlop) { + // 40250 drag: press where the finger landed (a slot attaches its icon, a title bar + // or scroll bar captures), then follow the finger. + ui_gesture_ = UiGesture::Dragging; + last_tap_time_ = -1.0; + PythonBoot::UIMouseButton(1, true, int(ui_start_x_), int(ui_start_y_)); + } PythonBoot::UIMouseMove(int(px), int(py)); #endif return true; @@ -169,38 +242,42 @@ public: } } - // 4. Two-finger pinch zoom - if (pinch_finger1_ >= 0 && pinch_finger2_ >= 0 && - (finger_id == pinch_finger1_ || finger_id == pinch_finger2_)) { - const float cur_dist = std::hypot(px - camera_last_x_, py - camera_last_y_); - if (pinch_last_dist_ > 1.0f && cur_dist > 1.0f) { - const float delta_d = cur_dist - pinch_last_dist_; - if (std::abs(delta_d) > 2.0f) { + // 4. Two-finger pinch zoom (look finger + second right-half finger) + if (pinch_finger_id_ >= 0 && (finger_id == pinch_finger_id_ || finger_id == look_finger_id_)) { + if (finger_id == pinch_finger_id_) { + pinch_x_ = px; + pinch_y_ = py; + } else { + look_last_x_ = px; + look_last_y_ = py; + } + const float cur_dist = std::hypot(pinch_x_ - look_last_x_, pinch_y_ - look_last_y_); + const float delta_d = cur_dist - pinch_last_dist_; + if (std::abs(delta_d) > 2.0f) { #ifdef MT_NATIVE_HAS_LIVE_CLIENT - PythonBoot::UIMouseWheel(int(delta_d * 8.0f)); + PythonBoot::UIMouseWheel(int(delta_d * 8.0f)); #endif - pinch_last_dist_ = cur_dist; - } + pinch_last_dist_ = cur_dist; } return true; } - // 5. Single-finger camera drag - if (finger_id == camera_finger_id_) { - const float total_dist = std::hypot(px - camera_start_x_, py - camera_start_y_); - if (total_dist > 6.0f) { - if (!camera_dragged_) { - camera_dragged_ = true; + // 5. Single-finger look drag: yaw by dx, pitch by dy, stops the moment the finger stops. + if (finger_id == look_finger_id_) { + if (!look_moved_ && std::hypot(px - look_start_x_, py - look_start_y_) > kLookDeadZone) { + look_moved_ = true; + look_rotating_ = look_can_rotate_; + look_last_x_ = px; // start from here so the dead zone does not jump the camera + look_last_y_ = py; + } + if (look_rotating_) { #ifdef MT_NATIVE_HAS_LIVE_CLIENT - PythonBoot::CameraBeginDrag(int(camera_start_x_), int(camera_start_y_)); -#endif - } -#ifdef MT_NATIVE_HAS_LIVE_CLIENT - PythonBoot::CameraDrag(int(px), int(py)); + PythonBoot::CameraRotateBy((px - look_last_x_) * kLookYawDegPerPx, + (py - look_last_y_) * kLookPitchDegPerPx); #endif } - camera_last_x_ = px; - camera_last_y_ = py; + look_last_x_ = px; + look_last_y_ = py; return true; } @@ -217,7 +294,41 @@ public: if (ui_touch_finger_id_ == finger_id) { ui_touch_finger_id_ = -1; #ifdef MT_NATIVE_HAS_LIVE_CLIENT - PythonBoot::UIMouseButton(1, false, int(px), int(py)); + if (ui_gesture_ == UiGesture::Dragging) { + // Dropped on a skill button: that quick slot takes the dragged icon. + const int btn_idx = gameplay_controls_visible() ? find_button(px, py) : -1; + if (btn_idx >= 0 && is_quick_slot_button(buttons_[btn_idx].id) && PythonBoot::UIIsAttaching()) { + PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(buttons_[btn_idx].id - 2); + PythonBoot::UIMouseButton(1, false, int(ui_start_x_), int(ui_start_y_)); + } else { + PythonBoot::UIMouseButton(1, false, int(px), int(py)); + } + } else if (ui_gesture_ == UiGesture::Pending) { + const double now = get_time_sec(); + const int x = int(ui_start_x_), y = int(ui_start_y_); + if (last_tap_time_ >= 0.0 && now - last_tap_time_ < kDoubleTapSec && + std::hypot(ui_start_x_ - last_tap_x_, ui_start_y_ - last_tap_y_) < kDoubleTapSlop) { + // Win32 sends DOWN, UP, DBLCLK, UP; the first click picked the icon up, which a + // mouse user would have seen and a finger hides, so put it back first. + if (last_tap_attached_ && PythonBoot::UIIsAttaching()) + PythonBoot::UIDeattachObject(); + PythonBoot::UIMouseDoubleClick(x, y); + PythonBoot::UIMouseButton(1, false, x, y); + last_tap_time_ = -1.0; + } else { + const bool was_attaching = PythonBoot::UIIsAttaching(); + PythonBoot::UIMouseButton(1, true, x, y); + PythonBoot::UIMouseButton(1, false, x, y); + last_tap_time_ = now; + last_tap_x_ = ui_start_x_; + last_tap_y_ = ui_start_y_; + last_tap_attached_ = !was_attaching && PythonBoot::UIIsAttaching(); + } + keyboard_requested_ = PythonBoot::TextInputFocused() && + PythonBoot::IsPointInsideFocusedWindow(x, y); + if (keyboard_requested_) ++keyboard_serial_; + } + ui_gesture_ = UiGesture::None; #endif return true; } @@ -240,34 +351,41 @@ public: if (btn.pressed) { btn.pressed = false; trigger_button(btn.dik, false); +#ifdef MT_NATIVE_HAS_LIVE_CLIENT + if (btn.id == kAutoHuntButton && !auto_hunt_long_pressed_) + PythonBoot::AutoHuntToggle(); +#endif } btn.finger_id = -1; return true; } } - // 4. Pinch end - if (finger_id == pinch_finger1_ || finger_id == pinch_finger2_) { - pinch_finger1_ = -1; - pinch_finger2_ = -1; + // 4. Pinch end: the finger left down keeps looking (never a tap). + if (pinch_finger_id_ >= 0 && (finger_id == pinch_finger_id_ || finger_id == look_finger_id_)) { + if (finger_id == look_finger_id_) { + look_finger_id_ = pinch_finger_id_; + look_last_x_ = pinch_x_; + look_last_y_ = pinch_y_; + } + pinch_finger_id_ = -1; pinch_last_dist_ = 0.0f; - if (finger_id == camera_finger_id_) camera_finger_id_ = -1; + look_rotating_ = look_can_rotate_; + look_moved_ = true; return true; } - // 5. Camera finger release - if (finger_id == camera_finger_id_) { - camera_finger_id_ = -1; - if (camera_dragged_) { -#ifdef MT_NATIVE_HAS_LIVE_CLIENT - PythonBoot::CameraEndDrag(); -#endif - } else if ((get_time_sec() - camera_down_time_) < 0.35) { - // Short tap on 3D world: select target (mob, NPC, ground) + // 5. Look finger release; a short still touch selects a target (mob, NPC, ground). + if (finger_id == look_finger_id_) { + look_finger_id_ = -1; + look_rotating_ = false; + keyboard_requested_ = false; + if (!look_moved_ && (get_time_sec() - look_down_time_) < kTapMaxSec && tap_stage_ == 0) { #ifdef MT_NATIVE_HAS_LIVE_CLIENT PythonBoot::UIMouseMove(int(px), int(py)); - PythonBoot::UIMouseButton(1, true, int(px), int(py)); - PythonBoot::UIMouseButton(1, false, int(px), int(py)); + tap_x_ = px; + tap_y_ = py; + tap_stage_ = 1; #endif } return true; @@ -290,9 +408,15 @@ public: 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()) { + if (btn.id == kAutoHuntButton) { + draw_auto_hunt_button(commands, btn); + continue; + } + if (is_quick_slot_button(btn.id)) { + std::string skill_icon; + float uv[4]; + int count = 0; + if (PythonBoot::LocalQuickSlotIcon(btn.id - 2, &skill_icon, uv, &count)) { UIRenderCommand icon{}; icon.kind = UIRenderCommand::Image; icon.x1 = btn.x - r * 0.68f; @@ -301,7 +425,17 @@ public: icon.y2 = btn.y + r * 0.68f; icon.argb = icon_col; icon.text = skill_icon; + icon.su = uv[0]; + icon.sv = uv[1]; + icon.eu = uv[2]; + icon.ev = uv[3]; commands.push_back(std::move(icon)); + if (count > 0) { + const std::string digits = std::to_string(std::min(count, 9999)); + const float h = r * 0.26f; + draw_segment_text(commands, digits, btn.x + r * 0.62f - segment_text_width(digits, h), + btn.y + r * 0.62f - h, h); + } continue; } } @@ -409,12 +543,48 @@ public: if (joystick_active_ && joystick_finger_id_ == 101) { handled |= on_finger_motion(101, norm_x, norm_y); } - if (camera_finger_id_ == 101) { + if (look_finger_id_ == 101) { handled |= on_finger_motion(101, norm_x, norm_y); } return handled; } + // Seven-segment text (digits, 'F', 'P', 'S', ' ') with its top-left at (left, top), each glyph h + // tall, on a dark backing. Used for quick-slot counts and the test-build FPS readout. + static void draw_segment_text(std::vector& commands, const std::string& text, + float left, float top, float h, std::uint32_t color = 0xFFFFFFFF) { + static constexpr std::uint8_t kDigits[10] = {0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F}; + const float w = h * 0.55f, gap = h * 0.3f; + UIRenderCommand back{}; + back.kind = UIRenderCommand::Bar; + back.x1 = left - 3.0f; + back.y1 = top - 3.0f; + back.x2 = left + segment_text_width(text, h) + 3.0f; + back.y2 = top + h + 3.0f; + back.argb = 0xA0000000; + commands.push_back(back); + float x = left; + for (char c : text) { + std::uint8_t seg = 0; + if (c >= '0' && c <= '9') seg = kDigits[c - '0']; + else if (c == 'F') seg = 0x71; + else if (c == 'P') seg = 0x73; + else if (c == 'S') seg = 0x6D; + const float t = top, m = top + h * 0.5f, b = top + h; + if (seg & 0x01) draw_line(commands, x, t, x + w, t, color); + if (seg & 0x02) draw_line(commands, x + w, t, x + w, m, color); + if (seg & 0x04) draw_line(commands, x + w, m, x + w, b, color); + if (seg & 0x08) draw_line(commands, x, b, x + w, b, color); + if (seg & 0x10) draw_line(commands, x, m, x, b, color); + if (seg & 0x20) draw_line(commands, x, t, x, m, color); + if (seg & 0x40) draw_line(commands, x, m, x + w, m, color); + x += w + gap; + } + } + static float segment_text_width(const std::string& text, float h) { + return text.empty() ? 0.0f : float(text.size()) * h * 0.85f - h * 0.3f; + } + private: bool gameplay_controls_visible() const { #ifdef MT_NATIVE_HAS_LIVE_CLIENT @@ -440,18 +610,47 @@ private: 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; + look_finger_id_ = -1; + look_rotating_ = false; + pinch_finger_id_ = -1; pinch_last_dist_ = 0.0f; } + static constexpr int kAutoHuntButton = 8; + static bool is_quick_slot_button(int id) { return id >= 2 && id <= 7; } + + // A circular arrow; green while the hunt runs. + static void draw_auto_hunt_button(std::vector& commands, const ButtonDef& btn) { + bool on = false; +#ifdef MT_NATIVE_HAS_LIVE_CLIENT + on = PythonBoot::AutoHuntIsEnabled(); +#endif + const uint32_t disc = btn.pressed ? btn.color_pressed : (on ? 0xC040C040 : btn.color_idle); + draw_filled_disc(commands, btn.x, btn.y, btn.radius, disc); + const uint32_t col = on ? 0xFFB0FFB0 : 0xDDFFFFFF; + const float r = btn.radius * 0.45f; + const float kPi = 3.14159265f; + const int segments = 12; + const float a0 = -kPi * 0.35f, a1 = a0 + kPi * 1.6f; + for (int i = 0; i < segments; ++i) { + const float t0 = a0 + (a1 - a0) * float(i) / float(segments); + const float t1 = a0 + (a1 - a0) * float(i + 1) / float(segments); + draw_line(commands, btn.x + std::cos(t0) * r, btn.y + std::sin(t0) * r, + btn.x + std::cos(t1) * r, btn.y + std::sin(t1) * r, col); + } + // Arrowhead at the arc's end, pointing along the direction of travel. + const float ex = btn.x + std::cos(a1) * r, ey = btn.y + std::sin(a1) * r; + const float tx = -std::sin(a1), ty = std::cos(a1); + const float nx = std::cos(a1), ny = std::sin(a1); + const float h = r * 0.55f; + draw_line(commands, ex, ey, ex - tx * h + nx * h * 0.6f, ey - ty * h + ny * h * 0.6f, col); + draw_line(commands, ex, ey, ex - tx * h - nx * h * 0.6f, ey - ty * h - ny * h * 0.6f, col); + if (on) { + // A play dot in the middle while running. + draw_rect_bar(commands, btn.x - 2.0f, btn.y - 2.0f, btn.x + 2.0f, btn.y + 2.0f, col); + } + } + void init_buttons() { buttons_.clear(); // Lower-right combat controls. All menus and status UI remain the original 40250 UI. @@ -462,10 +661,11 @@ private: 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}); + buttons_.push_back({kAutoHuntButton, 0, 0, 0, 24.0f, "AUTO", 0x80707070, 0xD0FFFFFF}); } void layout_buttons() { - const float W = float(screen_w_); + const float W = float(screen_w_) - safe_inset_; const float H = float(screen_h_); for (auto& btn : buttons_) { switch (btn.id) { @@ -504,6 +704,11 @@ private: btn.y = H - 225.0f; btn.radius = std::min(28.0f, H * 0.08f); break; + case kAutoHuntButton: + btn.x = W - 75.0f; + btn.y = H - 295.0f; + btn.radius = std::min(24.0f, H * 0.07f); + break; } } } @@ -623,9 +828,26 @@ private: bool enabled_ = false; int screen_w_ = 1280; int screen_h_ = 720; + float safe_inset_ = 0.0f; bool controls_were_visible_ = false; int64_t ui_touch_finger_id_ = -1; + enum class UiGesture { None, Pending, Dragging, LongPressed }; + UiGesture ui_gesture_ = UiGesture::None; + float ui_start_x_ = 0.0f; + float ui_start_y_ = 0.0f; + double ui_down_time_ = 0.0; + double last_tap_time_ = -1.0; + float last_tap_x_ = 0.0f; + float last_tap_y_ = 0.0f; + bool last_tap_attached_ = false; + void (*haptic_)() = nullptr; + static constexpr float kUiDragSlop = 10.0f; + static constexpr double kLongPressSec = 0.5; + static constexpr double kDoubleTapSec = 0.35; + static constexpr float kDoubleTapSlop = 24.0f; + bool keyboard_requested_ = false; + unsigned keyboard_serial_ = 0; bool joystick_active_ = false; int64_t joystick_finger_id_ = -1; @@ -637,17 +859,34 @@ private: float joystick_knob_radius_ = 28.0f; float move_angle_ = 0.0f; - int64_t camera_finger_id_ = -1; - float camera_start_x_ = 0.0f; - float camera_start_y_ = 0.0f; - float camera_last_x_ = 0.0f; - float camera_last_y_ = 0.0f; - double camera_down_time_ = 0.0; - bool camera_dragged_ = false; + // Zones are fractions of the screen; distances are logical UI pixels. + static constexpr float kMoveZoneRight = 0.5f; + static constexpr float kMoveZoneTop = 0.2f; + static constexpr float kLookDeadZone = 8.0f; + static constexpr float kLookYawDegPerPx = 0.25f; + static constexpr float kLookPitchDegPerPx = 0.15f; + static constexpr double kTapMaxSec = 0.3; - int64_t pinch_finger1_ = -1; - int64_t pinch_finger2_ = -1; + int64_t look_finger_id_ = -1; + bool look_can_rotate_ = false; + bool look_rotating_ = false; + bool look_moved_ = false; + float look_start_x_ = 0.0f; + float look_start_y_ = 0.0f; + float look_last_x_ = 0.0f; + float look_last_y_ = 0.0f; + double look_down_time_ = 0.0; + + int64_t pinch_finger_id_ = -1; + float pinch_x_ = 0.0f; + float pinch_y_ = 0.0f; float pinch_last_dist_ = 0.0f; + int tap_stage_ = 0; // 1: press next frame, 2: release next frame + float tap_x_ = 0.0f; + float tap_y_ = 0.0f; + std::vector buttons_; + double auto_hunt_down_time_ = 0.0; + bool auto_hunt_long_pressed_ = false; }; diff --git a/tools/40250/mt_register.rc b/tools/40250/mt_register.rc new file mode 100644 index 00000000..037e53d8 --- /dev/null +++ b/tools/40250/mt_register.rc @@ -0,0 +1,24 @@ +#!/bin/sh +# +# PROVIDE: mt_register +# REQUIRE: LOGIN mysql +# KEYWORD: shutdown +# +# Account registration service for the 40250 server (tools/40250/register_server.py). +# Install: /usr/local/etc/rc.d/mt_register, then `sysrc mt_register_enable=YES`. + +. /etc/rc.subr + +name=mt_register +rcvar=mt_register_enable +load_rc_config $name + +: ${mt_register_enable:=NO} +: ${mt_register_dir:=/usr/metin2/server/register} + +pidfile=/var/run/${name}.pid +procname=/usr/local/bin/python3.9 +command=/usr/sbin/daemon +command_args="-f -p ${pidfile} -o ${mt_register_dir}/register.log ${procname} ${mt_register_dir}/register_server.py" + +run_rc_command "$1" diff --git a/tools/40250/register_server.py b/tools/40250/register_server.py new file mode 100644 index 00000000..b67f1750 --- /dev/null +++ b/tools/40250/register_server.py @@ -0,0 +1,130 @@ +#!/usr/local/bin/python3.9 +"""Account registration service for the 40250 server (PORT: 40250 has no in-client registration). + +POST /register with a form body `login=..&password=..&social_id=..` inserts a row into +account.account the same way the existing accounts are stored (password = MySQL PASSWORD()). +The reply is one plain-text line the client shows directly: `OK` or `ERR `, where CODE is +BAD_LOGIN, BAD_PASSWORD, BAD_SOCIAL_ID, EXISTS, RATE_LIMIT, BAD_REQUEST or SERVER. + +Deployed to /usr/metin2/server/register/ and started by /usr/local/etc/rc.d/mt_register. +""" +import argparse +import re +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs + +LOGIN_RE = re.compile(r"^[A-Za-z0-9]{4,16}$") # LoginWindow ID_EditLine input_limit 16 +PASSWORD_RE = re.compile(r"^[\x21-\x7e]{4,16}$") # printable ASCII, Password_EditLine input_limit 16 +SOCIAL_ID_RE = re.compile(r"^[0-9]{7}$") # the delete code introselect asks for + +RATE_WINDOW = 3600 +RATE_MAX = 5 # successful registrations per IP per hour +MAX_BODY = 1024 + +_rate_lock = threading.Lock() +_rate = {} # ip -> [timestamps of successful registrations] + + +def log(msg): + sys.stdout.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n") + sys.stdout.flush() + + +def rate_limited(ip): + now = time.time() + with _rate_lock: + hits = [t for t in _rate.get(ip, []) if now - t < RATE_WINDOW] + _rate[ip] = hits + return len(hits) >= RATE_MAX + + +def rate_record(ip): + with _rate_lock: + _rate.setdefault(ip, []).append(time.time()) + + +def create_account(login, password, social_id): + # Every value is validated above; the password additionally travels as hex so no quoting + # can leak into the statement. + sql = ("INSERT INTO account.account (login, password, social_id, status, create_time, last_play) " + "VALUES ('%s', PASSWORD(UNHEX('%s')), '%s', 'OK', NOW(), NOW())" + % (login, password.encode("ascii").hex(), social_id)) + r = subprocess.run(["/usr/local/bin/mysql", "-uroot", "-N", "-e", sql], capture_output=True, text=True, timeout=10) + if r.returncode == 0: + return "OK" + if "Duplicate entry" in r.stderr or "ERROR 1062" in r.stderr: + return "ERR EXISTS" + log("mysql error: " + r.stderr.strip()) + return "ERR SERVER" + + +class Handler(BaseHTTPRequestHandler): + server_version = "mt-register/1" + + def log_message(self, fmt, *args): + pass + + def reply(self, code, text): + body = (text + "\n").encode("ascii") + self.send_response(code) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/ping": + self.reply(200, "OK") + else: + self.reply(404, "ERR BAD_REQUEST") + + def do_POST(self): + ip = self.client_address[0] + if self.path != "/register": + return self.reply(404, "ERR BAD_REQUEST") + try: + n = int(self.headers.get("Content-Length", "0")) + except ValueError: + n = -1 + if n < 0 or n > MAX_BODY: + return self.reply(400, "ERR BAD_REQUEST") + try: + form = parse_qs(self.rfile.read(n).decode("ascii"), keep_blank_values=True) + except (UnicodeDecodeError, ValueError): + return self.reply(400, "ERR BAD_REQUEST") + login = form.get("login", [""])[0] + password = form.get("password", [""])[0] + social_id = form.get("social_id", [""])[0] + if not LOGIN_RE.match(login): + result = "ERR BAD_LOGIN" + elif not PASSWORD_RE.match(password): + result = "ERR BAD_PASSWORD" + elif not SOCIAL_ID_RE.match(social_id): + result = "ERR BAD_SOCIAL_ID" + elif rate_limited(ip): + result = "ERR RATE_LIMIT" + else: + result = create_account(login.lower(), password, social_id) + if result == "OK": + rate_record(ip) + log("%s register %s -> %s" % (ip, login, result)) + self.reply(200, result) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--bind", default="0.0.0.0") + ap.add_argument("--port", type=int, default=11080) + args = ap.parse_args() + httpd = ThreadingHTTPServer((args.bind, args.port), Handler) + log("listening on %s:%d" % (args.bind, args.port)) + httpd.serve_forever() + + +if __name__ == "__main__": + main()