From 85e176bdd5dd70a70aa5d325f9162cfa70879528 Mon Sep 17 00:00:00 2001 From: shenlei Date: Thu, 24 Sep 2026 09:13:20 +0900 Subject: [PATCH] Batch 4-b: verify combat on the port route The fake server spawns a stray dog outside the village's BANPK safe disc and answers CG_TARGET/CG_ATTACK with GC_TARGET/GC_DAMAGE_INFO/GC_DEAD. port.login_flow and python_game_render_test hold the left button on it (MBF_SMART -> __OnPressActor) until it dies. Adds the host/test helper PythonBoot::Evaluate / Metin2PythonHost.evaluate. python_3d_surface: emission is now multiplied by the texture, like D3D's emissive-then-MODULATE, which removes the grey click-glow quad and the grey wash on characters. Damage numbers wait on CPythonSystem (4-g). Co-Authored-By: Claude Opus 5.5 --- audit/history.jsonl | 1 + docs/PORT-PLAN.md | 1 + .../src/platform/ScriptLib/PythonBoot.cpp | 19 +++++ extension/src/platform/ScriptLib/PythonBoot.h | 2 + extension/src/python_host_node.cpp | 14 ++++ extension/src/python_host_node.h | 1 + extension/tests/port_login_flow_server.cpp | 49 +++++++++++ extension/tests/port_login_flow_server.h | 5 ++ extension/tests/port_login_flow_test.cpp | 57 ++++++++++++- project/python_3d_surface.gd | 6 ++ project/python_game_render_test.gd | 81 +++++++++++++++++++ 11 files changed, 234 insertions(+), 2 deletions(-) diff --git a/audit/history.jsonl b/audit/history.jsonl index e4f235dd..c6b41f02 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -541,3 +541,4 @@ {"time": "2026-09-24T17:00:00Z", "event": "route_switch", "unit": "client_main default route -> port", "detail": "MT_PORT_PATH unset/port runs python_ui_surface + python_3d_surface (40250 system.py); MT_PORT_PATH=legacy runs AppFlow; smoke/playable test modes stay legacy; unset falls back to legacy when Metin2PythonHost or the 40250 pack is missing", "tests": ["client_main.tscn instantiated per route: default/port -> PythonUISurface+Python3DSurface with UI commands, legacy -> AppFlow+NetTrace"]} {"time": "2026-09-24T18:00:00Z", "event": "correction", "unit": "batch 3-c todo", "detail": "The sword next to the actor in python_game_render_test is the fake server ground item (GC_ITEM_GROUND_ADD vnum 19, P3-b), not a weapon-attach bug; the fake character has no weapon, AttachWeapon is never called. Todo withdrawn."} {"time": "2026-09-24T19:00:00Z", "event": "port", "unit": "batch 4-a text input: EterLib/IME.cpp (ADAPTED), UserInterface/PythonIME.cpp, PythonIMEModule.cpp", "detail": "ime module ported; IMM/TSF-free CIME in platform/; Godot key events -> ui_ime_key/ui_char -> CWindowManager RunIME*; login typed through the IME; in-game chat CG_CHAT round-trip", "tests": ["port.login_flow (typed id/password with edits, chat send/echo/close)", "port.login_live", "python_game_render_test (Godot key events)"]} +{"time": "2026-09-24T20:00:00Z", "event": "verify", "unit": "batch 4-b combat: InstanceBaseBattle / ActorInstanceCollisionDetection / PythonPlayerInput (already ported)", "detail": "fake server spawns a stray dog outside the BANPK safe disc, answers CG_TARGET/CG_ATTACK with GC_TARGET/GC_DAMAGE_INFO/GC_DEAD; held left button walks up and combo-attacks until death; damage numbers wait on CPythonSystem (4-g)", "tests": ["port.login_flow (step 9 held-button kill)", "port.login_live", "python_game_render_test (Godot mouse events, target HP 100/66/33/0)"]} diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index 86d47eb0..cecc1723 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -452,6 +452,7 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次 默认路由切换(2026-09-24):`client_main` 不设 `MT_PORT_PATH` 时走 port 路由(`python_ui_surface.gd` + `python_3d_surface.gd` 跑 40250 system.py),`MT_PORT_PATH=legacy` 回到 GDScript `AppFlow`,规则见 §2「迁移方式」。已验证:实例化 `client_main.tscn`,默认/`port` 起 PythonUISurface + Python3DSurface 并产出 UI 命令,`legacy` 起 AppFlow + NetTrace。下一步:port 路由稳定后删除 legacy 宿主与开关。 批次 4(2026-09-24 起):补齐 port 路由的玩法,以便删除 legacy。顺序:4-a 文字输入 → 4-b 战斗 → 4-c 商店 → 4-d 任务/NPC 对话 → 4-e 仓库 → 4-f 好友/公会/公会标记 → 4-g systemSetting → 4-h 声音。 批次 4-a 文字输入(2026-09-24):`UserInterface/PythonIME.cpp` 与 `PythonIMEModule.cpp` 原样移植(仅把 MSVC 专有的 `unsigned char(x)` 函数式转换改成 C 式转换),删除 ime 模块桩,`initime()` 按 40250 顺序在 `initdbg()` 之后,`CPythonApplication::Create` 按原序 `CPythonIME::Create` + `SetText("", 0)`。`EterLib/IME.cpp` 在 platform 下:缓冲区、光标、`WMChar`(退格、`|` 转义、按输入代码页 `MultiByteToWideChar`)、候选/读音状态、数字/字符串模式等函数体照抄;IMM/TSF 在 Godot 下没有对应物,组字相关处理器为空、`EnableIME` 恒关,已提交的非单字节文本由 `PythonBoot::UIChar` 模拟 `GCS_RESULTSTR`(`InsertString` + `OnUpdate`),`DelCurPos` 用 memmove 替代重叠的 wcscpy(全部记 ADAPTED)。`GameApplicationAdapter` 的 `RunIME*` 转发到 `CWindowManager`;注意 `RunIMEUpdate/RunIMEKeyDown` 只送达正在渲染的活动窗口(登录框未打开时输入被丢弃)。Godot 侧 `python_ui_surface.gd` 把按键送 `ui_ime_key`(VK)再送 `ui_char`(unicode)。`port.login_flow` 与 `port.login_live` 打开登录框后逐字键入账号(含 LEFT/DELETE/BACK 编辑)、TAB 切到密码、RETURN 登录(凭据只来自环境变量,测试随后删除 Python 局部变量);进游戏后 RETURN 打开聊天、键入 hello、RETURN 发出 `CG_CHAT`(假服回显 `GC_CHAT`),空行 RETURN 关闭聊天(40250 发完后只清空、不关闭)。`python_game_render_test` 用 Godot 按键事件走同一路径登录。 + 批次 4-b 战斗(2026-09-24):战斗代码此前已原样移植(`InstanceBaseBattle`、`ActorInstanceCollisionDetection`、`PythonPlayerInput*`、`uiTarget`),本批只补验证。假服在出生点北 18 m 放一只野狗(npclist 101,怪物不发 `GC_CHAR_ADDITIONAL_INFO`,名字取自 mob_proto;发了会 syserr),出生点在村庄 `ATTRIBUTE_BANPK` 圈内,`CInstanceBase::IsInSafe()` 会挡掉所有命中。`CG_TARGET` 回 `GC_TARGET`,`CG_ATTACK` 回 `GC_DAMAGE_INFO` + `GC_TARGET`,第三下回 `GC_DEAD`。与 40250 相同:单击怪物(`__OnClickActor`)只走过去,按住左键(MBF_SMART → `__OnPressActor`)才走近并连击。`port.login_flow` 第 9 步用显式透视矩阵投影野狗、按住左键直到死亡,检查 target 面板和至少 3 次 `CG_ATTACK`;`python_game_render_test` 用 Godot 鼠标事件走同一路径,截图 combat_mid/combat.png(野狗位于镜头与战士的连线上,被战士挡住,属正常)。新增宿主/测试辅助 `PythonBoot::Evaluate` / `Metin2PythonHost.evaluate`(40250 无对应物)。`python_3d_surface.gd` 修正:D3D 固定管线中材质 emissive 先加进顶点色,再被纹理 MODULATE,Godot 的 emission 却加在纹理之后,导致黑底加色的点击光圈(click_glow_select copy.jpg)显示成灰色方块,角色也被 0.32 的 emissive 洗灰;现在 emission 乘以同一张纹理(`EMISSION_OP_MULTIPLY`)。战斗步骤先等镜头稳定(上一步的走路可能尚未结束),野狗投影落在小地图或任务栏下时先点地面走近一段。已知缺口:伤害数字依赖 `CPythonSystem::IsShowDamage()`,该单元仍在 pending 桩(4-g),故暂不显示。 每个切片都必须同时跑 legacy/port 双路回归;默认路由已切到 `port`(2026-09-24);port 路由稳定后删除总开关和剩余 legacy 宿主。 diff --git a/extension/src/platform/ScriptLib/PythonBoot.cpp b/extension/src/platform/ScriptLib/PythonBoot.cpp index 5c3499d4..b349a90e 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.cpp +++ b/extension/src/platform/ScriptLib/PythonBoot.cpp @@ -554,6 +554,25 @@ bool RunLine(const char* source, std::string* error) return true; } +bool Evaluate(const char* expression, std::string* result, std::string* error) +{ + // A host/test helper with no 40250 counterpart: RunLine stores str(expression) in __main__, read back here. + if (!RunLine(("__mt_evaluate = str(" + std::string(expression ? expression : "None") + ")").c_str(), error)) + return false; + PyObject* main = PyImport_AddModule("__main__"); + PyObject* value = main ? PyObject_GetAttrString(main, "__mt_evaluate") : nullptr; + if (!value || !PyString_Check(value)) + { + Py_XDECREF(value); + PyErr_Clear(); + return fail(error, "evaluate: no result"); + } + result->assign(PyString_AsString(value)); + Py_DECREF(value); + PyObject_DelAttrString(main, "__mt_evaluate"); + return true; +} + void Stop() { if (!g_launcher) diff --git a/extension/src/platform/ScriptLib/PythonBoot.h b/extension/src/platform/ScriptLib/PythonBoot.h index b6ed8a91..77503b13 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.h +++ b/extension/src/platform/ScriptLib/PythonBoot.h @@ -48,6 +48,8 @@ void AppRequestQuit(); // check uses it to prove the interpreter and the standard library work in-process on a device that has // no 40250 packs, which is where RunMainScript cannot run at all. bool RunLine(const char* source, std::string* error); +// str(expression) evaluated in __main__ (a host/test helper; 40250 has no counterpart). +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. diff --git a/extension/src/python_host_node.cpp b/extension/src/python_host_node.cpp index 5dc2c828..57160724 100644 --- a/extension/src/python_host_node.cpp +++ b/extension/src/python_host_node.cpp @@ -1,6 +1,7 @@ #include "python_host_node.h" #include +#include #include "python_stdlib.h" @@ -63,6 +64,16 @@ String Metin2PythonHost::run_line(const String &source) { return String(); } +// str(expression) from __main__, or "" when it raised (the error goes to the log like run_line's). +String Metin2PythonHost::evaluate(const String &expression) { + std::string result, error; + if (!PythonBoot::Evaluate(expression.utf8().get_data(), &result, &error)) { + UtilityFunctions::push_error(String::utf8(error.c_str())); + return String(); + } + return String::utf8(result.c_str()); +} + bool Metin2PythonHost::is_app_looping() { return PythonBoot::IsAppLooping(); } void Metin2PythonHost::set_ui_size(int width, int height) { PythonBoot::SetUISize(width, height); } @@ -261,6 +272,8 @@ String Metin2PythonHost::run_line(const String &) { return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)"); } +String Metin2PythonHost::evaluate(const String &) { return String(); } + bool Metin2PythonHost::is_app_looping() { return false; } void Metin2PythonHost::set_ui_size(int, int) {} void Metin2PythonHost::ui_mouse_move(int, int) {} @@ -283,6 +296,7 @@ void Metin2PythonHost::_bind_methods() { ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("start", "stdlib_path"), &Metin2PythonHost::start, DEFVAL(String())); ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("is_running"), &Metin2PythonHost::is_running); + ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("evaluate", "expression"), &Metin2PythonHost::evaluate); ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_main_script", "command_line"), &Metin2PythonHost::run_main_script, DEFVAL(String())); ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_line", "source"), &Metin2PythonHost::run_line); diff --git a/extension/src/python_host_node.h b/extension/src/python_host_node.h index 5d107de1..443cbd68 100644 --- a/extension/src/python_host_node.h +++ b/extension/src/python_host_node.h @@ -32,6 +32,7 @@ public: static godot::String run_main_script(const godot::String &command_line); // One line of Python in the same __main__ dictionary. "" when it ran. static godot::String run_line(const godot::String &source); + static godot::String evaluate(const godot::String &expression); // True while system.py is inside app.Loop(); ui_update() then runs one CPythonApplication::Process(). static bool is_app_looping(); static void set_ui_size(int width, int height); diff --git a/extension/tests/port_login_flow_server.cpp b/extension/tests/port_login_flow_server.cpp index 7f1b8ee4..e1311b91 100644 --- a/extension/tests/port_login_flow_server.cpp +++ b/extension/tests/port_login_flow_server.cpp @@ -563,6 +563,24 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) std::strncpy(ownership.owner, kCharacterName, sizeof(ownership.owner) - 1); if (!c.Send(ground) || !c.Send(ownership)) return Fail(name + ": send ground item"); + // A monster in view: CHARACTER::EncodeInsertPacket sends ADDITIONAL_INFO only for PCs and NPCs, so a + // CHAR_TYPE_MONSTER is ADD + UPDATE (the client names it from mob_proto). + GCCharacterAdd mob = zeroed(); + mob.header = HDR_GC_CHARACTER_ADD; + mob.vid = kMobVID; + mob.x = kMobX; + mob.y = kMobY; + mob.type = 0; // CHAR_TYPE_MONSTER + mob.race = kMobRace; + mob.moving_speed = 100; + mob.attack_speed = 100; + GCCharacterUpdate mob_update = zeroed(); + mob_update.header = HDR_GC_CHARACTER_UPDATE; + mob_update.vid = kMobVID; + mob_update.moving_speed = 100; + mob_update.attack_speed = 100; + if (!c.Send(mob) || !c.Send(mob_update)) + return Fail(name + ": send monster"); Event(name + ":burst_sent"); // A ping after the burst proves that GamePhase consumed every packet above and can reply. @@ -575,6 +593,7 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) // Then hold the connection until the test stops us, reading what the game phase sends the way // CInputProcessor::Process frames it (static size or TDynamicSizePacketHeader, then the sequence // byte). A CG_MOVE (input_main.cpp CInputMain::Move) becomes ":move ". + int mob_hp = kMobHits; for (;;) { std::uint8_t packet[1024] = {}; @@ -671,6 +690,36 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) if (!c.Send(chat) || !c.Send(line.c_str(), line.size() + 1)) return Fail(name + ": send GC_CHAT"); } + // CInputMain::Target → CHARACTER::SetTarget → BroadcastTargetPacket: GC_TARGET with the monster's HP percent. + if (header == HDR_CG_TARGET) + { + CGTarget target; + std::memcpy(&target, packet, sizeof(target)); + Event(name + ":target " + std::to_string(target.vid)); + GCTarget out = {HDR_GC_TARGET, target.vid, + static_cast(target.vid == kMobVID ? mob_hp * 100 / kMobHits : 0)}; + if (!c.Send(out)) + return Fail(name + ": send GC_TARGET"); + } + // CInputMain::Attack → battle_hit → CHARACTER::Damage: GC_DAMAGE_INFO to the attacker, the target HP + // update, and at 0 HP CHARACTER::Dead's GC_DEAD. + if (header == HDR_CG_ATTACK) + { + CGAttack attack; + std::memcpy(&attack, packet, sizeof(attack)); + Event(name + ":attack " + std::to_string(attack.type) + " " + std::to_string(attack.victim_vid)); + if (attack.victim_vid == kMobVID && mob_hp > 0) + { + --mob_hp; + GCDamageInfo damage = {HDR_GC_DAMAGE_INFO, kMobVID, 1 /* DAMAGE_NORMAL */, 100}; + GCTarget target = {HDR_GC_TARGET, kMobVID, static_cast(mob_hp * 100 / kMobHits)}; + if (!c.Send(damage) || !c.Send(target)) + return Fail(name + ": send GC_DAMAGE_INFO"); + GCDead dead = {HDR_GC_DEAD, kMobVID}; + if (mob_hp == 0 && !c.Send(dead)) + return Fail(name + ": send GC_DEAD"); + } + } if (header == HDR_CG_MOVE) { CGMove move; diff --git a/extension/tests/port_login_flow_server.h b/extension/tests/port_login_flow_server.h index 343b63fb..5f96f069 100644 --- a/extension/tests/port_login_flow_server.h +++ b/extension/tests/port_login_flow_server.h @@ -26,6 +26,11 @@ public: static constexpr std::uint32_t kGroundItemVnum = 19; static constexpr std::int32_t kGroundItemX = kX + 150, kGroundItemY = kY; static constexpr std::uint32_t kExchangeItemVnum = 19, kExchangeGold = 1000; + // A stray dog (npclist 101) for the combat step; three hits kill it. The spawn point lies in the village's + // ATTRIBUTE_BANPK disc (CInstanceBase::IsInSafe blocks every hit there), so the dog stands 18 m north of it. + static constexpr std::uint32_t kMobVID = 0x00023456, kMobRace = 101; + static constexpr std::int32_t kMobX = kX, kMobY = kY - 1800; + static constexpr int kMobHits = 3; ~FakeLoginServer(); diff --git a/extension/tests/port_login_flow_test.cpp b/extension/tests/port_login_flow_test.cpp index c66d989e..0c21bd18 100644 --- a/extension/tests/port_login_flow_test.cpp +++ b/extension/tests/port_login_flow_test.cpp @@ -48,6 +48,7 @@ #include "GameLib/FlyingData.h" #include "GameLib/FlyingObjectManager.h" #include "EffectLib/EffectManager.h" +#include "EterLib/Camera.h" #include "GameLib/ItemManager.h" #include "UserInterface/PythonItem.h" #include "UserInterface/StdAfx.h" @@ -596,7 +597,7 @@ int main(int argc, char** argv) }; std::printf("port_login_flow_test: %s\n", tail_info().c_str()); CHECK(tail_info().find("ItemTail (Map 1,") != std::string::npos); - CHECK(tail_info().find("ChrTail (Map 1,") != std::string::npos); + CHECK(tail_info().find("ChrTail (Map 2,") != std::string::npos); // the actor and the stray dog // Holding ALT (game.GameWindow.ShowName) makes OnRender show every tail in range and project it after // app.RenderGame; the item tail keeps that screen rect, which is what textTail.Pick tests. PythonBoot::UIKey(DIK_LMENU, true); @@ -682,6 +683,57 @@ int main(int argc, char** argv) std::printf("port_login_flow_test: %s\n", e.c_str()); } } + // 9. (批次 4-b) Combat: a left click on the stray dog reaches CPythonPlayer::__OnClickActor, which targets it + // (CG_TARGET → GC_TARGET, uitarget shows the HP board) and walks into range to attack. Each attack frame's + // hit (CPythonPlayerEventHandler::OnHit) sends CG_ATTACK; the fake server answers GC_DAMAGE_INFO + GC_TARGET, + // and GC_DEAD on the third hit. + if (server) + { + CInstanceBase* mob = CPythonCharacterManager::Instance().GetInstancePtr(FakeLoginServer::kMobVID); + CHECK(mob != nullptr); + if (mob) + { + CHECK(!mob->IsDead()); + // Project the dog's body with RenderGame's perspective and the current camera (after the frame the + // graphic state holds the UI's ortho projection). + auto dog_on_screen = [&] { + UI::CWindowManager& wm = UI::CWindowManager::Instance(); + D3DXMATRIX proj, identity; + D3DXMatrixPerspectiveFovRH(&proj, D3DXToRadian(30.0f), wm.GetAspect(), 100.0f, + CPythonBackground::Instance().GetFarClip()); + D3DXMatrixIdentity(&identity); + D3DVIEWPORT8 viewport = {0, 0, (DWORD) wm.GetScreenWidth(), (DWORD) wm.GetScreenHeight(), 0.0f, 1.0f}; + const D3DXVECTOR3 body = mob->GetGraphicThingInstanceRef().GetPosition() + D3DXVECTOR3(0.0f, 0.0f, 40.0f); + D3DXVECTOR3 screen; + D3DXVec3Project(&screen, &body, &viewport, &proj, + &CCameraManager::Instance().GetCurrentCamera()->GetViewMatrix(), &identity); + return std::make_pair((int) screen.x, (int) screen.y); + }; + auto [sx, sy] = dog_on_screen(); + std::printf("port_login_flow_test: stray dog on screen at %d %d\n", sx, sy); + PythonBoot::UIMouseMove(sx, sy); + pump_until(0.1, [] { return false; }); + // game.GameWindow.OnMouseLeftButtonDown → player.SetMouseState(MBT_LEFT, MBS_PRESS): MBF_SMART on an + // actor is __OnPressActor, which walks into range (__ReserveClickActor) and attacks while the button is + // held; the cursor follows the dog as the camera follows the actor, as a player's hand would. + PythonBoot::UIMouseButton(1, true, sx, sy); + const std::string mob_vid = std::to_string(FakeLoginServer::kMobVID); + CHECK(pump_until(2, [&] { return server->Has("game2:target " + mob_vid); })); + CHECK(pump_until(2, [] { return py_true("_stream.curPhaseWindow.targetBoard.IsShow()"); })); + CHECK(pump_until(10, [&] { + std::tie(sx, sy) = dog_on_screen(); + PythonBoot::UIMouseMove(sx, sy); + return mob->IsDead(); + })); + PythonBoot::UIMouseButton(1, false, sx, sy); + size_t attacks = 0; + for (const auto& e : server->Events()) + if (e == "game2:attack 0 " + mob_vid) + ++attacks; + std::printf("port_login_flow_test: %zu attack(s) until the stray dog died\n", attacks); + CHECK(attacks >= (size_t) FakeLoginServer::kMobHits); + } + } if (server) { const std::vector expected = { @@ -700,7 +752,8 @@ int main(int argc, char** argv) for (const auto& e : events) if (e.rfind(prefix, 0) == 0 && e.find(":move ") == std::string::npos && e.find(":pickup ") == std::string::npos && - e.find(":exchange ") == std::string::npos && e.find(":chat ") == std::string::npos) + e.find(":exchange ") == std::string::npos && e.find(":chat ") == std::string::npos && + e.find(":target ") == std::string::npos && e.find(":attack ") == std::string::npos) got.push_back(e); CHECK(want == got); } diff --git a/project/python_3d_surface.gd b/project/python_3d_surface.gd index 861af016..53f43c84 100644 --- a/project/python_3d_surface.gd +++ b/project/python_3d_surface.gd @@ -286,6 +286,12 @@ func _material(draw: Dictionary) -> StandardMaterial3D: material.emission_enabled = lit and draw["material_emissive"] != Color(0, 0, 0, 0) if material.emission_enabled: material.emission = draw["material_emissive"] + # D3D adds the emissive term to the lit vertex colour, and D3DTOP_MODULATE then multiplies that by the + # texture; Godot adds emission after the texture, so a black additive glow (click_glow_select copy.jpg) + # came out as a grey quad. + if material.albedo_texture: + material.emission_texture = material.albedo_texture + material.emission_operator = BaseMaterial3D.EMISSION_OP_MULTIPLY material.shading_mode = BaseMaterial3D.SHADING_MODE_PER_VERTEX if lit \ else BaseMaterial3D.SHADING_MODE_UNSHADED material.specular_mode = BaseMaterial3D.SPECULAR_DISABLED diff --git a/project/python_game_render_test.gd b/project/python_game_render_test.gd index 089dacd6..f2d6d17f 100644 --- a/project/python_game_render_test.gd +++ b/project/python_game_render_test.gd @@ -311,4 +311,85 @@ func run() -> void: report["far_ground_texture_pairs"] = far_pairs _check(far_pairs > 20, "map behind the actor visible (%d local contrasts)" % far_pairs) ui.visible = true + + # Combat (批次 4-b) with Godot mouse events: hold the left button on the fake server's stray dog; the port's + # CPythonPlayer walks out of the village's safe zone and attacks, the fake server's GC_TARGET updates reach + # uiTarget.TargetBoard.SetHP (wrapped here to record them) and GC_DEAD comes with the 0 % update. + if in_game: + var dog_vid := 0x23456 + _check(_py("import uiTarget\n" + + "_target_hp = []\n" + + "_set_hp = uiTarget.TargetBoard.SetHP\n" + + "def _record_hp(board, hp):\n" + + "\t_target_hp.append(hp)\n" + + "\t_set_hp(board, hp)\n" + + "uiTarget.TargetBoard.SetHP = _record_hp"), "target HP recorder installed") + var dog_screen := func() -> Vector2: + var parts := Metin2PythonHost.evaluate("'%%d,%%d,%%d' %% __import__('chr').GetPixelPosition(%d)" % dog_vid).split(",") + if parts.size() != 3: + return Vector2(-1, -1) + var world_pos := Vector3(float(parts[0]), -float(parts[1]), float(parts[2]) + 40.0) + for draw in Metin2PythonHost.render3d_draws(): + if draw["proj"][11] == -1.0: + return world.camera.unproject_position(world.d3d_transform(draw["view"]) * world_pos) + return Vector2(-1, -1) + var move := func(position: Vector2) -> void: + var event := InputEventMouseMotion.new() + event.position = position + event.global_position = position + root.push_input(event) + var button := func(pressed: bool, position: Vector2) -> void: + var event := InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = pressed + event.position = position + event.global_position = position + root.push_input(event) + # The actor may still be walking from the earlier step, and the camera follows it: wait until the dog + # stands still on screen before every click. + var settled := func() -> Vector2: + var last := Vector2(-100, -100) + var now := Vector2.ZERO + for frame in 600: + await process_frame + now = dog_screen.call() + if now.distance_to(last) < 0.5: + break + last = now + return now + var at: Vector2 = await settled.call() + # The earlier walk does not end on the same spot every run, and the dog can land under the minimap or the + # taskbar: walk part of the way first (a ground click, which __OnClickGround turns into a move). + var clear := Rect2(40, 150, 780, 520) + for attempt in 3: + if clear.has_point(at): + break + var toward: Vector2 = clear.get_center().lerp(at, 0.5).clamp(clear.position, clear.end) + move.call(toward) + await process_frame + button.call(true, toward) + await process_frame + button.call(false, toward) + at = await settled.call() + report["dog_screen"] = str(at) + _check(at.x >= 0.0, "stray dog projected on screen (%s)" % str(at)) + move.call(at) + await process_frame + button.call(true, at) + var mid_shot := false + var dead := await _pump_until(20, func(): + var now: Vector2 = dog_screen.call() + if now.x >= 0.0: + at = now + move.call(at) + if not mid_shot and Metin2PythonHost.evaluate("33 in _target_hp") == "True" and DisplayServer.get_name() != "headless": + mid_shot = true + root.get_texture().get_image().save_png(output.path_join("combat_mid.png")) + return Metin2PythonHost.evaluate("_target_hp[-1:] == [0]") == "True") + button.call(false, at) + if DisplayServer.get_name() != "headless": + root.get_texture().get_image().save_png(output.path_join("combat.png")) + report["target_hp"] = Metin2PythonHost.evaluate("_target_hp") + _check(dead, "stray dog killed with the held left button (target HP %s)" % report["target_hp"]) + _check(_py("uiTarget.TargetBoard.SetHP = _set_hp\ndel _set_hp, _record_hp, _target_hp"), "target HP recorder removed") await _finish(output, report)