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 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-24 09:13:20 +09:00
co-authored by Claude Opus 5.5
parent 6ea0fa9889
commit 85e176bdd5
11 changed files with 234 additions and 2 deletions
+1
View File
@@ -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-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-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-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)"]}
+1
View File
@@ -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 宿主与开关。 默认路由切换(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(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-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 宿主。 每个切片都必须同时跑 legacy/port 双路回归;默认路由已切到 `port`(2026-09-24);port 路由稳定后删除总开关和剩余 legacy 宿主。
@@ -554,6 +554,25 @@ bool RunLine(const char* source, std::string* error)
return true; 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() void Stop()
{ {
if (!g_launcher) if (!g_launcher)
@@ -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 // 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. // no 40250 packs, which is where RunMainScript cannot run at all.
bool RunLine(const char* source, std::string* error); 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 // Forward physical Godot input to the original 40250 window manager. Coordinates
// are viewport pixels; SetUISize also establishes its logical resolution. // are viewport pixels; SetUISize also establishes its logical resolution.
+14
View File
@@ -1,6 +1,7 @@
#include "python_host_node.h" #include "python_host_node.h"
#include <godot_cpp/core/class_db.hpp> #include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include "python_stdlib.h" #include "python_stdlib.h"
@@ -63,6 +64,16 @@ String Metin2PythonHost::run_line(const String &source) {
return String(); 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(); } bool Metin2PythonHost::is_app_looping() { return PythonBoot::IsAppLooping(); }
void Metin2PythonHost::set_ui_size(int width, int height) { PythonBoot::SetUISize(width, height); } 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)"); 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; } bool Metin2PythonHost::is_app_looping() { return false; }
void Metin2PythonHost::set_ui_size(int, int) {} void Metin2PythonHost::set_ui_size(int, int) {}
void Metin2PythonHost::ui_mouse_move(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"), ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("start", "stdlib_path"),
&Metin2PythonHost::start, DEFVAL(String())); &Metin2PythonHost::start, DEFVAL(String()));
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("is_running"), &Metin2PythonHost::is_running); 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"), ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_main_script", "command_line"),
&Metin2PythonHost::run_main_script, DEFVAL(String())); &Metin2PythonHost::run_main_script, DEFVAL(String()));
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_line", "source"), &Metin2PythonHost::run_line); ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_line", "source"), &Metin2PythonHost::run_line);
+1
View File
@@ -32,6 +32,7 @@ public:
static godot::String run_main_script(const godot::String &command_line); static godot::String run_main_script(const godot::String &command_line);
// One line of Python in the same __main__ dictionary. "" when it ran. // One line of Python in the same __main__ dictionary. "" when it ran.
static godot::String run_line(const godot::String &source); 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(). // True while system.py is inside app.Loop(); ui_update() then runs one CPythonApplication::Process().
static bool is_app_looping(); static bool is_app_looping();
static void set_ui_size(int width, int height); static void set_ui_size(int width, int height);
@@ -563,6 +563,24 @@ bool FakeLoginServer::ServeGame(Connection& c, int index)
std::strncpy(ownership.owner, kCharacterName, sizeof(ownership.owner) - 1); std::strncpy(ownership.owner, kCharacterName, sizeof(ownership.owner) - 1);
if (!c.Send(ground) || !c.Send(ownership)) if (!c.Send(ground) || !c.Send(ownership))
return Fail(name + ": send ground item"); 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<GCCharacterAdd>();
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<GCCharacterUpdate>();
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"); Event(name + ":burst_sent");
// A ping after the burst proves that GamePhase consumed every packet above and can reply. // 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 // 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 // CInputProcessor::Process frames it (static size or TDynamicSizePacketHeader, then the sequence
// byte). A CG_MOVE (input_main.cpp CInputMain::Move) becomes "<name>:move <func> <arg> <rot> <x> <y>". // byte). A CG_MOVE (input_main.cpp CInputMain::Move) becomes "<name>:move <func> <arg> <rot> <x> <y>".
int mob_hp = kMobHits;
for (;;) for (;;)
{ {
std::uint8_t packet[1024] = {}; 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)) if (!c.Send(chat) || !c.Send(line.c_str(), line.size() + 1))
return Fail(name + ": send GC_CHAT"); 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<std::uint8_t>(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<std::uint8_t>(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) if (header == HDR_CG_MOVE)
{ {
CGMove move; CGMove move;
+5
View File
@@ -26,6 +26,11 @@ public:
static constexpr std::uint32_t kGroundItemVnum = 19; static constexpr std::uint32_t kGroundItemVnum = 19;
static constexpr std::int32_t kGroundItemX = kX + 150, kGroundItemY = kY; static constexpr std::int32_t kGroundItemX = kX + 150, kGroundItemY = kY;
static constexpr std::uint32_t kExchangeItemVnum = 19, kExchangeGold = 1000; 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(); ~FakeLoginServer();
+55 -2
View File
@@ -48,6 +48,7 @@
#include "GameLib/FlyingData.h" #include "GameLib/FlyingData.h"
#include "GameLib/FlyingObjectManager.h" #include "GameLib/FlyingObjectManager.h"
#include "EffectLib/EffectManager.h" #include "EffectLib/EffectManager.h"
#include "EterLib/Camera.h"
#include "GameLib/ItemManager.h" #include "GameLib/ItemManager.h"
#include "UserInterface/PythonItem.h" #include "UserInterface/PythonItem.h"
#include "UserInterface/StdAfx.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()); 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("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 // 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. // app.RenderGame; the item tail keeps that screen rect, which is what textTail.Pick tests.
PythonBoot::UIKey(DIK_LMENU, true); 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()); 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) if (server)
{ {
const std::vector<std::string> expected = { const std::vector<std::string> expected = {
@@ -700,7 +752,8 @@ int main(int argc, char** argv)
for (const auto& e : events) for (const auto& e : events)
if (e.rfind(prefix, 0) == 0 && e.find(":move ") == std::string::npos && if (e.rfind(prefix, 0) == 0 && e.find(":move ") == std::string::npos &&
e.find(":pickup ") == 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); got.push_back(e);
CHECK(want == got); CHECK(want == got);
} }
+6
View File
@@ -286,6 +286,12 @@ func _material(draw: Dictionary) -> StandardMaterial3D:
material.emission_enabled = lit and draw["material_emissive"] != Color(0, 0, 0, 0) material.emission_enabled = lit and draw["material_emissive"] != Color(0, 0, 0, 0)
if material.emission_enabled: if material.emission_enabled:
material.emission = draw["material_emissive"] 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 \ material.shading_mode = BaseMaterial3D.SHADING_MODE_PER_VERTEX if lit \
else BaseMaterial3D.SHADING_MODE_UNSHADED else BaseMaterial3D.SHADING_MODE_UNSHADED
material.specular_mode = BaseMaterial3D.SPECULAR_DISABLED material.specular_mode = BaseMaterial3D.SPECULAR_DISABLED
+81
View File
@@ -311,4 +311,85 @@ func run() -> void:
report["far_ground_texture_pairs"] = far_pairs report["far_ground_texture_pairs"] = far_pairs
_check(far_pairs > 20, "map behind the actor visible (%d local contrasts)" % far_pairs) _check(far_pairs > 20, "map behind the actor visible (%d local contrasts)" % far_pairs)
ui.visible = true 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) await _finish(output, report)