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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5590714b16
commit
141206cb69
@@ -35,6 +35,18 @@ func _ready() -> void:
|
||||
printerr("%d check(s) failed" % fail)
|
||||
get_tree().quit(1 if fail else 0)
|
||||
return
|
||||
# MT_TEST_MODE=python_ui:40250 system.py 的界面直接画在 Godot Canvas 上(PORT-PLAN 2V0-f),
|
||||
# Logo → 登录阶段,可用鼠标键盘操作;app.Exit() 退出进程。
|
||||
if OS.get_environment("MT_TEST_MODE") == "python_ui":
|
||||
AssetPack.ensure()
|
||||
var surface: Control = preload("res://python_ui_surface.gd").new()
|
||||
surface.name = "PythonUISurface"
|
||||
add_child(surface)
|
||||
var err: String = surface.run_app()
|
||||
if err != "":
|
||||
printerr("python_ui: " + err)
|
||||
get_tree().quit(1)
|
||||
return
|
||||
if OS.get_environment("MT_TEST_MODE") == "forest_render":
|
||||
var forest := ForestMapRenderTest.new()
|
||||
forest.name = "ForestMapRenderTest"
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# 和 ctest 的 `port.python_launcher` 跑的是同一条序列,区别只有一个:那边是独立可执行文件,这边在
|
||||
# Godot 进程里,由 GDScript 发起 —— step 4 要验的就是这个差别。2V0 把 C++ 模块注册上之前,
|
||||
# 2V0-e 的玩法桩让 system.py 真正走到 prototype.RunApp();
|
||||
# 2V0-f 的应用 adapter 尚未创建图形设备,RunApp 捕获 CREATE_DEVICE 并给出原版提示。
|
||||
# 没设画布尺寸时 app.Create 没有设备,RunApp 捕获 CREATE_DEVICE 并给出原版提示;设了尺寸以后
|
||||
# system.py 停在 app.Loop() 里,由 ui_update 逐帧推进(_run_app_loop)。
|
||||
#
|
||||
# 真机上通常没有 40250 的 Client/pack(APK 里只有 python27.zip),所以分两段:解释器 + 标准库这一段
|
||||
# 不需要 pack,任何平台都跑;system.py 这一段需要 pack,没有就跳过并说清楚。
|
||||
@@ -119,6 +120,16 @@ wndMgr.Show(_ui_clip)
|
||||
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)") == "", "裁剪窗口已销毁")
|
||||
@@ -159,4 +170,39 @@ wndMgr.Show(_ui_clip)
|
||||
|
||||
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() 并收尾")
|
||||
|
||||
@@ -20,6 +20,30 @@ const KEY_TO_DIK := {
|
||||
KEY_RIGHT: 0xcd, KEY_DOWN: 0xd0,
|
||||
}
|
||||
|
||||
# True when this surface started system.py itself (run_app) and so owns the interpreter's lifetime.
|
||||
var _owns_app := false
|
||||
|
||||
# 40250 WinMain → RunMainScript: start the interpreter, give it the canvas size (app.Create reads it),
|
||||
# run system.py until it parks in app.Loop(). Returns "" or the error RunApp reported.
|
||||
func run_app() -> String:
|
||||
if not AssetRoot.pack_ready():
|
||||
return "no 40250 Client/pack at %s" % AssetRoot.client_path()
|
||||
var err: String = Metin2PythonHost.start("")
|
||||
if err != "":
|
||||
return err
|
||||
_sync_size()
|
||||
err = Metin2PythonHost.run_main_script("")
|
||||
if err != "":
|
||||
Metin2PythonHost.stop()
|
||||
return err
|
||||
_owns_app = true
|
||||
return ""
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if _owns_app:
|
||||
_owns_app = false
|
||||
Metin2PythonHost.stop()
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
@@ -31,9 +55,17 @@ func _notification(what: int) -> void:
|
||||
|
||||
func _sync_size() -> void:
|
||||
if ClassDB.class_exists("Metin2PythonHost"):
|
||||
Metin2PythonHost.set_ui_size(int(size.x), int(size.y))
|
||||
# Before the first layout pass a full-rect Control is still 0x0; it will fill the viewport.
|
||||
var canvas := size if size.x > 0 and size.y > 0 else get_viewport_rect().size
|
||||
Metin2PythonHost.set_ui_size(int(canvas.x), int(canvas.y))
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# app.Exit()/Abort(): Loop() returned and RunApp() finished; 40250 WinMain then ends the process.
|
||||
if _owns_app and not Metin2PythonHost.is_app_looping():
|
||||
_owns_app = false
|
||||
Metin2PythonHost.stop()
|
||||
get_tree().quit(0)
|
||||
return
|
||||
if Metin2PythonHost.is_running():
|
||||
Metin2PythonHost.ui_update()
|
||||
queue_redraw()
|
||||
@@ -84,12 +116,35 @@ func _draw() -> void:
|
||||
elif command["kind"] == "image":
|
||||
var texture: Texture2D = UiAssets.load_tex(AssetRoot.path(), command["text"])
|
||||
if texture != null:
|
||||
var dest := Rect2(p1, p2 - p1)
|
||||
var visible := dest.intersection(clip)
|
||||
if visible.has_area() and dest.has_area():
|
||||
var scale := texture.get_size() / dest.size
|
||||
var source := Rect2((visible.position - dest.position) * scale, visible.size * scale)
|
||||
draw_texture_rect_region(texture, visible, source, color)
|
||||
var clipped := _clip_quad(command["quad"], command["uv"], clip)
|
||||
if clipped[0].size() >= 3:
|
||||
draw_polygon(clipped[0], PackedColorArray([color]), clipped[1], texture)
|
||||
|
||||
# 40250's image quad (TL, TR, BL, BR) clipped to the viewport. The quad is a
|
||||
# parallelogram, so texture coordinates are an affine function of position and are recomputed for
|
||||
# the clipped outline. Returns [points, uvs]; empty points when nothing is visible.
|
||||
# PORT: 40250's SCREEN / COLOR_DODGE / MODULATE blend states (command["blend"]) are not applied yet;
|
||||
# the canvas draws every quad with normal alpha blending.
|
||||
func _clip_quad(quad: PackedVector2Array, uv: PackedVector2Array, clip: Rect2) -> Array:
|
||||
var outline := PackedVector2Array([quad[0], quad[1], quad[3], quad[2]])
|
||||
var edge_u := quad[1] - quad[0]
|
||||
var edge_v := quad[2] - quad[0]
|
||||
var det := edge_u.cross(edge_v)
|
||||
if is_zero_approx(det):
|
||||
return [PackedVector2Array(), PackedVector2Array()]
|
||||
var rect := PackedVector2Array([clip.position, Vector2(clip.end.x, clip.position.y),
|
||||
clip.end, Vector2(clip.position.x, clip.end.y)])
|
||||
var pieces := Geometry2D.intersect_polygons(outline, rect)
|
||||
if pieces.is_empty():
|
||||
return [PackedVector2Array(), PackedVector2Array()]
|
||||
var points: PackedVector2Array = pieces[0]
|
||||
var uvs := PackedVector2Array()
|
||||
for point in points:
|
||||
var d := point - quad[0]
|
||||
var a := d.cross(edge_v) / det
|
||||
var b := edge_u.cross(d) / det
|
||||
uvs.push_back(uv[0] + (uv[1] - uv[0]) * a + (uv[2] - uv[0]) * b)
|
||||
return [points, uvs]
|
||||
|
||||
func _clip_line(a: Vector2, b: Vector2, clip: Rect2) -> PackedVector2Array:
|
||||
var delta := b - a
|
||||
|
||||
Reference in New Issue
Block a user