feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复

- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
shen
2026-09-19 08:51:25 -07:00
parent 1db9e9a129
commit 66d217b313
650 changed files with 53046 additions and 1586 deletions
+129 -12
View File
@@ -16,6 +16,8 @@ const UiAssets = preload("res://ui/ui_assets.gd")
const UiKit = preload("res://ui_kit.gd")
const PlayerView = preload("res://ui/player_view.gd")
const EquipModel = preload("res://ui/equip_model.gd")
const Audio = preload("res://audio.gd")
const EffectRegistry = preload("res://fx/effect_registry.gd")
const DESIGN_SIZE := Vector2(1472, 854)
signal select_requested(index: int)
@@ -68,11 +70,24 @@ var _rename_pending := false
var _layout: Control
var _board: Control
var _proto: Node
var _audio: Node
var _fxr: RefCounted
var _starting := false
var _btn_start: Button
var _btn_create: Button
var _btn_delete: Button
var _btn_quit: Button
var _nav_buttons: Array[TextureButton] = []
func setup(client: Node, assets_root: String, chars: Array) -> void:
_client = client
_assets = assets_root
_chars = _pad_slots(chars)
_audio = Audio.new()
_audio.setup(_assets)
add_child(_audio)
_fxr = EffectRegistry.new()
_fxr.setup(_assets)
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_background()
_build_stage()
@@ -179,13 +194,13 @@ func on_char_delete_failed() -> void:
# --- 背景 ---------------------------------------------------------------------
func _build_background() -> void:
func _add_background(parent: Node) -> void:
var tex := UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/select/select.sub")
var cr := ColorRect.new()
cr.color = Color(0.05, 0.06, 0.07)
cr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
cr.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(cr)
parent.add_child(cr)
if tex != null:
var tr := TextureRect.new()
tr.texture = tex
@@ -194,7 +209,10 @@ func _build_background() -> void:
# The original stretches the 1024x768 atlas region to the window.
tr.stretch_mode = TextureRect.STRETCH_SCALE
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(tr)
parent.add_child(tr)
func _build_background() -> void:
_add_background(self)
# --- 右侧 3D 角色 -----------------------------------------------------------
@@ -206,12 +224,27 @@ func _build_stage() -> void:
add_child(vpc)
_viewport = SubViewport.new()
_viewport.transparent_bg = true
# Refine effects use D3D-style SRC_ALPHA/ONE additive blending. Rendering
# them into a transparent texture first makes their black RGB texels increase
# the render target alpha; the later CanvasItem alpha composite then turns
# those invisible texels into large black rectangles. 40250 draws the select
# background before the character/effects, so reproduce that order inside an
# opaque viewport instead of trying to encode additive light in straight RGBA.
_viewport.transparent_bg = false
_viewport.own_world_3d = true
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_viewport.msaa_3d = Viewport.MSAA_4X
vpc.add_child(_viewport)
var background_canvas := CanvasLayer.new()
background_canvas.layer = -1
_viewport.add_child(background_canvas)
var background_root := Control.new()
background_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
background_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
background_canvas.add_child(background_root)
_add_background(background_root)
var root := Node3D.new()
_viewport.add_child(root)
@@ -228,8 +261,10 @@ func _build_stage() -> void:
var we := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_COLOR
e.background_color = Color(0, 0, 0, 0)
# Canvas layer -1 is the stage backdrop and is rendered before 3D. Parent UI
# remains outside the SubViewport and is drawn afterward as before.
e.background_mode = Environment.BG_CANVAS
e.background_canvas_max_layer = -1
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.8, 0.8, 0.8)
e.ambient_light_energy = 0.65
@@ -248,6 +283,9 @@ func _build_stage() -> void:
_cam.position = Vector3(0.0, 1.0, 4.0)
root.add_child(_cam)
_cam.make_current()
var listener := AudioListener3D.new()
listener.make_current()
_cam.add_child(listener)
# 相机按模型包围球自适应:球心 + 半径/sin(fovV/2) 保证整模无论姿势都不出框,
# 再沿 -X 平移“半径的一小截”把人物推到右半屏(横向视锥比纵向宽,不会切到远侧手)。
@@ -309,6 +347,7 @@ func _rebuild_model() -> void:
_pivot.add_child(pv)
if pv.build(_assets, job, pump):
_pv = pv
pv._audio = _audio
if pv.model and pv.model.has_signal("visual_bounds_changed"):
pv.model.visual_bounds_changed.connect(_on_model_visual_bounds_changed.bind(pv))
if _proto == null and ClassDB.class_exists("Metin2Proto"):
@@ -485,33 +524,54 @@ func _refresh_panel() -> void:
start.text = "开始"
start.custom_minimum_size = Vector2(180, 22)
_style_button(start)
start.disabled = not has_char or empire_required
start.disabled = not has_char or empire_required or _starting
start.pressed.connect(_do_start)
_panel_box.add_child(start)
_panel_box.move_child(start, _panel_box.get_child_count() - 2)
start.visible = has_char
_btn_start = start
var create := Button.new()
create.text = "创建"
create.custom_minimum_size = Vector2(180, 22)
_style_button(create)
create.disabled = has_char or empire_required
create.disabled = has_char or empire_required or _starting
create.pressed.connect(_open_create_dialog)
_panel_box.add_child(create)
_panel_box.move_child(create, _panel_box.get_child_count() - 2)
create.visible = not has_char
_btn_create = create
var del := Button.new()
del.text = "删除"
del.custom_minimum_size = Vector2(86, 20)
_style_button(del)
del.disabled = not has_char or empire_required
del.disabled = not has_char or empire_required or _starting
del.pressed.connect(_open_delete_dialog)
brow.add_child(del)
_btn_delete = del
var quit := Button.new()
quit.text = "退出"
quit.custom_minimum_size = Vector2(86, 20)
_style_button(quit)
quit.pressed.connect(func(): back_requested.emit())
quit.disabled = _starting
quit.pressed.connect(func(): if not _starting: back_requested.emit())
brow.add_child(quit)
_btn_quit = quit
func _set_buttons_disabled(disabled: bool) -> void:
if _btn_start and is_instance_valid(_btn_start):
_btn_start.disabled = disabled
if _btn_create and is_instance_valid(_btn_create):
_btn_create.disabled = disabled
if _btn_delete and is_instance_valid(_btn_delete):
_btn_delete.disabled = disabled
if _btn_quit and is_instance_valid(_btn_quit):
_btn_quit.disabled = disabled
for b in _nav_buttons:
if b and is_instance_valid(b):
b.disabled = disabled
func _style_button(button: Button) -> void:
button.add_theme_font_size_override("font_size", 10)
@@ -536,6 +596,7 @@ func _style_button(button: Button) -> void:
button.add_theme_stylebox_override(state, style)
func _build_navigation() -> void:
_nav_buttons.clear()
for direction in [-1, 1]:
var button := TextureButton.new()
var side := "left" if direction < 0 else "right"
@@ -543,11 +604,13 @@ func _build_navigation() -> void:
button.texture_normal = UiAssets.load_tex(_assets, base + "01.sub")
button.texture_hover = UiAssets.load_tex(_assets, base + "02.sub")
button.texture_pressed = UiAssets.load_tex(_assets, base + "03.sub")
button.texture_disabled = UiAssets.load_tex(_assets, base + "01.sub")
button.position = Vector2(714 if direction < 0 else 1024, 712)
button.custom_minimum_size = Vector2(160, 44)
button.tooltip_text = "上一个角色" if direction < 0 else "下一个角色"
button.pressed.connect(func(): _select(wrapi(_sel + direction, 0, _chars.size())))
button.pressed.connect(func(): if not _starting: _select(wrapi(_sel + direction, 0, _chars.size())))
_layout.add_child(button)
_nav_buttons.append(button)
func _resize_layout() -> void:
if _layout:
@@ -581,6 +644,8 @@ func _set_status(t: String) -> void:
# --- 选择 / 循环 ----------------------------------------------------------
func _select(i: int) -> void:
if _starting:
return
if _chars.is_empty():
_sel = 0
_refresh_class_name()
@@ -596,6 +661,8 @@ func _select(i: int) -> void:
_set_status("已选:%s" % nm if nm != "" else "空槽位(点“创建”建号)")
func _do_start() -> void:
if _starting:
return
var d := _cur()
if d.is_empty() or String(d.get("name", "")) == "":
_set_status("空槽位不能进入")
@@ -605,8 +672,58 @@ func _do_start() -> void:
if bool(d.get("change_name", false)):
_open_change_name_dialog()
return
_starting = true
_set_buttons_disabled(true)
if _audio and _audio.has_method("play_sound"):
_audio.play_sound("sound/ui/click.wav")
if _audio.has_method("fade_out_bgm"):
_audio.fade_out_bgm(2.5)
_set_status("进入游戏:%s" % d.get("name"))
var anim_dur := 2.2
if _pv != null and is_instance_valid(_pv):
var intro: String = _pv.motion_dir.get_base_dir().path_join("intro/selected.msa")
if _pv.anim and FileAccess.file_exists(intro):
_pv.anim.set("loop", false)
_pv.anim.set("blend_time", 0.1)
_pv.anim.set("anim_path", intro)
_pv._refresh_sound_script(intro)
if _pv.anim.has_method("get_duration"):
var dur := float(_pv.anim.call("get_duration"))
if dur > 0.0:
anim_dur = dur
await get_tree().create_timer(anim_dur).timeout
if not is_inside_tree():
return
select_requested.emit(_slot_index())
_set_status("进入游戏:%s" % d.get("name"))
func _unhandled_input(event: InputEvent) -> void:
if _dialog != null and is_instance_valid(_dialog):
return
if not (event is InputEventKey) or not event.pressed or event.echo:
return
if _starting:
return
var ke := event as InputEventKey
match ke.keycode:
KEY_ENTER, KEY_KP_ENTER:
if not _cur().is_empty() and String(_cur().get("name", "")) != "":
_do_start()
else:
_open_create_dialog()
KEY_LEFT:
_select(wrapi(_sel - 1, 0, _chars.size()))
KEY_RIGHT:
_select(wrapi(_sel + 1, 0, _chars.size()))
KEY_ESCAPE:
back_requested.emit()
KEY_1, KEY_2, KEY_3, KEY_4:
var slot := ke.keycode - KEY_1
if slot < _chars.size():
_select(slot)
# --- 建号 / 删号弹窗 ------------------------------------------------------
+664 -3
View File
@@ -19,6 +19,7 @@ const UiScript = preload("res://ui/uiscript.gd")
const UiBuild = preload("res://ui/ui_build.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd")
const SkillSlotMap = preload("res://skill_slot_map.gd")
# --- EPointTypesm2dev Packet.h),只列本窗要用的 ---
const P_LEVEL := 1
@@ -41,6 +42,7 @@ const P_CASTING_SPEED := 21
const P_MAGIC_ATT_GRADE := 22
const P_MAGIC_DEF_GRADE := 23
const P_STAT := 26 # 남은 능력치 포인트
const P_SKILL := 27 # 남은 스킬 포인트
const P_MIN_ATK := 29
const P_MAX_ATK := 30
const P_PARTY_ATT_GRADE := 91 # player.ATTACKER_BONUS
@@ -49,6 +51,39 @@ const P_DEF_GRADE_BONUS := 96 # player.DEF_BONUS
const P_MIN_MAGIC_WEP := 202
const P_MAX_MAGIC_WEP := 203
const EMOTE_NAMES := {
1: "喝彩", 2: "拍手", 3: "肯定", 4: "原谅",
5: "愤怒", 6: "诱惑", 7: "悲伤", 8: "害羞",
9: "欢呼", 10: "嘲讽", 11: "思考", 12: "跳舞 1",
13: "跳舞 2", 14: "跳舞 3", 15: "跳舞 4", 16: "跳舞 5",
17: "跳舞 6", 18: "大笑",
51: "亲吻", 52: "法式深吻", 53: "耳光",
}
const EMOTE_ICONS := {
1: "ETC/ymir work/ui/game/windows/emotion_cheers_1.sub",
2: "ETC/ymir work/ui/game/windows/emotion_clap.sub",
3: "icon/icon/action/congratulation.tga",
4: "icon/icon/action/forgive.tga",
5: "icon/icon/action/angry.tga",
6: "icon/icon/action/attractive.tga",
7: "icon/icon/action/sad.tga",
8: "icon/icon/action/shy.tga",
9: "icon/icon/action/cheerup.tga",
10: "icon/icon/action/banter.tga",
11: "icon/icon/action/joy.tga",
12: "icon/icon/action/dance1.tga",
13: "icon/icon/action/dance2.tga",
14: "icon/icon/action/dance3.tga",
15: "icon/icon/action/dance4.tga",
16: "icon/icon/action/dance5.tga",
17: "icon/icon/action/dance6.tga",
18: "ETC/ymir work/ui/game/windows/emotion_cheers_2.sub",
51: "ETC/ymir work/ui/game/windows/emotion_kiss.sub",
52: "ETC/ymir work/ui/game/windows/emotion_french_kiss.sub",
53: "ETC/ymir work/ui/game/windows/emotion_slap.sub",
}
# uicharacter.py statusPlusCommandDict / statusMinusCommandDict
const PLUS_CMD := {"HTH": "/stat ht", "INT": "/stat iq", "STR": "/stat st", "DEX": "/stat dx"}
const MINUS_CMD := {"HTH": "/stat- ht", "INT": "/stat- iq", "STR": "/stat- st", "DEX": "/stat- dx"}
@@ -57,10 +92,15 @@ const STATES := ["STATUS", "SKILL", "EMOTICON", "QUEST"]
const PAGE := {"STATUS": "Character_Page", "SKILL": "Skill_Page", "EMOTICON": "Emoticon_Page", "QUEST": "Quest_Page"}
const TITLEBAR := {"STATUS": "Character_TitleBar", "SKILL": "Skill_TitleBar", "EMOTICON": "Emoticon_TitleBar", "QUEST": "Quest_TitleBar"}
const TAB_BUTTON := {"STATUS": "Tab_Button_01", "SKILL": "Tab_Button_02", "EMOTICON": "Tab_Button_03", "QUEST": "Tab_Button_04"}
const TAB_IMAGE := {"STATUS": "Tab_01", "SKILL": "Tab_02", "EMOTICON": "Tab_03", "QUEST": "Tab_04"}
const FACE_BY_JOB := ["face_warrior", "face_assassin", "face_sura", "face_shaman"]
var ui: CanvasLayer # UiManager
var client: Node # M2Client
var skill_table: RefCounted # SkillTable
var item_mouse: Node # MouseController
var drag_skill_id := 0 # Quickbar 可读取的待拖拽技能
var _preview_group := 1 # 40250 uicharacter.py curSelectedSkillGroup (group==0 时预览分支)
var assets_root := ""
var uiscript_dir := ""
@@ -74,23 +114,66 @@ var _mobile_labels := {}
var _mobile_stats := {}
var _mobile_equipment_labels: Array[Label] = []
var _mobile_hint: Label
var _quest_showing_start_index := 0
func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
func setup(ui_manager: CanvasLayer, m2client: Node, assets := "", st_table: RefCounted = null, imouse: Node = null) -> void:
ui = ui_manager
client = m2client
assets_root = assets
if imouse != null:
item_mouse = imouse
if assets_root == "" and ui and "assets_root" in ui:
assets_root = ui.assets_root
uiscript_dir = assets_root.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets_root.path_join("uiscript")
skill_table = st_table
if skill_table == null:
var st_script := load("res://ui/skill_table.gd")
if st_script:
skill_table = st_script.new()
for lang in ["en", "common"]:
var desc_path := assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang)
if not FileAccess.file_exists(desc_path):
desc_path = assets_root.path_join("locale/%s/skilldesc.txt" % lang)
if FileAccess.file_exists(desc_path) and skill_table.has_method("load_file"):
skill_table.load_file(desc_path)
break
for lang in ["en", "common"]:
var tbl_path := assets_root.path_join("locale/locale/%s/skilltable.txt" % lang)
if not FileAccess.file_exists(tbl_path):
tbl_path = assets_root.path_join("locale/%s/skilltable.txt" % lang)
if FileAccess.file_exists(tbl_path) and skill_table.has_method("load_table"):
skill_table.load_table(tbl_path)
break
elif skill_table != null and skill_table.has_method("load_table") and skill_table.has_method("is_passive"):
if not skill_table.is_passive(121):
for lang in ["en", "common"]:
var tbl_path := assets_root.path_join("locale/locale/%s/skilltable.txt" % lang)
if not FileAccess.file_exists(tbl_path):
tbl_path = assets_root.path_join("locale/%s/skilltable.txt" % lang)
if FileAccess.file_exists(tbl_path):
skill_table.load_table(tbl_path)
break
if client and client.has_signal("points_changed"):
client.points_changed.connect(_on_points_changed)
if client and client.has_signal("entity_main_set"):
client.entity_main_set.connect(func(_v): if is_open(): _refresh())
if client and client.has_signal("skills_changed"):
client.skills_changed.connect(func(): if is_open(): _refresh())
if client and client.has_signal("skill_group_changed"):
client.skill_group_changed.connect(func(_g): if is_open(): _refresh())
if ui and ui.has_signal("window_closed"):
ui.window_closed.connect(func(w):
if not _win.is_empty() and _win.get("root") == w:
_win = {}
)
func set_job(_j: String) -> void:
pass
func set_mobile_mode(enabled: bool) -> void:
if not enabled and _mobile_root != null:
if enabled != _mobile_mode and is_open():
var old_mode := _mobile_mode
_mobile_mode = true
close()
@@ -106,7 +189,7 @@ func is_open() -> bool:
func toggle() -> void:
if is_open(): close()
else: open()
else: open("STATUS")
func close() -> void:
if _mobile_mode:
@@ -140,8 +223,21 @@ func open(state := "") -> void:
_win = ui.open_script(path, assets_root)
if not is_open():
return
var root_w: Control = _win.get("root") as Control
if root_w:
var vp_size := Vector2(800.0, 600.0)
if root_w.is_inside_tree():
vp_size = root_w.get_viewport_rect().size
elif ui and ui.is_inside_tree():
vp_size = ui.get_viewport().get_visible_rect().size
var target_y: float = (vp_size.y - 37.0 - 361.0) / 2.0
root_w.position = Vector2(24.0, maxf(10.0, target_y))
_wire_titlebars()
_wire_tabs()
_wire_stat_buttons()
_wire_skills()
_wire_emoticons()
_wire_quests()
_set_state(_state)
_refresh()
@@ -444,9 +540,17 @@ func _set_state(state: String) -> void:
var bar := _node(TITLEBAR[st])
if bar:
bar.visible = on
var tab_img := _node(TAB_IMAGE[st])
if tab_img:
tab_img.visible = on
var btn := _node(TAB_BUTTON[st])
if btn is BaseButton and btn.toggle_mode:
btn.set_pressed_no_signal(on)
match state:
"SKILL": _refresh_skills()
"EMOTICON": _refresh_emotes()
"QUEST": _refresh_quests()
"STATUS": _refresh()
# --- stat +/- buttons ----------------------------------------------------
@@ -477,6 +581,15 @@ func _u32(v: int) -> int:
func _refresh() -> void:
if not is_open() or client == null:
return
_refresh_status()
if _state == "SKILL":
_refresh_skills()
elif _state == "EMOTICON":
_refresh_emotes()
elif _state == "QUEST":
_refresh_quests()
func _refresh_status() -> void:
var pd: Dictionary = client.get_points() if client.has_method("get_points") else {}
if pd.is_empty():
return
@@ -583,3 +696,551 @@ func _set_text(nm: String, value: String) -> void:
n.text = value
elif n.has_method("set_text"):
n.set_text(value)
func _wire_titlebars() -> void:
for tb_name: String in TITLEBAR.values():
var tb := _node(tb_name)
if tb:
for btn in tb.find_children("*", "BaseButton", true, false):
if not btn.pressed.is_connected(close):
btn.pressed.connect(close)
func _wire_skills() -> void:
var g1 := _node("Skill_Group_Button_1")
if g1 is BaseButton and not g1.pressed.is_connected(_on_group1_pressed):
g1.pressed.connect(_on_group1_pressed)
var g2 := _node("Skill_Group_Button_2")
if g2 is BaseButton and not g2.pressed.is_connected(_on_group2_pressed):
g2.pressed.connect(_on_group2_pressed)
func _on_group1_pressed() -> void:
if _skill_group() == 0:
_preview_group = 1
_refresh_skills()
if client and client.has_method("say"):
client.say(0, "/skillgroup 1")
func _on_group2_pressed() -> void:
if _skill_group() == 0:
_preview_group = 2
_refresh_skills()
if client and client.has_method("say"):
client.say(0, "/skillgroup 2")
func _wire_emoticons() -> void:
pass
func _wire_quests() -> void:
var sb := _node("Quest_ScrollBar")
if sb is Range:
if not sb.value_changed.is_connected(_on_quest_scroll_changed):
sb.value_changed.connect(_on_quest_scroll_changed)
func _on_quest_scroll_changed(val: float) -> void:
_quest_showing_start_index = int(val)
_refresh_quests()
func _refresh_skills() -> void:
if not is_open() or client == null:
return
var race := _main_race()
var group := _skill_group()
var job := race & 3
var pd: Dictionary = client.get_points() if client.has_method("get_points") else {}
var pts: Array = pd.get("points", [])
var stat_skill: int = int(pts[P_SKILL]) if pts.size() > P_SKILL else 0
_set_text("Active_Skill_Point_Value", str(max(0, stat_skill)))
_set_text("Support_Skill_Point_Value", "0")
var sk_pt_lbl := _node("Active_Skill_Point_Label")
if sk_pt_lbl:
sk_pt_lbl.visible = stat_skill > 0
var grp_name := _node("Active_Skill_Group_Name")
var g1_btn := _node("Skill_Group_Button_1")
var g2_btn := _node("Skill_Group_Button_2")
var g3_btn := _node("Skill_Group_Button_3")
if g3_btn:
g3_btn.visible = false
var display_group := group
if group == 0:
# 40250 uicharacter.py: 当未选流派时显示两组按钮,供玩家预览切换
if grp_name: grp_name.visible = false
if g1_btn:
g1_btn.visible = true
if "text" in g1_btn: g1_btn.text = _get_skill_group_name(job, 1)
if g1_btn is BaseButton: g1_btn.set_pressed_no_signal(_preview_group == 1)
if g2_btn:
g2_btn.visible = true
if "text" in g2_btn: g2_btn.text = _get_skill_group_name(job, 2)
if g2_btn is BaseButton: g2_btn.set_pressed_no_signal(_preview_group == 2)
display_group = _preview_group
else:
# 40250 uicharacter.py: 已选流派时隐藏切换按钮,只展示当前流派名
if g1_btn: g1_btn.visible = false
if g2_btn: g2_btn.visible = false
if grp_name:
grp_name.visible = true
grp_name.text = _get_skill_group_name(job, group)
var active_slot := _node("Skill_Active_Slot")
if active_slot:
for slot_idx in range(1, 9):
var sid := SkillSlotMap.skill_id_for_slot(race, display_group, slot_idx)
var cell := active_slot.get_node_or_null("slot_%d" % slot_idx)
if cell:
_populate_skill_cell(cell, sid, slot_idx)
var plus_cell := active_slot.get_node_or_null("slot_%d" % (20 + slot_idx))
if plus_cell:
var pts_alloc := stat_skill if group > 0 else 0
_populate_skill_plus(plus_cell, sid, pts_alloc)
var etc_slot := _node("Skill_ETC_Slot")
if etc_slot:
for slot_idx in range(101, 113):
var sid := SkillSlotMap.skill_id_for_slot(race, display_group, slot_idx)
var cell := etc_slot.get_node_or_null("slot_%d" % slot_idx)
if cell:
_populate_skill_cell(cell, sid, slot_idx)
func _populate_skill_cell(cell: Control, skill_id: int, slot_idx: int) -> void:
var btn: TextureButton = cell.get_node_or_null("Btn")
if btn == null:
btn = TextureButton.new()
btn.name = "Btn"
btn.set_anchors_preset(Control.PRESET_FULL_RECT)
btn.ignore_texture_size = true
btn.stretch_mode = TextureButton.STRETCH_SCALE
btn.mouse_filter = Control.MOUSE_FILTER_PASS
btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
cell.add_child(btn)
var lvl_lbl: Label = cell.get_node_or_null("Lvl")
if lvl_lbl == null:
lvl_lbl = Label.new()
lvl_lbl.name = "Lvl"
lvl_lbl.position = Vector2(2, 16)
lvl_lbl.size = Vector2(28, 14)
lvl_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
lvl_lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
lvl_lbl.add_theme_font_size_override("font_size", 10)
lvl_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(lvl_lbl)
for conn in btn.gui_input.get_connections():
btn.gui_input.disconnect(conn["callable"])
if skill_id <= 0:
btn.texture_normal = null
lvl_lbl.text = ""
cell.tooltip_text = ""
btn.set_drag_forwarding(Callable(), Callable(), Callable())
return
btn.texture_normal = _skill_icon(skill_id)
var sk_data := _get_player_skill(skill_id)
var lvl: int = int(sk_data.get("level", 0))
var master: int = int(sk_data.get("master", 0))
if master == 1:
lvl_lbl.text = "M%d" % lvl
lvl_lbl.modulate = Color(1.0, 0.4, 0.4)
elif master == 2:
lvl_lbl.text = "G%d" % lvl
lvl_lbl.modulate = Color(0.4, 0.7, 1.0)
elif master == 3:
lvl_lbl.text = "P"
lvl_lbl.modulate = Color(0.9, 0.3, 1.0)
elif lvl > 0:
lvl_lbl.text = str(lvl)
lvl_lbl.modulate = Color(1.0, 1.0, 1.0)
else:
lvl_lbl.text = ""
var sk_name := _skill_name(skill_id)
cell.tooltip_text = "%s (Lv %d)" % [sk_name, lvl] if lvl > 0 else sk_name
btn.gui_input.connect(_on_skill_btn_input.bind(skill_id, slot_idx))
btn.set_drag_forwarding(
func(_at_pos: Vector2):
if not _can_drag_skill(skill_id):
return null
drag_skill_id = skill_id
var preview := TextureRect.new()
preview.texture = btn.texture_normal
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
preview.custom_minimum_size = Vector2(32, 32)
preview.size = Vector2(32, 32)
preview.position = -Vector2(16, 16)
btn.set_drag_preview(preview)
return {
"type": "skill",
"kind": "skill",
"id": skill_id,
"skill_id": skill_id,
"slot": slot_idx,
},
Callable(),
Callable()
)
func _can_drag_skill(skill_id: int) -> bool:
if skill_id <= 0:
return false
if skill_table and skill_table.has_method("is_passive") and skill_table.is_passive(skill_id):
return false
return true
func _find_quickbar() -> Node:
var root: Node = null
if is_inside_tree():
var tree := get_tree()
if tree: root = tree.root
elif ui != null and is_instance_valid(ui) and ui.is_inside_tree():
var tree := ui.get_tree()
if tree: root = tree.root
elif get_parent():
root = get_parent()
if root:
for n in root.find_children("*", "Quickbar", true, false):
return n
for n in root.find_children("*", "Node", true, false):
if n.has_method("assign_mobile") and n.has_method("assign"):
return n
return null
func _find_item_mouse() -> Node:
if item_mouse != null and is_instance_valid(item_mouse):
return item_mouse
var root: Node = null
if is_inside_tree():
var tree := get_tree()
if tree: root = tree.root
elif ui != null and is_instance_valid(ui) and ui.is_inside_tree():
var tree := ui.get_tree()
if tree: root = tree.root
elif get_parent():
root = get_parent()
if root:
for n in root.find_children("*", "MouseController", true, false):
return n
for n in root.find_children("*", "Node", true, false):
if n.has_method("attach_skill") and n.has_method("attach_item"):
return n
return null
func _on_skill_btn_input(ev: InputEvent, skill_id: int, slot_idx: int) -> void:
if not (ev is InputEventMouseButton and ev.pressed):
return
if ev.button_index == MOUSE_BUTTON_RIGHT:
_cast_skill(skill_id)
elif ev.button_index == MOUSE_BUTTON_LEFT:
if not _can_drag_skill(skill_id):
return
drag_skill_id = skill_id
# 40250 uicharacter.py: Ctrl + Left Click adds to first empty quickslot
if ev.ctrl_pressed or Input.is_key_pressed(KEY_CTRL):
var qb := _find_quickbar()
if qb and qb.has_method("assign_mobile"):
if qb.assign_mobile("skill", skill_id):
return
# 40250 mouseController.AttachObject(self, player.SLOT_TYPE_SKILL, srcSlotIndex, selectedSkillIndex)
var im := item_mouse if item_mouse else _find_item_mouse()
if im and im.has_method("attach_skill"):
im.attach_skill(skill_id, slot_idx, _skill_icon(skill_id), _skill_name(skill_id))
func _populate_skill_plus(cell: Control, skill_id: int, stat_skill: int) -> void:
var plus_btn: Button = cell.get_node_or_null("PlusBtn")
if plus_btn == null:
plus_btn = Button.new()
plus_btn.name = "PlusBtn"
plus_btn.set_anchors_preset(Control.PRESET_FULL_RECT)
plus_btn.text = "+"
plus_btn.add_theme_font_size_override("font_size", 10)
plus_btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
cell.add_child(plus_btn)
var sk_data := _get_player_skill(skill_id)
var lvl: int = int(sk_data.get("level", 0))
var master: int = int(sk_data.get("master", 0))
var can_up := (skill_id > 0 and stat_skill > 0 and master == 0 and lvl < 20)
plus_btn.visible = can_up
for conn in plus_btn.pressed.get_connections():
plus_btn.pressed.disconnect(conn["callable"])
if can_up:
plus_btn.pressed.connect(func():
if client and client.has_method("say"):
client.say(0, "/skillup %d" % skill_id))
func _refresh_emotes() -> void:
var solo := _node("SoloEmotionSlot")
if solo:
for id in range(1, 19):
var cell := solo.get_node_or_null("slot_%d" % id)
if cell:
_populate_emote_cell(cell, id)
var dual := _node("DualEmotionSlot")
if dual:
for id in [51, 52, 53]:
var cell := dual.get_node_or_null("slot_%d" % id)
if cell:
_populate_emote_cell(cell, id)
func _populate_emote_cell(cell: Control, emote_id: int) -> void:
var btn: TextureButton = cell.get_node_or_null("Btn")
if btn == null:
btn = TextureButton.new()
btn.name = "Btn"
btn.set_anchors_preset(Control.PRESET_FULL_RECT)
btn.ignore_texture_size = true
btn.stretch_mode = TextureButton.STRETCH_SCALE
btn.mouse_filter = Control.MOUSE_FILTER_PASS
btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
cell.add_child(btn)
for conn in btn.gui_input.get_connections():
btn.gui_input.disconnect(conn["callable"])
for conn in btn.pressed.get_connections():
btn.pressed.disconnect(conn["callable"])
var rel: String = EMOTE_ICONS.get(emote_id, "")
var tex: Texture2D = UiAssets.load_tex(assets_root, rel) if rel != "" else null
if tex:
btn.texture_normal = tex
var nm: String = EMOTE_NAMES.get(emote_id, "动作 %d" % emote_id)
cell.tooltip_text = nm
btn.gui_input.connect(func(ev: InputEvent):
if not (ev is InputEventMouseButton and ev.pressed):
return
if ev.button_index == MOUSE_BUTTON_RIGHT:
if client and client.has_method("send_emoticon"):
client.send_emoticon(emote_id)
elif ev.button_index == MOUSE_BUTTON_LEFT:
if ev.ctrl_pressed or Input.is_key_pressed(KEY_CTRL):
var qb := _find_quickbar()
if qb and qb.has_method("assign_mobile"):
if qb.assign_mobile("emote", emote_id):
return
var im := item_mouse if item_mouse else _find_item_mouse()
if im and im.has_method("attach_emotion"):
im.attach_emotion(emote_id, tex, nm)
)
btn.set_drag_forwarding(
func(_at_pos: Vector2):
var preview := TextureRect.new()
preview.texture = tex
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
preview.custom_minimum_size = Vector2(32, 32)
preview.size = Vector2(32, 32)
preview.position = -Vector2(16, 16)
btn.set_drag_preview(preview)
return {
"type": "emotion",
"kind": "emote",
"id": emote_id,
"emote_id": emote_id,
},
Callable(),
Callable()
)
func _refresh_quests() -> void:
var quest_slot := _node("Quest_Slot")
if quest_slot == null or client == null:
return
var quests: Array = []
if client.has_method("get_quests"):
var raw_quests: Array = client.get_quests()
for q in raw_quests:
if String(q.get("title", q.get("name", ""))).strip_edges() != "":
quests.append(q)
var quest_count := quests.size()
var sb := _node("Quest_ScrollBar")
if sb is Range:
if quest_count > 5:
sb.visible = true
sb.max_value = maxi(0, quest_count - 5)
sb.value = _quest_showing_start_index
else:
sb.visible = false
_quest_showing_start_index = 0
for i in range(5):
var data_idx := _quest_showing_start_index + i
var nm_node := _node("Quest_Name_0%d" % i)
var time_node := _node("Quest_LastTime_0%d" % i)
var cnt_node := _node("Quest_LastCount_0%d" % i)
var cell := quest_slot.get_node_or_null("slot_%d" % i)
if cell == null and i < quest_slot.get_child_count():
cell = quest_slot.get_child(i)
if data_idx < quest_count:
var q: Dictionary = quests[data_idx]
var q_title := String(q.get("title", q.get("name", "任务 %d" % data_idx)))
var q_idx := int(q.get("index", data_idx))
var counter_name := String(q.get("counter_name", ""))
var counter_val := int(q.get("counter_value", 0))
var clock_name := String(q.get("clock_name", ""))
var clock_val := int(q.get("clock_value", 0))
if nm_node:
nm_node.visible = true
nm_node.text = q_title
nm_node.mouse_filter = Control.MOUSE_FILTER_PASS
nm_node.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
for conn in nm_node.gui_input.get_connections():
nm_node.gui_input.disconnect(conn["callable"])
nm_node.gui_input.connect(func(ev: InputEvent):
if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
_select_quest(q_idx)
)
if cnt_node:
cnt_node.visible = true
if counter_name != "":
cnt_node.text = "%s : %d" % [counter_name, counter_val]
else:
cnt_node.text = ""
if time_node:
time_node.visible = true
if clock_name != "" and clock_val > 0:
var m := clock_val / 60
var s := clock_val % 60
time_node.text = "%s %02d:%02d" % [clock_name, m, s]
else:
time_node.text = ""
if cell:
cell.mouse_filter = Control.MOUSE_FILTER_PASS
cell.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
for conn in cell.gui_input.get_connections():
cell.gui_input.disconnect(conn["callable"])
cell.gui_input.connect(func(ev: InputEvent):
if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
_select_quest(q_idx)
)
else:
if nm_node:
nm_node.visible = false
nm_node.text = ""
if cnt_node:
cnt_node.visible = false
cnt_node.text = ""
if time_node:
time_node.visible = false
time_node.text = ""
if cell:
for conn in cell.gui_input.get_connections():
cell.gui_input.disconnect(conn["callable"])
func _select_quest(quest_idx: int) -> void:
if client and client.has_method("script_button"):
# 40250 uicharacter.py:655 event.QuestButtonClick(-2147483648 + questIndex)
client.script_button(-2147483648 + quest_idx)
func _main_race() -> int:
if client and client.has_method("get_main_vid") and client.has_method("get_entity"):
var vid := int(client.get_main_vid())
if vid != 0:
return int(client.get_entity(vid).get("race", 0))
return 0
const SKILL_GROUP_NAMES := {
0: {1: "剑宗", 2: "气宗"},
1: {1: "锋刀", 2: "百羿"},
2: {1: "幻舞", 2: "黑魔"},
3: {1: "潜龙", 2: "狂雷"},
}
func _get_skill_group_name(job: int, grp: int) -> String:
var names: Dictionary = SKILL_GROUP_NAMES.get(job, {})
return String(names.get(grp, "技能组 %d" % grp))
func _skill_group() -> int:
var grp := int(client.get_skill_group()) if client and client.has_method("get_skill_group") else 0
if grp > 0:
return grp
return _infer_skill_group()
func _infer_skill_group() -> int:
if client == null or not client.has_method("get_skills"):
return 0
var race := _main_race()
for sk in client.get_skills():
var sid := int(sk.get("id", 0))
var lvl := int(sk.get("level", 0))
if lvl <= 0 and int(sk.get("master", 0)) == 0:
continue
for g in [1, 2]:
var slot := SkillSlotMap.skill_slot_for_id(race, g, sid)
if slot >= 1 and slot <= 8:
return g
return 0
func _skill_icon(skill_id: int) -> Texture2D:
if assets_root == "":
return null
var data: Dictionary = skill_table.entry(skill_id) if skill_table and skill_table.has_method("entry") else {}
var job := String(data.get("job", "")).to_upper()
var motion := String(data.get("motion", "")).strip_edges()
if motion == "":
return null
var candidates: Array[String] = []
const JOB_DIR := { "WARRIOR": "warrior", "ASSASSIN": "assassin", "SURA": "sura", "SHAMAN": "shaman" }
var grade := _skill_grade(skill_id)
var suffixes: Array[String] = ["_01", "_02", "_03"]
if grade == 1:
suffixes = ["_02", "_01", "_03"]
elif grade >= 2:
suffixes = ["_03", "_02", "_01"]
if JOB_DIR.has(job):
var dir := String(JOB_DIR[job])
for sfx in suffixes:
candidates.append("ETC/ymir work/ui/skill/%s/%s%s.sub" % [dir, motion, sfx])
elif job == "SUPPORT":
for sfx in suffixes:
candidates.append("ETC/ymir work/ui/skill/common/support/%s%s.sub" % [motion, sfx])
candidates.append("ETC/ymir work/ui/skill/common/support/%s.sub" % motion)
elif job == "HORSE":
candidates.append("ETC/ymir work/ui/skill/common/horse/%s.sub" % motion)
elif job == "GUILD":
candidates.append("ETC/ymir work/ui/skill/common/guild/%s.sub" % motion)
for rel in candidates:
var tex: Texture2D = UiAssets.load_tex(assets_root, rel)
if tex != null:
return tex
return null
func _skill_grade(skill_id: int) -> int:
if client and client.has_method("get_skills"):
for skill in client.get_skills():
if int(skill.get("id", 0)) == skill_id:
return clampi(int(skill.get("master", 0)), 0, 3)
return 0
func _get_player_skill(skill_id: int) -> Dictionary:
if client and client.has_method("get_skills"):
for skill in client.get_skills():
if int(skill.get("id", 0)) == skill_id:
return skill
return {}
func _skill_name(skill_id: int) -> String:
if skill_table and skill_table.has_method("name_of"):
var nm := String(skill_table.name_of(skill_id))
if nm != "":
return nm
return "技能 %d" % skill_id
func _cast_skill(skill_id: int) -> void:
var tree := get_tree()
if tree and tree.root:
for n in tree.root.find_children("*", "Node", true, false):
if n.has_method("activate_skill_direct"):
n.activate_skill_direct(skill_id)
return
if client and client.has_method("use_skill"):
client.use_skill(skill_id, 0)
+8
View File
@@ -138,6 +138,14 @@ func toggle_log() -> void:
func is_log_visible() -> bool:
return _root != null and _root.visible
func set_view_chat(enabled: bool) -> void:
if _root:
_root.visible = enabled
func set_log_visible(v: bool) -> void:
if _root:
_root.visible = v
# --- 建 UI --------------------------------------------------------------
func _build(parent: Node) -> void:
+113 -5
View File
@@ -1,28 +1,117 @@
# CursorManager —— 对齐 ClientVS22 mousemodule.py 的光标状态机。
#
# 光标和“鼠标跟随物品”是两层:本节点只管理当前交互语义,
# MouseController 负责跟随鼠标的物品图标。没有对应 .sub 资源时使用 Godot
# 系统光标,避免把资源缺失误报成输入状态缺失
# MouseController 负责跟随鼠标的物品图标。使用 40250 的 .sub 纹理注册真实光标,
# 没有对应资源时回退到 Godot 系统光标。
extends Node
signal changed(shape: String)
const UiAssets = preload("res://ui/ui_assets.gd")
const NORMAL := "NORMAL"
const ATTACK := "ATTACK"
const TARGET := "TARGET"
const TALK := "TALK"
const CANT_GO := "CANT_GO"
const PICK := "PICK"
const DOOR := "DOOR"
const CHAIR := "CHAIR"
const MAGIC := "MAGIC"
const BUY := "BUY"
const SELL := "SELL"
const CAMERA_ROTATE := "CAMERA_ROTATE"
const HSIZE := "HSIZE"
const VSIZE := "VSIZE"
const HVSIZE := "HVSIZE"
const ITEM := "ITEM"
const ALL_CURSORS := [
NORMAL, ATTACK, TARGET, TALK, CANT_GO, PICK, DOOR, CHAIR, MAGIC,
BUY, SELL, CAMERA_ROTATE, HSIZE, VSIZE, HVSIZE, ITEM
]
var _shape := NORMAL
var _assets_root := ""
var _custom_cursors_registered := false
var _cursor_images: Dictionary = {}
func setup(assets_root: String) -> void:
_assets_root = assets_root
_register_custom_cursors()
func _register_custom_cursors() -> void:
if _assets_root.is_empty():
return
var cursors := {
NORMAL: "ETC/ymir work/ui/cursor/cursor.sub",
ATTACK: "ETC/ymir work/ui/cursor/cursor_attack.sub",
TARGET: "ETC/ymir work/ui/cursor/cursor_attack.sub",
TALK: "ETC/ymir work/ui/cursor/cursor_talk.sub",
CANT_GO: "ETC/ymir work/ui/cursor/cursor_no.sub",
PICK: "ETC/ymir work/ui/cursor/cursor_pick.sub",
DOOR: "ETC/ymir work/ui/cursor/cursor_door.sub",
CHAIR: "ETC/ymir work/ui/cursor/cursor_chair.sub",
MAGIC: "ETC/ymir work/ui/cursor/cursor_chair.sub",
BUY: "ETC/ymir work/ui/cursor/cursor_buy.sub",
SELL: "ETC/ymir work/ui/cursor/cursor_sell.sub",
CAMERA_ROTATE: "ETC/ymir work/ui/cursor/cursor_camera_rotate.sub",
HSIZE: "ETC/ymir work/ui/cursor/cursor_hsize.sub",
VSIZE: "ETC/ymir work/ui/cursor/cursor_vsize.sub",
HVSIZE: "ETC/ymir work/ui/cursor/cursor_hvsize.sub",
}
var shape_to_godot := {
NORMAL: Input.CURSOR_ARROW,
ATTACK: Input.CURSOR_CROSS,
TALK: Input.CURSOR_POINTING_HAND,
PICK: Input.CURSOR_CAN_DROP,
CANT_GO: Input.CURSOR_FORBIDDEN,
BUY: Input.CURSOR_DRAG,
SELL: Input.CURSOR_MOVE,
DOOR: Input.CURSOR_HELP,
CHAIR: Input.CURSOR_WAIT,
CAMERA_ROTATE: Input.CURSOR_BDIAGSIZE,
HSIZE: Input.CURSOR_HSIZE,
VSIZE: Input.CURSOR_VSIZE,
HVSIZE: Input.CURSOR_FDIAGSIZE,
}
var hotspots := {
HSIZE: Vector2(16, 16),
VSIZE: Vector2(16, 16),
HVSIZE: Vector2(16, 16),
}
for name in cursors:
var rel: String = cursors[name]
var tex: Texture2D = UiAssets.load_tex(_assets_root, rel)
if tex != null:
var img: Image = _extract_image(tex)
if img != null:
_cursor_images[name] = img
var hs: Vector2 = hotspots.get(name, Vector2.ZERO)
if shape_to_godot.has(name):
Input.set_custom_mouse_cursor(img, shape_to_godot[name], hs)
_custom_cursors_registered = true
func _extract_image(tex: Texture2D) -> Image:
if tex is AtlasTexture:
var at := tex as AtlasTexture
if at.atlas:
var base_img: Image = at.atlas.get_image()
if base_img:
var r := Rect2i(int(at.region.position.x), int(at.region.position.y), int(at.region.size.x), int(at.region.size.y))
return base_img.get_region(r)
elif tex:
return tex.get_image()
return null
func set_cursor(shape: String) -> void:
if shape not in [NORMAL, ATTACK, TALK, CANT_GO, PICK, BUY, SELL, ITEM]:
if shape not in ALL_CURSORS:
shape = NORMAL
_shape = shape
Input.set_default_cursor_shape(_godot_shape(shape))
if _cursor_images.has(shape):
var hs := Vector2(16, 16) if shape in [HSIZE, VSIZE, HVSIZE] else Vector2.ZERO
Input.set_custom_mouse_cursor(_cursor_images[shape], Input.CURSOR_ARROW, hs)
changed.emit(shape)
func get_cursor() -> String:
@@ -33,13 +122,32 @@ func reset() -> void:
func _godot_shape(shape: String) -> Input.CursorShape:
match shape:
ATTACK:
ATTACK, TARGET:
return Input.CURSOR_CROSS
TALK, BUY, SELL, PICK:
PICK:
return Input.CURSOR_CAN_DROP
TALK:
return Input.CURSOR_POINTING_HAND
DOOR:
return Input.CURSOR_HELP
CHAIR, MAGIC:
return Input.CURSOR_WAIT
BUY:
return Input.CURSOR_DRAG
SELL:
return Input.CURSOR_MOVE
CAMERA_ROTATE:
return Input.CURSOR_BDIAGSIZE
HSIZE:
return Input.CURSOR_HSIZE
VSIZE:
return Input.CURSOR_VSIZE
HVSIZE:
return Input.CURSOR_FDIAGSIZE
CANT_GO:
return Input.CURSOR_FORBIDDEN
ITEM:
return Input.CURSOR_DRAG
_:
return Input.CURSOR_ARROW
+82 -35
View File
@@ -1,17 +1,21 @@
# DeathUI (P4) —— 主角死亡 → 灰屏 + 复活窗。
#
# var d := preload("res://ui/death_ui.gd").new()
# add_child(d)
# d.setup(m2client, canvas_parent)
#
# 触发:M2Client.entity_dead(main_vid) 或 phase_changed("dead")。
# 复活走服务器 quest 命令(同客户端):/restart_here · /restart_townCG_CHAT)。
# 复活成功服务器发 GC_PLAYER_POINTS(hp>0) / 相位回 game -> 自动关。
# death_ui.gd —— 40250 死亡结算与复活面板(RestartDialog1:1 实现
# 严格对照:
# root/uirestart.py (RestartDialog)
# uiscript/restartdialog.py (200x88 ThinBoard)
# metin2/src/server/game/src/cmd_general.cpp:478 (10s restart_here cooldown)
extends Node
const RESTART_HERE_COOLDOWN := 10.0 # 40250 官方 10 秒原地复活保护冷却
const BOARD_WIDTH := 200.0
const BOARD_HEIGHT := 88.0
var client: Node
var _root: Control
var _board: Panel
var _btn_restart_here: Button
var _btn_restart_town: Button
var _shown := false
var _here_cooldown_remaining := 0.0
func setup(m2client: Node, parent: Node) -> void:
client = m2client
@@ -23,6 +27,19 @@ func setup(m2client: Node, parent: Node) -> void:
if client.has_signal("vitals_changed"):
client.vitals_changed.connect(_on_vitals)
func _process(delta: float) -> void:
if not _shown:
return
if _here_cooldown_remaining > 0.0:
_here_cooldown_remaining -= delta
if _here_cooldown_remaining <= 0.0:
_here_cooldown_remaining = 0.0
_btn_restart_here.disabled = false
_btn_restart_here.text = "原地复活"
else:
_btn_restart_here.disabled = true
_btn_restart_here.text = "原地复活 (%d 秒)" % int(ceilf(_here_cooldown_remaining))
func _on_dead(vid: int) -> void:
if client.has_method("get_main_vid") and vid == client.get_main_vid():
show_dialog()
@@ -39,54 +56,84 @@ func show_dialog() -> void:
if _shown:
return
_shown = true
_here_cooldown_remaining = RESTART_HERE_COOLDOWN
_btn_restart_here.disabled = true
_btn_restart_here.text = "原地复活 (10 秒)"
_root.visible = true
func hide_dialog() -> void:
_shown = false
_here_cooldown_remaining = 0.0
_root.visible = false
func is_open() -> bool:
return _shown and _root != null and _root.visible
func _build(parent: Node) -> void:
_root = Control.new()
_root.name = "RestartDialog"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.visible = false
_root.mouse_filter = Control.MOUSE_FILTER_STOP
parent.add_child(_root)
# 全屏暗红暗角
var dim := ColorRect.new()
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.color = Color(0.1, 0.0, 0.0, 0.55)
dim.color = Color(0.12, 0.0, 0.0, 0.6)
_root.add_child(dim)
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
panel.position = Vector2(-150, -70)
panel.size = Vector2(300, 140)
# 40250 uiscript/restartdialog.py 1:1 尺寸 200x88 ThinBoard
_board = Panel.new()
_board.name = "Board"
_board.set_anchors_preset(Control.PRESET_CENTER)
_board.position = Vector2(-BOARD_WIDTH / 2.0, -BOARD_HEIGHT / 2.0)
_board.size = Vector2(BOARD_WIDTH, BOARD_HEIGHT)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.12, 0.1, 0.1, 0.97)
sb.border_color = Color(0.5, 0.2, 0.2)
sb.bg_color = Color(0.3333, 0.2941, 0.2588, 0.95)
sb.border_color = Color(0.45, 0.38, 0.22, 0.95)
sb.set_border_width_all(1)
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
sb.set_corner_radius_all(2)
_board.add_theme_stylebox_override("panel", sb)
_root.add_child(_board)
var title := Label.new()
title.text = "你已阵亡"
title.position = Vector2(0, 16)
title.size = Vector2(300, 0)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
panel.add_child(title)
# 40250 restart_here_button (10, 17), 180x26
_btn_restart_here = Button.new()
_btn_restart_here.name = "RestartHereButton"
_btn_restart_here.position = Vector2(10, 17)
_btn_restart_here.size = Vector2(180, 26)
_btn_restart_here.text = "原地复活 (10 秒)"
_btn_restart_here.disabled = true
_btn_restart_here.add_theme_font_size_override("font_size", 11)
_btn_restart_here.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_btn_restart_here.pressed.connect(_on_restart_here_pressed)
_board.add_child(_btn_restart_here)
_btn(panel, "在此复活", 24, 60, func(): _restart("/restart_here"))
_btn(panel, "回城复活", 158, 60, func(): _restart("/restart_town"))
# 40250 restart_town_button (10, 47), 180x26
_btn_restart_town = Button.new()
_btn_restart_town.name = "RestartTownButton"
_btn_restart_town.position = Vector2(10, 47)
_btn_restart_town.size = Vector2(180, 26)
_btn_restart_town.text = "在村庄复活"
_btn_restart_town.add_theme_font_size_override("font_size", 11)
_btn_restart_town.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_btn_restart_town.pressed.connect(_on_restart_town_pressed)
_board.add_child(_btn_restart_town)
func _btn(parent: Control, text: String, x: int, y: int, cb: Callable) -> void:
var b := Button.new()
b.text = text
b.position = Vector2(x, y)
b.size = Vector2(118, 30)
b.pressed.connect(cb)
parent.add_child(b)
# 40250 uirestart.py: OnPressEscapeKey / OnPressExitKey returns True (禁止 ESC 关闭)
_root.gui_input.connect(func(event: InputEvent):
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
_root.accept_event()
)
func _on_restart_here_pressed() -> void:
if _here_cooldown_remaining > 0.0:
return
_restart("/restart_here")
func _on_restart_town_pressed() -> void:
_restart("/restart_town")
func _restart(cmd: String) -> void:
if client and client.has_method("say"):
client.say(0, cmd) # CHAT_TYPE_TALKING
client.say(0, cmd)
+167 -9
View File
@@ -25,6 +25,8 @@ extends Node
const RaceSpec = preload("res://ui/race_spec.gd")
const PartHiding = preload("res://part_hiding.gd")
const EffectRegistry = preload("res://fx/effect_registry.gd")
const MotionEffectAnchor = preload("res://fx/motion_effect_anchor.gd")
const WEAR_BODY := 0
const WEAR_HEAD := 1
@@ -39,7 +41,13 @@ const CHR_EQUIPPART_HAIR := 3 # 兼容旧引用
# item_length.h EWeaponSubTypesCItemData::GetWeaponType = bSubType
const WEAPON_SUB_DAGGER := 1
const WEAPON_SUB_BOW := 2
const WEAPON_SUB_TWO_HANDED := 3
const WEAPON_SUB_BELL := 4
const WEAPON_SUB_FAN := 5
const WEAPON_SUB_ARROW := 6
const ITEM_TYPE_WEAPON := 1
const ITEM_TYPE_ARMOR := 2
const ARMOR_BODY := 0
const HAND_RIGHT := 1
const HAND_LEFT := 2
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
@@ -65,6 +73,9 @@ var _remote_parts: Array = [] # 远端 PC 的 awPart[ARMOR..HAIR]
var armor_shape_of: Callable = func(vnum: int) -> int: return _armor_shape_default(vnum)
# 应用方可注入:vnum -> 身体 gr2 路径(优先于 race_spec
var armor_model_map := {}
var effect_registry: RefCounted
var _weapon_refine_fx: Array[Node] = []
var _armor_refine_fx: Array[Node] = []
func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "",
race_index := -1) -> void:
@@ -153,15 +164,154 @@ func refresh() -> void:
var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON)
var wpn_eff := PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly)
var hands := _weapon_hands(wpn_eff)
if wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands:
var weapon_changed := wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands
if weapon_changed:
_last_weapon_vnum = wpn_eff
_last_weapon_hands = hands
# 左手用 GetSubModelThing()item .msm 加载在参考端被注释掉,恒为同一个 gr2
var wpath := _resolve_weapon(wpn_eff)
model.set("weapon_gr2", wpath if hands & HAND_RIGHT else "")
model.set("shield_gr2", wpath if hands & HAND_LEFT else "")
# SetArmor / SetWeapon both call __GetRefinedEffect.
_refresh_weapon_refine_effect(model, _last_weapon_vnum)
_refresh_armor_refine_effect(model, _last_body_vnum)
# ActorInstanceAttach.cpp __IsRightHandWeapon / __IsLeftHandWeapon -> HAND_* 位掩码
static func refine_effect_level(vnum: int, item: Dictionary) -> int:
if vnum <= 0 or item.is_empty():
return 0
var sockets := int(item.get("gain_socket_pct", 0))
if sockets != 3:
return 0
return maxi(vnum % 10, 0)
static func refine_effect_specs(vnum: int, item: Dictionary) -> Array:
var out: Array = []
if vnum <= 0 or item.is_empty():
return out
var item_type := int(item.get("type", -1))
var subtype := int(item.get("sub_type", -1))
var level := refine_effect_level(vnum, item)
if item_type == ITEM_TYPE_WEAPON:
if level < 7 or level > 9:
return out
var stem := "sword_%d" % level
match subtype:
WEAPON_SUB_DAGGER:
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
out.append({"slot": "left", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
WEAPON_SUB_FAN:
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_f.mse" % stem})
WEAPON_SUB_BOW:
out.append({"slot": "left", "effect": "d:/ymir work/pc/common/effect/sword/%s_b.mse" % stem})
WEAPON_SUB_BELL, WEAPON_SUB_ARROW:
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
_:
# Sword and two-handed sword share the normal sword effect.
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s.mse" % stem})
elif item_type == ITEM_TYPE_ARMOR and subtype == ARMOR_BODY:
# 40250 always layers these two Blue Steel armour effects, in addition
# to the regular +7/+8/+9 body effect.
if vnum >= 12010 and vnum <= 12049:
out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor-4-2-1.mse"})
out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor-4-2-2.mse"})
if level >= 7 and level <= 9:
out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor_%d.mse" % level})
return out
func _item(vnum: int) -> Dictionary:
if vnum > 0 and proto and proto.has_method("item"):
return proto.item(vnum)
return {}
func _ensure_effect_registry() -> RefCounted:
if effect_registry == null and assets_root != "":
effect_registry = EffectRegistry.new()
effect_registry.call("setup", assets_root)
return effect_registry
func _clear_refine_effects(nodes: Array[Node]) -> void:
for node in nodes:
if is_instance_valid(node) and not node.is_queued_for_deletion():
node.queue_free()
nodes.clear()
func _effect_bone(model: Node, slot: String) -> String:
match slot:
"right":
if model and "_right_bone" in model and String(model.get("_right_bone")) != "":
return String(model.get("_right_bone"))
return "equip_right_hand"
"left":
if model and "_left_bone" in model and String(model.get("_left_bone")) != "":
return String(model.get("_left_bone"))
return "equip_left"
"body": return "Bip01"
return ""
func _effect_part(model: Node, slot: String) -> Node3D:
if model == null:
return null
match slot:
"right": return model.find_child("Weapon", true, false) as Node3D
"left": return model.find_child("Shield", true, false) as Node3D
"body": return model as Node3D
return model as Node3D
func _spawn_bone_refine(registry: RefCounted, model: Node, spec: Dictionary) -> Node:
# Weapons with a non-identity Granny grip pre-transform (for
# example 03150): it moves the particles away even though the mesh is held.
if not model is Node3D or not ("anim" in model):
return null
var animator: Variant = model.get("anim")
if not animator is Node or not animator.has_method("get_effect_bone_pose"):
return null
var bone := _effect_bone(model, String(spec.get("slot", "")))
if bone == "":
return null
var effect: Node = registry.call("spawn", String(spec.get("effect", "")),
model as Node3D, false)
if effect == null:
return null
var anchor := MotionEffectAnchor.new()
effect.add_child(anchor)
var event := {"attaching": true, "following": true, "independent": false,
"bone": bone, "pos": Vector3.ZERO}
if anchor.configure(model as Node3D, event):
return effect
effect.free()
return null
func _spawn_refine_specs(model: Node, specs: Array, output: Array[Node]) -> void:
var registry := _ensure_effect_registry()
if registry == null or not registry.has_method("spawn"):
return
for spec in specs:
var parent := _effect_part(model, String(spec.get("slot", "")))
var effect: Node = _spawn_bone_refine(registry, model, spec)
if effect == null and parent != null:
effect = registry.call("spawn", String(spec.get("effect", "")), parent, false)
if effect:
output.append(effect)
func _refresh_weapon_refine_effect(model: Node, vnum: int) -> void:
_clear_refine_effects(_weapon_refine_fx)
_spawn_refine_specs(model, refine_effect_specs(vnum, _item(vnum)), _weapon_refine_fx)
func _refresh_armor_refine_effect(model: Node, vnum: int) -> void:
_clear_refine_effects(_armor_refine_fx)
_spawn_refine_specs(model, refine_effect_specs(vnum, _item(vnum)), _armor_refine_fx)
# A RemotePlayerView is assembled before NetWorld adds it to the SceneTree.
# Re-run only the effect attachment after _ready so skeleton-following anchors
# can resolve global transforms; do not reload body/weapon geometry.
func refresh_refine_effects() -> void:
var model: Node = _model_getter.call() if _model_getter.is_valid() else null
if model == null:
return
_refresh_armor_refine_effect(model, _last_body_vnum)
_refresh_weapon_refine_effect(model, _last_weapon_vnum)
func _weapon_hands(vnum: int) -> int:
if vnum == 0:
return 0
@@ -302,7 +452,7 @@ func _armor_shape_default(vnum: int) -> int:
if vnum > 1 and proto and proto.has_method("item"):
var it: Dictionary = proto.item(vnum)
var v = it.get("values", [])
if v is Array and v.size() > 3 and int(v[3]) != 0:
if not it.is_empty() and v is Array and v.size() > 3:
return int(v[3])
return vnum
@@ -312,10 +462,13 @@ func _armor_specular(vnum: int) -> int:
return 0
func _apply_body(model: Node, vnum: int) -> void:
# 强化等级越高越亮(PARITY §2.7fSpecular = bSpecular / 100
if model == null:
return
if model.has_method("clear_skin_textures"):
model.call("clear_skin_textures")
# 强化等级越高越亮(PARITY §2.7fSpecular = bSpecular / 100,无高光时重置为 0)
var sp := _armor_specular(vnum)
if sp > 0:
model.set("specular_power", sp / 100.0)
model.set("specular_power", sp / 100.0 if sp > 0 else 0.0)
if armor_model_map.has(vnum):
model.set("gr2_path", String(armor_model_map[vnum]))
return
@@ -326,13 +479,18 @@ func _apply_body(model: Node, vnum: int) -> void:
var body := _resolve_spec_asset(e, String(e.get("model", "")))
if body != "":
model.set("gr2_path", body)
# 换肤(同模型不同 TargetSkin):覆盖 surface 0
# 换肤(同模型不同 TargetSkin):按 40250 SetMaterialImagePointer 材质名映射
var src: String = e.get("source_skin", "")
var tgt: String = e.get("target_skin", "")
if tgt != "" and tgt != src and model.has_method("set_surface_texture"):
if tgt != "" and tgt != src:
var t := _resolve_spec_asset(e, tgt)
if t != "":
model.call("set_surface_texture", 0, t)
var s := _resolve_spec_asset(e, src)
if model.has_method("set_skin_texture"):
if t != "":
model.call("set_skin_texture", s if s != "" else src, t)
if model.has_method("set_surface_texture"):
if t != "":
model.call("set_surface_texture", 0, t)
func _spec_shape(idx: int) -> Dictionary:
_ensure_spec()
+32 -4
View File
@@ -49,7 +49,7 @@ const LABELS := {
"pvp_mode": "PK 模式", "block": "屏蔽", "chat": "聊天显示",
"always_show_name": "总显示名字", "effect_on_off": "伤害数字", "salestext_on_off": "叫卖文字",
"name_color_normal": "普通", "name_color_empire": "阵营",
"target_board_no_view": "不看他国", "target_board_view": "看他国",
"target_board_no_view": "", "target_board_view": "",
"pvp_peace": "和平", "pvp_revenge": "反击", "pvp_guild": "帮会", "pvp_free": "自由",
"block_exchange_button": "交易", "block_party_button": "组队", "block_guild_button": "帮会",
"block_whisper_button": "密语", "block_friend_button": "好友", "block_party_request_button": "组队申请",
@@ -99,6 +99,11 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
_block_mode = int(_cfg.get_value("gameopt", "block_mode", 0))
if client and client.has_signal("block_mode_changed"):
client.block_mode_changed.connect(_on_server_block_mode)
if ui and ui.has_signal("window_closed"):
ui.window_closed.connect(func(w):
if not _win.is_empty() and _win.get("root") == w:
_win = {}
)
func is_open() -> bool:
if _mobile_mode:
@@ -127,9 +132,10 @@ func close() -> void:
ui.close(_mobile_root)
_mobile_root = null
return
if is_open():
ui.close(_win["root"])
var r: Control = _win.get("root", null)
_win = {}
if r and is_instance_valid(r) and ui:
ui.close(r)
func open() -> void:
if _mobile_mode:
@@ -141,13 +147,35 @@ func open() -> void:
if not FileAccess.file_exists(path):
push_warning("GameOptionUI: no gameoptiondialog.py at " + path)
return
_win = ui.open_script(path, assets_root)
_win = ui.open_script(path, assets_root, false, true)
if not is_open():
return
set_center_position()
_relabel()
_wire()
_sync()
## 1:1 对齐 40250 uigameoption.py: self.SetCenterPosition()
func set_center_position(offset := Vector2.ZERO) -> void:
var r: Control = _win.get("root", null)
if r == null or not is_instance_valid(r):
return
if ui and ui.has_method("center_window"):
ui.center_window(r, offset)
else:
var vp_sz := Vector2(1920, 1080)
var vp := r.get_viewport()
if vp:
var vrect := vp.get_visible_rect().size
if vrect.x > 300 and vrect.y > 300:
vp_sz = vrect
elif ui and "screen" in ui and ui.screen.x > 0:
vp_sz = Vector2(ui.screen)
var sz := r.size
if sz.x <= 0 or sz.y <= 0:
sz = r.custom_minimum_size
r.position = ((vp_sz - sz) / 2.0 + offset).round()
func _open_mobile() -> void:
if is_open():
return
+208 -26
View File
@@ -8,6 +8,8 @@
# M2Client.ground_item_added(Dict{vid,vnum,pos,owner}) / ground_item_removed(vid) 驱动。
extends Node
signal cannot_pick_item(owner_name: String)
const PICKUP_RANGE := 3.0
# §8.8 seam ⑩(CPythonTextTail::ArrangeTextTail, PythonTextTail.cpp:153-190):多条掉落
@@ -49,12 +51,56 @@ var proto: Node
var item_list: RefCounted
var _player_getter: Callable
var _camera_getter: Callable
var _world_getter: Callable
var _world: Node
var _by_vid := {} # vid -> Node3D
var _next_tcp_time_ms := 0
func get_pickable_distance() -> float:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p and bool(p.get_meta("mounted", false)):
return 5.0
return PICKUP_RANGE
func _get_main_player_name() -> String:
if client == null:
return ""
if client.has_method("get_main_vid") and client.has_method("get_entity"):
var vid := int(client.get_main_vid())
if vid > 0:
var e: Dictionary = client.get_entity(vid)
var n := String(e.get("name", ""))
if n != "":
return n
if client.has_method("get_character_name"):
return String(client.get_character_name())
return ""
func _on_cannot_pick_item(owner: String) -> void:
cannot_pick_item.emit(owner)
print("[拾取] 这不是你的物品,无法拾取!(所有者: %s)" % owner)
func _is_rare_drop(vnum: int, d: Dictionary) -> bool:
if vnum == 1:
return false
if vnum in [25040, 50513, 70024, 70031]:
return true
if proto and proto.has_method("item"):
var pd: Dictionary = proto.item(vnum)
var itype := int(pd.get("type", 0))
if itype in [1, 2] and (vnum % 10) >= 7:
return true
if int(d.get("rarity", 0)) >= 2 or int(d.get("grade", 0)) >= 3:
return true
return false
var assets_override := ""
var _possessive := OWNER_POSSESSIVE # setup(possessive) 注入;空 -> 字面 "'s"SetItemTextTailOwner :722
func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
proto_node: Node = null, il: RefCounted = null,
camera_getter: Callable = Callable(), possessive := "") -> void:
camera_getter: Callable = Callable(), possessive := "",
world_getter: Callable = Callable()) -> void:
client = m2client
_possessive = possessive if possessive != "" else OWNER_POSSESSIVE # :722 == "" ? "'s" : loc
mount = mount_node
@@ -62,6 +108,7 @@ func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
item_list = il
_player_getter = player_getter
_camera_getter = camera_getter
_world_getter = world_getter
if client.has_signal("ground_item_added"):
client.ground_item_added.connect(_on_added)
if client.has_signal("ground_item_removed"):
@@ -71,6 +118,15 @@ func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
for d in client.get_ground_items():
_on_added(d)
func _to_world_pos(raw_pos: Variant) -> Vector3:
if not (raw_pos is Vector3):
return Vector3.ZERO
var pos: Vector3 = MapCoord.to_world(raw_pos)
var w: Node = _world_getter.call() if _world_getter.is_valid() else _world
if w and w.has_method("sample_height"):
pos.y = float(w.call("sample_height", pos.x, pos.z)) + 0.1
return pos
func _on_added(d: Dictionary) -> void:
var vid := int(d.get("vid", 0))
if vid == 0:
@@ -78,9 +134,11 @@ func _on_added(d: Dictionary) -> void:
var vnum := int(d.get("vnum", 0))
var owner := String(d.get("owner", ""))
var item_color := _item_color(vnum, d)
var pos := _to_world_pos(d.get("pos", Vector3.ZERO))
if _by_vid.has(vid):
var existing: Node3D = _by_vid[vid]
if is_instance_valid(existing):
existing.position = pos
var existing_tag := existing.get_node("tag") as Label3D
existing_tag.text = _name_for(vnum)
existing_tag.modulate = item_color
@@ -91,18 +149,51 @@ func _on_added(d: Dictionary) -> void:
return
var node := Node3D.new()
node.name = "drop_%d" % vid
node.position = d.get("pos", Vector3.ZERO)
var mesh := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(0.25, 0.25, 0.25)
mesh.mesh = box
mesh.position.y = 0.3
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.85, 0.3)
mat.emission_enabled = true
mat.emission = Color(0.6, 0.5, 0.1)
mesh.material_override = mat
node.add_child(mesh)
node.position = pos
var root := "" if assets_override == "__none__" else (assets_override if assets_override != "" else (AssetRoot.path() if AssetRoot.available() else ""))
var model_path := ""
if root != "":
if vnum == 1:
for p in ["item/ymir work/item/etc/money.gr2", "item/etc/money.gr2"]:
var fp := root.path_join(p)
if FileAccess.file_exists(fp):
model_path = fp
break
else:
for p in ["item/ymir work/item/etc/item_bag.gr2", "item/etc/item_bag.gr2"]:
var fp := root.path_join(p)
if FileAccess.file_exists(fp):
model_path = fp
break
if model_path != "" and ClassDB.class_exists("Metin2Model"):
var model: Node3D = ClassDB.instantiate("Metin2Model")
model.name = "model"
model.set("flip_winding", true)
model.set("texture_dir", model_path.get_base_dir())
model.set("gr2_path", model_path)
node.add_child(model)
else:
var mesh := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(0.25, 0.25, 0.25)
mesh.mesh = box
mesh.position.y = 0.15
var mat := StandardMaterial3D.new()
if vnum == 1:
mat.albedo_color = Color(1.0, 0.85, 0.2)
mat.metallic = 0.8
mat.roughness = 0.2
mat.emission_enabled = true
mat.emission = Color(0.5, 0.4, 0.1)
else:
mat.albedo_color = Color(1.0, 0.85, 0.3)
mat.emission_enabled = true
mat.emission = Color(0.6, 0.5, 0.1)
mesh.material_override = mat
node.add_child(mesh)
var tag := Label3D.new()
tag.name = "tag"
tag.text = _name_for(vnum) # 名字实例只放名字,owner 走独立 owner_tag:723 不再拼串)
@@ -117,6 +208,23 @@ func _on_added(d: Dictionary) -> void:
node.set_meta("item_owner", owner)
node.set_meta("item_color", item_color)
_apply_owner_tag(node, owner, item_color)
if _is_rare_drop(vnum, d):
var beam := MeshInstance3D.new()
beam.name = "light_beam"
var cyl := CylinderMesh.new()
cyl.top_radius = 0.08
cyl.bottom_radius = 0.12
cyl.height = 3.5
beam.mesh = cyl
beam.position.y = 1.75
var bmat := StandardMaterial3D.new()
bmat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
bmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
bmat.albedo_color = Color(1.0, 0.85, 0.2, 0.45)
bmat.emission_enabled = true
bmat.emission = Color(1.0, 0.85, 0.3)
beam.material_override = bmat
node.add_child(beam)
mount.add_child(node)
_by_vid[vid] = node
@@ -214,49 +322,123 @@ func has_nearby_item() -> bool:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p == null:
return false
var p_pos: Vector3 = p.global_position if p.is_inside_tree() else p.position
for node in _by_vid.values():
if is_instance_valid(node) and p.global_position.distance_to(node.global_position) < PICKUP_RANGE:
if not is_instance_valid(node):
continue
var n_pos: Vector3 = node.global_position if node.is_inside_tree() else node.position
if p_pos.distance_to(n_pos) < PICKUP_RANGE:
return true
return false
# 捡最近的一个(范围内)。返回捡的 vid,0 = 没有。
# 40250 1:1 PickCloseItem: 500ms 节流 + 距离 (步行 3m / 骑马 5m) + 所有权保护预检
func try_pickup() -> int:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p == null or client == null:
return 0
var now_ms := Time.get_ticks_msec()
if now_ms < _next_tcp_time_ms:
return 0
var p_pos: Vector3 = p.global_position if p.is_inside_tree() else p.position
var best_vid := 0
var best_d := PICKUP_RANGE
var pick_dist := get_pickable_distance()
var best_d := pick_dist
var best_owner := ""
for vid in _by_vid:
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
continue
var d := p.global_position.distance_to(n.global_position)
var n_pos: Vector3 = n.global_position if n.is_inside_tree() else n.position
var d := p_pos.distance_to(n_pos)
if d < best_d:
best_d = d
best_vid = int(vid)
if best_vid != 0:
best_owner = String(n.get_meta("item_owner", ""))
if best_vid == 0:
return 0
var my_name := _get_main_player_name()
if best_owner != "" and my_name != "" and best_owner != my_name:
_on_cannot_pick_item(best_owner)
return 0
_next_tcp_time_ms = now_ms + 500
if client.has_method("pickup_item"):
client.pickup_item(best_vid)
return best_vid
func try_pickup_vid(vid: int) -> bool:
if client == null or not _by_vid.has(vid):
return false
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
return false
var now_ms := Time.get_ticks_msec()
if now_ms < _next_tcp_time_ms:
return false
var best_owner := String(n.get_meta("item_owner", ""))
var my_name := _get_main_player_name()
if best_owner != "" and my_name != "" and best_owner != my_name:
_on_cannot_pick_item(best_owner)
return false
_next_tcp_time_ms = now_ms + 500
if client.has_method("pickup_item"):
client.pickup_item(vid)
return true
# Mouse hover probe used by PlayerController's cursor state. Ground drops are
# deliberately tested in screen space, so clicking a visible label / mesh
# selects PICK even when the item is not the nearest drop in world distance.
func hover_at(camera: Camera3D, screen_pos: Vector2) -> bool:
return pick_at(camera, screen_pos) != 0
# Pick ground item at mouse screen position (40250 __Pick + __OnClickItem + CPythonTextTail::Pick).
# 同时支持点选地面模型、头顶名称标签(tag)与所有者标签(owner_tag)。
func pick_at(camera: Camera3D, screen_pos: Vector2) -> int:
if camera == null:
return false
var best := INF
for node in _by_vid.values():
return 0
var best_d := 64.0
var best_vid := 0
for vid in _by_vid:
var node: Node3D = _by_vid[vid]
if not is_instance_valid(node):
continue
if camera.is_position_behind(node.global_position):
continue
var p := camera.unproject_position(node.global_position + Vector3(0, 0.35, 0))
var d := p.distance_to(screen_pos)
if d <= 28.0:
best = minf(best, d)
return best < INF
var test_pts: Array[Vector3] = [
node.global_position + Vector3(0, 0.35, 0)
]
var tag: Node = node.get_node_or_null("tag")
if tag is Node3D and is_instance_valid(tag):
test_pts.append(tag.global_position)
var otag: Node = node.get_node_or_null("owner_tag")
if otag is Node3D and is_instance_valid(otag):
test_pts.append(otag.global_position)
for tp in test_pts:
if camera.is_position_behind(tp):
continue
var p := camera.unproject_position(tp)
var d := p.distance_to(screen_pos)
if d < best_d:
best_d = d
best_vid = int(vid)
return best_vid
func get_item_world_pos(vid: int) -> Variant:
var node: Node3D = _by_vid.get(vid, null)
if node and is_instance_valid(node):
return node.global_position if node.is_inside_tree() else node.position
return null
func _name_for(vnum: int) -> String:
if vnum == 1:
return "Yang"
if proto:
var pd: Dictionary = proto.item(vnum)
if not pd.is_empty():
+8 -2
View File
@@ -117,6 +117,11 @@ func setup(ui_manager: CanvasLayer, m2client: Node = null, assets := "") -> void
if LocaleScript:
_loc = LocaleScript.new()
_loc.setup(assets_root, "en")
if ui and ui.has_signal("window_closed"):
ui.window_closed.connect(func(w):
if not _win.is_empty() and _win.get("root") == w:
_win = {}
)
# --- open / close ---------------------------------------------------
@@ -144,9 +149,10 @@ func close() -> void:
_mobile_root = null
_mobile_labels.clear()
return
if is_open():
ui.close(_win["root"])
var r: Control = _win.get("root", null)
_win = {}
if r and is_instance_valid(r) and ui:
ui.close(r)
func open() -> void:
if _mobile_mode:
+1
View File
@@ -0,0 +1 @@
uid://d1nugtcuikfs4
+31 -2
View File
@@ -28,6 +28,10 @@ var context_consumer: Callable = Callable()
const UiAssets = preload("res://ui/ui_assets.gd")
const EquipRules = preload("res://equip_rules.gd")
const ItemTooltip = preload("res://ui/item_tooltip.gd")
const ChestSystem = preload("res://chest_system.gd")
const ItemAttrSystem = preload("res://item_attr_system.gd")
const MetinSocketSystem = preload("res://metin_socket_system.gd")
const ConsumableSystem = preload("res://consumable_system.gd")
const EQUIP_BASE := 90 # uiscript EQUIPMENT_START_INDEX
const BELT_BASE := 200 # 仅 UI 内部索引,避免与旧 slot_index 相撞
@@ -94,6 +98,11 @@ func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets: St
uiscript_dir = assets.path_join("uiscript")
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(_on_inv_changed)
if ui and ui.has_signal("window_closed"):
ui.window_closed.connect(func(w):
if not _win.is_empty() and _win.get("root") == w:
close()
)
set_process_input(true)
func is_open() -> bool:
@@ -127,13 +136,14 @@ func close() -> void:
item_mouse.unregister_owner(self)
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
item_mouse.cancel()
if is_open():
ui.close(_win["root"])
var r: Control = _win.get("root", null)
_win = {}
_cells.clear()
_combine_from = -1
_mobile_selected_ui = -1
_hint = null
if r and is_instance_valid(r) and ui:
ui.close(r)
func open() -> void:
if is_open():
@@ -147,6 +157,7 @@ func open() -> void:
_add_extended_cells(_win["root"])
_wire_cells()
_wire_money()
_wire_mall_button()
refresh()
# --- 填充 ---------------------------------------------------------------
@@ -258,6 +269,14 @@ func _wire_money() -> void:
else:
_money_button = null
func _wire_mall_button() -> void:
var mall_btn = _win.get("nodes", {}).get("MallButton", null)
if mall_btn is BaseButton:
mall_btn.pressed.connect(func():
if client and client.has_method("say"):
client.say(0, "/click_mall")
)
func _current_gold() -> int:
if client and client.has_method("get_points"):
return maxi(0, int(client.get_points().get("gold", 0)))
@@ -623,6 +642,16 @@ func _drop_mouse_item(payload: Dictionary, target_ui: int) -> bool:
and client.mall_checkout(source_cell, target[0], target[1])
if source_window not in [WINDOW_INVENTORY, 2]:
return false
var target_cell_node: Panel = _cells.get(target_ui, null)
var target_vnum := int(target_cell_node.get_meta("vnum", 0)) if target_cell_node else 0
var source_vnum := int(payload.get("vnum", 0))
if target_vnum != 0 and source_vnum != 0:
if ChestSystem.is_treasure_key(source_vnum) and ChestSystem.is_treasure_box(target_vnum):
if client and client.has_method("use_item_to_item"):
return client.use_item_to_item(source_window, source_cell, int(target[0]), int(target[1]))
if ItemAttrSystem.is_attr_scroll(source_vnum) or MetinSocketSystem.is_metin_stone(source_vnum) or MetinSocketSystem.is_clean_socket_scroll(source_vnum):
if client and client.has_method("use_item_to_item"):
return client.use_item_to_item(source_window, source_cell, int(target[0]), int(target[1]))
return move_wire(source_window, source_cell, int(target[0]), int(target[1]),
maxi(1, int(payload.get("count", 1))))
+6 -3
View File
@@ -30,13 +30,14 @@ func setup(m2client: Node = null) -> void:
func _on_phase(phase: String) -> void:
if phase.to_lower() == "loading":
show_for(String(HINTS.get("loading", "载入")))
else:
hide_screen() # login / select / game —— 让位给对应界面
show_for(String(HINTS.get("loading", "载入地图")))
elif phase.to_lower() in ["login", "select"]:
hide_screen()
func show_for(text: String) -> void:
_label.text = text
_bar.visible = false
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.visible = true
func set_progress(frac: float) -> void:
@@ -44,6 +45,7 @@ func set_progress(frac: float) -> void:
_bar.value = clampf(frac, 0.0, 1.0) * 100.0
func hide_screen() -> void:
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.visible = false
func is_showing() -> bool:
@@ -58,6 +60,7 @@ func _build() -> void:
_panel = ColorRect.new()
_panel.color = Color(0.02, 0.03, 0.05, 0.96)
_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.visible = false
add_child(_panel)
var box := VBoxContainer.new()
+74 -10
View File
@@ -20,11 +20,15 @@ var _password_dialog: ConfirmationDialog
var item_mouse: Node
const MALL_WINDOW := 4
const MALL_PAGE_SLOTS := 45
const USE_MALL_LIMIT_RANGE_M := 10.0
var _grid: GridContainer
var _cells: Dictionary = {}
var _page_label: Label
var _page := 0
var _mobile_mode := false
var _open_char_pos := Vector3.ZERO
var _has_open_pos := false
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
@@ -33,7 +37,9 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
_tooltip_builder.setup(AssetRoot.path(), "en", proto)
_build(parent)
if client.has_signal("mall_opened"):
client.mall_opened.connect(func(_s): refresh())
client.mall_opened.connect(func(_s): open())
if client.has_signal("mall_closed"):
client.mall_closed.connect(_close_local)
if client.has_signal("mall_changed"):
client.mall_changed.connect(refresh)
if client.has_signal("mall_password_required"):
@@ -42,6 +48,35 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func _get_main_char_pos() -> Variant:
if client == null:
return null
var vid := 0
if client.has_method("get_main_vid"):
vid = int(client.get_main_vid())
if vid != 0 and client.has_method("get_entity"):
var e: Dictionary = client.get_entity(vid)
if not e.is_empty() and e.has("pos"):
return e["pos"]
return null
func _process(_delta: float) -> void:
if not is_open() or not _has_open_pos:
return
var cur = _get_main_char_pos()
if cur is Vector3:
if _open_char_pos.distance_to(cur) > USE_MALL_LIMIT_RANGE_M:
close()
func open() -> void:
if _root:
_root.visible = true
var p = _get_main_char_pos()
if p is Vector3:
_open_char_pos = p
_has_open_pos = true
refresh()
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
@@ -52,6 +87,10 @@ func close() -> void:
# 对齐原版 MallWindow.Close():先发 /mall_close 让服务器关掉商城仓库。
if client and client.has_method("mall_close") and is_open():
client.mall_close()
_close_local()
func _close_local() -> void:
_has_open_pos = false
if item_mouse and item_mouse.has_method("unregister_owner"):
item_mouse.unregister_owner(self)
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
@@ -133,18 +172,43 @@ func _render_grid() -> void:
cell.set_meta("vnum", vnum)
cell.set_meta("count", count)
func _drop_to_slot(_payload: Dictionary, _local_pos: int) -> bool:
if is_instance_valid(_status):
_status.text = "无法将物品放入商城仓库!"
if item_mouse and item_mouse.has_method("cancel"):
item_mouse.cancel()
return false
func _on_grid_input(local_pos: int, event: InputEvent) -> void:
if not item_mouse or not item_mouse.has_method("attach_item"):
if not (event is InputEventMouseButton and event.pressed):
return
if not (event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT
and event.pressed):
if event.button_index == MOUSE_BUTTON_RIGHT:
var mall_pos := _page * MALL_PAGE_SLOTS + local_pos
var target := _first_free_inv()
if target < 0:
if is_instance_valid(_status):
_status.text = "背包已满"
return
if client and client.has_method("mall_checkout"):
client.mall_checkout(mall_pos, 1, target)
return
var cell: Button = _cells[local_pos]
var vnum := int(cell.get_meta("vnum", 0))
if vnum == 0:
return
item_mouse.attach_item(MALL_WINDOW, _page * MALL_PAGE_SLOTS + local_pos, vnum,
int(cell.get_meta("count", 1)), null, "mall")
elif event.button_index == MOUSE_BUTTON_LEFT:
if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
if is_instance_valid(_status):
_status.text = "无法将物品放入商城仓库!"
item_mouse.cancel()
return
if _mobile_mode:
_on_mobile_grid_tap(local_pos)
return
if not item_mouse or not item_mouse.has_method("attach_item"):
return
var cell: Button = _cells[local_pos]
var vnum := int(cell.get_meta("vnum", 0))
if vnum == 0:
return
item_mouse.attach_item(MALL_WINDOW, _page * MALL_PAGE_SLOTS + local_pos, vnum,
int(cell.get_meta("count", 1)), null, "mall")
func _on_mobile_grid_tap(local_pos: int) -> void:
if not _mobile_mode or client == null or not _cells.has(local_pos):
+55 -3
View File
@@ -29,6 +29,10 @@ var player_getter: Callable
var _root: Control
var _view: Control
var _ch_label: Label
var _coord_label: Label
var _btn_zoom_in: Button
var _btn_zoom_out: Button
var _btn_atlas: Button
var _scale := SCALE
var _signal_points: Array[Vector2] = []
var _observer_tracks: Dictionary = {}
@@ -47,10 +51,12 @@ func setup(m2client: Node, parent: Node, get_player: Callable) -> void:
func _build(parent: Node) -> void:
_root = Control.new()
_root.name = "MiniMap"
_root.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_root.position = Vector2(-2 * RADIUS - 16, 12)
_root.size = Vector2(2 * RADIUS, 2 * RADIUS + 16)
_root.size = Vector2(2 * RADIUS + 16, 2 * RADIUS + 38)
parent.add_child(_root)
_view = Control.new()
_view.size = Vector2(2 * RADIUS, 2 * RADIUS)
_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -58,13 +64,59 @@ func _build(parent: Node) -> void:
_root.add_child(_view)
_root.gui_input.connect(_on_gui_input)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
_ch_label = Label.new()
_ch_label.position = Vector2(0, 2 * RADIUS)
_ch_label.add_theme_font_size_override("font_size", 11)
_ch_label.position = Vector2(4, 2 * RADIUS + 2)
_ch_label.add_theme_font_size_override("font_size", 10)
_ch_label.text = "CH -"
_root.add_child(_ch_label)
# 40250 uiminimap.py:440 实时坐标 "(X, Y)"
_coord_label = Label.new()
_coord_label.position = Vector2(46, 2 * RADIUS + 2)
_coord_label.size = Vector2(90, 16)
_coord_label.add_theme_font_size_override("font_size", 10)
_coord_label.text = "(0, 0)"
_root.add_child(_coord_label)
# 40250 uiminimap.py: ScaleUp / ScaleDown / Atlas
_btn_zoom_in = Button.new()
_btn_zoom_in.name = "ScaleUpButton"
_btn_zoom_in.position = Vector2(2 * RADIUS - 2, 2 * RADIUS - 38)
_btn_zoom_in.size = Vector2(18, 18)
_btn_zoom_in.text = "+"
_btn_zoom_in.add_theme_font_size_override("font_size", 11)
_btn_zoom_in.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_btn_zoom_in.pressed.connect(func(): set_scale(_scale + 0.05))
_root.add_child(_btn_zoom_in)
_btn_zoom_out = Button.new()
_btn_zoom_out.name = "ScaleDownButton"
_btn_zoom_out.position = Vector2(2 * RADIUS - 2, 2 * RADIUS - 18)
_btn_zoom_out.size = Vector2(18, 18)
_btn_zoom_out.text = "-"
_btn_zoom_out.add_theme_font_size_override("font_size", 11)
_btn_zoom_out.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_btn_zoom_out.pressed.connect(func(): set_scale(_scale - 0.05))
_root.add_child(_btn_zoom_out)
_btn_atlas = Button.new()
_btn_atlas.name = "AtlasButton"
_btn_atlas.position = Vector2(2 * RADIUS - 2, 2 * RADIUS + 2)
_btn_atlas.size = Vector2(18, 18)
_btn_atlas.text = "M"
_btn_atlas.add_theme_font_size_override("font_size", 10)
_btn_atlas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_btn_atlas.pressed.connect(func(): map_pressed.emit())
_root.add_child(_btn_atlas)
func _process(_dt: float) -> void:
if _coord_label:
var pl := _player()
if pl:
var pos: Vector3 = pl.global_position if pl.is_inside_tree() else pl.position
# 40250 uiminimap.py: (x // 100, y // 100)
_coord_label.text = "(%d, %d)" % [int(pos.x), int(-pos.z)]
if _view:
_view.queue_redraw()
+199 -57
View File
@@ -53,8 +53,16 @@ var _sound_frame := -1
# root/npclist.txt: vnum -> 模型代号(bear_brown / tiger_big / stray_dog…)。
# 翻译版 mob_proto 把 szName 也本地化了("Brown Bear"),解不出目录,必须靠这张表。
static var _npclist: Dictionary = {}
static var _race_src_name: Dictionary = {}
static var _npclist_loaded := false
# race -> { "dir": String, "mesh_stem": String, "gr2": String, "motions": Dictionary }
# 同一种怪/NPC 只解析一次目录、主网格与 motlist.txt,后续实例直接复用,避免数万次同步磁盘 IO。
static var _race_spec_cache: Dictionary = {}
static func clear_cache() -> void:
_race_spec_cache.clear()
static func _load_npclist(assets_root: String) -> void:
if _npclist_loaded:
return
@@ -77,43 +85,37 @@ static func _load_npclist(assets_root: String) -> void:
var code := String(parts[1]).strip_edges()
if vnum > 0 and code != "":
_npclist[vnum] = code
elif vnum == 0 and parts.size() >= 3:
var src := String(parts[2]).strip_edges()
if code != "" and src != "":
_race_src_name[code] = src
break
func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> bool:
if not ClassDB.class_exists("Metin2Model") or race < 1:
return false
_load_npclist(assets_root)
# 候选代号:npclist 优先,proto 名兜底
var stems: Array[String] = []
var code: String = _npclist.get(race, "")
if code != "":
stems.append(code)
for c: String in _folder_candidates(proto, race):
if not stems.has(c):
stems.append(c)
# 逐个代号:目录名从完整代号逐段回退(bear_brown -> bear),网格名保留完整代号
var mesh_stem := ""
for stem in stems:
var segs := stem.split("_")
for cut in range(segs.size(), 0, -1):
var folder := "_".join(segs.slice(0, cut))
var d := _find_dir(assets_root, folder)
if d != "":
_dir = d
mesh_stem = stem
break
if _dir != "":
break
if _dir == "":
return false
_mesh_stem = mesh_stem
var gr2 := _dir.path_join(mesh_stem + ".gr2")
if not FileAccess.file_exists(gr2):
gr2 = _dir.path_join(_dir.get_file() + ".gr2") # 目录同名主网格
if not FileAccess.file_exists(gr2):
gr2 = _pick_base_gr2() # 目录里第一个非动作 .gr2
if gr2 == "":
return false
var gr2 := ""
var cached: Variant = _race_spec_cache.get(race, null)
if cached is Dictionary:
_dir = String(cached.get("dir", ""))
_mesh_stem = String(cached.get("mesh_stem", ""))
gr2 = String(cached.get("gr2", ""))
_motions = (cached.get("motions", {}) as Dictionary).duplicate()
else:
_load_npclist(assets_root)
var spec := _find_model_spec(assets_root, proto, race)
if spec.is_empty():
return false
_dir = String(spec.get("dir", ""))
_mesh_stem = String(spec.get("mesh_stem", ""))
gr2 = String(spec.get("gr2", ""))
_load_motlist()
_race_spec_cache[race] = {
"dir": _dir,
"mesh_stem": _mesh_stem,
"gr2": gr2,
"motions": _motions.duplicate()
}
if pump.is_valid(): pump.call()
model = ClassDB.instantiate("Metin2Model")
model.name = "Metin2Model"
@@ -126,7 +128,6 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
add_child(model)
model.ready.connect(_ground_model)
_ground_model()
_load_motlist()
if ClassDB.class_exists("Metin2AnimPlayer"):
anim = ClassDB.instantiate("Metin2AnimPlayer")
anim.set("model_path", NodePath("../Metin2Model"))
@@ -176,8 +177,13 @@ func _loop_speed(state: String) -> float:
## 走 / 跑动作的根运动速度(cm/smovSpd 100):x = walky = run。0 = 取不到。
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
func get_move_motion_speeds() -> Vector2:
return Vector2(MsaMotion.move_speed(String(resolve_motion("walk").path)),
MsaMotion.move_speed(String(resolve_motion("run").path)))
var w := MsaMotion.move_speed(String(resolve_motion("walk").path))
var r := MsaMotion.move_speed(String(resolve_motion("run").path))
if w <= 0.0 and r > 0.0:
w = r
elif r <= 0.0 and w > 0.0:
r = w
return Vector2(w, r)
# CGraphicThingInstance::InsertDelay(fStiffenTime)
func insert_delay(d: float) -> void:
@@ -193,6 +199,10 @@ func hit_greate(scalar: float, stunned: bool) -> void:
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
# 40250 CActorInstance::__Shake(100)
func shake() -> void:
_hit.shake()
func is_in_hit_reaction() -> bool:
return _hit.active
@@ -241,6 +251,7 @@ func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not loop and String(anim.get("anim_path")) == path:
anim.set("anim_path", "") # 同一段一次性动作再触发:先清,否则原生播放器视为无变化
anim.set("anim_path", path)
anim.set("playing", true)
anim.set("time_scale", _hit.scale_for(speed_ratio))
_refresh_sound_script(path)
motion_bound.emit(state)
@@ -351,7 +362,10 @@ func _load_motlist() -> void:
_motions[motion] = abs
func _pick_base_gr2() -> String:
var da := DirAccess.open(_dir)
return _pick_base_gr2_in(_dir)
func _pick_base_gr2_in(d: String) -> String:
var da := DirAccess.open(d)
if da == null:
return ""
for fn in da.get_files():
@@ -363,30 +377,158 @@ func _pick_base_gr2() -> String:
var stem := fn.get_basename()
if stem.is_valid_int() or (stem.contains("_") and stem.split("_")[0].is_valid_int()):
continue
return _dir.path_join(fn)
return d.path_join(fn)
return ""
func _find_dir(assets_root: String, folder: String) -> String:
# <assets>/*/ymir work/{monster,npc}/<folder>
var rels := [
"ymir work/monster/%s" % folder,
"ymir work/monster2/%s" % folder,
"ymir work/npc/%s" % folder,
"ymir work/npc2/%s" % folder,
"Monster/ymir work/monster/%s" % folder,
"Monster/ymir work/monster2/%s" % folder,
"NPC/ymir work/npc/%s" % folder,
"NPC/ymir work/npc2/%s" % folder,
]
for rel: String in rels:
var d := assets_root.path_join(rel)
if DirAccess.dir_exists_absolute(d):
return d
# MSM BaseModelFileName 解析:如 campfire.msm 指向 zone/b/obj/general_obj_campfire.gr2
func _resolve_msm_base_model(assets_root: String, msm_path: String) -> String:
if not FileAccess.file_exists(msm_path):
return ""
var f := FileAccess.open(msm_path, FileAccess.READ)
if f == null:
return ""
var content := f.get_as_text()
var regex := RegEx.create_from_string("(?i)BaseModelFileName\\s+\"([^\"]+)\"")
if regex == null:
return ""
var match := regex.search(content)
if match == null:
return ""
var raw_path := match.get_string(1).replace("\\", "/").strip_edges()
var lower := raw_path.to_lower()
var rel := ""
for prefix in ["d:/ymir work/", "c:/ymir work/", "ymir work/", "/ymir work/"]:
if lower.begins_with(prefix):
rel = raw_path.substr(prefix.length())
break
if rel == "":
rel = raw_path
for candidate in [
assets_root.path_join("ymir work").path_join(rel),
assets_root.path_join(rel),
assets_root.path_join("Zone/ymir work").path_join(rel),
assets_root.path_join("NPC/ymir work").path_join(rel),
assets_root.path_join("Monster/ymir work").path_join(rel),
]:
if FileAccess.file_exists(candidate):
return candidate
var da := DirAccess.open(assets_root)
if da:
for sub in da.get_directories():
for rel: String in rels:
var c := assets_root.path_join(sub).path_join(rel)
if DirAccess.dir_exists_absolute(c):
return c
var c := assets_root.path_join(sub).path_join("ymir work").path_join(rel)
if FileAccess.file_exists(c):
return c
c = assets_root.path_join(sub).path_join(rel)
if FileAccess.file_exists(c):
return c
return ""
func _find_model_spec(assets_root: String, proto: Node, race: int) -> Dictionary:
var codes: Array[String] = []
var primary: String = _npclist.get(race, "")
if primary != "":
codes.append(primary)
for c: String in _folder_candidates(proto, race):
if not codes.has(c):
codes.append(c)
for code in codes:
var spec := _resolve_code_spec(assets_root, code)
if not spec.is_empty():
return spec
return {}
func _resolve_code_spec(assets_root: String, code: String) -> Dictionary:
if code.begins_with("#"):
# 40250 LOAD_LOCAL_RESOURCE:直接相对路径(如 #season1/npc/chagirap/
var rel := code.trim_prefix("#").strip_edges().trim_prefix("/").trim_suffix("/")
var stem := rel.get_file()
var candidate_dirs: Array[String] = [
assets_root.path_join(rel),
assets_root.path_join(rel.replace("chenjo", "cheonjo")),
]
var sub := rel.split("/")[0]
candidate_dirs.append(assets_root.path_join(sub).path_join(rel))
candidate_dirs.append(assets_root.path_join(sub).path_join(rel.replace("chenjo", "cheonjo")))
var da := DirAccess.open(assets_root)
if da:
for d in da.get_directories():
candidate_dirs.append(assets_root.path_join(d).path_join("ymir work").path_join(rel.get_slice("/", 1)).path_join(stem))
candidate_dirs.append(assets_root.path_join(d).path_join(rel))
for d in candidate_dirs:
var spec := _check_dir_for_model(assets_root, d, stem)
if not spec.is_empty():
return spec
return {}
var mesh_stem := code
var folders: Array[String] = []
# npclist vnum=0 别名(如 red_wild_boar -> wild_boar
if _race_src_name.has(code):
folders.append(String(_race_src_name[code]))
folders.append(code)
var segs := code.split("_")
for cut in range(segs.size(), 0, -1):
var sub_folder := "_".join(segs.slice(0, cut))
if not folders.has(sub_folder):
folders.append(sub_folder)
for folder in folders:
var candidate_dirs: Array[String] = [
assets_root.path_join("ymir work/monster/%s" % folder),
assets_root.path_join("ymir work/monster2/%s" % folder),
assets_root.path_join("ymir work/npc/%s" % folder),
assets_root.path_join("ymir work/npc2/%s" % folder),
assets_root.path_join("ymir work/guild/%s" % folder),
assets_root.path_join("Monster/ymir work/monster/%s" % folder),
assets_root.path_join("Monster/ymir work/monster2/%s" % folder),
assets_root.path_join("monster2/ymir work/monster2/%s" % folder),
assets_root.path_join("NPC/ymir work/npc/%s" % folder),
assets_root.path_join("NPC/ymir work/npc2/%s" % folder),
assets_root.path_join("npc2/ymir work/npc2/%s" % folder),
assets_root.path_join("guild/ymir work/guild/%s" % folder),
]
var da := DirAccess.open(assets_root)
if da:
for sub_dir in da.get_directories():
for rel in [
"ymir work/monster/%s" % folder,
"ymir work/monster2/%s" % folder,
"ymir work/npc/%s" % folder,
"ymir work/npc2/%s" % folder,
"ymir work/guild/%s" % folder,
]:
candidate_dirs.append(assets_root.path_join(sub_dir).path_join(rel))
for d in candidate_dirs:
var spec := _check_dir_for_model(assets_root, d, mesh_stem)
if not spec.is_empty():
return spec
return {}
func _check_dir_for_model(assets_root: String, d: String, mesh_stem: String) -> Dictionary:
if not DirAccess.dir_exists_absolute(d):
return {}
# 1. 对应代号的网格
var gr2 := d.path_join(mesh_stem + ".gr2")
if FileAccess.file_exists(gr2):
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
# 2. 目录同名主网格
gr2 = d.path_join(d.get_file() + ".gr2")
if FileAccess.file_exists(gr2):
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
# 3. 目录里第一个非动作 .gr2
gr2 = _pick_base_gr2_in(d)
if gr2 != "":
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
# 4. 检查 .msm 的 BaseModelFileName(如 campfire.msm, flag_red.msm
for msm_name in [mesh_stem + ".msm", d.get_file() + ".msm", "shape.msm"]:
var msm_p := d.path_join(msm_name)
if FileAccess.file_exists(msm_p):
var base_gr2 := _resolve_msm_base_model(assets_root, msm_p)
if base_gr2 != "":
return {"dir": d, "mesh_stem": mesh_stem, "gr2": base_gr2}
return {}
func _find_dir(assets_root: String, folder: String) -> String:
var spec := _resolve_code_spec(assets_root, folder)
return String(spec.get("dir", ""))
+167 -13
View File
@@ -16,6 +16,7 @@ var _attached: Dictionary = {}
var _targets: Array[Dictionary] = []
var _touch_index := -1
var _touch_position := Vector2.ZERO
var world_drop_handler: Callable
func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -> void:
cursor = cursor_manager
@@ -42,6 +43,18 @@ func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -
_overlay.add_child(_label)
set_process_input(true)
var _drag_started := false
var _attach_pos := Vector2(-1, -1)
var _just_attached := false
func _on_attach() -> void:
_drag_started = false
_just_attached = true
if is_inside_tree():
_attach_pos = get_viewport().get_mouse_position()
else:
_attach_pos = Vector2(-1, -1)
func attach_item(window: int, cell: int, vnum: int, count: int,
texture: Texture2D = null, source := "inventory", metadata: Dictionary = {},
touch_index := -1, touch_position := Vector2(-1, -1)) -> bool:
@@ -69,6 +82,7 @@ func attach_item(window: int, cell: int, vnum: int, count: int,
if cursor and cursor.has_method("set_cursor"):
cursor.set_cursor(CursorManager.ITEM)
_play_ui("pick.wav")
_on_attach()
return true
func attach_money(amount: int, source := "inventory") -> bool:
@@ -89,12 +103,67 @@ func attach_money(amount: int, source := "inventory") -> bool:
if cursor and cursor.has_method("set_cursor"):
cursor.set_cursor(CursorManager.ITEM)
_play_ui("pick.wav")
_on_attach()
return true
func attach_skill(skill_id: int, slot_idx: int = 0, texture: Texture2D = null,
skill_name := "", touch_index := -1, touch_position := Vector2(-1, -1)) -> bool:
if skill_id <= 0:
return false
_attached = {
"source": "skill",
"skill_id": skill_id,
"slot_idx": slot_idx,
}
_touch_index = touch_index
if touch_position.x >= 0.0 and touch_position.y >= 0.0:
_touch_position = touch_position
if _icon:
_icon.texture = texture
_icon.visible = texture != null
if _label:
_label.text = skill_name if skill_name != "" else "技能 %d" % skill_id
_label.visible = true
if _overlay:
_overlay.visible = true
if cursor and cursor.has_method("set_cursor"):
cursor.set_cursor(CursorManager.ITEM)
_play_ui("pick.wav")
_on_attach()
return true
func attach_emotion(emote_id: int, texture: Texture2D = null,
emote_name := "", touch_index := -1, touch_position := Vector2(-1, -1)) -> bool:
if emote_id <= 0:
return false
_attached = {
"source": "emotion",
"emote_id": emote_id,
}
_touch_index = touch_index
if touch_position.x >= 0.0 and touch_position.y >= 0.0:
_touch_position = touch_position
if _icon:
_icon.texture = texture
_icon.visible = texture != null
if _label:
_label.text = emote_name if emote_name != "" else "动作 %d" % emote_id
_label.visible = true
if _overlay:
_overlay.visible = true
if cursor and cursor.has_method("set_cursor"):
cursor.set_cursor(CursorManager.ITEM)
_play_ui("pick.wav")
_on_attach()
return true
func cancel() -> void:
_attached.clear()
_touch_index = -1
_touch_position = Vector2.ZERO
_drag_started = false
_attach_pos = Vector2(-1, -1)
_just_attached = false
if _overlay:
_overlay.visible = false
if cursor and cursor.has_method("reset"):
@@ -128,47 +197,132 @@ func _process(_dt: float) -> void:
if c == null or not is_instance_valid(c):
_targets.remove_at(i)
func _set_handled() -> void:
var vp := get_viewport()
if vp:
vp.set_input_as_handled()
func _input(event: InputEvent) -> void:
if not is_attached():
return
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
cancel()
get_viewport().set_input_as_handled()
_set_handled()
return
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
cancel()
get_viewport().set_input_as_handled()
_set_handled()
return
if event is InputEventMouseButton and not event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_drop_at(event.position)
get_viewport().set_input_as_handled()
if event is InputEventMouseMotion:
if _attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 6.0:
_drag_started = true
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
# If user already picked up an item and left-clicks on a target:
if not _just_attached:
if _drop_at(event.position):
_set_handled()
return
# If clicked outside any target, cancel attachment
if _attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 6.0:
_play_ui("loginfail.wav")
cancel()
_set_handled()
return
else:
# Left mouse release (not pressed)
_just_attached = false
# If dragged, attempt drop at release position
if _drag_started or (_attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 8.0):
_drop_at(event.position)
_set_handled()
return
# If not dragged (clicked to pick up), keep item attached to cursor!
return
if event is InputEventScreenDrag and event.index == _touch_index:
_touch_position = event.position
get_viewport().set_input_as_handled()
_set_handled()
return
if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index:
_touch_position = event.position
_drop_at(event.position)
get_viewport().set_input_as_handled()
_set_handled()
func _drop_at(position: Vector2) -> void:
func _drop_at(position: Vector2) -> bool:
var payload := attached()
if payload.is_empty():
return false
var icon_center := position + Vector2(24, 24)
var best_idx := -1
var best_dist := 999999.0
# Pass 1: exact hit test (cursor position or icon center)
for i in range(_targets.size() - 1, -1, -1):
var c: Control = _targets[i].get("control", null)
if c == null or not is_instance_valid(c):
_targets.remove_at(i)
continue
if not c.visible or not c.get_global_rect().has_point(position):
if not c.visible:
continue
var cb: Callable = _targets[i].get("callback", Callable())
if cb.is_valid() and bool(cb.call(payload)):
var rect := c.get_global_rect()
if rect.has_point(position):
var d := position.distance_to(rect.get_center())
if d < best_dist:
best_dist = d
best_idx = i
elif rect.has_point(icon_center):
var d := icon_center.distance_to(rect.get_center())
if d < best_dist:
best_dist = d
best_idx = i
# Pass 2: generous hit test with 16px tolerance margin
if best_idx < 0:
for i in range(_targets.size() - 1, -1, -1):
var c: Control = _targets[i].get("control", null)
if c == null or not is_instance_valid(c) or not c.visible:
continue
var rect := c.get_global_rect().grow(16.0)
if rect.has_point(position):
var d := position.distance_to(rect.get_center())
if d < best_dist:
best_dist = d
best_idx = i
elif rect.has_point(icon_center):
var d := icon_center.distance_to(rect.get_center())
if d < best_dist:
best_dist = d
best_idx = i
if best_idx >= 0:
var cb: Callable = _targets[best_idx].get("callback", Callable())
if cb.is_valid():
var ok := false
if cb.get_argument_count() >= 2:
ok = bool(cb.call(payload, position))
else:
ok = bool(cb.call(payload))
if ok:
_play_ui("drop.wav")
cancel()
return true
if world_drop_handler.is_valid():
var ok := false
if world_drop_handler.get_argument_count() >= 2:
ok = bool(world_drop_handler.call(payload, position))
else:
ok = bool(world_drop_handler.call(payload))
if ok:
_play_ui("drop.wav")
cancel()
return
# 与参考端释放到无效区域的取消语义一致。
return true
_play_ui("loginfail.wav")
cancel()
return false
func _play_ui(name: String) -> void:
if audio and audio.has_method("play_ui"):
+1
View File
@@ -0,0 +1 @@
uid://620gbknp3qd6
+175 -11
View File
@@ -5,8 +5,8 @@
# game_scene.set_player_model(pv)
# pv.set_anim_state("run") # wait/walk/run/attack/dead
#
# 转发 weapon_gr2 / gr2_path / hair_gr2 / hair_skin 的 set 给内部 Metin2Model
# 所以 equip_model.gd 可以直接 `player.set("weapon_gr2", path)`
# 转发 weapon_gr2 / gr2_path / hair_gr2 / hair_skin 属性及材质换肤调用给内部
# Metin2Model所以 equip_model.gd 可以直接操作外层 PlayerView
extends Node3D
# race -> (class, 是否女). playersettingmodule: 0=WAR_M 1=ASN_W 2=SURA_M 3=SHA_W
@@ -54,6 +54,7 @@ var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin"
var _left_bone := ""
var _race := 0
var _hit := HitReaction.new()
var _motion_mode := 1 # CRaceMotionData::MODE_GENERAL
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
@@ -132,12 +133,19 @@ func set_anim_state(s: String) -> void:
if _hit.active and s in HitReaction.LOOP_STATES:
_hit.resume = s
return
# 单次动作(技能、攻击、表情)播放中:普通循环动作(wait/walk/run)不得强行打断
if _state in ["__skill", "attack", "combo", "__motion"] and s in HitReaction.LOOP_STATES:
if anim.has_method("get_duration") and anim.has_method("get_time"):
var d: float = float(anim.call("get_duration"))
var t: float = float(anim.call("get_time"))
if d > 0.0 and t < d and bool(anim.get("playing")):
return
else:
return
_hit.cancel()
_state = s
var msa := motion_dir.path_join(s + ".msa")
if not FileAccess.file_exists(msa):
msa = motion_dir.path_join(s + ".gr2")
if FileAccess.file_exists(msa):
var msa := _motion_file(s)
if msa != "":
_bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
# CActorInstance::SetMoveSpeedm_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放
@@ -155,10 +163,29 @@ func _loop_speed(state: String) -> float:
## 走 / 跑动作的根运动速度(cm/smovSpd 100):x = walky = run。0 = 取不到。
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
func get_move_motion_speeds() -> Vector2:
if motion_dir == "":
var dir := _mode_motion_dir() if motion_dir != "" else ""
if dir == "":
return Vector2.ZERO
return Vector2(MsaMotion.move_speed(motion_dir.path_join("walk.msa")),
MsaMotion.move_speed(motion_dir.path_join("run.msa")))
return Vector2(MsaMotion.move_speed(dir.path_join("walk.msa")),
MsaMotion.move_speed(dir.path_join("run.msa")))
# CInstanceBase::RefreshState selects every motion through m_wcurMotionMode,
# not only attack/combo. A two-handed weapon therefore uses twohand_sword for
# wait/walk/run/damage too; the left-hand grip is authored in those animations
# while the weapon model itself remains attached to PART_WEAPON (right hand).
func set_motion_mode(mode: int) -> void:
var next := mode if MOTION_MODE_DIRS.has(mode) else 1
if next == _motion_mode:
return
_motion_mode = next
var resume := _state
if resume == "" or resume == "__motion":
resume = "wait"
_state = "" # force rebind even though the semantic state did not change
set_anim_state(resume)
func get_motion_mode() -> int:
return _motion_mode
# Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends
# the numeric motion id; paired emotions use the other entity's race to select
@@ -246,6 +273,98 @@ func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void:
return
_bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0)
# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310)
# 播放指定技能动作(.msa),依据 grade (0=普, 1=M, 2=G, 3=P) 读取对应阶位动作文件。
func play_skill_motion(motion_name: String, grade: int = 0) -> bool:
if anim == null:
return false
var cls: String = CLASS_OF[_race & 3]
var pc_dir := "pc2/ymir work/pc2/%s/skill" % cls if _race >= 4 else "PC/ymir work/pc/%s/skill" % cls
var sdir := _resolve_dir(_assets_root, pc_dir)
if sdir == "":
return false
var suffix := ""
if grade >= 1:
suffix = "_%d" % (grade + 1)
var msa := sdir.path_join(motion_name + suffix + ".msa")
if not FileAccess.file_exists(msa):
msa = sdir.path_join(motion_name + ".msa")
if not FileAccess.file_exists(msa):
return false
_bind("__skill", msa, false, 1.0)
return true
const AFFECT_EFFECT_MAP := {
15: {"file": "d:/ymir work/pc/warrior/effect/geom_sword_loop.mse", "bone": "equip_right_hand"}, # AFFECT_GEOMGYEONG
16: {"file": "d:/ymir work/pc/warrior/effect/gyeokgongjang_loop.mse", "bone": ""}, # AFFECT_CHEONGEUN
17: {"file": "d:/ymir work/pc/assassin/effect/gyeonggong_loop.mse", "bone": ""}, # AFFECT_GYEONGGONG
19: {"file": "d:/ymir work/pc/sura/effect/gwigeom_loop.mse", "bone": "Bip01 R Finger2"}, # AFFECT_GWIGEOM
20: {"file": "d:/ymir work/pc/sura/effect/fear_loop.mse", "bone": ""}, # AFFECT_FEAR
21: {"file": "d:/ymir work/pc/sura/effect/jumagap_loop.mse", "bone": ""}, # AFFECT_JUMAGAP
22: {"file": "d:/ymir work/pc/shaman/effect/3hosin_loop.mse", "bone": ""}, # AFFECT_HOSIN
23: {"file": "d:/ymir work/pc/shaman/effect/boho_loop.mse", "bone": ""}, # AFFECT_BOHO
24: {"file": "d:/ymir work/pc/shaman/effect/10kwaesok_loop.mse", "bone": ""}, # AFFECT_KWAESOK
25: {"file": "d:/ymir work/pc/sura/effect/heuksin_loop.mse", "bone": ""}, # AFFECT_HEUKSIN
26: {"file": "d:/ymir work/pc/sura/effect/muyeong_loop.mse", "bone": ""}, # AFFECT_MUYEONG
29: {"file": "d:/ymir work/pc/shaman/effect/6gicheon_hand.mse", "bone": "Bip01 R Hand"}, # AFFECT_GICHEON
30: {"file": "d:/ymir work/pc/shaman/effect/jeungryeok_hand.mse", "bone": "Bip01 L Hand"}, # AFFECT_JEUNGRYEOK
32: {"file": "d:/ymir work/pc/sura/effect/pabeop_loop.mse", "bone": "Bip01 Head"}, # AFFECT_PABEOP
3: {"file": "d:/ymir work/effect/hit/blow_poison/poison_loop.mse", "bone": "Bip01"}, # AFFECT_POISON
4: {"file": "d:/ymir work/effect/affect/slow.mse", "bone": ""}, # AFFECT_SLOW
5: {"file": "d:/ymir work/effect/etc/stun/stun_loop.mse", "bone": "Bip01 Head"}, # AFFECT_STUN
}
var _fx: RefCounted
var _affect_nodes: Dictionary = {}
func set_fx(fx_registry: RefCounted) -> void:
_fx = fx_registry
func set_affect(affect_type: int, visible: bool, fx_registry: RefCounted = null) -> void:
var reg: RefCounted = fx_registry if fx_registry != null else _fx
if not visible:
if _affect_nodes.has(affect_type):
var n: Node = _affect_nodes[affect_type]
_affect_nodes.erase(affect_type)
if is_instance_valid(n):
n.queue_free()
return
if _affect_nodes.has(affect_type) or reg == null:
return
if not AFFECT_EFFECT_MAP.has(affect_type):
return
var spec: Dictionary = AFFECT_EFFECT_MAP[affect_type]
var fx_name: String = String(spec.get("file", ""))
if affect_type == 15 and _motion_mode == 3:
fx_name = "d:/ymir work/pc/warrior/effect/geom_spear_loop.mse"
if fx_name == "":
return
var effect: Node = reg.call("spawn", fx_name, self, false)
if effect == null:
return
var bone: String = String(spec.get("bone", ""))
if bone != "":
var anchor := preload("res://fx/motion_effect_anchor.gd").new()
effect.add_child(anchor)
var ev := {"attaching": true, "following": true, "independent": false, "bone": bone, "pos": Vector3.ZERO}
if not anchor.configure(self, ev):
effect.queue_free()
return
_affect_nodes[affect_type] = effect
func set_affect_flags(flags: int, fx_registry: RefCounted = null) -> void:
for t in AFFECT_EFFECT_MAP.keys():
var active: bool = (flags & (1 << t)) != 0
set_affect(t, active, fx_registry)
func clear_affects() -> void:
for t in _affect_nodes.keys():
var n: Node = _affect_nodes[t]
if is_instance_valid(n):
n.queue_free()
_affect_nodes.clear()
# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。
func insert_delay(d: float) -> void:
_hit.insert_delay(d, anim)
@@ -261,6 +380,10 @@ func hit_greate(scalar: float, stunned: bool) -> void:
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
# 40250 CActorInstance::__Shake(100)
func shake() -> void:
_hit.shake()
# 受击方 m_DefendingPointInstanceList 的来源:root/msm/<class>_<m|w>.msm 的 DEFENDING 球。
func get_defending_spheres() -> Array:
var sex := "w" if _race in FEMALE_RACES else "m"
@@ -276,9 +399,18 @@ func _on_playback_finished() -> void:
_state = ""
set_anim_state(_hit.resume)
return
if _state == "__motion":
if _state in ["__motion", "__skill", "attack", "combo"]:
_state = ""
set_anim_state("wait")
motion_bound.emit("wait")
_ground_model()
func reset_skill_motion() -> void:
if _state in ["__skill", "__motion"]:
_state = ""
set_anim_state("wait")
motion_bound.emit("wait")
_ground_model()
# True while the one-shot hit/knockback chain is still playing.
func is_in_hit_reaction() -> bool:
@@ -287,7 +419,7 @@ func is_in_hit_reaction() -> bool:
func _bind_reaction(step: String) -> bool:
if anim == null or motion_dir == "":
return false
var msa := _pick_msa(motion_dir, REACTION_FILES.get(step, []))
var msa := _pick_mode_msa(REACTION_FILES.get(step, []))
if msa == "":
return false
_bind(step, msa, false, 1.0)
@@ -303,6 +435,7 @@ func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not loop and String(anim.get("anim_path")) == path:
anim.set("anim_path", "")
anim.set("anim_path", path)
anim.set("playing", true)
anim.set("time_scale", _hit.scale_for(speed_ratio))
_refresh_sound_script(path)
motion_bound.emit(state)
@@ -315,6 +448,26 @@ func _pick_msa(dir: String, names: Array) -> String:
found.append(p)
return "" if found.is_empty() else String(found[randi() % found.size()])
func _mode_motion_dir() -> String:
if motion_dir == "":
return ""
var candidate := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(_motion_mode, "general")))
return candidate if DirAccess.dir_exists_absolute(candidate) else motion_dir
func _motion_file(state: String) -> String:
for dir in [_mode_motion_dir(), motion_dir]:
if dir == "":
continue
for ext in [".msa", ".gr2"]:
var path: String = String(dir).path_join(state + String(ext))
if FileAccess.file_exists(path):
return path
return ""
func _pick_mode_msa(names: Array) -> String:
var picked := _pick_msa(_mode_motion_dir(), names)
return picked if picked != "" else _pick_msa(motion_dir, names)
func _process(delta: float) -> void:
_hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
@@ -347,6 +500,17 @@ func _set(prop: StringName, val: Variant) -> bool:
return true
return false
# EquipModel operates on the outer PlayerView, while the material palette lives
# on the native Metin2Model child. Keep these as explicit methods so MSM
# SourceSkin -> TargetSkin remaps reach both character select and the game.
func set_skin_texture(source: String, target: String) -> void:
if model and model.has_method("set_skin_texture"):
model.call("set_skin_texture", source, target)
func clear_skin_textures() -> void:
if model and model.has_method("clear_skin_textures"):
model.call("clear_skin_textures")
func _ground_model() -> void:
if model and model.has_method("get_ground_offset"):
model.position.y = float(model.call("get_ground_offset"))
+94
View File
@@ -0,0 +1,94 @@
# QuestCurtain (40250 uiquest.py:58-105 对齐)
# 剧情 / 过场运镜模式的 16:9 电影上下黑边(Curtain / Letterbox
class_name QuestCurtain
extends Control
const CURTAIN_SPEED := 200.0
const MIN_BAR_HEIGHT := 50.0
var top_bar: ColorRect
var bottom_bar: ColorRect
var _bar_height := 60.0
var _is_open := false
var _tween: Tween
func _init() -> void:
name = "QuestCurtain"
set_anchors_preset(PRESET_FULL_RECT)
mouse_filter = MOUSE_FILTER_IGNORE
z_index = 95
top_bar = ColorRect.new()
top_bar.name = "TopBar"
top_bar.color = Color.BLACK
top_bar.mouse_filter = MOUSE_FILTER_IGNORE
add_child(top_bar)
bottom_bar = ColorRect.new()
bottom_bar.name = "BottomBar"
bottom_bar.color = Color.BLACK
bottom_bar.mouse_filter = MOUSE_FILTER_IGNORE
add_child(bottom_bar)
_reset_layout()
func _ready() -> void:
get_viewport().size_changed.connect(_on_size_changed)
_reset_layout()
func _get_screen_size() -> Vector2:
if is_inside_tree() and get_viewport():
return get_viewport_rect().size
return Vector2(1024, 768)
func _on_size_changed() -> void:
if _is_open:
_calculate_bar_height()
var sz := _get_screen_size()
top_bar.position = Vector2(0, 0)
top_bar.size = Vector2(sz.x, _bar_height)
bottom_bar.position = Vector2(0, sz.y - _bar_height)
bottom_bar.size = Vector2(sz.x, _bar_height)
else:
_reset_layout()
func _calculate_bar_height() -> float:
var sz := _get_screen_size()
# 40250: (GetScreenHeight() - GetScreenWidth() * 9 // 16) // 2
var h: float = floorf((sz.y - sz.x * 9.0 / 16.0) / 2.0)
if h < MIN_BAR_HEIGHT:
h = MIN_BAR_HEIGHT
_bar_height = h
return _bar_height
func _reset_layout() -> void:
var sz := _get_screen_size()
_calculate_bar_height()
top_bar.position = Vector2(0, -_bar_height - 1.0)
top_bar.size = Vector2(sz.x, _bar_height)
bottom_bar.position = Vector2(0, sz.y + 1.0)
bottom_bar.size = Vector2(sz.x, _bar_height)
func open(duration: float = 0.4) -> void:
_is_open = true
var sz := _get_screen_size()
_calculate_bar_height()
if _tween and _tween.is_valid():
_tween.kill()
_tween = create_tween().set_parallel(true).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
top_bar.size = Vector2(sz.x, _bar_height)
bottom_bar.size = Vector2(sz.x, _bar_height)
_tween.tween_property(top_bar, "position:y", 0.0, duration)
_tween.tween_property(bottom_bar, "position:y", sz.y - _bar_height, duration)
func close(duration: float = 0.4) -> void:
_is_open = false
var sz := _get_screen_size()
if _tween and _tween.is_valid():
_tween.kill()
_tween = create_tween().set_parallel(true).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
_tween.tween_property(top_bar, "position:y", -_bar_height - 1.0, duration)
_tween.tween_property(bottom_bar, "position:y", sz.y + 1.0, duration)
func is_open() -> bool:
return _is_open
+1
View File
@@ -0,0 +1 @@
uid://ds2h7uklxv4vs
+138 -28
View File
@@ -37,10 +37,13 @@ signal camera_event_requested_full(kind: String, setting: Dictionary, blendtime:
signal fade_event_requested_full(kind: String, speed: float)
signal done_event()
const MAX_CHOICES_PER_PAGE := 8
var client: Node
var proto: Node
var _assets_root := ""
var _root: Control
var _panel: Panel
var _text: RichTextLabel
var _btnrow: VBoxContainer
var _image_layer: Control
@@ -50,6 +53,10 @@ var _active_set
var _active_commands: Array = []
var _active_cursor := 0
var _active_script := false
var _current_skin := 0
var _current_left_w := 0.0
var _current_choice_page := 0
var _cached_choices: Array = []
signal dungeon_result_requested(result: Dictionary)
@@ -246,23 +253,32 @@ func _present_event_set(es: EventSet) -> void:
and not parsed.confirm_wait and not _active_script:
close()
return
if _panel:
# 40250 uiquest.py:328 - SKIN_CINEMA(5) and SKIN_NOWINDOW(1) hide background board
_panel.visible = (_current_skin != 1 and _current_skin != 5)
_text.text = parsed.body
_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER if parsed.text_centered else HORIZONTAL_ALIGNMENT_LEFT
var width := _root.size.x
var height := _root.size.y
if parsed.window_size.size() >= 2:
var width := maxi(320, int(parsed.window_size[0]))
var height := maxi(220, int(parsed.window_size[1]))
width = maxi(320, int(parsed.window_size[0]))
height = maxi(220, int(parsed.window_size[1]))
_root.size = Vector2(width, height)
_text.size = Vector2(width - 32, height - 100)
_btnrow.position = Vector2(16, height - 78)
_btnrow.custom_minimum_size = Vector2(width - 32, 0)
var left_w := _render_images(parsed.images, parsed.title_image)
_current_left_w = left_w
var content_w := maxf(200.0, width - 32.0 - left_w)
_text.position = Vector2(16.0 + left_w, 14.0)
_text.size = Vector2(content_w, height - 100.0)
_btnrow.position = Vector2(16.0 + left_w, height - 78.0)
_btnrow.custom_minimum_size = Vector2(content_w, 0)
_confirm_wait = parsed.confirm_wait
_render_images(parsed.images, parsed.title_image)
_fill_buttons(parsed.choices, parsed.has_next, parsed.has_input, parsed.confirm_wait,
String(parsed.get("next_button_type", "")))
# --- 收 GC_SCRIPT ------------------------------------------------------
func _on_script(_skin: int, text: String) -> void:
func _on_script(skin: int, text: String) -> void:
_current_skin = skin
begin_script(text)
func _emit_side_effects(parsed: Dictionary) -> void:
@@ -312,16 +328,20 @@ func _unhandled_input(event: InputEvent) -> void:
return
var key := event as InputEventKey
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
# The legacy client sends QUEST_CANCEL when the active script is dismissed
# with Escape; ordinary answer buttons continue to use SCRIPT_ANSWER.
if _active_script:
skip()
get_viewport().set_input_as_handled()
var vp := get_viewport()
if vp:
vp.set_input_as_handled()
return
if client and client.has_method("quest_cancel"):
client.quest_cancel()
elif client and client.has_method("script_answer"):
client.script_answer(254)
close()
get_viewport().set_input_as_handled()
var vp := get_viewport()
if vp:
vp.set_input_as_handled()
# --- 脚本解析(CPythonEventManager 1:1----------------------------------
#
@@ -783,6 +803,30 @@ func _evt_color(es: EventSet, tok: String, pos: int, divisor: float) -> void:
# MakeQuestionPythonEventManager.cpp:884)。
func _make_question(es: EventSet, tok: String) -> void:
# 40250 官方标准格式:[QUESTION 1;选项一|2;选项二|...]
# 参考 40250 服务端 questlua.cpp:642 GotoSelectState 与 客户端 EterLib/parser.cpp:180
var space_idx := tok.find(" ")
var body := tok.substr(space_idx + 1).strip_edges() if space_idx >= 0 else ""
if body.contains("|") or (body.contains(";") and not body.contains("arg(") and not body.contains("value(")):
var parts := body.split("|", false)
var options: Array[String] = []
for p in parts:
var trimmed := p.strip_edges()
if trimmed.is_empty():
continue
var semi := trimmed.find(";")
if semi >= 0:
var label := trimmed.substr(semi + 1).strip_edges()
options.append(label)
else:
options.append(trimmed)
if not options.is_empty():
for opt in options:
es.choices.append(opt)
es.nAnswer = options.size()
return
# 回落至 legacy 格式:[QUESTION arg("A") arg("B")]
var args := _tag_strings(tok)
if args.is_empty():
return
@@ -985,6 +1029,7 @@ func _build(parent: Node) -> void:
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
_panel = panel
_image_layer = Control.new()
_image_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
_image_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -1001,11 +1046,13 @@ func _build(parent: Node) -> void:
_btnrow.add_theme_constant_override("separation", 4)
_root.add_child(_btnrow)
func _render_images(images: Array, title_image: String) -> void:
func _render_images(images: Array, title_image: String) -> float:
if _image_layer == null:
return
return 0.0
for child in _image_layer.get_children():
_image_layer.remove_child(child)
child.queue_free()
var left_w := 0.0
for spec in images:
var path := String(spec.get("path", ""))
var tex: Texture2D = UiAssets.load_tex(_assets_root, path)
@@ -1023,8 +1070,11 @@ func _render_images(images: Array, title_image: String) -> void:
image.position = Vector2(0, 0)
image.size = Vector2(_root.size.x, minf(84.0, tex.get_size().y))
elif panel_name == "LEFTIMAGE":
image.position = Vector2(0, 0)
image.size = Vector2(minf(150.0, tex.get_size().x), _root.size.y)
var img_w := minf(160.0, tex.get_size().x)
var img_h := minf(_root.size.y - 28.0, tex.get_size().y)
image.position = Vector2(10, 14)
image.size = Vector2(img_w, img_h)
left_w = maxf(left_w, img_w + 14.0)
else:
image.position = Vector2(float(spec.get("x", 0)), float(spec.get("y", 0)))
image.size = tex.get_size()
@@ -1040,9 +1090,11 @@ func _render_images(images: Array, title_image: String) -> void:
title.position = Vector2((_root.size.x - title_tex.get_size().x) * 0.5, 4)
title.size = title_tex.get_size()
_image_layer.add_child(title)
return left_w
func _clear_buttons() -> void:
for c in _btnrow.get_children():
_btnrow.remove_child(c)
c.queue_free()
func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_wait := false,
@@ -1050,18 +1102,27 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_w
_clear_buttons()
if has_input:
var input := LineEdit.new()
input.placeholder_text = "请输入"
input.custom_minimum_size = Vector2(428, 28)
input.placeholder_text = "请输入内容"
input.custom_minimum_size = Vector2(maxf(200.0, 428.0 - _current_left_w), 28)
_btnrow.add_child(input)
var submit := _mkbtn("提交")
var submit := _mkbtn("确定")
submit.pressed.connect(func(): _submit_input(input))
input.text_submitted.connect(func(_text): _submit_input(input))
if input.is_inside_tree():
input.grab_focus()
else:
input.ready.connect(func():
if input.is_inside_tree():
input.grab_focus()
, CONNECT_ONE_SHOT)
return
if confirm_wait:
var cancel := _mkbtn("取消")
cancel.pressed.connect(func():
if client and client.has_method("quest_cancel"):
client.quest_cancel()
elif client and client.has_method("script_answer"):
client.script_answer(254)
close())
return
if choices.is_empty():
@@ -1069,24 +1130,72 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_w
var b := _mkbtn("关闭" if is_done else ("继续" if has_next else "关闭"))
b.pressed.connect(func():
if is_done:
# MakeNextButton(BUTTON_TYPE_DONE) wires DoneEvent to the
# actual click. Keep parse_script() side-effect free and only
# notify consumers when the player confirms the DONE button.
done_event.emit()
if client and client.has_method("script_answer"):
client.script_answer(255)
client.script_answer(254)
close())
return
for i in choices.size():
_render_choices(choices, 0)
func _render_choices(choices: Array, page: int = 0) -> void:
_cached_choices = choices
_current_choice_page = page
_clear_buttons()
var total := choices.size()
if total == 0:
return
if total <= MAX_CHOICES_PER_PAGE:
for i in total:
var idx := i
var b := _mkbtn(String(choices[i]))
b.pressed.connect(func():
if client and client.has_method("script_answer"):
client.script_answer(idx)
close())
return
# Multi-page pagination (40250 uiquest.py parity)
var start_idx := page * MAX_CHOICES_PER_PAGE
var end_idx := mini(start_idx + MAX_CHOICES_PER_PAGE, total)
var total_pages := int(ceil(float(total) / float(MAX_CHOICES_PER_PAGE)))
for i in range(start_idx, end_idx):
var idx := i
var b := _mkbtn(String(choices[i]))
b.pressed.connect(func():
client.script_answer(idx)
if client and client.has_method("script_answer"):
client.script_answer(idx)
close())
var nav_row := HBoxContainer.new()
nav_row.alignment = BoxContainer.ALIGNMENT_CENTER
nav_row.add_theme_constant_override("separation", 12)
_btnrow.add_child(nav_row)
if page > 0:
var prev_btn := Button.new()
prev_btn.text = "上一页"
prev_btn.custom_minimum_size = Vector2(80, 26)
prev_btn.pressed.connect(func(): _render_choices(_cached_choices, page - 1))
nav_row.add_child(prev_btn)
var page_label := Label.new()
page_label.text = "%d / %d" % [page + 1, total_pages]
page_label.add_theme_font_size_override("font_size", 12)
nav_row.add_child(page_label)
if page < total_pages - 1:
var next_btn := Button.new()
next_btn.text = "下一页"
next_btn.custom_minimum_size = Vector2(80, 26)
next_btn.pressed.connect(func(): _render_choices(_cached_choices, page + 1))
nav_row.add_child(next_btn)
func _submit_input(input: LineEdit) -> void:
if client and client.has_method("quest_input") and client.quest_input(input.text):
close()
var val := input.text.strip_edges()
if client and client.has_method("quest_input"):
client.quest_input(val)
close()
func _fill_confirm() -> void:
_clear_buttons()
@@ -1099,13 +1208,14 @@ func _fill_confirm() -> void:
b.text = spec[0]
b.custom_minimum_size = Vector2(120, 30)
b.pressed.connect(func():
client.quest_confirm(yes, _confirm_pid)
if client and client.has_method("quest_confirm"):
client.quest_confirm(yes, _confirm_pid)
close())
row.add_child(b)
func _mkbtn(text: String) -> Button:
var b := Button.new()
b.text = text
b.custom_minimum_size = Vector2(428, 26)
b.custom_minimum_size = Vector2(maxf(200.0, 428.0 - _current_left_w), 26)
_btnrow.add_child(b)
return b
+1039 -98
View File
File diff suppressed because it is too large Load Diff
+397 -70
View File
@@ -1,31 +1,63 @@
# RefineUI (M4) —— 精炼 / 强化对话框:`refine_ask` 来时弹
# RefineUI —— 1:1 复刻官方 40250 uirefine.py:RefineDialogNew 与 QuestionDialog2
#
# var ru := preload("res://ui/refine_ui.gd").new()
# add_child(ru)
# ru.setup(m2client, canvas_parent, proto) # proto 可空(出名字)
#
# `GC_REFINE_INFORMATION` → `M2Client.refine_ask({type,pos,src_vnum,result_vnum,cost,prob,materials})`。
# [精炼] → `M2Client.refine(pos, type)`[取消] 关闭。
# 协议驱动:
# GC_REFINE_INFORMATION (119) -> refine_ask({type, pos, src_vnum, result_vnum, cost, prob, materials})
# 发送发包:
# 确定 -> CG_REFINE (96): client.refine(pos, type)
# 取消/ESC/超距 -> CG_REFINE (96): client.refine(255, 255)
# 40250 规则:
# 1. prob == 100 或 type == 5 时直接 Accept,免弹二次确认窗;
# 2. type == 3 (铁匠石) 弹彻底摧毁+概率加成警告;
# 3. type == 2 (祝福卷轴) 弹降级警告;
# 4. 其他 (普通铁匠) 弹彻底摧毁警告;
# 5. 材料根据背包实际存量显示白字(>= 需求)或红字(< 需求);
# 6. 离场超 10.0m 自动发送取消发包并关闭。
extends Node
const QuestionDialog2Scene = null
const USE_REFINE_LIMIT_RANGE_M := 10.0
var client: Node
var proto: Node
var audio: Node
var _root: Control
var _board: Panel
var _title_bar: Control
var _title_label: Label
var _close_btn: Button
var _preview_slot: Panel
var _preview_icon: TextureRect
var _text: RichTextLabel
var _cur := {}
var _mobile_mode := false
var _material_container: VBoxContainer
var _prob_label: Label
var _cost_label: Label
var _action_row: HBoxContainer
var _refine_button: Button
var _cancel_button: Button
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
# QuestionDialog2 确认弹窗
var _question_dialog: Control
var _qd_message1: Label
var _qd_message2: Label
var _qd_accept_btn: Button
var _qd_cancel_btn: Button
var _cur := {}
var _mobile_mode := false
var _open_char_pos := Vector3.ZERO
var _has_open_pos := false
func setup(m2client: Node, parent: Node, proto_node: Node = null, audio_node: Node = null) -> void:
client = m2client
proto = proto_node
audio = audio_node
_build(parent)
if client.has_signal("refine_ask"):
client.refine_ask.connect(_on_ask)
if client.has_signal("refine_result"):
client.refine_result.connect(_on_result)
if client != null:
if client.has_signal("refine_ask"):
client.refine_ask.connect(_on_ask)
if client.has_signal("refine_result"):
client.refine_result.connect(_on_result)
func is_open() -> bool:
return _root != null and _root.visible
@@ -37,11 +69,6 @@ func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
_apply_mobile_layout()
func close() -> void:
if _root:
_root.visible = false
_cur = {}
func _name_of(vnum: int) -> String:
if vnum == 0:
return "-"
@@ -52,90 +79,390 @@ func _name_of(vnum: int) -> String:
return n
return "#%d" % vnum
func _get_inventory_count(vnum: int) -> int:
if client == null or not client.has_method("get_inventory"):
return 0
var total := 0
for item in client.get_inventory():
if int(item.get("vnum", 0)) == vnum:
total += maxi(1, int(item.get("count", 1)))
return total
func _get_main_char_pos() -> Variant:
if client == null:
return null
var vid := 0
if client.has_method("get_main_vid"):
vid = int(client.get_main_vid())
if vid != 0 and client.has_method("get_entity"):
var e: Dictionary = client.get_entity(vid)
if not e.is_empty() and e.has("pos"):
return e["pos"]
return null
func _process(_delta: float) -> void:
if not is_open() or not _has_open_pos:
return
var cur = _get_main_char_pos()
if cur is Vector3:
if _open_char_pos.distance_to(cur) > USE_REFINE_LIMIT_RANGE_M:
cancel_refine()
func _unhandled_input(event: InputEvent) -> void:
if not is_open():
return
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
cancel_refine()
func _on_ask(info: Dictionary) -> void:
_cur = info
var pos_val = _get_main_char_pos()
if pos_val is Vector3:
_open_char_pos = pos_val
_has_open_pos = true
else:
_has_open_pos = false
var src_vnum := int(info.get("src_vnum", 0))
var result_vnum := int(info.get("result_vnum", 0))
var prob := int(info.get("prob", 0))
var cost := int(info.get("cost", 0))
var materials: Array = info.get("materials", [])
# 1. 组装向下兼容的 _text(供 headless 自动化测试断言及快速阅读)
var lines := PackedStringArray()
lines.append("[b]%s[/b] → [b]%s[/b]" % [
_name_of(int(info.get("src_vnum", 0))), _name_of(int(info.get("result_vnum", 0)))])
lines.append("成功率 %d%% 费用 %d" % [int(info.get("prob", 0)), int(info.get("cost", 0))])
var mats: Array = info.get("materials", [])
if not mats.is_empty():
lines.append("[b]%s[/b] → [b]%s[/b]" % [_name_of(src_vnum), _name_of(result_vnum)])
lines.append("成功率 %d%% 费用 %d" % [prob, cost])
if not materials.is_empty():
lines.append("材料:")
for m in mats:
for m in materials:
lines.append(" %s ×%d" % [_name_of(int(m.get("vnum", 0))), int(m.get("count", 1))])
_text.text = "\n".join(lines)
# 2. 40250 视觉:底部成功率与费用
_prob_label.text = "成功率: %d%%" % prob
_cost_label.text = "费用: %d" % cost
var cur_gold := 0
if client and client.has_method("get_gold"):
cur_gold = int(client.get_gold())
elif client and client.has_method("get_points"):
cur_gold = int(client.get_points().get("gold", 0))
if cur_gold > 0 and cur_gold < cost:
_cost_label.modulate = Color(1.0, 0.33, 0.33)
else:
_cost_label.modulate = Color(1.0, 1.0, 1.0)
# 3. 40250 视觉:材质栏 AppendMaterial 动态排布
for child in _material_container.get_children():
_material_container.remove_child(child)
child.queue_free()
for m in materials:
var mvnum := int(m.get("vnum", 0))
var mcount := int(m.get("count", 1))
var owned := _get_inventory_count(mvnum)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
row.custom_minimum_size = Vector2(250, 32)
# Slot
var slot := Panel.new()
slot.custom_minimum_size = Vector2(32, 32)
var slot_sb := StyleBoxFlat.new()
slot_sb.bg_color = Color(0.12, 0.1, 0.08, 0.9)
slot_sb.border_color = Color(0.35, 0.3, 0.22, 1.0)
slot_sb.set_border_width_all(1)
slot.add_theme_stylebox_override("panel", slot_sb)
var mat_icon := TextureRect.new()
mat_icon.set_anchors_preset(Control.PRESET_FULL_RECT)
mat_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
mat_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
slot.add_child(mat_icon)
row.add_child(slot)
# ThinBoard with label
var tb := Panel.new()
tb.custom_minimum_size = Vector2(190, 28)
tb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var tb_sb := StyleBoxFlat.new()
tb_sb.bg_color = Color(0.15, 0.12, 0.1, 0.8)
tb_sb.set_corner_radius_all(2)
tb.add_theme_stylebox_override("panel", tb_sb)
var lbl := Label.new()
lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
lbl.text = " %s x %02d" % [_name_of(mvnum), mcount]
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
# 40250 存量足显示白字,不足显示红字 0xffff5555
if owned >= mcount:
lbl.modulate = Color(0.87, 0.87, 0.87)
else:
lbl.modulate = Color(1.0, 0.33, 0.33)
tb.add_child(lbl)
row.add_child(tb)
_material_container.add_child(row)
# 4. 自适应尺寸
var base_h := 220
var mats_h := materials.size() * 36
var new_h := base_h + mats_h
_root.size = Vector2(340, new_h)
if _question_dialog:
_question_dialog.visible = false
_root.visible = true
func _do_refine() -> void:
func _on_accept_clicked() -> void:
if _cur.is_empty():
return
var prob := int(_cur.get("prob", 0))
var rtype := int(_cur.get("type", 0))
# 40250 uirefine.py:385:成功率 100% 或 type == 5 直接执行,不弹确认框
if prob == 100 or rtype == 5:
_accept()
return
# 唤起 QuestionDialog2
_open_question_dialog(rtype)
func _open_question_dialog(rtype: int) -> void:
if _question_dialog == null:
return
# 40250 对齐文案:
_qd_message2.text = "确定要继续吗?"
if rtype == 3:
_qd_message1.text = "强化失败时装备将会被彻底销毁,同时成功率提升!"
elif rtype == 2:
_qd_message1.text = "强化失败时装备等级将会降低1级!"
else:
_qd_message1.text = "强化失败时装备将会被彻底销毁!"
_question_dialog.visible = true
_question_dialog.move_to_front()
func _accept() -> void:
if client and client.has_method("refine") and not _cur.is_empty():
client.refine(int(_cur.get("pos", 0)), int(_cur.get("type", 0)))
_root.visible = false
close()
func cancel_refine() -> void:
# 40250 uirefine.py:418: net.SendRefinePacket(255, 255)
if client and client.has_method("refine") and not _cur.is_empty():
client.refine(255, 255)
close()
func close() -> void:
if _question_dialog:
_question_dialog.visible = false
if _root:
_root.visible = false
_cur = {}
_has_open_pos = false
func _on_result(ok: bool) -> void:
_cur = {}
_text.text = "[b]精炼成功[/b]" if ok else "[b]精炼失败[/b]"
if _prob_label:
_prob_label.text = "强化成功!" if ok else "强化失败!"
_prob_label.modulate = Color(0.2, 1.0, 0.4) if ok else Color(1.0, 0.3, 0.3)
if _cost_label:
_cost_label.text = ""
for child in _material_container.get_children():
_material_container.remove_child(child)
child.queue_free()
if audio and audio.has_method("play_ui"):
if ok:
audio.play_ui("refine_success.wav")
else:
audio.play_ui("refine_fail.wav")
_root.visible = true
func _apply_mobile_layout() -> void:
if _root == null or _text == null or _action_row == null:
if _root == null:
return
if _mobile_mode:
_root.size = Vector2(460, 300)
_root.position = Vector2(-230, -150)
_text.position = Vector2(18, 52)
_text.size = Vector2(424, 178)
_text.custom_minimum_size = Vector2(424, 178)
_action_row.position = Vector2(18, 242)
_refine_button.custom_minimum_size = Vector2(204, 44)
_cancel_button.custom_minimum_size = Vector2(204, 44)
_root.size = Vector2(460, 320)
_root.position = Vector2(-230, -160)
else:
_root.size = Vector2(320, 260)
_root.position = Vector2(-160, -130)
_text.position = Vector2(14, 34)
_text.size = Vector2(292, 160)
_text.custom_minimum_size = Vector2(292, 160)
_action_row.position = Vector2(14, 208)
_refine_button.custom_minimum_size = Vector2(120, 30)
_cancel_button.custom_minimum_size = Vector2(120, 30)
_root.size = Vector2(340, 260)
_root.position = Vector2(-170, -130)
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-160, -130)
_root.size = Vector2(320, 260)
_root.name = "RefineDialogNew"
_root.size = Vector2(340, 260)
_root.position = Vector2(200, 150)
_root.size = Vector2(340, 260)
_root.visible = false
_root.set_meta("is_titlebar", true)
parent.add_child(_root)
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
# 主底板 Board
_board = Panel.new()
_board.name = "Board"
_board.set_anchors_preset(Control.PRESET_FULL_RECT)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.09, 0.07, 0.05, 0.98)
sb.bg_color = Color(0.08, 0.06, 0.05, 0.98)
sb.border_color = Color(0.45, 0.38, 0.28, 1.0)
sb.set_border_width_all(2)
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
var title := Label.new()
title.text = "精炼"
title.position = Vector2(12, 8)
_root.add_child(title)
_board.add_theme_stylebox_override("panel", sb)
_root.add_child(_board)
# 标题栏 TitleBar (40250 经典红标题栏)
_title_bar = Panel.new()
_title_bar.name = "TitleBar"
_title_bar.position = Vector2(8, 8)
_title_bar.size = Vector2(324, 26)
var title_sb := StyleBoxFlat.new()
title_sb.bg_color = Color(0.45, 0.1, 0.1, 0.95)
title_sb.set_corner_radius_all(2)
_title_bar.add_theme_stylebox_override("panel", title_sb)
_root.add_child(_title_bar)
_title_label = Label.new()
_title_label.name = "TitleName"
_title_label.set_anchors_preset(Control.PRESET_FULL_RECT)
_title_label.text = "强化"
_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_title_bar.add_child(_title_label)
_close_btn = Button.new()
_close_btn.text = "×"
_close_btn.position = Vector2(300, 2)
_close_btn.size = Vector2(22, 22)
_close_btn.pressed.connect(cancel_refine)
_title_bar.add_child(_close_btn)
# 物品展示与说明
_text = RichTextLabel.new()
_text.bbcode_enabled = true
_text.position = Vector2(14, 34)
_text.custom_minimum_size = Vector2(292, 160)
_text.size = Vector2(292, 160)
_text.position = Vector2(16, 40)
_text.size = Vector2(308, 60)
_root.add_child(_text)
# 材料列表容器 (AppendMaterial)
_material_container = VBoxContainer.new()
_material_container.position = Vector2(16, 106)
_material_container.size = Vector2(308, 50)
_material_container.add_theme_constant_override("separation", 4)
_root.add_child(_material_container)
# 底部区域:成功率 + 费用
var bottom_info := VBoxContainer.new()
bottom_info.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
bottom_info.offset_top = -90
bottom_info.offset_bottom = -45
bottom_info.offset_left = 16
bottom_info.offset_right = -16
bottom_info.alignment = BoxContainer.ALIGNMENT_CENTER
_root.add_child(bottom_info)
_prob_label = Label.new()
_prob_label.name = "SuccessPercentage"
_prob_label.text = "成功率: --%"
_prob_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
bottom_info.add_child(_prob_label)
_cost_label = Label.new()
_cost_label.name = "Cost"
_cost_label.text = "费用: -- 金"
_cost_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
bottom_info.add_child(_cost_label)
# 底部按钮行:AcceptButton ("精炼") 与 CancelButton ("取消")
_action_row = HBoxContainer.new()
var row := _action_row
row.position = Vector2(14, 208)
row.add_theme_constant_override("separation", 12)
_root.add_child(row)
_action_row.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
_action_row.offset_top = -42
_action_row.offset_bottom = -10
_action_row.offset_left = 16
_action_row.offset_right = -16
_action_row.alignment = BoxContainer.ALIGNMENT_CENTER
_action_row.add_theme_constant_override("separation", 24)
_root.add_child(_action_row)
_refine_button = Button.new()
_refine_button.name = "AcceptButton"
_refine_button.text = "精炼"
_refine_button.custom_minimum_size = Vector2(120, 30)
_refine_button.pressed.connect(_do_refine)
row.add_child(_refine_button)
_refine_button.custom_minimum_size = Vector2(110, 30)
_refine_button.pressed.connect(_on_accept_clicked)
_action_row.add_child(_refine_button)
_cancel_button = Button.new()
_cancel_button.name = "CancelButton"
_cancel_button.text = "取消"
_cancel_button.custom_minimum_size = Vector2(120, 30)
_cancel_button.pressed.connect(func(): _root.visible = false)
row.add_child(_cancel_button)
_apply_mobile_layout()
_cancel_button.custom_minimum_size = Vector2(110, 30)
_cancel_button.pressed.connect(cancel_refine)
_action_row.add_child(_cancel_button)
# 构建内嵌 QuestionDialog2
_build_question_dialog(parent)
func _build_question_dialog(parent: Node) -> void:
_question_dialog = Control.new()
_question_dialog.name = "QuestionDialog2"
_question_dialog.size = Vector2(290, 120)
_question_dialog.position = Vector2(225, 200)
_question_dialog.visible = false
_question_dialog.z_index = 100
parent.add_child(_question_dialog)
var qd_board := Panel.new()
qd_board.name = "board"
qd_board.set_anchors_preset(Control.PRESET_FULL_RECT)
var qd_sb := StyleBoxFlat.new()
qd_sb.bg_color = Color(0.12, 0.08, 0.06, 0.98)
qd_sb.border_color = Color(0.65, 0.5, 0.3, 1.0)
qd_sb.set_border_width_all(2)
qd_sb.set_corner_radius_all(4)
qd_board.add_theme_stylebox_override("panel", qd_sb)
_question_dialog.add_child(qd_board)
_qd_message1 = Label.new()
_qd_message1.name = "message1"
_qd_message1.position = Vector2(12, 14)
_qd_message1.size = Vector2(266, 32)
_qd_message1.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_qd_message1.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_qd_message1.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_question_dialog.add_child(_qd_message1)
_qd_message2 = Label.new()
_qd_message2.name = "message2"
_qd_message2.position = Vector2(12, 48)
_qd_message2.size = Vector2(266, 24)
_qd_message2.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_qd_message2.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_qd_message2.text = "确定要继续吗?"
_question_dialog.add_child(_qd_message2)
var qd_btn_row := HBoxContainer.new()
qd_btn_row.position = Vector2(12, 78)
qd_btn_row.size = Vector2(266, 32)
qd_btn_row.alignment = BoxContainer.ALIGNMENT_CENTER
qd_btn_row.add_theme_constant_override("separation", 24)
_question_dialog.add_child(qd_btn_row)
_qd_accept_btn = Button.new()
_qd_accept_btn.name = "accept"
_qd_accept_btn.text = ""
_qd_accept_btn.custom_minimum_size = Vector2(80, 28)
_qd_accept_btn.pressed.connect(func():
_question_dialog.visible = false
_accept()
)
qd_btn_row.add_child(_qd_accept_btn)
_qd_cancel_btn = Button.new()
_qd_cancel_btn.name = "cancel"
_qd_cancel_btn.text = ""
_qd_cancel_btn.custom_minimum_size = Vector2(80, 28)
_qd_cancel_btn.pressed.connect(func():
_question_dialog.visible = false
)
qd_btn_row.add_child(_qd_cancel_btn)
+30 -1
View File
@@ -8,11 +8,28 @@ const EquipModel = preload("res://ui/equip_model.gd")
var remote_equip: Node
var _remote_parts: Array = []
var _remote_proto: Node
const ITEM_TYPE_WEAPON := 1
const MODE_GENERAL := 1
const MODE_ONEHAND_SWORD := 2
const MODE_TWOHAND_SWORD := 3
const MODE_DUALHAND_SWORD := 4
const MODE_BOW := 5
const MODE_FAN := 6
const MODE_BELL := 7
const WEAPON_MODE := {0: MODE_ONEHAND_SWORD, 1: MODE_DUALHAND_SWORD, 2: MODE_BOW,
3: MODE_TWOHAND_SWORD, 4: MODE_BELL, 5: MODE_FAN}
func _ready() -> void:
if remote_equip and remote_equip.has_method("refresh_refine_effects"):
remote_equip.call_deferred("refresh_refine_effects")
func build_remote(assets_root: String, race: int, parts: Array, item_list: RefCounted,
proto: Node = null, pump := Callable()) -> bool:
proto: Node = null, pump := Callable(), effects: RefCounted = null) -> bool:
if not build(assets_root, race, pump):
return false
_remote_proto = proto
_remote_parts = parts.duplicate()
while _remote_parts.size() < 4:
_remote_parts.append(0)
@@ -21,8 +38,10 @@ func build_remote(assets_root: String, race: int, parts: Array, item_list: RefCo
remote_equip = EquipModel.new()
remote_equip.name = "RemoteEquipModel"
add_child(remote_equip)
remote_equip.effect_registry = effects
remote_equip.setup_remote(item_list, func() -> Node: return self, assets_root, race,
_remote_parts, proto)
_refresh_weapon_motion()
return true
# net_world 按 NetworkActorManager.cpp:473 的顺序调用这四个字段。
@@ -31,6 +50,7 @@ func set_armor(vnum: int) -> void:
func set_weapon(vnum: int) -> void:
_set_remote_part(EquipModel.PART_WEAPON, vnum)
_refresh_weapon_motion()
func set_head(vnum: int) -> void:
_set_remote_part(EquipModel.PART_HEAD, vnum)
@@ -45,3 +65,12 @@ func _set_remote_part(index: int, vnum: int) -> void:
if remote_equip:
remote_equip._set_remote_part(index, vnum)
remote_equip.refresh()
func _refresh_weapon_motion() -> void:
var vnum := int(_remote_parts[EquipModel.PART_WEAPON]) if _remote_parts.size() > EquipModel.PART_WEAPON else 0
var mode := MODE_GENERAL
if vnum > 0 and _remote_proto and _remote_proto.has_method("item"):
var item: Dictionary = _remote_proto.item(vnum)
if int(item.get("type", -1)) == ITEM_TYPE_WEAPON:
mode = int(WEAPON_MODE.get(int(item.get("sub_type", -1)), MODE_GENERAL))
set_motion_mode(mode)
+428 -157
View File
@@ -1,27 +1,37 @@
# SafeboxUI (P8) —— 仓库窗。
# SafeboxUI (P8) —— 仓库窗1:1 对齐 40250 `root/uisafebox.py` 与 `uiscript/safeboxwindow.py`
#
# var bu := preload("res://ui/safebox_ui.gd").new()
# add_child(bu)
# bu.setup(m2client, canvas_parent, proto) # proto 可空
# bu.setup(m2client, canvas_parent, proto)
#
# `safebox_changed` 刷新(size>0 → 打开)。列出仓库道具 + [取出]
# inventory_ui 在仓库开着时右键道具 → 调 bu.deposit(win, cell)
# 取出/存入用第一个空位(简版:deposit 用 safe_pos = 下一个空格;checkout 用道具 cell
# 采用 40250 经典 176×373 羊皮纸金属 Board 边框与 TitleBar
# 5×9 格子(45 格/页),使用 Slot_Base.sub 底图
# 底部单选分页按键(I / II / III)、修改密码与关闭大按钮
# 右键格子道具直接取出至背包;在背包中右键直接存入仓库;支持鼠标拖放。
extends Node
const ItemTooltip = preload("res://ui/item_tooltip.gd")
const UiKit = preload("res://ui_kit.gd")
const UiBuild = preload("res://ui/ui_build.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
var client: Node
var proto: Node
var _tooltip_builder: RefCounted
var _assets_root := ""
var _root: Control
var _board_panel: Control
var _list: VBoxContainer
var _gold: Label
var _title: Label
var _status: Label
var _password_dialog: ConfirmationDialog
var _grid: GridContainer
var _grid_container: Control
var _cells: Dictionary = {}
var _page_buttons: Array[Button] = []
var _page_button_container: HBoxContainer
var _change_pw_btn: Button
var _exit_btn: Button
var _page_label: Label
var _page := 0
var item_mouse: Node
@@ -35,44 +45,113 @@ var _change_pw_dialog: ConfirmationDialog
const SAFE_PAGE_SLOTS := 45
const SAFE_WINDOW := 3
const INVENTORY_WINDOW := 1
const USE_SAFEBOX_LIMIT_RANGE_M := 10.0
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
var ui: CanvasLayer
var _open_char_pos := Vector3.ZERO
var _has_open_pos := false
func setup(m2client: Node, parent: Node, proto_node: Node = null, assets_override := "") -> void:
client = m2client
proto = proto_node
ui = parent as CanvasLayer
_assets_root = assets_override if assets_override != "" else AssetRoot.path()
_tooltip_builder = ItemTooltip.new()
_tooltip_builder.setup(AssetRoot.path(), "en", proto)
_tooltip_builder.setup(_assets_root, "en", proto)
_build(parent)
if client.has_signal("safebox_changed"):
if client and client.has_signal("safebox_opened"):
client.safebox_opened.connect(func(_size): open())
if client and client.has_signal("safebox_closed"):
client.safebox_closed.connect(_close_local)
if client and client.has_signal("safebox_changed"):
client.safebox_changed.connect(refresh)
if client.has_signal("safebox_password_required"):
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(_refresh_inventory_candidates)
if client and client.has_signal("safebox_password_required"):
client.safebox_password_required.connect(func(): _ask_password("safebox"))
if client.has_signal("safebox_wrong_password"):
if client and client.has_signal("safebox_wrong_password"):
client.safebox_wrong_password.connect(func():
if is_instance_valid(_status):
_status.text = "仓库密码错误")
if ui and ui.has_signal("window_closed"):
ui.window_closed.connect(func(w):
if w == _root and is_open():
close()
)
func is_open() -> bool:
return _root != null and _root.visible
func _get_main_char_pos() -> Variant:
if client == null:
return null
var vid := 0
if client.has_method("get_main_vid"):
vid = int(client.get_main_vid())
if vid != 0 and client.has_method("get_entity"):
var e: Dictionary = client.get_entity(vid)
if not e.is_empty() and e.has("pos"):
return e["pos"]
return null
func _process(_delta: float) -> void:
if not is_open() or not _has_open_pos:
return
var cur = _get_main_char_pos()
if cur is Vector3:
if _open_char_pos.distance_to(cur) > USE_SAFEBOX_LIMIT_RANGE_M:
close()
func open() -> void:
if _root:
var vp_size := Vector2(800.0, 600.0)
if _root.is_inside_tree():
vp_size = _root.get_viewport_rect().size
elif ui and ui.is_inside_tree():
vp_size = ui.get_viewport().get_visible_rect().size
_root.position.x = maxf(0.0, (vp_size.x - _root.size.x) / 2.0)
_root.position.y = maxf(0.0, (vp_size.y - _root.size.y) / 2.0)
_root.visible = true
if ui and ui.has_method("open"):
ui.open(_root)
var p = _get_main_char_pos()
if p is Vector3:
_open_char_pos = p
_has_open_pos = true
refresh()
func toggle_window() -> void:
if is_open():
close()
else:
open()
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
_apply_mobile_layout()
_refresh_mobile_inventory()
_refresh_inventory_candidates()
func get_mobile_window() -> Control:
return _root
func close() -> void:
# 对齐原版 SafeboxWindow.Close():先发 /safebox_close 让服务器同步关仓
#CloseSafebox 命令回来后 is_safebox_open() 变 false),本地只是立即收尾。
if client and client.has_method("safebox_close") and is_open():
client.safebox_close()
_close_local()
func _close_local() -> void:
_has_open_pos = false
if item_mouse and item_mouse.has_method("unregister_owner"):
item_mouse.unregister_owner(self)
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
item_mouse.cancel()
if _root:
_root.visible = false
if ui and "_stack" in ui:
ui._stack.erase(_root)
if ui.has_method("_restack"):
ui._restack()
_page = 0
_mobile_deposit_pending.clear()
if is_instance_valid(_password_dialog):
@@ -83,27 +162,33 @@ func deposit(inv_window: int, inv_cell: int) -> void:
if not is_open() or inv_window < 0 or inv_cell < 0:
return
if inv_window == INVENTORY_WINDOW and _mobile_deposit_pending.has(inv_cell):
_status.text = "该物品的存入请求已发送"
if is_instance_valid(_status):
_status.text = "该物品的存入请求已发送"
return
var safe_pos := _next_free_slot()
if safe_pos < 0:
_status.text = "仓库已满"
if is_instance_valid(_status):
_status.text = "仓库已满"
return
if not client.has_method("safebox_checkin"):
_status.text = "存入请求发送失败"
if is_instance_valid(_status):
_status.text = "存入请求发送失败"
return
if not client.safebox_checkin(safe_pos, inv_window, inv_cell):
_status.text = "存入请求发送失败"
if is_instance_valid(_status):
_status.text = "存入请求发送失败"
return
if _mobile_mode and inv_window == INVENTORY_WINDOW:
if inv_window == INVENTORY_WINDOW:
_mobile_deposit_pending[inv_cell] = true
_status.text = "已发送存入请求"
if is_instance_valid(_status):
_status.text = "已发送存入请求"
func _next_free_slot() -> int:
var used := {}
for it in client.get_safebox_items():
used[int(it.get("cell", -1))] = true
var cap: int = maxi(1, client.get_safebox_size()) * 45
if client and client.has_method("get_safebox_items"):
for it in client.get_safebox_items():
used[int(it.get("cell", -1))] = true
var cap: int = maxi(1, int(client.get_safebox_size()) if client and client.has_method("get_safebox_size") else 1) * 45
for i in range(cap):
if not used.has(i):
return i
@@ -127,21 +212,27 @@ func _tooltip_for(item: Dictionary) -> String:
func refresh() -> void:
if client == null:
return
_root.visible = client.is_safebox_open()
_root.visible = client.is_safebox_open() if client.has_method("is_safebox_open") else true
if not _root.visible:
return
_status.text = ""
if is_instance_valid(_status):
_status.text = ""
var pages := maxi(1, int(client.get_safebox_size()))
_page = clampi(_page, 0, pages - 1)
_title.text = "仓库(第 %d/%d 页)" % [_page + 1, pages]
_page_label.text = "%d / %d" % [_page + 1, pages]
# 40250 无仓库金钱存取协议(HEADER_CG_SAFEBOX_MONEY 不存在,GC 84 从不发送,
# 原版 RefreshSafeboxMoney 也是 pass)——只读展示,通常恒 0。
_gold.text = "仓库金币: %d(本版本不可存取)" % client.get_safebox_gold()
if is_instance_valid(_title):
_title.text = "仓库" if not _mobile_mode else "仓库(第 %d/%d 页)" % [_page + 1, pages]
if is_instance_valid(_page_label):
_page_label.text = "%d / %d" % [_page + 1, pages]
if is_instance_valid(_gold):
_gold.text = "仓库金币: %d" % client.get_safebox_gold()
for i in _page_buttons.size():
var btn: Button = _page_buttons[i]
btn.visible = i < pages
btn.set_pressed_no_signal(i == _page)
_render_grid()
for c in _list.get_children():
c.queue_free()
_render_grid()
var items: Array = client.get_safebox_items()
var items: Array = client.get_safebox_items() if client and client.has_method("get_safebox_items") else []
if items.is_empty():
var e := Label.new()
e.text = "(空)"
@@ -149,36 +240,80 @@ func refresh() -> void:
else:
for it in items:
_list.add_child(_row(it))
_refresh_mobile_inventory()
_refresh_inventory_candidates()
func _render_grid() -> void:
if _grid == null:
return
for pos in _cells:
var cell: Button = _cells[pos]
cell.text = ""
cell.tooltip_text = ""
cell.set_meta("vnum", 0)
cell.set_meta("count", 0)
var cell: Control = _cells[pos]
_clear_grid_cell(cell)
var items: Array = client.get_safebox_items() if client and client.has_method("get_safebox_items") else []
for it in items:
var absolute_pos := int(it.get("cell", -1))
var pos := absolute_pos - _page * SAFE_PAGE_SLOTS
if not _cells.has(pos):
continue
var cell: Button = _cells[pos]
var vnum := int(it.get("vnum", 0))
var count := int(it.get("count", 1))
cell.text = "%s%s" % [_name_of(vnum).substr(0, 7), (" ×%d" % count) if count > 1 else ""]
cell.tooltip_text = _tooltip_for(it)
cell.set_meta("vnum", vnum)
cell.set_meta("count", count)
var cell: Control = _cells[pos]
_fill_grid_cell(cell, it)
func _clear_grid_cell(cell: Control) -> void:
cell.tooltip_text = ""
cell.set_meta("vnum", 0)
cell.set_meta("count", 0)
for c in cell.get_children():
c.queue_free()
func _fill_grid_cell(cell: Control, item: Dictionary) -> void:
var vnum := int(item.get("vnum", 0))
var count := int(item.get("count", 1))
cell.set_meta("vnum", vnum)
cell.set_meta("count", count)
cell.tooltip_text = _tooltip_for(item)
var tex := _icon(vnum)
if tex != null:
var tr := TextureRect.new()
tr.name = "Icon"
tr.texture = tex
tr.set_anchors_preset(Control.PRESET_FULL_RECT)
tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(tr)
else:
var lbl := Label.new()
lbl.name = "Icon"
lbl.text = _name_of(vnum).substr(0, 4)
lbl.add_theme_font_size_override("font_size", 9)
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(lbl)
if count > 1:
var cnt_lbl := Label.new()
cnt_lbl.name = "Count"
cnt_lbl.text = str(count)
cnt_lbl.position = Vector2(2, 16)
cnt_lbl.size = Vector2(28, 14)
cnt_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
cnt_lbl.add_theme_font_size_override("font_size", 10)
cnt_lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 0.8))
cnt_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(cnt_lbl)
func _icon(vnum: int) -> Texture2D:
if _assets_root == "":
return null
var rel := "icon/item/%05d.tga" % vnum
var tex := UiAssets.load_tex(_assets_root, rel)
if tex == null:
rel = "icon/item/%05d.tga" % ((vnum / 10) * 10)
tex = UiAssets.load_tex(_assets_root, rel)
return tex
func _drop_to_slot(payload: Dictionary, local_pos: int) -> bool:
if payload.is_empty() or not _cells.has(local_pos):
return false
var safe_pos := _page * SAFE_PAGE_SLOTS + local_pos
var cell: Button = _cells[local_pos]
var cell: Control = _cells[local_pos]
if int(cell.get_meta("vnum", 0)) != 0:
return false
var source_window := int(payload.get("window", -1))
@@ -191,41 +326,67 @@ func _drop_to_slot(payload: Dictionary, local_pos: int) -> bool:
return client.safebox_move(source_cell, safe_pos, 1)
return false
func _on_grid_input(local_pos: int, event: InputEvent) -> void:
if not item_mouse or not item_mouse.has_method("attach_item"):
func _on_cell_input(local_pos: int, ev: InputEvent) -> void:
if not (ev is InputEventMouseButton and ev.pressed):
return
if not (event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT
and event.pressed):
return
var cell: Button = _cells[local_pos]
var vnum := int(cell.get_meta("vnum", 0))
if vnum == 0:
return
item_mouse.attach_item(SAFE_WINDOW, _page * SAFE_PAGE_SLOTS + local_pos, vnum,
int(cell.get_meta("count", 1)), null, "safebox")
if ev.button_index == MOUSE_BUTTON_RIGHT:
var safe_pos := _page * SAFE_PAGE_SLOTS + local_pos
var target := _first_free_inv()
if target < 0:
if is_instance_valid(_status): _status.text = "背包已满"
return
if client and client.has_method("safebox_checkout"):
client.safebox_checkout(safe_pos, 1, target)
elif ev.button_index == MOUSE_BUTTON_LEFT:
if _mobile_mode:
_on_mobile_grid_tap(local_pos)
return
if item_mouse and item_mouse.has_method("attach_item"):
var cell: Control = _cells.get(local_pos, null)
if cell:
var vnum := int(cell.get_meta("vnum", 0))
if vnum > 0:
item_mouse.attach_item(SAFE_WINDOW, _page * SAFE_PAGE_SLOTS + local_pos, vnum,
int(cell.get_meta("count", 1)), null, "safebox")
func _on_mobile_grid_tap(local_pos: int) -> void:
if not _mobile_mode or client == null or not _cells.has(local_pos):
if client == null or not _cells.has(local_pos):
return
var cell: Button = _cells[local_pos]
var cell: Control = _cells[local_pos]
if int(cell.get_meta("vnum", 0)) == 0 or not client.has_method("safebox_checkout"):
return
var target := _first_free_inv()
if target < 0:
_status.text = "背包已满"
if is_instance_valid(_status):
_status.text = "背包已满"
return
if not client.safebox_checkout(_page * SAFE_PAGE_SLOTS + local_pos, 1, target):
_status.text = "取出请求发送失败"
if is_instance_valid(_status):
_status.text = "取出请求发送失败"
func _select_page(index: int) -> void:
_page = index
for i in _page_buttons.size():
var btn: Button = _page_buttons[i]
btn.set_pressed_no_signal(i == _page)
if _page_label:
_page_label.text = "%d / 3" % (_page + 1)
if _title:
_title.text = "仓库" if not _mobile_mode else "仓库(第 %d/3 页)" % (_page + 1)
_render_grid()
func _change_page(delta: int) -> void:
var pages := maxi(1, int(client.get_safebox_size()) if client and client.has_method("get_safebox_size") else 3)
_select_page(posmod(_page + delta, pages))
func _apply_mobile_layout() -> void:
if _root == null:
return
if _mobile_mode:
# The source-bag candidates stay inside the same hosted surface as the
# warehouse grid, so both obey one safe-area scale and touch boundary.
_root.size = Vector2(688, 400)
_root.position = Vector2(-344, -200)
_grid.position = Vector2(12, 54)
if _grid_container:
_grid_container.position = Vector2(12, 54)
if _mobile_inventory_title:
_mobile_inventory_title.visible = true
_mobile_inventory_title.position = Vector2(350, 48)
@@ -233,17 +394,36 @@ func _apply_mobile_layout() -> void:
_mobile_inventory_scroll.visible = true
_mobile_inventory_scroll.position = Vector2(350, 76)
_mobile_inventory_scroll.size = Vector2(326, 292)
if _gold:
_gold.visible = true
_gold.position = Vector2(12, 350)
if _change_pw_btn:
_change_pw_btn.position = Vector2(12, 316)
if _exit_btn:
_exit_btn.position = Vector2(174, 316)
else:
_root.size = Vector2(340, 400)
_root.position = Vector2(-170, -200)
_grid.position = Vector2(12, 54)
# 桌面端也显示背包候选面板(右侧),方便存入物品。
_root.size = Vector2(530, 418)
if _grid_container:
_grid_container.position = Vector2(8, 35)
if _mobile_inventory_title:
_mobile_inventory_title.visible = false
_mobile_inventory_title.visible = true
_mobile_inventory_title.position = Vector2(184, 35)
if _mobile_inventory_scroll:
_mobile_inventory_scroll.visible = false
_mobile_inventory_scroll.visible = true
_mobile_inventory_scroll.position = Vector2(184, 56)
_mobile_inventory_scroll.size = Vector2(336, 296)
if _gold:
_gold.visible = false
if _page_button_container:
_page_button_container.position = Vector2(10, 333)
if _change_pw_btn:
_change_pw_btn.position = Vector2(51, 360)
if _exit_btn:
_exit_btn.position = Vector2(51, 381)
func _refresh_mobile_inventory() -> void:
if not _mobile_mode or _mobile_inventory_list == null or not is_open():
func _refresh_inventory_candidates() -> void:
if _mobile_inventory_list == null or not is_open():
return
for child in _mobile_inventory_list.get_children():
child.queue_free()
@@ -269,11 +449,6 @@ func _refresh_mobile_inventory() -> void:
empty.add_theme_font_size_override("font_size", 12)
_mobile_inventory_list.add_child(empty)
func _change_page(delta: int) -> void:
var pages := maxi(1, int(client.get_safebox_size()))
_page = clampi(_page + delta, 0, pages - 1)
refresh()
func _ask_password(kind: String) -> void:
if is_instance_valid(_password_dialog):
_password_dialog.queue_free()
@@ -309,7 +484,6 @@ func _ask_password(kind: String) -> void:
else:
_password_dialog.popup_centered()
# 修改仓库密码:/safebox_change_password <旧> <新>(服务器两个参数各限 1..6 位)。
func _ask_change_password() -> void:
if not is_open():
return
@@ -344,8 +518,7 @@ func _ask_change_password() -> void:
if new_pw != edits[2].text:
if is_instance_valid(_status):
_status.text = "两次输入的新密码不一致"
elif old_pw.is_empty() or new_pw.is_empty() or new_pw.length() > 6 \
or old_pw.length() > 6:
elif old_pw.is_empty() or new_pw.is_empty() or new_pw.length() > 6 or old_pw.length() > 6:
if is_instance_valid(_status):
_status.text = "密码须为 16 位"
elif not client.safebox_change_password(old_pw, new_pw):
@@ -384,17 +557,18 @@ func _row(it: Dictionary) -> Control:
out.pressed.connect(func() -> void:
var target := _first_free_inv()
if target < 0:
_status.text = "背包已满"
if is_instance_valid(_status): _status.text = "背包已满"
return
if not client.safebox_checkout(cell, 1, target):
_status.text = "取出请求发送失败")
if is_instance_valid(_status): _status.text = "取出请求发送失败")
row.add_child(out)
return row
func _first_free_inv() -> int:
var used := {}
for it in client.get_inventory():
used[int(it.get("cell", -1))] = true
if client and client.has_method("get_inventory"):
for it in client.get_inventory():
used[int(it.get("cell", -1))] = true
for i in range(90):
if not used.has(i):
return i
@@ -402,93 +576,190 @@ func _first_free_inv() -> int:
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-170, -200)
_root.size = Vector2(340, 400)
_root.name = "SafeboxWindow"
_root.size = Vector2(176, 418)
parent.add_child(_root)
var vp_size := Vector2(800.0, 600.0)
if _root.is_inside_tree():
vp_size = _root.get_viewport_rect().size
elif ui and ui.is_inside_tree():
vp_size = ui.get_viewport().get_visible_rect().size
_root.position.x = maxf(0.0, (vp_size.x - _root.size.x) / 2.0)
_root.position.y = maxf(0.0, (vp_size.y - _root.size.y) / 2.0)
_root.visible = false
_root.set_meta("is_titlebar", true)
parent.add_child(_root)
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.06, 0.08, 0.09, 0.97)
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
# 1. 经典羊皮纸金属 Board 边框
var board_np := UiKit.board(_assets_root, "board", 32, 128)
if board_np and board_np.texture:
board_np.set_anchors_preset(Control.PRESET_FULL_RECT)
board_np.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(board_np)
else:
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.06, 0.08, 0.09, 0.97)
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
# 2. 官方 TitleBar
var titlebar := UiBuild._titlebar(_assets_root, 161, {})
titlebar.name = "TitleBar"
titlebar.position = Vector2(8, 7)
_root.add_child(titlebar)
_title = Label.new()
_title.name = "TitleName"
_title.text = "仓库"
_title.position = Vector2(12, 8)
_root.add_child(_title)
var prev := Button.new()
prev.text = ""
prev.position = Vector2(235, 6)
prev.size = Vector2(28, 24)
prev.pressed.connect(func(): _change_page(-1))
_root.add_child(prev)
_page_label = Label.new()
_page_label.position = Vector2(266, 10)
_page_label.size = Vector2(60, 18)
_page_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_root.add_child(_page_label)
var next := Button.new()
next.text = ""
next.position = Vector2(302, 6)
next.size = Vector2(28, 24)
next.pressed.connect(func(): _change_page(1))
_root.add_child(next)
var change_pw := Button.new()
change_pw.text = "改密"
change_pw.position = Vector2(186, 6)
change_pw.size = Vector2(42, 24)
change_pw.add_theme_font_size_override("font_size", 11)
change_pw.pressed.connect(_ask_change_password)
_root.add_child(change_pw)
_gold = Label.new()
_gold.text = "仓库金币: 0"
_gold.position = Vector2(12, 30)
_gold.modulate = Color(0.95, 0.85, 0.5)
_gold.add_theme_font_size_override("font_size", 12)
_root.add_child(_gold)
_status = Label.new()
_status.position = Vector2(12, 382)
_status.add_theme_font_size_override("font_size", 11)
_status.add_theme_color_override("font_color", Color(1, 0.65, 0.55))
_root.add_child(_status)
_list = VBoxContainer.new()
_list.position = Vector2(12, 54)
_list.add_theme_constant_override("separation", 4)
_root.add_child(_list)
_list.visible = false
_grid = GridContainer.new()
_grid.columns = 5
_grid.position = Vector2(12, 54)
_grid.add_theme_constant_override("h_separation", 4)
_grid.add_theme_constant_override("v_separation", 4)
_root.add_child(_grid)
_title.position = Vector2(0, 0)
_title.size = Vector2(161, 23)
_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_title.add_theme_color_override("font_color", Color(1.0, 0.9, 0.6))
_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
titlebar.add_child(_title)
var close_btn: Button = titlebar.find_child("CloseButton", true, false)
if close_btn:
close_btn.pressed.connect(close)
# 3. 5×9 GridSlotWindow (8, 35)
_grid_container = Control.new()
_grid_container.name = "wndItem"
_grid_container.position = Vector2(8, 35)
_grid_container.size = Vector2(160, 288)
_root.add_child(_grid_container)
var slot_base_tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/Slot_Base.sub")
for pos in SAFE_PAGE_SLOTS:
var cell := Button.new()
cell.custom_minimum_size = Vector2(58, 30)
cell.add_theme_font_size_override("font_size", 9)
var col := pos % 5
var row := pos / 5
var cell := Panel.new()
cell.name = "slot_%d" % pos
cell.position = Vector2(col * 32, row * 32)
cell.custom_minimum_size = Vector2(32, 32)
cell.size = Vector2(32, 32)
cell.set_meta("safe_pos", pos)
cell.gui_input.connect(func(e: InputEvent): _on_grid_input(pos, e))
cell.pressed.connect(func(): _on_mobile_grid_tap(pos))
cell.mouse_filter = Control.MOUSE_FILTER_STOP
if slot_base_tex:
var sbt := StyleBoxTexture.new()
sbt.texture = slot_base_tex
cell.add_theme_stylebox_override("panel", sbt)
else:
var sbf := StyleBoxFlat.new()
sbf.bg_color = Color(0.12, 0.12, 0.15)
cell.add_theme_stylebox_override("panel", sbf)
cell.gui_input.connect(func(e: InputEvent): _on_cell_input(pos, e))
if item_mouse and item_mouse.has_method("register_target"):
item_mouse.register_target(cell,
func(payload: Dictionary): return _drop_to_slot(payload, pos), self)
_grid.add_child(cell)
_grid_container.add_child(cell)
_cells[pos] = cell
# 4. 单选分页按键 (I, II, III) - 40250 原版在 y = 333 (418 - 85)
_page_button_container = HBoxContainer.new()
_page_button_container.name = "PageButtons"
_page_button_container.position = Vector2(10, 333)
_page_button_container.size = Vector2(156, 20)
_page_button_container.alignment = BoxContainer.ALIGNMENT_CENTER
_page_button_container.add_theme_constant_override("separation", 6)
_root.add_child(_page_button_container)
for p_idx in 3:
var p_btn := Button.new()
p_btn.name = "PageBtn_%d" % p_idx
p_btn.text = ["I", "II", "III"][p_idx]
p_btn.custom_minimum_size = Vector2(44, 18)
p_btn.toggle_mode = true
var t1 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/windows/tab_button_middle_01.sub")
var t2 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/windows/tab_button_middle_02.sub")
var t3 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/windows/tab_button_middle_03.sub")
if t1:
var sb1 := StyleBoxTexture.new(); sb1.texture = t1
p_btn.add_theme_stylebox_override("normal", sb1)
if t2:
var sb2 := StyleBoxTexture.new(); sb2.texture = t2
p_btn.add_theme_stylebox_override("hover", sb2)
if t3:
var sb3 := StyleBoxTexture.new(); sb3.texture = t3
p_btn.add_theme_stylebox_override("pressed", sb3)
p_btn.pressed.connect(func(): _select_page(p_idx))
_page_button_container.add_child(p_btn)
_page_buttons.append(p_btn)
# 5. 修改密码与关闭大按钮 - 40250 原版居中纵向排列
_change_pw_btn = _create_large_button("ChangePasswordButton", "修改密码", Vector2(51, 360), func(): _ask_change_password())
_change_pw_btn.size = Vector2(74, 21)
_root.add_child(_change_pw_btn)
_exit_btn = _create_large_button("ExitButton", "关闭", Vector2(51, 381), func(): close())
_exit_btn.size = Vector2(74, 21)
_root.add_child(_exit_btn)
# 兼容字段(测试与移动端使用)
_gold = Label.new()
_gold.name = "Gold"
_gold.visible = false
_root.add_child(_gold)
_page_label = Label.new()
_page_label.name = "PageLabel"
_page_label.visible = false
_root.add_child(_page_label)
_status = Label.new()
_status.name = "Status"
_status.visible = false
_root.add_child(_status)
_list = VBoxContainer.new()
_list.name = "List"
_list.visible = false
_root.add_child(_list)
# 移动端适配容器
_mobile_inventory_title = Label.new()
_mobile_inventory_title.text = "背包 · 点按存入"
_mobile_inventory_title.add_theme_font_size_override("font_size", 12)
_mobile_inventory_title.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58))
_mobile_inventory_title.text = "背包候选 · 点按存入"
_mobile_inventory_title.position = Vector2(350, 48)
_mobile_inventory_title.visible = false
_root.add_child(_mobile_inventory_title)
_mobile_inventory_scroll = ScrollContainer.new()
_mobile_inventory_scroll.name = "MobileInventory"
_mobile_inventory_scroll.position = Vector2(350, 76)
_mobile_inventory_scroll.size = Vector2(326, 292)
_mobile_inventory_scroll.visible = false
_mobile_inventory_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_mobile_inventory_list = VBoxContainer.new()
_mobile_inventory_list.add_theme_constant_override("separation", 4)
_mobile_inventory_scroll.add_child(_mobile_inventory_list)
_root.add_child(_mobile_inventory_scroll)
_mobile_inventory_list = VBoxContainer.new()
_mobile_inventory_list.add_theme_constant_override("separation", 6)
_mobile_inventory_scroll.add_child(_mobile_inventory_list)
_select_page(0)
_apply_mobile_layout()
func _create_large_button(btn_name: String, text: String, pos: Vector2, on_click: Callable) -> Button:
var btn := Button.new()
btn.name = btn_name
btn.text = text
btn.position = pos
btn.size = Vector2(74, 22)
btn.custom_minimum_size = Vector2(74, 22)
btn.add_theme_font_size_override("font_size", 11)
var t1 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/large_button_01.sub")
var t2 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/large_button_02.sub")
var t3 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/large_button_03.sub")
if t1:
var sb1 := StyleBoxTexture.new(); sb1.texture = t1
btn.add_theme_stylebox_override("normal", sb1)
if t2:
var sb2 := StyleBoxTexture.new(); sb2.texture = t2
btn.add_theme_stylebox_override("hover", sb2)
if t3:
var sb3 := StyleBoxTexture.new(); sb3.texture = t3
btn.add_theme_stylebox_override("pressed", sb3)
btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
btn.pressed.connect(on_click)
return btn
+75 -1
View File
@@ -11,13 +11,16 @@ extends Node
const CursorManager = preload("res://ui/cursor_manager.gd")
const ItemTooltip = preload("res://ui/item_tooltip.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
const SHOP_SLOT_COUNT := 40 # shop.SHOP_SLOT_COUNT (== SHOP_HOST_ITEM_MAX_NUM)
const ITEM_ANTIFLAG_SELL := 1 << 8
const ITEM_FLAG_COUNT_PER_1GOLD := 1 << 3
const USE_SHOP_LIMIT_RANGE_M := 10.0 # 40250 USE_SHOP_LIMIT_RANGE = 1000cm (uishop.py:483)
var client: Node
var proto: Node
var item_list # ItemListDB (RefCounted)
var _assets_root := ""
var _tooltip_builder: RefCounted
var _root: Control
var _tabbar: HBoxContainer
@@ -25,6 +28,8 @@ var _grid: GridContainer
var _err: Label
var _sell_quantity: SpinBox
var _active_tab := 0
var _open_char_pos := Vector3.ZERO
var _has_open_pos := false
var item_mouse: Node
var cursor_manager: Node
var audio: Node
@@ -42,13 +47,16 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCoun
client = m2client
proto = proto_node
item_list = ilist
_assets_root = AssetRoot.path()
_tooltip_builder = ItemTooltip.new()
_tooltip_builder.setup(AssetRoot.path(), "en", proto)
_tooltip_builder.setup(_assets_root, "en", proto)
_build(parent)
if client.has_signal("shop_opened"):
client.shop_opened.connect(func(_v): open())
if client.has_signal("shop_closed"):
client.shop_closed.connect(_close)
if client.has_signal("shop_updated"):
client.shop_updated.connect(_on_shop_slot_updated)
if client.has_signal("shop_error"):
client.shop_error.connect(_on_error)
@@ -64,17 +72,63 @@ func get_mobile_window() -> Control:
return _root
func close() -> void:
if client and client.has_method("shop_close") and is_open():
client.shop_close()
_close()
func _get_main_char_pos() -> Variant:
if client == null:
return null
var vid := 0
if client.has_method("get_main_vid"):
vid = int(client.get_main_vid())
if vid != 0 and client.has_method("get_entity"):
var e: Dictionary = client.get_entity(vid)
if not e.is_empty() and e.has("pos"):
return e["pos"]
return null
func _process(_delta: float) -> void:
if not is_open() or not _has_open_pos:
return
var cur = _get_main_char_pos()
if cur is Vector3:
if _open_char_pos.distance_to(cur) > USE_SHOP_LIMIT_RANGE_M:
close()
func _icon(vnum: int) -> Texture2D:
if vnum <= 0:
return null
var rel := ""
if item_list and item_list.has_method("has") and item_list.has(vnum):
rel = item_list.icon(vnum)
elif item_list and item_list.has_method("icon"):
rel = item_list.icon(vnum)
if rel != "":
return UiAssets.load_tex(_assets_root, rel)
var exact_rel := "icon/item/%05d.tga" % vnum
var tex := UiAssets.load_tex(_assets_root, exact_rel)
if tex != null:
return tex
var fallback_rel := "icon/item/%05d.tga" % ((vnum / 10) * 10)
return UiAssets.load_tex(_assets_root, fallback_rel)
func open() -> void:
_root.visible = true
_err.text = ""
_sell_quantity.value = 1
_active_tab = 0
_set_mode(1)
var p = _get_main_char_pos()
if p is Vector3:
_open_char_pos = p
_has_open_pos = true
else:
_has_open_pos = false
refresh()
func _close() -> void:
_has_open_pos = false
if item_mouse and item_mouse.has_method("unregister_owner"):
item_mouse.unregister_owner(self)
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
@@ -310,15 +364,35 @@ func _slot(it: Dictionary, pos: int) -> Button:
slot.text = _name_of(vnum).substr(0, 7)
if count > 1:
slot.text += " ×%d" % count
var tex := _icon(vnum)
if tex:
slot.icon = tex
slot.expand_icon = true
if _tooltip_builder:
var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
slot.tooltip_text = _tooltip_builder.format(vnum, maxi(1, count), pd, it)
slot.tooltip_text += "\n价格:%d" % int(it.get("price", 0))
slot.pressed.connect(func() -> void: _buy_slot(pos, it))
slot.gui_input.connect(func(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
_direct_buy(pos, it)
)
else:
slot.disabled = true
return slot
func _direct_buy(pos: int, item: Dictionary) -> void:
if _mode != 1 or item.is_empty() or pos < 0:
return
if client and client.has_method("shop_buy"):
if not client.shop_buy(pos, 1):
_on_error("SEND_FAILED")
func _on_shop_slot_updated(_pos: int) -> void:
if not is_open():
return
refresh()
func _buy_slot(pos: int, item: Dictionary) -> void:
if _mode != 1:
_set_mode(1)
+9
View File
@@ -228,6 +228,15 @@ func name_of(id: int) -> String:
func motion_idx_of(id: int) -> int:
return int(_by_id.get(id, {}).get("motion_idx", 0))
func motion_name_of(id: int) -> String:
return String(_by_id.get(id, {}).get("motion", ""))
func motion_name_by_idx(motion_idx: int) -> String:
for id in _by_id:
if int(_by_id[id].get("motion_idx", 0)) == motion_idx:
return String(_by_id[id].get("motion", ""))
return ""
func _attrs(id: int) -> String:
return String(_by_id.get(id, {}).get("attrs", ""))
+8 -3
View File
@@ -23,6 +23,8 @@ const HIDE_SUPPORT_SKILL_POINT := true
# uicharacter.py __RefreshSkillPlusButton 的 HORSE 分支硬编码 `if skillLevel < 20`。
const RIDING_SKILL_MAX_LEVEL := 20
signal skill_use_requested(skill_id: int)
var client: Node
var table: RefCounted # SkillTable
var ui: CanvasLayer # UiManager
@@ -235,9 +237,12 @@ func _rebuild() -> void:
_rows[id] = {"lv": lv, "up": null}
if not _mobile_mode:
nm.gui_input.connect(func(e: InputEvent):
if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT:
drag_skill_id = sid
if _mobile_hint: _mobile_hint.text = "已选择:%s;点击快捷栏按钮自动放入空位" % table.name_of(sid)
if e is InputEventMouseButton and e.pressed:
if e.button_index == MOUSE_BUTTON_LEFT:
drag_skill_id = sid
if _mobile_hint: _mobile_hint.text = "已选择:%s;点击快捷栏按钮自动放入空位" % table.name_of(sid)
elif e.button_index == MOUSE_BUTTON_RIGHT:
skill_use_requested.emit(sid)
nm.accept_event())
if _mobile_mode:
var assign := Button.new()
+8 -2
View File
@@ -54,6 +54,11 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "",
uiscript_dir = assets_root.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets_root.path_join("uiscript")
if ui and ui.has_signal("window_closed"):
ui.window_closed.connect(func(w):
if not _win.is_empty() and _win.get("root") == w:
_win = {}
)
func is_open() -> bool:
if _mobile_mode:
@@ -79,9 +84,10 @@ func close() -> void:
if root and is_instance_valid(root) and ui:
ui.close(root)
return
if is_open():
ui.close(_win["root"])
var r: Control = _win.get("root", null)
_win = {}
if r and is_instance_valid(r) and ui:
ui.close(r)
func open() -> void:
if _mobile_mode:
+38 -4
View File
@@ -66,6 +66,11 @@ func setup(ui_manager: CanvasLayer, assets: String, audio_node: Node = null,
uiscript_dir = assets_root.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets_root.path_join("uiscript")
if ui and ui.has_signal("window_closed"):
ui.window_closed.connect(func(w):
if not _win.is_empty() and _win.get("root") == w:
_win = {}
)
audio = audio_node
_cam_get = cam_getter
_env_get = env_getter
@@ -80,7 +85,9 @@ func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func set_mobile_mode(enabled: bool) -> void:
if not enabled and _mobile_root != null:
if enabled == _mobile_mode:
return
if is_open():
var old_mode := _mobile_mode
_mobile_mode = true
close()
@@ -97,9 +104,10 @@ func close() -> void:
ui.close(_mobile_root)
_mobile_root = null
return
if is_open():
ui.close(_win["root"])
var r: Control = _win.get("root", null)
_win = {}
if r and is_instance_valid(r) and ui:
ui.close(r)
func open() -> void:
if _mobile_mode:
@@ -111,13 +119,35 @@ func open() -> void:
if not FileAccess.file_exists(path):
push_warning("SystemOptionUI: no systemoptiondialog.py at " + path)
return
_win = ui.open_script(path, assets_root)
_win = ui.open_script(path, assets_root, false, true)
if not is_open():
return
set_center_position()
_relabel()
_wire()
_sync_controls()
## 1:1 对齐 40250 uisystemoption.py: self.SetCenterPosition()
func set_center_position(offset := Vector2.ZERO) -> void:
var r: Control = _win.get("root", null)
if r == null or not is_instance_valid(r):
return
if ui and ui.has_method("center_window"):
ui.center_window(r, offset)
else:
var vp_sz := Vector2(1920, 1080)
var vp := r.get_viewport()
if vp:
var vrect := vp.get_visible_rect().size
if vrect.x > 300 and vrect.y > 300:
vp_sz = vrect
elif ui and "screen" in ui and ui.screen.x > 0:
vp_sz = Vector2(ui.screen)
var sz := r.size
if sz.x <= 0 or sz.y <= 0:
sz = r.custom_minimum_size
r.position = ((vp_sz - sz) / 2.0 + offset).round()
func _open_mobile() -> void:
if is_open():
return
@@ -255,6 +285,10 @@ func _wire() -> void:
var bgm_btn := _node("bgm_button")
if bgm_btn is BaseButton:
bgm_btn.disabled = true # 换 BGM 需音乐文件选择器,暂缺
var tb := _node("titlebar")
if tb:
for x in tb.find_children("*", "BaseButton", true, false):
x.pressed.connect(close)
func _bind_radio(names: Array, on_pick: Callable) -> void:
var boxes: Array = []
+215
View File
@@ -0,0 +1,215 @@
# target_ui.gd —— 40250 目标信息板(TargetBoard1:1 实现
# 严格对照:
# root/uitarget.py:14-340 (TargetBoard)
# UserInterface/InstanceBase.cpp (CanViewTargetHP)
# root/localeinfo.py (TARGET_LEVEL_PAWN..KING)
extends Node
const UiAssets = preload("res://ui/ui_assets.gd")
const GRADE_NAMES := {
0: "1阶", # nonplayer.PAWN: TARGET_LEVEL_PAWN
1: "2阶", # nonplayer.S_PAWN: TARGET_LEVEL_S_PAWN
2: "3阶", # nonplayer.KNIGHT: TARGET_LEVEL_KNIGHT
3: "4阶", # nonplayer.S_KNIGHT: TARGET_LEVEL_S_KNIGHT
4: "5阶首领", # nonplayer.BOSS: TARGET_LEVEL_BOSS
5: "6阶王", # nonplayer.KING: TARGET_LEVEL_KING
}
const DEFAULT_BOARD_WIDTH := 250.0
const BOARD_HEIGHT := 40.0
const GAUGE_WIDTH := 130.0
signal whisper_requested(name: String)
signal exchange_requested(vid: int)
signal fight_requested(vid: int)
signal view_equipment_requested(vid: int)
var client: Node
var _root: Control
var _board: Panel
var _name_label: Label
var _hp_gauge_bg: ColorRect
var _hp_gauge_fill: ColorRect
var _hp_text: Label
var _close_button: Button
var _button_container: HBoxContainer
var _action_buttons: Dictionary = {}
var _current_vid := 0
var _current_name := ""
var _current_hp_pct := 100
var _is_pc := false
var _shown := false
var _assets_root := ""
func setup(m2client: Node, parent: Node, assets := "") -> void:
client = m2client
_assets_root = assets
_build(parent)
func is_open() -> bool:
return _shown and _root != null and _root.visible
func get_target_vid() -> int:
return _current_vid
func get_target_name() -> String:
return _current_name
func get_hp_percentage() -> int:
return _current_hp_pct
func open(vid: int, target_name: String, level := -1, grade := -1, is_pc := false) -> void:
_current_vid = vid
_is_pc = is_pc
var display_name := target_name
var prefix := ""
if level > 0:
prefix += "[Lv. %d] " % level
if grade in GRADE_NAMES:
prefix += "(%s) " % GRADE_NAMES[grade]
_current_name = prefix + display_name
_name_label.text = _current_name
# 40250 uitarget.py:301 板面自适应宽度:200 + 7 * nameLength
var calculated_width := maxf(DEFAULT_BOARD_WIDTH, 200.0 + 7.0 * float(_current_name.length()))
_update_layout(calculated_width, is_pc)
_shown = true
_root.visible = true
_update_screen_position()
func set_hp(hp_pct: int) -> void:
_current_hp_pct = clampi(hp_pct, 0, 100)
if _hp_gauge_fill:
_hp_gauge_fill.size.x = GAUGE_WIDTH * (_current_hp_pct / 100.0)
if _hp_text:
_hp_text.text = "%d%%" % _current_hp_pct
if _hp_gauge_bg and not _is_pc:
_hp_gauge_bg.visible = true
func close() -> void:
_shown = false
_current_vid = 0
_current_name = ""
if _root:
_root.visible = false
func _on_close_button_pressed() -> void:
if client and client.has_method("clear_target"):
client.clear_target()
close()
func _update_layout(width: float, is_pc: bool) -> void:
_board.size = Vector2(width, BOARD_HEIGHT)
if is_pc:
_hp_gauge_bg.visible = false
_button_container.visible = true
_button_container.position = Vector2(width - 200, 8)
else:
_hp_gauge_bg.visible = true
_button_container.visible = false
_hp_gauge_bg.position = Vector2(width - 175, 14)
_close_button.position = Vector2(width - 28, 10)
func _update_screen_position() -> void:
if _root == null or not is_open():
return
var screen_w := 800.0
var vp := _root.get_viewport()
if vp:
screen_w = vp.get_visible_rect().size.x
# 40250 uitarget.py:254 UpdatePosition: ScreenWidth // 2 - Width // 2, 10
_root.position = Vector2(screen_w / 2.0 - _board.size.x / 2.0, 10.0)
func _build(parent: Node) -> void:
_root = Control.new()
_root.name = "TargetBoard"
_root.set_anchors_preset(Control.PRESET_TOP_LEFT)
_root.size = Vector2(DEFAULT_BOARD_WIDTH, BOARD_HEIGHT)
_root.visible = false
parent.add_child(_root)
# 经典 ThinBoard 风格
_board = Panel.new()
_board.name = "Board"
_board.size = Vector2(DEFAULT_BOARD_WIDTH, BOARD_HEIGHT)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.04, 0.05, 0.07, 0.88)
bg.border_color = Color(0.45, 0.38, 0.22, 0.95)
bg.set_border_width_all(1)
bg.set_corner_radius_all(2)
_board.add_theme_stylebox_override("panel", bg)
_root.add_child(_board)
# 目标名称标签
_name_label = Label.new()
_name_label.name = "Name"
_name_label.position = Vector2(23, 11)
_name_label.size = Vector2(200, 20)
_name_label.add_theme_font_size_override("font_size", 11)
_name_label.add_theme_color_override("font_color", Color(1.0, 0.92, 0.75))
_name_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
_name_label.add_theme_constant_override("outline_size", 2)
_board.add_child(_name_label)
# 红色 HP Gauge40250 130px 经典红槽)
_hp_gauge_bg = ColorRect.new()
_hp_gauge_bg.name = "HPGaugeBG"
_hp_gauge_bg.position = Vector2(DEFAULT_BOARD_WIDTH - 175, 14)
_hp_gauge_bg.size = Vector2(GAUGE_WIDTH, 12)
_hp_gauge_bg.color = Color(0.12, 0.02, 0.02, 0.95)
_board.add_child(_hp_gauge_bg)
_hp_gauge_fill = ColorRect.new()
_hp_gauge_fill.name = "HPGaugeFill"
_hp_gauge_fill.position = Vector2.ZERO
_hp_gauge_fill.size = Vector2(GAUGE_WIDTH, 12)
_hp_gauge_fill.color = Color(0.85, 0.12, 0.12, 1.0)
_hp_gauge_bg.add_child(_hp_gauge_fill)
_hp_text = Label.new()
_hp_text.name = "HPText"
_hp_text.set_anchors_preset(Control.PRESET_FULL_RECT)
_hp_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_hp_text.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_hp_text.add_theme_font_size_override("font_size", 9)
_hp_text.add_theme_color_override("font_color", Color(1, 1, 1, 0.95))
_hp_text.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
_hp_text.add_theme_constant_override("outline_size", 1)
_hp_text.text = "100%"
_hp_gauge_bg.add_child(_hp_text)
# 关闭按钮 (X)
_close_button = Button.new()
_close_button.name = "CloseButton"
_close_button.position = Vector2(DEFAULT_BOARD_WIDTH - 28, 10)
_close_button.size = Vector2(18, 18)
_close_button.text = "×"
_close_button.add_theme_font_size_override("font_size", 12)
_close_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_close_button.pressed.connect(_on_close_button_pressed)
_board.add_child(_close_button)
# PC 交互按钮条
_button_container = HBoxContainer.new()
_button_container.name = "ButtonContainer"
_button_container.position = Vector2(DEFAULT_BOARD_WIDTH - 200, 8)
_button_container.visible = false
_board.add_child(_button_container)
_add_action_btn("私聊", func(): whisper_requested.emit(_current_name))
_add_action_btn("交易", func(): exchange_requested.emit(_current_vid))
_add_action_btn("装备", func(): view_equipment_requested.emit(_current_vid))
func _add_action_btn(text: String, callback: Callable) -> void:
var btn := Button.new()
btn.text = text
btn.custom_minimum_size = Vector2(42, 22)
btn.add_theme_font_size_override("font_size", 10)
btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
btn.pressed.connect(callback)
_button_container.add_child(btn)
+1
View File
@@ -0,0 +1 @@
uid://dh05do42dtgef
+5 -1
View File
@@ -9,6 +9,9 @@ extends RefCounted
static var _cache := {}
static func clear_cache() -> void:
_cache.clear()
# DDS 运行时解码:走 Metin2World.load_ddsC++ 里的 dxt.cppDXT1/3/5 + BGRA8)。
# Godot 无原生运行时 DDS 解码,UI 里的 select.dds / 职业名图都得走这条路。
static func load_dds_image(path: String) -> Image:
@@ -128,5 +131,6 @@ static func _load_sub(path: String) -> Texture2D:
return base
var at := AtlasTexture.new()
at.atlas = base
at.region = Rect2(l, t, r - l, b - t)
var region_top := t
at.region = Rect2(l, region_top, r - l, b - t)
return at
+295 -21
View File
@@ -35,11 +35,50 @@ static func _make(spec: Dictionary, ctx: Dictionary, parent_size: Vector2i) -> C
var type := _str(spec.get("type", "window"))
var w := _i(spec, "width", 0)
var h := _i(spec, "height", 0)
if type == "titlebar" and h == 0:
h = 23
elif type == "horizontalbar" and h == 0:
h = 17
elif (type == "grid_table" or type == "slot") and (w == 0 or h == 0):
if w == 0 and spec.has("x_count"):
var xc := maxi(1, _i(spec, "x_count", 1))
var xs := _i(spec, "x_step", 32)
var xb := _i(spec, "x_blank", 0)
w = xc * (xs + xb)
if h == 0 and spec.has("y_count"):
var yc := maxi(1, _i(spec, "y_count", 1))
var ys := _i(spec, "y_step", 32)
var yb := _i(spec, "y_blank", 0)
h = yc * (ys + yb)
var node := _make_by_type(type, spec, ctx, Vector2i(w, h))
if node == null:
node = Control.new()
node.name = _safe_name(_str(spec.get("name", type)))
var halign := _str(spec.get("text_horizontal_align", ""))
var valign := _str(spec.get("text_vertical_align", ""))
var allalign := _str(spec.get("all_align", ""))
if type == "text":
if allalign == "center":
if w == 0: w = parent_size.x
if h == 0: h = parent_size.y
else:
if halign == "center" and w == 0:
w = parent_size.x if parent_size.x > 0 else 100
if (valign == "center" or _str(spec.get("vertical_align", "")) == "center") and h == 0:
h = parent_size.y if parent_size.y > 0 else 17
if (w == 0 or h == 0) and node is TextureRect and (node as TextureRect).texture != null:
var tex: Texture2D = (node as TextureRect).texture
if w == 0: w = tex.get_width()
if h == 0: h = tex.get_height()
elif (w == 0 or h == 0) and node is Button:
var btn := node as Button
var nrm_sb = btn.get_theme_stylebox("normal")
if nrm_sb is StyleBoxTexture and (nrm_sb as StyleBoxTexture).texture != null:
var tex: Texture2D = (nrm_sb as StyleBoxTexture).texture
if w == 0: w = tex.get_width()
if h == 0: h = tex.get_height()
if w > 0:
node.custom_minimum_size = Vector2(w, h)
node.size = Vector2(w, h)
@@ -47,11 +86,17 @@ static func _make(spec: Dictionary, ctx: Dictionary, parent_size: Vector2i) -> C
# 位置:x/y + horizontal_align / vertical_align(相对父)
var x := _i(spec, "x", 0)
var y := _i(spec, "y", 0)
if type == "text" and halign == "center" and _i(spec, "width", 0) == 0:
x = 0
match _str(spec.get("horizontal_align", "left")):
"center": x = (parent_size.x - w) / 2 + x
"right": x = parent_size.x - w - x
match _str(spec.get("vertical_align", "top")):
"center": y = (parent_size.y - h) / 2 + y
"center":
if type == "text" and (valign == "center" or _str(spec.get("vertical_align", "")) == "center") and h == parent_size.y:
pass
else:
y = (parent_size.y - h) / 2 + y
"bottom": y = parent_size.y - h - y
node.position = Vector2(x, y)
@@ -59,7 +104,7 @@ static func _make(spec: Dictionary, ctx: Dictionary, parent_size: Vector2i) -> C
if nm != "":
ctx["nodes"][nm] = node
var self_size := Vector2i(w, h) if w > 0 else parent_size
var self_size := Vector2i(w, h) if (w > 0 and h > 0) else parent_size
for child in spec.get("children", []):
if child is Dictionary:
var c := _make(child, ctx, self_size)
@@ -85,36 +130,109 @@ static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: V
p.set_meta("titlebar_h", 24)
return p
"titlebar":
var t := Panel.new()
t.set_meta("is_titlebar", true)
return t
return _titlebar(assets, sz.x, spec)
"text":
var lbl := Label.new()
lbl.text = _str(spec.get("text", ""))
if spec.has("text_color"):
lbl.add_theme_color_override("font_color", _argb(int(spec["text_color"])))
match _str(spec.get("text_horizontal_align", "left")):
elif spec.has("color"):
lbl.add_theme_color_override("font_color", _argb(int(spec["color"])))
elif spec.has("r") and spec.has("g") and spec.has("b"):
var r := float(spec["r"])
var g := float(spec["g"])
var b := float(spec["b"])
var a := float(spec.get("a", 1.0))
lbl.add_theme_color_override("font_color", Color(r, g, b, a))
else:
lbl.add_theme_color_override("font_color", Color(0.8549, 0.8549, 0.8549))
var font_size := 12
var fs_spec := _str(spec.get("fontsize", "")).to_upper()
var fn_spec := _str(spec.get("fontname", "")).to_upper()
if fs_spec == "LARGE" or "LARGE" in fn_spec:
font_size = 14
elif fs_spec == "SMALL" or "SMALL" in fn_spec:
font_size = 9
lbl.add_theme_font_size_override("font_size", font_size)
var halign := _str(spec.get("text_horizontal_align", ""))
var valign := _str(spec.get("text_vertical_align", ""))
if _str(spec.get("all_align", "")) == "center":
halign = "center"
valign = "center"
match halign:
"center": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
"right": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_: lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
match valign:
"center": lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
"bottom": lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_: lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
return lbl
"button", "toggle_button":
"button", "toggle_button", "radio_button":
var b := Button.new()
b.text = _str(spec.get("text", ""))
b.toggle_mode = (type == "toggle_button")
b.toggle_mode = (type == "toggle_button" or type == "radio_button")
if spec.has("text_color"):
var col := _argb(int(spec["text_color"]))
b.add_theme_color_override("font_color", col)
b.add_theme_color_override("font_pressed_color", col)
b.add_theme_color_override("font_hover_color", col)
b.add_theme_color_override("font_focus_color", col)
elif spec.has("color"):
var col := _argb(int(spec["color"]))
b.add_theme_color_override("font_color", col)
b.add_theme_color_override("font_pressed_color", col)
b.add_theme_color_override("font_hover_color", col)
b.add_theme_color_override("font_focus_color", col)
b.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER
b.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
b.alignment = HORIZONTAL_ALIGNMENT_CENTER
b.clip_text = true
var btn_fsize := 12
var fs_spec := _str(spec.get("fontsize", "")).to_upper()
var fn_spec := _str(spec.get("fontname", "")).to_upper()
if fs_spec == "LARGE" or "LARGE" in fn_spec:
btn_fsize = 14
elif fs_spec == "SMALL" or "SMALL" in fn_spec:
btn_fsize = 9
b.add_theme_font_size_override("font_size", btn_fsize)
var nrm := UiAssets.load_tex(assets, _str(spec.get("default_image", "")))
var ovr := UiAssets.load_tex(assets, _str(spec.get("over_image", "")))
var dwn := UiAssets.load_tex(assets, _str(spec.get("down_image", "")))
var empty := StyleBoxEmpty.new()
empty.content_margin_left = 0
empty.content_margin_right = 0
empty.content_margin_top = 0
empty.content_margin_bottom = 0
if nrm:
b.add_theme_stylebox_override("normal", _sb_tex(nrm))
if ovr: b.add_theme_stylebox_override("hover", _sb_tex(ovr))
if dwn: b.add_theme_stylebox_override("pressed", _sb_tex(dwn))
b.add_theme_stylebox_override("hover", _sb_tex(ovr if ovr else nrm))
b.add_theme_stylebox_override("pressed", _sb_tex(dwn if dwn else nrm))
b.add_theme_stylebox_override("disabled", _sb_tex(nrm))
b.add_theme_stylebox_override("focus", empty)
var tex_w := nrm.get_width()
var tex_h := nrm.get_height()
b.custom_minimum_size = Vector2(tex_w, tex_h)
b.size = Vector2(tex_w, tex_h)
# 40250 对齐:进入节点树时重设尺寸,防止被 Godot 默认最小高度撑大到 31px
b.tree_entered.connect(func():
b.size = b.custom_minimum_size
)
if btn_fsize + 6 > tex_h:
btn_fsize = maxi(8, tex_h - 6)
b.add_theme_font_size_override("font_size", btn_fsize)
else:
b.add_theme_stylebox_override("normal", empty)
b.add_theme_stylebox_override("hover", empty)
b.add_theme_stylebox_override("pressed", empty)
b.add_theme_stylebox_override("focus", empty)
b.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
return b
"radio_button":
var cb := CheckBox.new()
cb.text = _str(spec.get("text", ""))
return cb
"image", "expanded_image", "ani_image", "mark":
var ir := TextureRect.new()
ir.texture = UiAssets.load_tex(assets, _str(spec.get("image", "")))
@@ -138,11 +256,13 @@ static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: V
return ln
"listbox", "candidate_list":
return ItemList.new()
"gauge", "bar", "horizontalbar":
"gauge", "bar":
var pb := ProgressBar.new()
pb.show_percentage = false
pb.value = 100.0
return pb
"horizontalbar":
return _horizontalbar(assets, _i(spec, "width", 100))
"grid_table", "slotbar", "slot":
# Metin2 物品格容器:有 start_index/x_count 或显式 "slot" 元组 -> 建子格;
# 否则 grid_table 退化成普通 GridContainer。
@@ -153,6 +273,8 @@ static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: V
return g
var s := Control.new()
s.set_meta("is_slot_container", true)
var base_img := _str(spec.get("image", ""))
var base_tex: Texture2D = UiAssets.load_tex(assets, base_img) if base_img != "" else null
for cd in cells:
var cell := Panel.new()
cell.name = "slot_%d" % int(cd["index"])
@@ -161,10 +283,33 @@ static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: V
cell.size = Vector2(cd["w"], cd["h"])
cell.set_meta("slot_index", int(cd["index"]))
cell.mouse_filter = Control.MOUSE_FILTER_STOP
if base_tex != null:
cell.add_theme_stylebox_override("panel", _sb_tex(base_tex))
s.add_child(cell)
return s
"sliderbar":
return HSlider.new()
var slider := HSlider.new()
# 40250 sliderbar.sub (175×12), sliderbar_cursor.sub (53×12)
slider.custom_minimum_size = Vector2(175, 12)
slider.size = Vector2(175, 12)
slider.min_value = 0.0
slider.max_value = 1.0
slider.step = 0.01
var track_tex := UiAssets.load_tex(assets, "d:/ymir work/ui/game/windows/sliderbar.sub")
var grab_tex := UiAssets.load_tex(assets, "d:/ymir work/ui/game/windows/sliderbar_cursor.sub")
if track_tex:
var sb_track := StyleBoxTexture.new()
sb_track.texture = track_tex
sb_track.content_margin_left = 0
sb_track.content_margin_right = 0
sb_track.content_margin_top = 0
sb_track.content_margin_bottom = 0
slider.add_theme_stylebox_override("slider", sb_track)
if grab_tex:
slider.add_theme_icon_override("grabber", grab_tex)
slider.add_theme_icon_override("grabber_highlight", grab_tex)
slider.add_theme_icon_override("grabber_disabled", grab_tex)
return slider
"scrollbar", "thin_scrollbar":
return VScrollBar.new()
_:
@@ -174,14 +319,20 @@ static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: V
static func _board(assets: String, prefix: String, sz: Vector2i) -> Panel:
var p := Panel.new()
if sz.x > 0 and sz.y > 0:
p.custom_minimum_size = Vector2(sz)
p.size = Vector2(sz)
var np: NinePatchRect = null
if assets != "":
np = UiKit.board(assets, prefix, 32, 128)
var m := 16 if prefix == "thinboard" else 32
var base_sz := 32 if prefix == "thinboard" else 128
np = UiKit.board(assets, prefix, m, base_sz)
if np and np.texture:
np.set_anchors_preset(Control.PRESET_FULL_RECT)
np.mouse_filter = Control.MOUSE_FILTER_IGNORE
p.add_child(np)
np.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
p.get_child(0).show_behind_parent = true
p.add_theme_stylebox_override("panel", StyleBoxEmpty.new())
else:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.10, 0.11, 0.14, 0.92)
@@ -191,6 +342,123 @@ static func _board(assets: String, prefix: String, sz: Vector2i) -> Panel:
p.add_theme_stylebox_override("panel", sb)
return p
static func _titlebar(assets: String, width: int, spec: Dictionary) -> Control:
var t := Control.new()
var w := maxi(64, width)
t.custom_minimum_size = Vector2(w, 23)
t.size = Vector2(w, 23)
t.set_meta("is_titlebar", true)
# Background textures (left, center, right)
var left_tex := UiKit.ui_tex(assets, "titlebar_left")
var center_tex := UiKit.ui_tex(assets, "titlebar_center")
var right_tex := UiKit.ui_tex(assets, "titlebar_right")
if left_tex and center_tex and right_tex:
var img_left := TextureRect.new()
img_left.name = "TitleBarLeft"
img_left.texture = left_tex
img_left.position = Vector2.ZERO
img_left.size = Vector2(32, 23)
img_left.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
img_left.mouse_filter = Control.MOUSE_FILTER_IGNORE
t.add_child(img_left)
var img_center := TextureRect.new()
img_center.name = "TitleBarCenter"
img_center.texture = center_tex
img_center.position = Vector2(32, 0)
img_center.size = Vector2(maxi(0, w - 64), 23)
img_center.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
img_center.stretch_mode = TextureRect.STRETCH_TILE
img_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
t.add_child(img_center)
var img_right := TextureRect.new()
img_right.name = "TitleBarRight"
img_right.texture = right_tex
img_right.position = Vector2(w - 32, 0)
img_right.size = Vector2(32, 23)
img_right.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
img_right.mouse_filter = Control.MOUSE_FILTER_IGNORE
t.add_child(img_right)
else:
var p := Panel.new()
p.set_anchors_preset(Control.PRESET_FULL_RECT)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.2, 0.2, 0.25, 0.9)
p.add_theme_stylebox_override("panel", sb)
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
t.add_child(p)
# Close Button
var btn_close := Button.new()
btn_close.name = "CloseButton"
var nrm := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_01.sub")
var ovr := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_02.sub")
var dwn := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_03.sub")
if nrm:
btn_close.add_theme_stylebox_override("normal", _sb_tex(nrm))
if ovr: btn_close.add_theme_stylebox_override("hover", _sb_tex(ovr))
if dwn: btn_close.add_theme_stylebox_override("pressed", _sb_tex(dwn))
var bw := nrm.get_width()
var bh := nrm.get_height()
btn_close.size = Vector2(bw, bh)
btn_close.position = Vector2(w - bw - 3, 3)
else:
btn_close.text = "×"
btn_close.size = Vector2(16, 16)
btn_close.position = Vector2(w - 19, 3)
btn_close.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
t.add_child(btn_close)
t.set_meta("close_button", btn_close)
return t
static func _horizontalbar(assets: String, width: int) -> Control:
var w := maxi(96, width)
var bar := Control.new()
bar.custom_minimum_size = Vector2(w, 17)
bar.size = Vector2(w, 17)
bar.mouse_filter = Control.MOUSE_FILTER_PASS
var left_tex := UiKit.ui_tex(assets, "horizontalbar_left")
var center_tex := UiKit.ui_tex(assets, "horizontalbar_center")
var right_tex := UiKit.ui_tex(assets, "horizontalbar_right")
if left_tex and center_tex and right_tex:
var img_left := TextureRect.new()
img_left.name = "HorizontalBarLeft"
img_left.texture = left_tex
img_left.position = Vector2.ZERO
img_left.size = Vector2(32, 17)
img_left.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_child(img_left)
var img_center := TextureRect.new()
img_center.name = "HorizontalBarCenter"
img_center.texture = center_tex
img_center.position = Vector2(32, 0)
img_center.size = Vector2(maxi(0, w - 64), 17)
img_center.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
img_center.stretch_mode = TextureRect.STRETCH_TILE
img_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_child(img_center)
var img_right := TextureRect.new()
img_right.name = "HorizontalBarRight"
img_right.texture = right_tex
img_right.position = Vector2(w - 32, 0)
img_right.size = Vector2(32, 17)
img_right.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_child(img_right)
else:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.2, 0.22, 0.25, 0.85)
var p := Panel.new()
p.set_anchors_preset(Control.PRESET_FULL_RECT)
p.add_theme_stylebox_override("panel", sb)
bar.add_child(p)
return bar
# slot 容器的子格:显式 "slot" 元组,或 start_index/x_count/y_count/x_step/y_step 网格。
static func _slot_cells(spec: Dictionary) -> Array:
var cells := []
@@ -205,15 +473,21 @@ static func _slot_cells(spec: Dictionary) -> Array:
var yc := maxi(1, _i(spec, "y_count", 1))
var xs := _i(spec, "x_step", 32)
var ys := _i(spec, "y_step", 32)
var xb := _i(spec, "x_blank", 0)
var yb := _i(spec, "y_blank", 0)
for row in yc:
for col in xc:
cells.append({"index": si + row * xc + col, "x": col * xs, "y": row * ys,
"w": 32, "h": 32})
cells.append({"index": si + row * xc + col, "x": col * (xs + xb), "y": row * (ys + yb),
"w": xs, "h": ys})
return cells
static func _sb_tex(t: Texture2D) -> StyleBoxTexture:
var sb := StyleBoxTexture.new()
sb.texture = t
sb.content_margin_left = 0
sb.content_margin_top = 0
sb.content_margin_right = 0
sb.content_margin_bottom = 0
return sb
static func _argb(v: int) -> Color:
+56 -12
View File
@@ -33,9 +33,16 @@ var _last_mobile_area := Rect2(-1, -1, 0, 0)
var _drag_win: Control = null
var _drag_from := Vector2.ZERO
func _ready() -> void:
func _init() -> void:
layer = 10
_ensure_root()
func _ensure_root() -> void:
if _root != null:
return
layer = 10
_root = Control.new()
_root.name = "UiRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_root)
@@ -51,6 +58,9 @@ func _ready() -> void:
_mobile_blocker.mouse_filter = Control.MOUSE_FILTER_STOP
_mobile_blocker.visible = false
_root.add_child(_mobile_blocker)
func _ready() -> void:
_ensure_root()
set_process_input(true)
set_process_unhandled_input(true)
set_process(true)
@@ -121,16 +131,40 @@ func build_script(path: String, assets_root: String) -> Dictionary:
return {"root": Control.new(), "nodes": {}}
return UiBuild.build(spec, assets_root)
## 1:1 对齐 40250 ui.py: SetCenterPosition(x = 0, y = 0)
## 将窗口居中于当前屏幕 / 视口
func center_window(win: Control, offset := Vector2.ZERO) -> void:
if win == null or not is_instance_valid(win):
return
var vp_sz := Vector2(1920, 1080)
var vp := win.get_viewport()
if vp:
var vrect := vp.get_visible_rect().size
if vrect.x > 300 and vrect.y > 300:
vp_sz = vrect
elif _root and _root.size.x > 300 and _root.size.y > 300:
vp_sz = _root.size
elif screen.x > 0 and screen.y > 0:
vp_sz = Vector2(screen)
var win_sz := win.size
if win_sz.x <= 0 or win_sz.y <= 0:
win_sz = win.custom_minimum_size
win.position = ((vp_sz - win_sz) / 2.0 + offset).round()
# 解析 + 构建 + 打开,返回 { root, nodes }
func open_script(path: String, assets_root: String, modal := false) -> Dictionary:
func open_script(path: String, assets_root: String, modal := false, center := false) -> Dictionary:
var r := build_script(path, assets_root)
open(r.root, modal)
if center and r.root is Control:
center_window(r.root)
return r
func open(win: Control, modal := false) -> void:
_ensure_root()
if win.get_parent() == null:
_root.add_child(win)
win.set_meta("modal", modal)
_wire_close_buttons(win)
if mobile_mode:
MobileWindowHost.install(win, _mobile_area(), _window_title(win),
Callable(self, "_close_hosted_window").bind(win))
@@ -141,6 +175,13 @@ func open(win: Control, modal := false) -> void:
_restack()
window_opened.emit(win)
func _wire_close_buttons(win: Control) -> void:
for btn in win.find_children("CloseButton", "BaseButton", true, false):
var b := btn as BaseButton
if not b.has_meta("_close_wired"):
b.set_meta("_close_wired", true)
b.pressed.connect(func(): close(win))
func close(win: Control) -> void:
if win == null:
return
@@ -383,19 +424,22 @@ func _close_mobile_popup() -> bool:
# --- 标题栏拖动 --------------------------------------------------------
func _wire_drag(win: Control) -> void:
var handle := _find_drag_handle(win)
if handle == null or handle.has_meta("_drag_wired"):
return
handle.set_meta("_drag_wired", true)
handle.mouse_filter = Control.MOUSE_FILTER_STOP
handle.gui_input.connect(func(ev: InputEvent): _on_handle_input(win, ev))
var handles := _find_drag_handles(win)
for handle in handles:
if handle == null or handle.has_meta("_drag_wired"):
continue
handle.set_meta("_drag_wired", true)
handle.mouse_filter = Control.MOUSE_FILTER_STOP
handle.gui_input.connect(func(ev: InputEvent): _on_handle_input(win, ev))
func _find_drag_handle(win: Control) -> Control:
# 优先 titlebar;否则 board_with_titlebar 的顶部条;否则整个 window 根
func _find_drag_handles(win: Control) -> Array[Control]:
var list: Array[Control] = []
for n in win.find_children("*", "", true, false):
if n is Control and (n.get_meta("is_titlebar", false) or n.get_meta("titlebar_h", 0) > 0):
return n
return win
list.append(n as Control)
if list.is_empty():
list.append(win)
return list
func _on_handle_input(win: Control, ev: InputEvent) -> void:
if ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT:
+30
View File
@@ -244,11 +244,41 @@ func _parse_name():
# 未知名字:数值上下文当 0,字符串上下文当 "";这里返回 0_str_or 会兜
return 0
static var _locale_dict := {}
static func _ensure_locale_dict() -> void:
if not _locale_dict.is_empty():
return
_locale_dict["WINDOWS_PATH"] = "locale/en/ui/windows/"
_locale_dict["LOCALE_UISCRIPT_PATH"] = "locale/en/ui/"
_locale_dict["LOGIN_PATH"] = "locale/en/ui/login/"
_locale_dict["EMPIRE_PATH"] = "locale/en/ui/empire/"
_locale_dict["GUILD_PATH"] = "locale/en/ui/guild/"
_locale_dict["SELECT_PATH"] = "locale/en/ui/select/"
_locale_dict["MAPNAME_PATH"] = "locale/en/ui/mapname/"
var cands := [
"assets/locale/locale/en/locale_interface.txt",
"assets/locale/en/locale_interface.txt",
"../assets/locale/locale/en/locale_interface.txt",
"../assets/locale/en/locale_interface.txt",
]
for p in cands:
if FileAccess.file_exists(p):
var txt := FileAccess.get_file_as_string(p)
for line in txt.split("\n"):
var parts := line.strip_edges().split("\t", false)
if parts.size() >= 2:
_locale_dict[parts[0].strip_edges()] = parts[1].strip_edges()
break
func _resolve_locale(key: String):
if locale.is_valid():
var v = locale.call(key)
if v != null and String(v) != "":
return String(v)
_ensure_locale_dict()
if _locale_dict.has(key):
return _locale_dict[key]
return "<" + key + ">"
func _parse_dict() -> Dictionary: