Files
mtgodot-poc/project/python_host_check.gd
T
shenleiandClaude Opus 5.5 141206cb69 2V0-f: run the real system.py app loop in Godot
- system.py runs app.Loop() on the script fiber; each UIUpdate is one
  CPythonApplication::Process(). Logo -> Login, popup ESC/click and
  app.Exit teardown (launcher cleared first, as in 40250 Main) work.
- Image commands are base-texture quads with UVs (a .sub shares its parent
  texture); ImageInstance/ExpandedImageInstance OnRender and SetRenderingRect
  ported verbatim, so scale/rotation/rendering rect are drawn.
- CGraphicBase::GetColor / grp.GenerateColor ported (ThinBoard background).
- port.app_loop test, python_host_test app-loop checks, MT_TEST_MODE=python_ui.
- TODO: .fnt/CGraphicText font pipeline, ExpandedImage blend modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 13:38:13 +09:00

209 lines
12 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# python_host_check —— 在 app 进程里把内嵌解释器起来并跑 system.py(docs/PORT-PLAN.md 批次 2P step 4)。
#
# 和 ctest 的 `port.python_launcher` 跑的是同一条序列,区别只有一个:那边是独立可执行文件,这边在
# Godot 进程里,由 GDScript 发起 —— step 4 要验的就是这个差别。2V0 把 C++ 模块注册上之前,
# 2V0-e 的玩法桩让 system.py 真正走到 prototype.RunApp();
# 没设画布尺寸时 app.Create 没有设备,RunApp 捕获 CREATE_DEVICE 并给出原版提示;设了尺寸以后
# system.py 停在 app.Loop() 里,由 ui_update 逐帧推进(_run_app_loop)。
#
# 真机上通常没有 40250 的 Client/pack(APK 里只有 python27.zip),所以分两段:解释器 + 标准库这一段
# 不需要 pack,任何平台都跑;system.py 这一段需要 pack,没有就跳过并说清楚。
#
# 桌面:python_host_test.gd(--script);真机:MT_TEST_MODE=python 从 client_main.gd 进来。
extends RefCounted
const PythonUISurface = preload("res://python_ui_surface.gd")
const EXPECTED_STOP := "Sorry, Your system does not support 3D graphics"
var fail := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
fail += 1
printerr("FAIL: " + message)
else:
print("ok: " + message)
# 返回失败数;0 表示落在预期的停止点上(或按上面的规则跳过)。
func run() -> int:
if not ClassDB.class_exists("Metin2PythonHost"):
printerr("FAIL: Metin2PythonHost extension is not registered")
return 1
if not FileAccess.file_exists("res://python27.zip"):
print("SKIP: python_host_check(没有 res://python27.zip,需要 -DMTGODOT_EMBED_PYTHON=ON 的构建)")
return 0
# 40250 Main() 先注册 pack,再造 CPythonLauncher —— system.py 是从 pack 里读出来的。
var has_pack := AssetRoot.pack_ready()
var start_error: String = Metin2PythonHost.start("")
_ck(start_error == "", "解释器在 app 进程里起来了:" + start_error)
if start_error != "":
return fail
_ck(Metin2PythonHost.is_running(), "is_running() 为真")
_ck(Metin2PythonHost.start("") == "", "重复 start() 是空操作")
# 标准库真的在 sys.path 上(真机是沙盒里那份 python27.zip),codec 注册表也重建过。
_ck(Metin2PythonHost.run_line("import os, string, types, codecs") == "", "标准库可 import")
_ck(Metin2PythonHost.run_line("assert u'\\xe4'.encode('cp1252') == '\\xe4'") == "", "codec 注册表可用")
_ck(Metin2PythonHost.run_line("import sys; assert sys.path") == "", "sys.path 非空")
if has_pack:
# RunMainScript:initpack() + __DEBUG__ + __COMMAND_LINE__ + RunFile("system.py")。
var err: String = Metin2PythonHost.run_main_script("")
print("system.py -> %s" % err)
_ck(err.contains(EXPECTED_STOP),
"system.py 进入 prototype.RunApp(),Create() 报告待实现的图形设备")
_ck(Metin2PythonHost.run_line("import net, player, item; assert net.__port_stub__ == 1 and player.__port_stub__ == 1 and item.__port_stub__ == 1") == "",
"2V0 玩法模块桩已注册,调用会记录到 stderr")
# 真正从 Godot 入口转发事件给 40250 的 CWindowManager,再由窗口树回调 Python。
Metin2PythonHost.set_ui_size(800, 600)
var probe_source := """
import wndMgr
_ui_events = []
class _UIProbe:
def OnMouseOverIn(self): _ui_events.append('in')
def OnMouseOverOut(self): _ui_events.append('out')
def OnMouseOver(self): pass
def OnMouseLeftButtonDown(self): _ui_events.append('down')
def OnMouseLeftButtonUp(self): _ui_events.append('up'); return 1
def OnSetFocus(self): _ui_events.append('focus')
def OnKillFocus(self): _ui_events.append('blur')
def OnUpdate(self): _ui_events.append('update')
def OnKeyDown(self, key): _ui_events.append('key:%d' % key); return 1
def OnKeyUp(self, key): return 1
_ui_probe = _UIProbe()
_ui_window = wndMgr.Register(_ui_probe, 'UI')
wndMgr.SetWindowSize(_ui_window, 100, 50)
wndMgr.SetWindowPosition(_ui_window, 10, 20)
wndMgr.Show(_ui_window)
wndMgr.SetFocus(_ui_window)
"""
_ck(Metin2PythonHost.run_line(probe_source) == "", "40250 窗口测试对象已在 Godot 进程中创建")
Metin2PythonHost.ui_mouse_move(15, 25)
Metin2PythonHost.ui_mouse_button(1, true, 15, 25)
Metin2PythonHost.ui_mouse_button(1, false, 15, 25)
Metin2PythonHost.ui_key(42, true)
Metin2PythonHost.ui_update()
_ck(Metin2PythonHost.run_line("assert 'in' in _ui_events and 'down' in _ui_events and 'up' in _ui_events and 'focus' in _ui_events and 'key:42' in _ui_events and 'update' in _ui_events") == "",
"Godot 输入、焦点和更新事件抵达 40250 Python 窗口")
_ck(Metin2PythonHost.run_line("_ui_bar = wndMgr.RegisterBar(_UIProbe(), 'UI'); wndMgr.SetWindowSize(_ui_bar, 20, 10); wndMgr.SetWindowPosition(_ui_bar, 30, 40); wndMgr.SetColor(_ui_bar, 0x7f336699); wndMgr.Show(_ui_bar)") == "",
"40250 Bar 窗口已创建")
var draws: Array = Metin2PythonHost.ui_render_commands()
var has_bar := false
for draw in draws:
if draw["kind"] == "bar" and draw["x1"] == 30.0 and draw["y1"] == 40.0 and draw["x2"] == 50.0 and draw["y2"] == 50.0 and draw["argb"] == 0x7f336699:
has_bar = true
_ck(has_bar, "40250 Bar 绘制调用进入 Godot Canvas 命令队列")
var clip_source := """
import grp
class _ClipProbe(_UIProbe):
def OnRender(self):
_ui_events.append('renderclip')
grp.SetViewport(0.0, 0.0, 0.05, 0.08)
_ui_clip_probe = _ClipProbe()
_ui_clip = wndMgr.Register(_ui_clip_probe, 'UI')
wndMgr.SetWindowSize(_ui_clip, 800, 600)
wndMgr.SetParent(_ui_bar, _ui_clip)
wndMgr.Show(_ui_clip)
"""
_ck(Metin2PythonHost.run_line(clip_source) == "", "40250 裁剪窗口已创建")
draws = Metin2PythonHost.ui_render_commands()
_ck(Metin2PythonHost.run_line("assert 'renderclip' in _ui_events") == "", "父窗口 OnRender 已执行")
var has_clip := false
for draw in draws:
if draw["kind"] == "bar" and draw["clip_x2"] == 40.0 and draw["clip_y2"] == 48.0:
has_clip = true
_ck(has_clip, "40250 viewport 裁剪范围传给 Godot Canvas")
var surface := PythonUISurface.new()
var segment: PackedVector2Array = surface._clip_line(Vector2(-10, 10), Vector2(50, 10), Rect2(0, 0, 30, 30))
_ck(segment.size() == 2 and segment[0] == Vector2(0, 10) and segment[1] == Vector2(30, 10),
"Godot Canvas 线段裁剪保留可见区间")
# ExpandedImage 的四边形(TL, TR, BL, BR)被 viewport 裁掉右半边,UV 随位置按仿射关系重算。
var quad := PackedVector2Array([Vector2(0, 0), Vector2(20, 0), Vector2(0, 10), Vector2(20, 10)])
var quad_uv := PackedVector2Array([Vector2(0, 0), Vector2(1, 0), Vector2(0, 1), Vector2(1, 1)])
var clipped: Array = surface._clip_quad(quad, quad_uv, Rect2(0, 0, 10, 10))
var max_u := 0.0
for i in clipped[1].size():
max_u = maxf(max_u, clipped[1][i].x)
if not is_equal_approx(clipped[1][i].x, clipped[0][i].x / 20.0):
max_u = -1.0
_ck(clipped[0].size() >= 3 and is_equal_approx(max_u, 0.5), "ExpandedImage 四边形裁剪后 UV 同步收缩")
surface.free()
_ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_bar)") == "", "Bar 窗口已销毁")
_ck(Metin2PythonHost.run_line("grp.RestoreViewport(); wndMgr.Destroy(_ui_clip)") == "", "裁剪窗口已销毁")
_ck(Metin2PythonHost.run_line("_ui_text_probe = _UIProbe(); _ui_text = wndMgr.RegisterTextLine(_ui_text_probe, 'UI'); wndMgr.SetWindowPosition(_ui_text, 70, 80); wndMgr.SetText(_ui_text, 'Hello'); wndMgr.SetFontColor(_ui_text, 1.0, 0.5, 0.0); wndMgr.Show(_ui_text)") == "",
"40250 TextLine 已创建")
draws = Metin2PythonHost.ui_render_commands()
var has_text := false
for draw in draws:
if draw["kind"] == "text" and draw["text"] == "Hello" and draw["x1"] == 70.0 and draw["y1"] == 80.0:
has_text = true
_ck(has_text, "40250 TextLine 文本进入 Godot Canvas 命令队列")
_ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_text)") == "", "TextLine 已销毁")
var image_path := "locale/en/ui/vkey/key_backspace.tga"
if Metin2Pack.exists(image_path):
var packed_texture: Texture2D = preload("res://ui/ui_assets.gd").load_tex(AssetRoot.path(), image_path)
_ck(packed_texture != null and packed_texture.get_width() > 0,
"40250 pack 图片可解码为 Godot 纹理")
var image_source := "_ui_image_probe = _UIProbe(); _ui_image = wndMgr.RegisterImageBox(_ui_image_probe, 'UI'); wndMgr.LoadImage(_ui_image, '%s'); wndMgr.SetWindowPosition(_ui_image, 120, 130); wndMgr.Show(_ui_image)" % image_path
_ck(Metin2PythonHost.run_line(image_source) == "", "40250 pack 图片已加载到 ImageBox")
draws = Metin2PythonHost.ui_render_commands()
var has_image := false
for draw in draws:
if draw["kind"] == "image" and String(draw["text"]).replace("\\", "/") == image_path and draw["x1"] == 120.0 and draw["y1"] == 130.0 and draw["x2"] > draw["x1"] and draw["y2"] > draw["y1"]:
has_image = true
_ck(has_image, "40250 ImageBox 图片进入 Godot Canvas 命令队列")
_ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_image)") == "", "ImageBox 已销毁")
var sub_path := "d:/ymir work/ui/cursor/cursor.sub"
if Metin2Pack.exists(sub_path):
_ck(Metin2PythonHost.run_line("import grpImage; _ui_sub = grpImage.Generate('%s'); assert _ui_sub; grpImage.Delete(_ui_sub)" % sub_path) == "",
"40250 .sub 子图可创建并释放")
_ck(Metin2PythonHost.run_line("_ui_expanded = grpImage.GenerateExpanded('%s'); assert _ui_expanded; grpImage.DeleteExpanded(_ui_expanded)" % image_path) == "",
"40250 ExpandedImage 可创建并释放")
else:
print("SKIP: 40250 image test(pack 缺少 %s)" % image_path)
_ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_window)") == "", "窗口已销毁")
else:
print("SKIP: system.py 这一段(没有 40250 Client/pack:%s)" % AssetRoot.client_path())
Metin2PythonHost.stop()
_ck(not Metin2PythonHost.is_running(), "stop() 之后 is_running() 为假")
if has_pack:
_run_app_loop()
return fail
# 2V0-f:有画布尺寸时 app.Create 成功,system.py 停在 app.Loop() 里(脚本线程),每次 ui_update 就是
# 一次 CPythonApplication::Process()。Logo 阶段 → LoginWindow → 弹窗 ESC 关闭 → stop 走完 RunApp 收尾。
func _run_app_loop() -> void:
_ck(Metin2PythonHost.start("") == "", "第二次 start()")
Metin2PythonHost.set_ui_size(800, 600)
var err: String = Metin2PythonHost.run_main_script("")
_ck(err == "", "system.py 进入 app.Loop():" + err)
_ck(Metin2PythonHost.is_app_looping(), "is_app_looping() 为真")
_ck(Metin2PythonHost.run_line("import __main__, gc, networkModule, introLogo\n__main__._stream = [o for o in gc.get_objects() if isinstance(o, networkModule.MainStream)][0]\nassert isinstance(_stream.curPhaseWindow, introLogo.LogoWindow)") == "",
"Logo 阶段已打开")
var reached := false
for i in 600:
if not Metin2PythonHost.is_app_looping():
break
Metin2PythonHost.ui_update()
if Metin2PythonHost.run_line("import introLogin\nif not isinstance(_stream.curPhaseWindow, introLogin.LoginWindow): raise SystemError") == "":
reached = true
break
_ck(reached, "帧推进后进入 LoginWindow")
_ck(Metin2PythonHost.run_line("_stream.popupWindow.Open('POPUP_PROBE')\nassert _stream.popupWindow.IsShow()") == "", "PopupDialog 已打开")
Metin2PythonHost.ui_update()
var has_popup := false
for draw in Metin2PythonHost.ui_render_commands():
if draw["kind"] == "text" and draw["text"] == "POPUP_PROBE":
has_popup = true
_ck(has_popup, "弹窗文字进入 Godot Canvas 命令队列")
Metin2PythonHost.ui_key(1, true) # DIK_ESCAPE
Metin2PythonHost.ui_key(1, false)
Metin2PythonHost.ui_update()
_ck(Metin2PythonHost.run_line("assert not _stream.popupWindow.IsShow()") == "", "ESC 关闭弹窗")
Metin2PythonHost.stop()
_ck(not Metin2PythonHost.is_running() and not Metin2PythonHost.is_app_looping(), "stop() 结束 app.Loop() 并收尾")