Metin2 game client (P0–P11) + mobile asset pipeline

Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+242
View File
@@ -0,0 +1,242 @@
# AtlasUI (P9) —— 对齐 CPythonMiniMap 的 Atlas 窗口:大地图、拖动、玩家
# 标记和坐标查询。地图底图复用每个区块的 minimap.dds;缺贴图时仍显示
# 可交互的中性底图,便于无资源/打包环境继续使用坐标查询。
#
# var atlas := preload("res://ui/atlas_ui.gd").new()
# atlas.setup(world, ui_manager, func() -> Node3D: return player, "map_a1")
# atlas.toggle() # M 键
extends Node
class AtlasOverlay extends Control:
var owner_ui: Node
func _draw() -> void:
if owner_ui:
owner_ui._draw_world_marks(self)
const VIEW_SIZE := Vector2(720, 520)
const CELL_PIXELS := 128
const MARK_SIZE := 8.0
var world: Node
var client: Node
var ui: CanvasLayer
var player_getter: Callable
var map_name := ""
var _win: Control
var _map_view: Control
var _map: TextureRect
var _overlay: Control
var _player_mark: ColorRect
var _coord: Label
var _dragging := false
var _drag_origin := Vector2.ZERO
var _map_origin := Vector2.ZERO
var _map_px := Vector2.ZERO
var _world_size_m := Vector2(1, 1)
func setup(metin_world: Node, ui_manager: CanvasLayer, get_player: Callable,
name := "", m2client: Node = null) -> void:
world = metin_world
client = m2client
ui = ui_manager
player_getter = get_player
map_name = name if name != "" else "地图"
func is_open() -> bool:
return _win != null and is_instance_valid(_win)
func toggle() -> void:
if is_open():
close()
else:
open()
func open() -> void:
if is_open() or ui == null:
return
_build_window()
ui.open(_win, false)
_update_player_marker()
func close() -> void:
if is_open() and ui:
ui.close(_win)
_win = null
func _build_window() -> void:
_win = Control.new()
_win.name = "AtlasWindow"
_win.set_anchors_preset(Control.PRESET_CENTER)
_win.position = -VIEW_SIZE * 0.5
_win.size = VIEW_SIZE
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.055, 0.065, 0.09, 0.97)
style.border_color = Color(0.4, 0.45, 0.58, 0.9)
style.set_border_width_all(1)
style.set_corner_radius_all(6)
panel.add_theme_stylebox_override("panel", style)
_win.add_child(panel)
var title := Label.new()
title.text = "Atlas · %s" % map_name
title.position = Vector2(14, 8)
title.add_theme_font_size_override("font_size", 15)
_win.add_child(title)
var close_btn := Button.new()
close_btn.text = "×"
close_btn.position = Vector2(VIEW_SIZE.x - 42, 5)
close_btn.size = Vector2(32, 28)
close_btn.pressed.connect(close)
_win.add_child(close_btn)
_coord = Label.new()
_coord.position = Vector2(14, VIEW_SIZE.y - 30)
_coord.text = "坐标:--"
_coord.add_theme_font_size_override("font_size", 12)
_win.add_child(_coord)
_map_view = Control.new()
_map_view.name = "MapView"
_map_view.position = Vector2(14, 42)
_map_view.size = Vector2(VIEW_SIZE.x - 28, VIEW_SIZE.y - 80)
_map_view.clip_contents = true
_map_view.gui_input.connect(_on_map_input)
_win.add_child(_map_view)
_overlay = AtlasOverlay.new()
_overlay.owner_ui = self
_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_map_view.add_child(_overlay)
_build_map()
func _build_map() -> void:
var tiles := Vector2i(1, 1)
if world and world.has_method("get_map_size_tiles"):
tiles = world.get_map_size_tiles()
tiles.x = maxi(1, tiles.x)
tiles.y = maxi(1, tiles.y)
_world_size_m = Vector2(tiles.x * 256.0, tiles.y * 256.0)
_map_px = Vector2(tiles.x * CELL_PIXELS, tiles.y * CELL_PIXELS)
var image := Image.create_empty(int(_map_px.x), int(_map_px.y), false, Image.FORMAT_RGBA8)
image.fill(Color(0.12, 0.14, 0.18, 1.0))
var loaded := 0
for tx in tiles.x:
for ty in tiles.y:
if not world or not world.has_method("chunk_dir") or not world.has_method("load_dds"):
continue
var path: String = world.chunk_dir(tx, ty).path_join("minimap.dds")
if not FileAccess.file_exists(path):
continue
var tile: Image = world.load_dds(path)
if tile == null:
continue
tile.resize(CELL_PIXELS, CELL_PIXELS, Image.INTERPOLATE_BILINEAR)
image.blit_rect(tile, Rect2i(Vector2i.ZERO, Vector2i(CELL_PIXELS, CELL_PIXELS)),
Vector2i(tx * CELL_PIXELS, ty * CELL_PIXELS))
loaded += 1
var tex := ImageTexture.create_from_image(image)
_map = TextureRect.new()
_map.name = "AtlasTexture"
_map.texture = tex
_map.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_map.stretch_mode = TextureRect.STRETCH_SCALE
_map.size = _map_px
_map.mouse_filter = Control.MOUSE_FILTER_IGNORE
_map_origin = (_map_view.size - _map_px) * 0.5
_map.position = _map_origin
_map_view.add_child(_map)
_overlay.position = _map.position
_overlay.size = _map.size
_player_mark = ColorRect.new()
_player_mark.name = "PlayerMark"
_player_mark.color = Color(1.0, 0.85, 0.15, 1.0)
_player_mark.size = Vector2(MARK_SIZE, MARK_SIZE)
_player_mark.mouse_filter = Control.MOUSE_FILTER_IGNORE
_overlay.add_child(_player_mark)
_overlay.move_to_front()
if loaded == 0:
_coord.text = "坐标:--(缺 minimap.dds,仍可查询)"
func _on_map_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
_dragging = event.pressed
if _dragging:
_drag_origin = event.position
_map_origin = _map.position
_map_view.accept_event()
elif event is InputEventMouseMotion and _dragging:
_map.position = _map_origin + event.position - _drag_origin
_overlay.position = _map.position
_map_view.accept_event()
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
_map_origin = (_map_view.size - _map.size) * 0.5
_map.position = _map_origin
_overlay.position = _map.position
_map_view.accept_event()
func _process(_dt: float) -> void:
if is_open():
_update_player_marker()
_overlay.queue_redraw()
func _player() -> Node3D:
return player_getter.call() if player_getter.is_valid() else null
func _update_player_marker() -> void:
if _player_mark == null or not is_instance_valid(_player_mark):
return
var p := _player()
if p == null:
return
var world_pos: Vector3 = p.global_position
var px := Vector2(world_pos.x / _world_size_m.x * _map_px.x,
world_pos.z / _world_size_m.y * _map_px.y)
# Marker is a child of the overlay, whose origin tracks the draggable map.
_player_mark.position = px - Vector2(MARK_SIZE, MARK_SIZE) * 0.5
var server_cm := MapCoord.to_server_cm(world_pos)
_coord.text = "坐标:X %d Y %d" % [roundi(server_cm.x), roundi(server_cm.y)]
func _world_to_px(world_pos: Vector3) -> Vector2:
return Vector2(world_pos.x / _world_size_m.x * _map_px.x,
world_pos.z / _world_size_m.y * _map_px.y)
func _draw_world_marks(canvas: CanvasItem) -> void:
if client == null:
return
if client.has_method("get_party") and client.has_method("get_entity"):
for member in client.get_party():
var vid := int(member.get("vid", 0))
if vid == 0:
continue
var entity: Dictionary = client.get_entity(vid)
if entity.is_empty():
continue
var party_pos := _world_to_px(MapCoord.to_world(entity.get("pos", Vector3.ZERO)))
canvas.draw_circle(party_pos, 5.0, Color(0.35, 1.0, 0.55, 0.95))
if bool(member.get("leader", false)):
canvas.draw_arc(party_pos, 8.0, 0.0, TAU, 20, Color(1.0, 0.9, 0.25, 0.95), 2.0)
# Guild areas arrive in global server centimetres, matching GC_LAND_LIST.
if client.has_method("get_land_areas"):
for area in client.get_land_areas():
var x := float(area.get("x", 0))
var y := float(area.get("y", 0))
var w := float(area.get("width", 0))
var h := float(area.get("height", 0))
var p0 := _world_to_px(MapCoord.to_world(Vector3(x * 0.01, 0, -y * 0.01)))
var p1 := _world_to_px(MapCoord.to_world(Vector3((x + w) * 0.01, 0, -(y + h) * 0.01)))
var rect := Rect2(Vector2(min(p0.x, p1.x), min(p0.y, p1.y)),
Vector2(abs(p1.x - p0.x), abs(p1.y - p0.y)))
var col := Color(0.35, 0.95, 0.95, 0.8) if int(area.get("guild_id", 0)) == 0 \
else Color(1.0, 0.75, 0.2, 0.9)
canvas.draw_rect(rect, col, false, 2.0)
# Observer points are exposed in the same network/Godot frame as entities.
if client.has_method("get_observers"):
for observer in client.get_observers():
var p: Vector3 = observer.get("pos", Vector3.ZERO)
var op := _world_to_px(MapCoord.to_world(p))
canvas.draw_colored_polygon(PackedVector2Array([op + Vector2(0, -6), op + Vector2(6, 0),
op + Vector2(0, 6), op + Vector2(-6, 0)]), Color(0.8, 0.35, 1.0, 0.95))
# Quest/warp markers remain visible when the Atlas is used for navigation.
if client.has_method("get_world_markers"):
for marker in client.get_world_markers():
var mp := _world_to_px(MapCoord.to_world(marker.get("pos", Vector3.ZERO)))
canvas.draw_rect(Rect2(mp - Vector2(4, 4), Vector2(8, 8)), Color(1.0, 0.55, 0.1, 0.95))
+1
View File
@@ -0,0 +1 @@
uid://xm8a0g4337da
+683
View File
@@ -0,0 +1,683 @@
# CharSelectScreen —— Metin2 风格的选人界面。
#
# var s := preload("res://ui/char_select_screen.gd").new()
# parent.add_child(s)
# s.setup(m2client, assets_root, chars) # chars = M2Client.char_list 数组
# s.select_requested.connect(func(idx): client.select_character(idx))
# s.back_requested.connect(_goto_login)
#
# 组成:全屏背景(select.jpg+ 右侧 3D 角色(SubViewport 里的 PlayerView,缓转 + wait
# + 左侧信息板(thinboard:帮会徽 / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷)
# + 左上职业名(locale name_<class>.dds,解不出就用中文描边字)+ 底部 开始/创建/删除/退出。
# 所有素材都是可选的:缺哪个降级哪个,绝不崩。
extends Control
const UiAssets = preload("res://ui/ui_assets.gd")
const UiKit = preload("res://ui_kit.gd")
const PlayerView = preload("res://ui/player_view.gd")
signal select_requested(index: int)
signal delete_requested(index: int, private_code: String)
signal create_requested(spec: Dictionary) # {slot, name, job, shape, con, int, str, dex}
signal back_requested()
const CLASS_CN := ["猛将", "刺客", "术士", "巫女"]
const CLASS_DIR := ["warrior", "assassin", "sura", "shaman"]
# 建号默认四维(CON, INT, STR, DEX)—— 对齐经典 Metin2 各职业起始属性。
const JOB_BASE_STATS := {
0: {"con": 4, "int": 3, "str": 6, "dex": 3}, # warrior
1: {"con": 3, "int": 3, "str": 4, "dex": 6}, # assassin
2: {"con": 3, "int": 5, "str": 5, "dex": 3}, # sura
3: {"con": 4, "int": 6, "str": 3, "dex": 3}, # shaman
}
const EMPIRE_KEY := {1: "EMPIRE_A", 2: "EMPIRE_B", 3: "EMPIRE_C"}
const EMPIRE_FALLBACK := {0: "", 1: "神獸王國", 2: "天朝王國", 3: "眞魔王國"}
const EMPIRE_FLAG := {1: "a", 2: "b", 3: "c"}
const STAT_ROWS := [
{"key": "ht", "label": "体力", "color": Color(0.90, 0.30, 0.30)},
{"key": "iq", "label": "智力", "color": Color(0.85, 0.45, 0.95)},
{"key": "st", "label": "力量", "color": Color(0.70, 0.55, 1.00)},
{"key": "dx", "label": "敏捷", "color": Color(0.35, 0.75, 1.00)},
]
var _client: Node
var _assets := ""
var _chars: Array = []
var _sel := 0
var _viewport: SubViewport
var _pivot: Node3D
var _cam: Camera3D
var _pv: Node # PlayerView
var _panel_box: VBoxContainer
var _class_img: TextureRect
var _class_lbl: Label
var _status: Label
var _slot_lbl: Label
var _spin := 0.0
func setup(client: Node, assets_root: String, chars: Array) -> void:
_client = client
_assets = assets_root
_chars = _pad_slots(chars)
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_background()
_build_stage()
_build_class_name()
_build_panel()
_build_status()
set_process(true)
_select(_first_occupied())
# char_list 只带占位了角色的槽;补齐到 slot_count 个(空槽 name=""),◀▶ 能走到空槽建号。
func _pad_slots(chars: Array) -> Array:
var n := 3
if _client and _client.has_method("get_slot_count"):
n = maxi(int(_client.get_slot_count()), chars.size())
n = maxi(n, chars.size())
var by_idx := {}
for c in chars:
by_idx[int(c.get("index", 0))] = c
var out: Array = []
for i in n:
out.append(by_idx.get(i, {"index": i, "name": "", "job": 0, "level": 0}))
return out
func _first_occupied() -> int:
for i in _chars.size():
if String(_chars[i].get("name", "")) != "":
return i
return 0
# 由 app_flow 在收到新的 char_list(建/删号后 M2Client 重发)时调用,保持当前选中槽。
func set_chars(chars: Array) -> void:
var keep := _slot_index()
_chars = _pad_slots(chars)
for i in _chars.size():
if int(_chars[i].get("index", -1)) == keep:
_sel = i
break
_sel = clampi(_sel, 0, maxi(_chars.size() - 1, 0))
_refresh_class_name()
_refresh_panel()
_rebuild_model()
func on_char_created(slot: int) -> void:
_close_dialog()
_set_status("角色已创建(槽位 %d" % slot)
func on_char_create_failed(reason_type: int) -> void:
const R := {1: "该名称已被使用", 2: "名称不合法", 3: "该槽位已有角色", 4: "权限不足"}
_set_status("创建失败:%s" % R.get(reason_type, "错误码 %d" % reason_type))
func on_char_deleted(slot: int) -> void:
_close_dialog()
_set_status("角色已删除(槽位 %d" % slot)
func on_char_delete_failed() -> void:
var e: Node = _dialog_node()
if e:
var code := e.find_child("Code", true, false)
if code is LineEdit:
(code as LineEdit).text = ""
_set_status("删除失败:删除码(社交号)不正确")
# --- 背景 ---------------------------------------------------------------------
func _build_background() -> 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)
if tex != null:
var tr := TextureRect.new()
tr.texture = tex
tr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(tr)
# --- 右侧 3D 角色 -----------------------------------------------------------
func _build_stage() -> void:
var vpc := SubViewportContainer.new()
vpc.stretch = true
vpc.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vpc.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(vpc)
_viewport = SubViewport.new()
_viewport.transparent_bg = true
_viewport.own_world_3d = true
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_viewport.msaa_3d = Viewport.MSAA_4X
vpc.add_child(_viewport)
var root := Node3D.new()
_viewport.add_child(root)
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-32, 28, 0)
key.light_energy = 1.7
key.light_color = Color(1.0, 0.96, 0.90)
root.add_child(key)
var rim := DirectionalLight3D.new()
rim.rotation_degrees = Vector3(-6, 200, 0)
rim.light_energy = 0.7
rim.light_color = Color(0.75, 0.83, 1.0)
root.add_child(rim)
var we := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_COLOR
e.background_color = Color(0, 0, 0, 0)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.42, 0.45, 0.55)
e.ambient_light_energy = 0.9
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
we.environment = e
root.add_child(we)
_pivot = Node3D.new()
root.add_child(_pivot)
_cam = Camera3D.new()
_cam.fov = 30.0
_cam.position = Vector3(0.0, 1.0, 4.0)
root.add_child(_cam)
_cam.make_current()
# 相机按模型包围球自适应:球心 + 半径/sin(fovV/2) 保证整模无论姿势都不出框,
# 再沿 -X 平移“半径的一小截”把人物推到右半屏(横向视锥比纵向宽,不会切到远侧手)。
func _frame_model() -> void:
if _cam == null or _pv == null or not is_instance_valid(_pv):
return
var aabb := AABB()
var first := true
for mi in (_pv as Node).find_children("*", "MeshInstance3D", true, false):
var m := mi as MeshInstance3D
if m.mesh == null:
continue
var wb: AABB = m.get_global_transform() * m.get_aabb()
aabb = wb if first else aabb.merge(wb)
first = false
if first:
return
var c := aabb.get_center()
var r: float = maxf(aabb.size.length() * 0.5, 0.9)
var dist: float = r / sin(deg_to_rad(_cam.fov * 0.5)) * 1.12 # 12% 余量
var off := Vector3(-r * 0.5, 0.0, 0.0)
_cam.position = c + off + Vector3(0.0, r * 0.04, dist)
_cam.look_at(c + off, Vector3.UP)
func _rebuild_model() -> void:
if _pivot == null:
return
if _pv != null and is_instance_valid(_pv):
_pv.queue_free()
_pv = null
var c := _cur()
if c.is_empty() or String(c.get("name", "")) == "":
return
var job := clampi(int(c.get("job", 0)), 0, 3)
var pump: Callable = _client.net_poll if _client and _client.has_method("net_poll") else Callable()
var pv := PlayerView.new()
_pivot.add_child(pv)
if pv.build(_assets, job, pump):
_pv = pv
pv.set_anim_state("wait")
# 蒙皮 / LOD 定型要几帧,多 fit 两次取稳定包围盒
for i in 4:
await get_tree().process_frame
if pv != _pv:
return
_frame_model()
else:
pv.queue_free()
# --- 左上职业名 -----------------------------------------------------------
func _build_class_name() -> void:
_class_img = TextureRect.new()
_class_img.position = Vector2(48, 60)
_class_img.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
_class_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_class_img)
_class_lbl = Label.new()
_class_lbl.position = Vector2(50, 54)
_class_lbl.add_theme_font_size_override("font_size", 84)
_class_lbl.add_theme_color_override("font_color", Color(0.86, 0.11, 0.09))
_class_lbl.add_theme_color_override("font_outline_color", Color(0.15, 0.05, 0.02))
_class_lbl.add_theme_constant_override("outline_size", 10)
_class_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_class_lbl)
func _refresh_class_name() -> void:
var job := clampi(int(_cur().get("job", 0)), 0, 3)
var has_char := not _cur().is_empty() and String(_cur().get("name", "")) != ""
var tex: Texture2D = null
if has_char:
var cn: String = CLASS_DIR[job]
for loc in ["en", "de", "tr", "gr", "ru"]:
tex = UiAssets.load_tex(_assets, "locale/locale/%s/ui/select/name_%s.sub" % [loc, cn])
if tex != null:
break
_class_img.texture = tex
_class_img.visible = tex != null
_class_lbl.visible = tex == null and has_char
_class_lbl.text = CLASS_CN[job] if has_char else ""
# --- 左侧信息板 --------------------------------------------------------------
func _build_panel() -> void:
var board := UiKit.board(_assets, "board", 24, 96)
board.position = Vector2(56, 168)
board.size = Vector2(300, 396)
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(board)
# 板底纹太透就垫一层
var tint := ColorRect.new()
tint.color = Color(0.03, 0.05, 0.06, 0.62)
tint.position = board.position + Vector2(10, 10)
tint.size = board.size - Vector2(20, 20)
tint.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(tint)
_panel_box = VBoxContainer.new()
_panel_box.position = board.position + Vector2(22, 22)
_panel_box.custom_minimum_size = Vector2(256, 0)
_panel_box.add_theme_constant_override("separation", 9)
add_child(_panel_box)
func _refresh_panel() -> void:
for c in _panel_box.get_children():
c.queue_free()
var d := _cur()
var has_char := not d.is_empty() and String(d.get("name", "")) != ""
# 帮会徽 + 帮会名
var head := HBoxContainer.new()
head.add_theme_constant_override("separation", 10)
_panel_box.add_child(head)
var crest := TextureRect.new()
crest.custom_minimum_size = Vector2(56, 56)
crest.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
var gid := int(d.get("guild_id", 0))
if has_char and gid != 0 and _client and _client.has_method("get_guild_mark_image"):
var gimg = _client.call("get_guild_mark_image", gid)
if gimg is Image and gimg.get_width() > 0:
crest.texture = ImageTexture.create_from_image(gimg)
if crest.texture == null and has_char:
crest.texture = _empire_flag_tex() # 无会徽时退国旗
if crest.texture == null:
var ph := Image.create_empty(48, 48, false, Image.FORMAT_RGBA8)
ph.fill(Color(0.16, 0.20, 0.28, 0.9))
crest.texture = ImageTexture.create_from_image(ph)
head.add_child(crest)
var gbox := VBoxContainer.new()
head.add_child(gbox)
gbox.add_child(_plain(_empire_name() if has_char else "", 14, Color(0.95, 0.9, 0.7)))
var gname := String(d.get("guild_name", "")).strip_edges()
gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 13, Color(0.8, 0.82, 0.86)))
_panel_box.add_child(_sep())
# 名称 / 等级 / 游戏时间
_panel_box.add_child(_kv("名称", String(d.get("name", "")) if has_char else "空槽位"))
_panel_box.add_child(_kv("等级", str(int(d.get("level", 0))) if has_char else "-"))
var mins := int(d.get("play_minutes", 0))
_panel_box.add_child(_kv("游戏时间", ("%d%d" % [mins / 60, mins % 60]) if has_char else "-"))
_panel_box.add_child(_sep())
# 四维
for row in STAT_ROWS:
var rd: Dictionary = row
var v := int(d.get(rd["key"], 0)) if has_char else 0
_panel_box.add_child(_stat(String(rd["label"]), v, Color(rd["color"])))
_panel_box.add_child(_sep())
# 槽位切换
if _chars.size() > 1:
var nav := HBoxContainer.new()
nav.alignment = BoxContainer.ALIGNMENT_CENTER
nav.add_theme_constant_override("separation", 12)
_panel_box.add_child(nav)
var prev := Button.new(); prev.text = ""; prev.custom_minimum_size = Vector2(38, 28)
prev.pressed.connect(func(): _select(wrapi(_sel - 1, 0, _chars.size())))
nav.add_child(prev)
_slot_lbl = _plain("%d / %d" % [_sel + 1, _chars.size()], 14, Color(1, 1, 1))
nav.add_child(_slot_lbl)
var nxt := Button.new(); nxt.text = ""; nxt.custom_minimum_size = Vector2(38, 28)
nxt.pressed.connect(func(): _select(wrapi(_sel + 1, 0, _chars.size())))
nav.add_child(nxt)
# 按钮
var brow := GridContainer.new()
brow.columns = 2
brow.add_theme_constant_override("h_separation", 8)
brow.add_theme_constant_override("v_separation", 8)
_panel_box.add_child(brow)
var start := Button.new()
start.text = "开始"
start.custom_minimum_size = Vector2(122, 34)
start.disabled = not has_char
start.pressed.connect(_do_start)
brow.add_child(start)
var create := Button.new()
create.text = "创建"
create.custom_minimum_size = Vector2(122, 34)
create.disabled = has_char
create.pressed.connect(_open_create_dialog)
brow.add_child(create)
var del := Button.new()
del.text = "删除"
del.custom_minimum_size = Vector2(122, 34)
del.disabled = not has_char
del.pressed.connect(_open_delete_dialog)
brow.add_child(del)
var quit := Button.new()
quit.text = "退出"
quit.custom_minimum_size = Vector2(122, 34)
quit.pressed.connect(func(): back_requested.emit())
brow.add_child(quit)
# --- 底部状态 --------------------------------------------------------------
func _build_status() -> void:
_status = Label.new()
_status.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
_status.position = Vector2(24, -32)
_status.add_theme_font_size_override("font_size", 13)
_status.add_theme_color_override("font_color", Color(0.9, 0.92, 0.95))
_status.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_status.add_theme_constant_override("outline_size", 4)
add_child(_status)
func set_status(t: String) -> void:
_set_status(t)
# 会徽下载完成后由 app_flow 调用,重绘信息板即可拿到真徽记。
func refresh_crest() -> void:
_refresh_panel()
func _set_status(t: String) -> void:
if _status:
_status.text = t
# --- 选择 / 循环 ----------------------------------------------------------
func _select(i: int) -> void:
if _chars.is_empty():
_sel = 0
_refresh_class_name()
_refresh_panel()
return
_sel = clampi(i, 0, _chars.size() - 1)
if _slot_lbl:
_slot_lbl.text = "%d / %d" % [_sel + 1, _chars.size()]
_refresh_class_name()
_refresh_panel()
_rebuild_model()
var nm := String(_cur().get("name", ""))
_set_status("已选:%s" % nm if nm != "" else "空槽位(点“创建”建号)")
func _do_start() -> void:
var d := _cur()
if d.is_empty() or String(d.get("name", "")) == "":
_set_status("空槽位不能进入")
return
select_requested.emit(_slot_index())
_set_status("进入游戏:%s" % d.get("name"))
# --- 建号 / 删号弹窗 ------------------------------------------------------
var _dialog: Control
func _close_dialog() -> void:
if _dialog and is_instance_valid(_dialog):
_dialog.queue_free()
_dialog = null
func _dialog_node() -> Node:
return _dialog if _dialog and is_instance_valid(_dialog) else null
func _modal_root(title: String, w: int, h: int) -> VBoxContainer:
_close_dialog()
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.55)
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(dim)
_dialog = dim
var board := UiKit.board(_assets, "board", 24, 96)
board.set_anchors_preset(Control.PRESET_CENTER)
board.position = Vector2(-w / 2.0, -h / 2.0)
board.size = Vector2(w, h)
dim.add_child(board)
var bg := ColorRect.new()
bg.color = Color(0.04, 0.06, 0.07, 0.92)
bg.position = Vector2(10, 10)
bg.size = Vector2(w - 20, h - 20)
board.add_child(bg)
var box := VBoxContainer.new()
box.position = Vector2(24, 22)
box.custom_minimum_size = Vector2(w - 48, 0)
box.add_theme_constant_override("separation", 10)
board.add_child(box)
var t := _plain(title, 18, Color(0.95, 0.85, 0.55))
box.add_child(t)
box.add_child(_sep())
return box
func _open_create_dialog() -> void:
var d := _cur()
if String(d.get("name", "")) != "":
_set_status("该槽位已有角色")
return
var slot := _slot_index()
# 空槽默认按 slot 序轮职业,让四个槽能各建一职
var job := clampi(slot, 0, 3)
var box := _modal_root("创建角色 · 槽位 %d" % slot, 420, 360)
var jrow := HBoxContainer.new()
jrow.add_theme_constant_override("separation", 8)
box.add_child(jrow)
jrow.add_child(_plain("职业", 13, Color(0.72, 0.78, 0.85)))
var jopt := OptionButton.new()
jopt.name = "Job"
for i in 4:
jopt.add_item("%s / %s" % [CLASS_CN[i], CLASS_DIR[i]], i)
jopt.selected = job
jrow.add_child(jopt)
var nrow := HBoxContainer.new()
nrow.add_theme_constant_override("separation", 8)
box.add_child(nrow)
nrow.add_child(_plain("名称", 13, Color(0.72, 0.78, 0.85)))
var name_edit := LineEdit.new()
name_edit.name = "Name"
name_edit.max_length = 12
name_edit.custom_minimum_size = Vector2(240, 0)
name_edit.placeholder_text = "角色名(最多 12"
nrow.add_child(name_edit)
# 四维(起始属性,只读,随职业变)
var stat_box := VBoxContainer.new()
stat_box.name = "Stats"
stat_box.add_theme_constant_override("separation", 5)
box.add_child(stat_box)
var render_stats := func(j: int) -> void:
for c in stat_box.get_children():
c.queue_free()
var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0])
stat_box.add_child(_stat("体力", int(bs["con"]), Color(0.90, 0.30, 0.30)))
stat_box.add_child(_stat("智力", int(bs["int"]), Color(0.85, 0.45, 0.95)))
stat_box.add_child(_stat("力量", int(bs["str"]), Color(0.70, 0.55, 1.00)))
stat_box.add_child(_stat("敏捷", int(bs["dex"]), Color(0.35, 0.75, 1.00)))
render_stats.call(job)
jopt.item_selected.connect(func(j: int): render_stats.call(j))
box.add_child(_sep())
var brow := HBoxContainer.new()
brow.alignment = BoxContainer.ALIGNMENT_CENTER
brow.add_theme_constant_override("separation", 16)
box.add_child(brow)
var ok := Button.new(); ok.text = "创建"; ok.custom_minimum_size = Vector2(120, 34)
ok.pressed.connect(func():
var nm := name_edit.text.strip_edges()
if nm.length() < 2:
_set_status("名称太短")
return
var j := jopt.get_selected_id()
var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0])
create_requested.emit({
"slot": slot, "name": nm, "job": j, "shape": 0,
"con": int(bs["con"]), "int": int(bs["int"]),
"str": int(bs["str"]), "dex": int(bs["dex"]),
})
_set_status("创建中…"))
brow.add_child(ok)
var cancel := Button.new(); cancel.text = "取消"; cancel.custom_minimum_size = Vector2(120, 34)
cancel.pressed.connect(_close_dialog)
brow.add_child(cancel)
func _open_delete_dialog() -> void:
var d := _cur()
if String(d.get("name", "")) == "":
return
var slot := _slot_index()
var box := _modal_root("删除角色", 400, 240)
box.add_child(_plain("将永久删除 “%s” (Lv.%d)。" % [d.get("name", ""), int(d.get("level", 0))],
13, Color(0.95, 0.75, 0.7)))
var crow := HBoxContainer.new()
crow.add_theme_constant_override("separation", 8)
box.add_child(crow)
crow.add_child(_plain("删除码", 13, Color(0.72, 0.78, 0.85)))
var code := LineEdit.new()
code.name = "Code"
code.secret = true
code.max_length = 7
code.custom_minimum_size = Vector2(200, 0)
code.placeholder_text = "社交号 / 删除码"
crow.add_child(code)
box.add_child(_sep())
var brow := HBoxContainer.new()
brow.alignment = BoxContainer.ALIGNMENT_CENTER
brow.add_theme_constant_override("separation", 16)
box.add_child(brow)
var ok := Button.new(); ok.text = "删除"; ok.custom_minimum_size = Vector2(120, 34)
ok.pressed.connect(func():
delete_requested.emit(slot, code.text)
_set_status("删除中…"))
brow.add_child(ok)
var cancel := Button.new(); cancel.text = "取消"; cancel.custom_minimum_size = Vector2(120, 34)
cancel.pressed.connect(_close_dialog)
brow.add_child(cancel)
# --- 国家 --------------------------------------------------------------
func _empire_id() -> int:
if _client and _client.has_method("get_empire"):
return int(_client.get_empire())
return 0
func _empire_name() -> String:
var e := _empire_id()
# 优先 locale_game.txt 的 EMPIRE_A/B/C
var key: String = EMPIRE_KEY.get(e, "")
if key != "":
var loc := _locale_line(key)
if loc != "":
return loc
return EMPIRE_FALLBACK.get(e, "")
static var _loc_cache: Dictionary = {}
func _locale_line(key: String) -> String:
if _loc_cache.has(key):
return _loc_cache[key]
var val := ""
for rel in ["locale/locale/en/locale_game.txt", "locale/locale/de/locale_game.txt"]:
var p := _assets.path_join(rel)
if not FileAccess.file_exists(p):
continue
var f := FileAccess.open(p, FileAccess.READ)
while f and not f.eof_reached():
var parts := f.get_line().split("\t", false)
if parts.size() >= 2 and String(parts[0]) == key:
val = String(parts[1]).strip_edges()
break
if val != "":
break
_loc_cache[key] = val
return val
func _empire_flag_tex() -> Texture2D:
var f: String = EMPIRE_FLAG.get(_empire_id(), "")
if f == "":
return null
return UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/empire/empireflag_%s.sub" % f)
func _process(dt: float) -> void:
_spin += dt
if _pivot:
# 正面朝相机 + 轻微来回摆
_pivot.rotation.y = PI + sin(_spin * 0.3) * 0.35
# --- 小工具 --------------------------------------------------------------
func _cur() -> Dictionary:
return _chars[_sel] if _sel >= 0 and _sel < _chars.size() else {}
func _slot_index() -> int:
return int(_cur().get("index", _sel))
func _plain(text: String, size: int, col: Color) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", col)
return l
func _sep() -> Control:
var c := ColorRect.new()
c.color = Color(1, 1, 1, 0.12)
c.custom_minimum_size = Vector2(0, 2)
return c
func _kv(k: String, v: String) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var kl := _plain(k, 13, Color(0.72, 0.78, 0.85))
kl.custom_minimum_size = Vector2(64, 0)
row.add_child(kl)
row.add_child(_plain(v, 14, Color(1, 1, 1)))
return row
func _stat(label: String, value: int, col: Color) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var kl := _plain(label, 13, Color(0.72, 0.78, 0.85))
kl.custom_minimum_size = Vector2(44, 0)
row.add_child(kl)
var track := ColorRect.new()
track.color = Color(0, 0, 0, 0.5)
track.custom_minimum_size = Vector2(150, 14)
row.add_child(track)
var fill := ColorRect.new()
fill.color = col
fill.anchor_left = 0.0
fill.anchor_top = 0.0
fill.anchor_bottom = 1.0
fill.offset_left = 1
fill.offset_top = 1
fill.offset_bottom = -1
fill.offset_right = 1 + int(clampf(value / 25.0, 0.0, 1.0) * 148.0)
track.add_child(fill)
row.add_child(_plain(str(value), 13, Color(1, 1, 1)))
return row
+1
View File
@@ -0,0 +1 @@
uid://mvxphl3dhhlt
+43
View File
@@ -0,0 +1,43 @@
# CharShadow —— 角色脚下阴影(첫 버전).
#
# 参考端 `GameLib/MapOutdoorCharacterShadow.cpp` 把角色渲到小 RT + 投一张模糊暗贴图
# 到脚下。这里按「结果」对齐,不复刻 RT:
# 1) 强制角色所有 MeshInstance3D `cast_shadow = ON`(挡住 world 里 OFF 的误伤)
# 2) 脚下一张径向渐变 `Decal`,投影到地形/物体上 —— 太阳接近正午投影很短时也有接触暗影
#
# CharShadow.attach(entity_root_node3d) # 传实体根(不是内部 make_conv 的 Metin2Model)
extends RefCounted
class_name CharShadow
static var _tex: Texture2D
static func _blob_tex() -> Texture2D:
if _tex != null:
return _tex
var sz := 64
var img := Image.create_empty(sz, sz, false, Image.FORMAT_RGBA8)
var c := sz * 0.5
for y in sz:
for x in sz:
var d := Vector2(x + 0.5 - c, y + 0.5 - c).length() / c
var a: float = clampf(1.0 - d, 0.0, 1.0)
img.set_pixel(x, y, Color(0.0, 0.0, 0.0, a * a * 0.55))
_tex = ImageTexture.create_from_image(img)
return _tex
static func attach(root: Node3D, footprint := 1.4) -> void:
if root == null:
return
for m in root.find_children("*", "MeshInstance3D", true, false):
(m as MeshInstance3D).cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON
if root.has_node("BlobShadow"):
return
var dec := Decal.new()
dec.name = "BlobShadow"
dec.texture_albedo = _blob_tex()
# size = 投影盒 (x,z 足印;y 向下够到地面即可)
dec.size = Vector3(footprint, 2.5, footprint)
dec.modulate = Color(1, 1, 1, 0.55)
dec.position = Vector3(0, 0.6, 0) # 盒中心略高于脚,向下投
dec.cull_mask = 0xFFFFF
root.add_child(dec)
+1
View File
@@ -0,0 +1 @@
uid://buc0fgb3nbkq6
+282
View File
@@ -0,0 +1,282 @@
# CharStatusUI (P11) —— 角色状态窗(1:1 迁移 `assets/root/uicharacter.py` 的 STATUS 页)。
#
# var cs := preload("res://ui/char_status_ui.gd").new()
# add_child(cs)
# cs.setup(ui_manager, m2client)
# cs.toggle() # V 键
#
# 布局直接走 `assets/uiscript/uiscript/characterwindow.py`UiScript → UiBuild),
# 数值绑定逐字对照 uicharacter.py.RefreshStatus
# Level/Exp/RestExp、HP/SP、STR/DEX/HTH/INT、ATT/DEF、MATT/MDEF、ASPD/MSPD/CSPD/ER。
# 加点:HTH/INT/STR/DEX 的 / 按钮发聊天命令 `/stat ht` `/stat- ht`(与原客户端
# `statusPlusCommandDict` 完全一致)。points_changed 信号来时刷新。
#
# 技能 / 表情 / 任务三页只做页签切换(保留 uiscript 静态布局),实际数据在各自
# 的专用窗(SkillUI / QuestLog)里。
extends Node
const UiScript = preload("res://ui/uiscript.gd")
const UiBuild = preload("res://ui/ui_build.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
# --- EPointTypesm2dev Packet.h),只列本窗要用的 ---
const P_LEVEL := 1
const P_EXP := 3
const P_NEXT_EXP := 4
const P_HP := 5
const P_MAX_HP := 6
const P_SP := 7
const P_MAX_SP := 8
const P_ST := 12 # 근력 STR
const P_HT := 13 # 체력 HTH
const P_DX := 14 # 민첩 DEX
const P_IQ := 15 # 정신 INT
const P_ATT_POWER := 16
const P_ATT_SPEED := 17
const P_EVADE_RATE := 18
const P_MOV_SPEED := 19
const P_DEF_GRADE := 20
const P_CASTING_SPEED := 21
const P_MAGIC_ATT_GRADE := 22
const P_MAGIC_DEF_GRADE := 23
const P_STAT := 26 # 남은 능력치 포인트
const P_MIN_ATK := 29
const P_MAX_ATK := 30
const P_PARTY_ATT_GRADE := 91 # player.ATTACKER_BONUS
const P_ATT_GRADE_BONUS := 95 # player.ATT_BONUS
const P_DEF_GRADE_BONUS := 96 # player.DEF_BONUS
const P_MIN_MAGIC_WEP := 202
const P_MAX_MAGIC_WEP := 203
# 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"}
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 FACE_BY_JOB := ["face_warrior", "face_assassin", "face_sura", "face_shaman"]
var ui: CanvasLayer # UiManager
var client: Node # M2Client
var assets_root := ""
var uiscript_dir := ""
var _win: Dictionary = {} # { root, nodes }
var _state := "STATUS"
func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
ui = ui_manager
client = m2client
assets_root = assets
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")
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())
# --- open / close -----------------------------------------------------
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
if is_open():
ui.close(_win["root"])
_win = {}
func open(state := "") -> void:
if state != "" and state in STATES:
_state = state
if is_open():
_set_state(_state)
_refresh()
return
var path := uiscript_dir.path_join("characterwindow.py")
if not FileAccess.file_exists(path):
push_warning("CharStatusUI: no characterwindow.py at " + path)
return
_win = ui.open_script(path, assets_root)
if not is_open():
return
_wire_tabs()
_wire_stat_buttons()
_set_state(_state)
_refresh()
func _node(nm: String) -> Control:
if _win.is_empty():
return null
var nodes: Dictionary = _win.get("nodes", {})
var n = nodes.get(nm, null)
return n if n is Control else null
# --- tab switching (uicharacter.py.SetState) -------------------------
func _wire_tabs() -> void:
for st: String in STATES:
var key := st
var btn := _node(TAB_BUTTON[key])
if btn is BaseButton:
btn.pressed.connect(func(): _set_state(key))
func _set_state(state: String) -> void:
if not state in STATES:
return
_state = state
for st: String in STATES:
var on := (st == state)
var page := _node(PAGE[st])
if page:
page.visible = on
var bar := _node(TITLEBAR[st])
if bar:
bar.visible = on
var btn := _node(TAB_BUTTON[st])
if btn is BaseButton and btn.toggle_mode:
btn.set_pressed_no_signal(on)
# --- stat +/- buttons ----------------------------------------------------
func _wire_stat_buttons() -> void:
for key: String in PLUS_CMD:
var pb := _node(key + "_Plus")
if pb is BaseButton:
var cmd: String = PLUS_CMD[key]
pb.pressed.connect(func(): _send_stat(cmd))
var mb := _node(key + "_Minus")
if mb is BaseButton:
var mcmd: String = MINUS_CMD[key]
mb.pressed.connect(func(): _send_stat(mcmd))
func _send_stat(cmd: String) -> void:
if client and client.has_method("say"):
client.say(0, cmd) # CHAT_TYPE_TALKING —— 与 net.SendChatPacket 一致
# --- value binding (uicharacter.py.RefreshStatus) ----------------------
func _on_points_changed(_pts: Dictionary) -> void:
if is_open():
_refresh()
func _u32(v: int) -> int:
return v & 0xFFFFFFFF
func _refresh() -> void:
if not is_open() or client == null:
return
var pd: Dictionary = client.get_points() if client.has_method("get_points") else {}
if pd.is_empty():
return
var pts: Array = pd.get("points", [])
var get := func(i: int) -> int:
return int(pts[i]) if i >= 0 and i < pts.size() else 0
_set_text("Level_Value", str(get.call(P_LEVEL)))
_set_text("Exp_Value", str(_u32(int(pd.get("exp", get.call(P_EXP))))))
var rest_exp := _u32(int(pd.get("next_exp", get.call(P_NEXT_EXP)))) - _u32(int(pd.get("exp", get.call(P_EXP))))
_set_text("RestExp_Value", str(rest_exp))
var hp := int(pd.get("hp", get.call(P_HP)))
var max_hp := int(pd.get("max_hp", get.call(P_MAX_HP)))
var sp := int(pd.get("sp", get.call(P_SP)))
var max_sp := int(pd.get("max_sp", get.call(P_MAX_SP)))
_set_text("HP_Value", "%d/%d" % [hp, max_hp])
_set_text("SP_Value", "%d/%d" % [sp, max_sp])
_set_text("STR_Value", str(get.call(P_ST)))
_set_text("DEX_Value", str(get.call(P_DX)))
_set_text("HTH_Value", str(get.call(P_HT)))
_set_text("INT_Value", str(get.call(P_IQ)))
_set_text("ATT_Value", _att_text(get))
_set_text("DEF_Value", _def_text(get))
_set_text("MATT_Value", _matt_text(get))
_set_text("MDEF_Value", str(get.call(P_MAGIC_DEF_GRADE)))
_set_text("ASPD_Value", str(get.call(P_ATT_SPEED)))
_set_text("MSPD_Value", str(get.call(P_MOV_SPEED)))
_set_text("CSPD_Value", str(get.call(P_CASTING_SPEED)))
_set_text("ER_Value", str(get.call(P_EVADE_RATE)))
_refresh_stat_points(get.call(P_STAT))
_refresh_identity()
# uicharacter.__GetTotalAtkText: (min|max) + ATT_BONUS + ATTACKER_BONUS
func _att_text(get: Callable) -> String:
var lo := int(get.call(P_MIN_ATK))
var hi := int(get.call(P_MAX_ATK))
var bonus := int(get.call(P_ATT_GRADE_BONUS)) + int(get.call(P_PARTY_ATT_GRADE))
if lo == 0 and hi == 0:
# 服务器未下发 MIN/MAX_ATK 时退回等效攻击力(原客户端此处会显示 0)
var eff := int(get.call(P_ATT_POWER)) + bonus
return str(eff)
if lo == hi:
return str(lo + bonus)
return "%d-%d" % [lo + bonus, hi + bonus]
# uicharacter.__GetTotalDefText: DEF_GRADE (+ DEF_BONUS if ADD_DEF_BONUS_ENABLE)
func _def_text(get: Callable) -> String:
var d := int(get.call(P_DEF_GRADE))
var db := int(get.call(P_DEF_GRADE_BONUS))
if db != 0:
d += db
return str(d)
# uicharacter.__GetTotalMagAtkText: MAG_ATT + (MIN|MAX)_MAGIC_WEP
func _matt_text(get: Callable) -> String:
var base := int(get.call(P_MAGIC_ATT_GRADE))
var lo := base + int(get.call(P_MIN_MAGIC_WEP))
var hi := base + int(get.call(P_MAX_MAGIC_WEP))
if lo == hi:
return str(lo)
return "%d-%d" % [lo, hi]
# uicharacter.__RefreshStatusPlusButtonList
func _refresh_stat_points(stat_points: int) -> void:
_set_text("Status_Plus_Value", str(max(0, stat_points)))
var lbl := _node("Status_Plus_Label")
if lbl:
lbl.visible = stat_points > 0
for key: String in PLUS_CMD:
var pb := _node(key + "_Plus")
if pb:
pb.visible = stat_points > 0
func _refresh_identity() -> void:
var vid := int(client.get_main_vid()) if client.has_method("get_main_vid") else 0
var ent: Dictionary = client.get_entity(vid) if (vid != 0 and client.has_method("get_entity")) else {}
_set_text("Character_Name", String(ent.get("name", "")))
var guild_id := int(ent.get("guild", 0))
var guild_name := ""
if guild_id != 0 and client.has_method("get_guild_name"):
guild_name = String(client.get_guild_name(guild_id))
_set_text("Guild_Name", guild_name)
# 职业头像:race % 4 -> warrior/assassin/sura/shaman
var face := _node("Face_Image")
if face is TextureRect:
var job := int(ent.get("race", 0)) % 4
var tex := UiAssets.load_tex(assets_root,
"d:/ymir work/ui/game/windows/%s.sub" % FACE_BY_JOB[job])
if tex:
face.texture = tex
func _set_text(nm: String, value: String) -> void:
var n := _node(nm)
if n == null:
return
if n is Label:
n.text = value
elif n is Button:
n.text = value
elif n.has_method("set_text"):
n.set_text(value)
+1
View File
@@ -0,0 +1 @@
uid://d1tgslry3nyo4
+212
View File
@@ -0,0 +1,212 @@
# ChatUI (P3) —— 聊天窗:多标签 + 输入行 + 频道前缀 + 系统/战斗日志。
#
# var chat := preload("res://ui/chat_ui.gd").new()
# add_child(chat)
# chat.setup(m2client, canvas_parent) # canvas_parent: 一个 CanvasLayer/Control
# chat.focus_input() # Enter 键调
#
# 输入行前缀: /w <名> <话> 私聊 · /g 公会 · /p 队伍 · /s 喊话 · 其它 普通。
# 消费 M2Clientchat / whisper / item_picked_up。按类型分到 全部 / 私聊 / 系统 / 战斗 标签。
extends Node
# EChatType (wire.h)
const T_TALKING := 0
const T_INFO := 1
const T_NOTICE := 2
const T_PARTY := 3
const T_GUILD := 4
const T_SHOUT := 6
const T_WHISPER := 7
const FISHING_SUCCESS := 3
const FISHING_FAIL := 4
const FISHING_FISH := 5
const TABS := ["全部", "私聊", "系统", "战斗"]
const COLOR := {
0: Color(0.92, 0.92, 0.92), # talking
1: Color(0.55, 0.85, 1.0), # info
2: Color(1.0, 0.85, 0.3), # notice
3: Color(0.6, 0.85, 1.0), # party
4: Color(0.5, 1.0, 0.6), # guild
6: Color(1.0, 0.7, 0.4), # shout
7: Color(1.0, 0.6, 0.95), # whisper
}
signal line_added(tab: int, bbcode: String)
var client: Node
var _root: Control
var _log := {} # tab_index -> RichTextLabel
var _input: LineEdit
var _tab := 0
var _tab_btns := []
var _max_lines := 200
var _buffers := {} # tab -> Array[String]
func setup(m2client: Node, parent: Node) -> void:
client = m2client
_build(parent)
if client:
if client.has_signal("chat"):
client.chat.connect(_on_chat)
if client.has_signal("whisper_received"):
client.whisper_received.connect(_on_whisper)
if client.has_signal("item_picked_up"):
client.item_picked_up.connect(_on_pickup)
if client.has_signal("fishing_event"):
client.fishing_event.connect(_on_fishing_event)
func focus_input() -> void:
if _input:
_input.grab_focus()
# 预填 "/w <名> " 并聚焦(好友列表点名字时调)。
func start_whisper(name: String) -> void:
if _input:
_input.text = "/w %s " % name
_input.caret_column = _input.text.length()
_input.grab_focus()
func is_typing() -> bool:
return _input != null and _input.has_focus()
# --- 建 UI --------------------------------------------------------------
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
_root.position = Vector2(12, -232)
_root.size = Vector2(460, 220)
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.05, 0.06, 0.08, 0.72)
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
var tabrow := HBoxContainer.new()
tabrow.position = Vector2(6, 4)
_root.add_child(tabrow)
for i in TABS.size():
var b := Button.new()
b.text = TABS[i]
b.toggle_mode = true
b.button_pressed = (i == 0)
b.add_theme_font_size_override("font_size", 11)
var idx := i
b.pressed.connect(func(): _select_tab(idx))
tabrow.add_child(b)
_tab_btns.append(b)
for i in TABS.size():
var rt := RichTextLabel.new()
rt.bbcode_enabled = true
rt.scroll_following = true
rt.selection_enabled = true
rt.position = Vector2(6, 30)
rt.size = Vector2(448, 158)
rt.visible = (i == 0)
rt.add_theme_font_size_override("normal_font_size", 12)
_root.add_child(rt)
_log[i] = rt
_buffers[i] = []
_input = LineEdit.new()
_input.placeholder_text = "回车发送 · /w 名 私聊 · /g 公会 · /p 队伍 · /s 喊话"
_input.position = Vector2(6, 192)
_input.size = Vector2(448, 24)
_input.add_theme_font_size_override("font_size", 12)
_input.text_submitted.connect(_on_submit)
_root.add_child(_input)
func _select_tab(i: int) -> void:
_tab = i
for t in _log:
_log[t].visible = (t == i)
for k in _tab_btns.size():
_tab_btns[k].button_pressed = (k == i)
# --- 输入 -------------------------------------------------------------
func _on_submit(text: String) -> void:
_input.clear()
if _input.is_inside_tree():
_input.release_focus()
var s := text.strip_edges()
if s == "" or client == null:
return
if s.begins_with("/w ") or s.begins_with("/whisper "):
var rest := s.substr(s.find(" ") + 1).strip_edges()
var sp := rest.find(" ")
if sp > 0:
var to := rest.substr(0, sp)
var msg := rest.substr(sp + 1).strip_edges()
client.whisper(to, msg)
_append(1, T_WHISPER, "%s: %s" % [to, msg])
return
var map := {"/g ": T_GUILD, "/p ": T_PARTY, "/s ": T_SHOUT}
for pre in map:
if s.begins_with(pre):
client.say(map[pre], s.substr(3).strip_edges())
return
client.say(T_TALKING, s)
# --- 收消息 --------------------------------------------------------------
func _on_chat(type: int, vid: int, text: String) -> void:
var who := ""
if client and client.has_method("get_entity"):
var e: Dictionary = client.get_entity(vid)
if not e.is_empty():
who = String(e.get("name", ""))
var body := ("%s: %s" % [who, text]) if who != "" else text
var tab := 0
if type == T_INFO or type == T_NOTICE:
tab = 2
_append(tab, type, body)
if tab != 0:
_append(0, type, body) # 全部标签也收一份
func _on_whisper(sub: int, from: String, text: String) -> void:
var tag := from
if sub == 1:
tag = "[系统]"
elif sub == 2:
tag = "[GM] " + from
_append(1, T_WHISPER, "%s: %s" % [tag, text])
_append(0, T_WHISPER, "%s: %s" % [tag, text])
func _on_pickup(vnum: int, count: int, from: String) -> void:
var s := "拾取 #%d x%d" % [vnum, count]
if from != "":
s += "(来自 %s" % from
_append(2, T_INFO, s)
_append(3, T_INFO, s)
func _on_fishing_event(subheader: int, info: int, _dir: int) -> void:
var s := ""
if subheader == FISHING_SUCCESS:
s = "鱼钩命中"
elif subheader == FISHING_FAIL:
s = "钓鱼失败"
elif subheader == FISHING_FISH:
s = "捕获物品 #%d" % info if info != 0 else "没有捕获到物品"
if s != "":
_append(2, T_INFO, s)
_append(3, T_INFO, s)
# --- 追加行 --------------------------------------------------------------
func _append(tab: int, type: int, body: String) -> void:
var c: Color = COLOR.get(type, COLOR[0])
var line := "[color=#%s]%s[/color]" % [c.to_html(false), body]
var buf: Array = _buffers[tab]
buf.append(line)
if buf.size() > _max_lines:
buf.pop_front()
var rt: RichTextLabel = _log[tab]
rt.text = "\n".join(buf)
line_added.emit(tab, line)
+1
View File
@@ -0,0 +1 @@
uid://bcbcym6kbm16i
+174
View File
@@ -0,0 +1,174 @@
# CubeUI (첫 버전) —— 도깨비 방주 / 제작 (Cube) 窗。
#
# var cu := preload("res://ui/cube_ui.gd").new()
# add_child(cu)
# cu.setup(m2client, canvas_parent, proto) # proto 可空
#
# 服务器通过 GC_CHAT/COMMAND 的 "cube ..." 行驱动(见 EntityStore::apply_server_command):
# cube_opened(npc) → 显示 + 请求配方列表
# cube_changed → 刷新配方 / 材料 / 金币
# cube_result(vnum,count,ok) → 结果提示
# cube_closed → 隐藏
# 选中一个配方 → [制作] → M2Client.cube_make(index)。
extends Node
var client: Node
var proto: Node
var _root: Control
var _title: Label
var _status: Label
var _list: VBoxContainer
var _mat: VBoxContainer
var _make_btn: Button
var _sel := -1
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
proto = proto_node
_build(parent)
if client.has_signal("cube_opened"):
client.cube_opened.connect(_on_open)
if client.has_signal("cube_closed"):
client.cube_closed.connect(func() -> void: _root.visible = false)
if client.has_signal("cube_changed"):
client.cube_changed.connect(refresh)
if client.has_signal("cube_result"):
client.cube_result.connect(_on_result)
func is_open() -> bool:
return _root != null and _root.visible
func _on_open(npc_vnum: int) -> void:
_sel = -1
_root.visible = true
_title.text = "제작 (Cube) · NPC #%d" % npc_vnum
_status.text = ""
if client.has_method("cube_request_result_list"):
client.cube_request_result_list(npc_vnum)
refresh()
func _on_result(vnum: int, count: int, ok: bool) -> void:
if ok:
_status.text = "제작 성공: %s ×%d" % [_name_of(vnum), count]
else:
_status.text = "제작 실패"
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
var n := String(d.get("locale_name", d.get("name", "")))
if n != "":
return n
return "#%d" % vnum
func refresh() -> void:
if client == null or _root == null or not _root.visible:
return
var cube: Dictionary = client.get_cube()
var recipes: Array = cube.get("recipes", [])
var results: Array = cube.get("results", [])
for c in _list.get_children():
c.queue_free()
var n: int = maxi(recipes.size(), results.size())
if n == 0:
var e := Label.new()
e.text = "(이 NPC의 제작 목록 없음)"
_list.add_child(e)
for i in n:
var vnum := 0
if i < recipes.size():
vnum = int((recipes[i] as Dictionary).get("result_vnum", 0))
if vnum == 0 and i < results.size():
vnum = int((results[i] as Dictionary).get("vnum", 0))
var b := Button.new()
b.toggle_mode = true
b.button_pressed = (i == _sel)
b.text = "%d. %s" % [i + 1, _name_of(vnum)]
var idx := i
b.pressed.connect(func() -> void: _select(idx))
_list.add_child(b)
_refresh_materials()
func _select(i: int) -> void:
_sel = i
for k in _list.get_child_count():
var b := _list.get_child(k)
if b is Button:
b.button_pressed = (k == i)
# 材料信息可能未拉过 → 请求
if client.has_method("cube_request_materials"):
client.cube_request_materials(i, 1)
_refresh_materials()
func _refresh_materials() -> void:
for c in _mat.get_children():
c.queue_free()
var cube: Dictionary = client.get_cube()
var recipes: Array = cube.get("recipes", [])
_make_btn.disabled = _sel < 0
if _sel < 0 or _sel >= recipes.size():
_mat.add_child(_lbl("재료 정보를 불러오는 중…", 12))
return
var r: Dictionary = recipes[_sel]
_mat.add_child(_lbl("필요 골드: %d" % int(r.get("gold", 0)), 12))
var groups: Array = r.get("material_groups", [])
if groups.is_empty():
_mat.add_child(_lbl("(재료 없음)", 12))
for g in groups:
var parts: Array = []
for s in (g as Array):
parts.append("%s ×%d" % [_name_of(int((s as Dictionary).get("vnum", 0))),
int((s as Dictionary).get("count", 1))])
_mat.add_child(_lbl("" + " / ".join(parts), 12))
func _lbl(t: String, sz: int) -> Label:
var l := Label.new()
l.text = t
l.add_theme_font_size_override("font_size", sz)
return l
func _build(parent: Node) -> void:
_root = Panel.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-200, -190)
_root.custom_minimum_size = Vector2(400, 380)
_root.size = Vector2(400, 380)
_root.visible = false
parent.add_child(_root)
var box := VBoxContainer.new()
box.position = Vector2(16, 14)
box.custom_minimum_size = Vector2(368, 0)
box.add_theme_constant_override("separation", 6)
_root.add_child(box)
_title = _lbl("제작 (Cube)", 16)
box.add_child(_title)
var split := HBoxContainer.new()
split.add_theme_constant_override("separation", 10)
box.add_child(split)
var lsc := ScrollContainer.new()
lsc.custom_minimum_size = Vector2(180, 250)
split.add_child(lsc)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 3)
lsc.add_child(_list)
_mat = VBoxContainer.new()
_mat.custom_minimum_size = Vector2(178, 250)
_mat.add_theme_constant_override("separation", 3)
split.add_child(_mat)
_status = _lbl("", 12)
_status.add_theme_color_override("font_color", Color(1, 0.9, 0.5))
box.add_child(_status)
var brow := HBoxContainer.new()
brow.add_theme_constant_override("separation", 12)
box.add_child(brow)
_make_btn = Button.new()
_make_btn.text = "제작"
_make_btn.disabled = true
_make_btn.pressed.connect(func() -> void:
if _sel >= 0 and client.has_method("cube_make"):
client.cube_make(_sel))
brow.add_child(_make_btn)
var close := Button.new()
close.text = "닫기"
close.pressed.connect(func() -> void: _root.visible = false)
brow.add_child(close)
+1
View File
@@ -0,0 +1 @@
uid://ojp8p3ktvd2c
+92
View File
@@ -0,0 +1,92 @@
# 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 -> 自动关。
extends Node
var client: Node
var _root: Control
var _shown := false
func setup(m2client: Node, parent: Node) -> void:
client = m2client
_build(parent)
if client.has_signal("entity_dead"):
client.entity_dead.connect(_on_dead)
if client.has_signal("phase_changed"):
client.phase_changed.connect(func(p): if p == "dead": show_dialog())
if client.has_signal("vitals_changed"):
client.vitals_changed.connect(_on_vitals)
func _on_dead(vid: int) -> void:
if client.has_method("get_main_vid") and vid == client.get_main_vid():
show_dialog()
func _on_vitals(vid: int) -> void:
if not _shown:
return
if client.has_method("get_main_vid") and vid == client.get_main_vid():
var e: Dictionary = client.get_entity(vid)
if int(e.get("hp", 0)) > 0 and not e.get("dead", false):
hide_dialog()
func show_dialog() -> void:
if _shown:
return
_shown = true
_root.visible = true
func hide_dialog() -> void:
_shown = false
_root.visible = false
func _build(parent: Node) -> void:
_root = Control.new()
_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)
_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)
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.set_border_width_all(1)
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(0, 16)
title.size = Vector2(300, 0)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
panel.add_child(title)
_btn(panel, "在此复活", 24, 60, func(): _restart("/restart_here"))
_btn(panel, "回城复活", 158, 60, func(): _restart("/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)
func _restart(cmd: String) -> void:
if client and client.has_method("say"):
client.say(0, cmd) # CHAT_TYPE_TALKING
+1
View File
@@ -0,0 +1 @@
uid://crxoqgwj72u71
+84
View File
@@ -0,0 +1,84 @@
# Dialogs (P1) —— 基于 UiManager 的通用弹窗:confirm / alert / input。
#
# var dlg := preload("res://ui/dialogs.gd").new(ui_manager) # ui_manager: UiManager
# dlg.confirm("确定要删除吗?", func(): do_delete())
# dlg.alert("已保存")
# dlg.input("输入名字", func(t): rename(t), "默认值")
#
# 走 UiManager.open(root, modal=true)ESC / 取消 关闭,回调只在确认时触发。
extends RefCounted
var ui: CanvasLayer # UiManager
func _init(ui_manager: CanvasLayer) -> void:
ui = ui_manager
func alert(text: String, on_close: Callable = Callable()) -> void:
var d := _panel(320, 130)
_label(d, text, 20, 24, 280)
_button(d, "确定", 130, 88, func():
ui.close(d)
if on_close.is_valid(): on_close.call())
ui.open(d, true)
func confirm(text: String, on_ok: Callable, on_cancel: Callable = Callable()) -> void:
var d := _panel(340, 140)
_label(d, text, 20, 22, 300)
_button(d, "确定", 60, 96, func():
ui.close(d)
if on_ok.is_valid(): on_ok.call())
_button(d, "取消", 190, 96, func():
ui.close(d)
if on_cancel.is_valid(): on_cancel.call())
ui.open(d, true)
func input(prompt: String, on_submit: Callable, default_text := "") -> void:
var d := _panel(360, 150)
_label(d, prompt, 20, 18, 320)
var le := LineEdit.new()
le.text = default_text
le.position = Vector2(20, 52)
le.size = Vector2(320, 28)
d.add_child(le)
_button(d, "确定", 70, 104, func():
var t := le.text
ui.close(d)
if on_submit.is_valid(): on_submit.call(t))
_button(d, "取消", 200, 104, func(): ui.close(d))
le.grab_focus()
ui.open(d, true)
# --- 小工具 ---------------------------------------------------------------
func _panel(w: int, h: int) -> Control:
var p := Panel.new()
p.custom_minimum_size = Vector2(w, h)
p.size = Vector2(w, h)
p.position = Vector2((1920 - w) / 2.0, (1080 - h) / 2.0)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.11, 0.12, 0.15, 0.97)
sb.border_color = Color(0.4, 0.36, 0.28)
sb.set_border_width_all(1)
sb.set_corner_radius_all(4)
p.add_theme_stylebox_override("panel", sb)
p.set_meta("is_titlebar", true) # 整块可拖
return p
func _label(parent: Control, text: String, x: int, y: int, w: int) -> Label:
var l := Label.new()
l.text = text
l.position = Vector2(x, y)
l.size = Vector2(w, 0)
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
parent.add_child(l)
return l
func _button(parent: Control, text: String, x: int, y: int, on_press: Callable) -> Button:
var b := Button.new()
b.text = text
b.position = Vector2(x, y)
b.size = Vector2(90, 26)
b.pressed.connect(on_press)
parent.add_child(b)
return b
+1
View File
@@ -0,0 +1 @@
uid://dtrft82p6n3br
+170
View File
@@ -0,0 +1,170 @@
# DragonSoulUI (M4) —— 龙魂精炼窗:`ds_window_open` 来时弹。
#
# var du := preload("res://ui/dragon_soul_ui.gd").new()
# add_child(du)
# du.setup(m2client, canvas_parent, proto) # proto 可空(出名字)
#
# 三种模式:升级(0) / 改良(1) / 精炼(2) —— 对应 CG_DRAGON_SOUL_REFINE 的
# DS_SUB_DO_UPGRADE / IMPROVEMENT / REFINE。
# 打开时右键背包道具 → 填进 15 格中的下一个空位(grid[0] = 龙魂,其余为材料)。
# [执行] → `M2Client.ds_refine(mode, cells)``ds_refine_result(ok, sub, cell)` → 提示。
extends Node
const SLOTS := 15
const MODE_NAMES := ["升级", "改良", "精炼"]
var client: Node
var proto: Node
var _root: Control
var _mode := 2
var _cells: Array[int] = [] # 背包 cell,按加入顺序
var _mode_btns: Array[Button] = []
var _slot_labels: Array[Label] = []
var _status: Label
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
proto = proto_node
_build(parent)
if client.has_signal("ds_window_open"):
client.ds_window_open.connect(_on_open)
if client.has_signal("ds_refine_result"):
client.ds_refine_result.connect(_on_result)
func is_open() -> bool:
return _root != null and _root.visible
func toggle() -> void:
if _root:
_root.visible = not _root.visible
func _on_open() -> void:
_cells.clear()
_refresh()
if _root:
_root.visible = true
# 供 game_scene._on_inv_context 调用:把背包 cell 塞进下一个空格。
func add_cell(window: int, cell: int) -> void:
if window != 1: # 仅背包(WINDOW_INVENTORY
return
if _cells.has(cell):
_cells.erase(cell) # 再次右键 = 取出
elif _cells.size() < SLOTS:
_cells.append(cell)
_refresh()
func _name_of(vnum: int) -> String:
if vnum == 0:
return "-"
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
var n := String(d.get("locale_name", d.get("name", "")))
if n != "":
return n
return "#%d" % vnum
func _cell_text(i: int) -> String:
if i >= _cells.size():
return "[%d] —" % i
var c: int = _cells[i]
var vnum := 0
if client and client.has_method("get_item"):
var it: Dictionary = client.get_item(1, c)
vnum = int(it.get("vnum", 0))
var tag := "龙魂" if i == 0 else "材料"
return "[%d] %s %s (格%d)" % [i, tag, _name_of(vnum), c]
func _refresh() -> void:
for i in SLOTS:
_slot_labels[i].text = _cell_text(i)
for m in 3:
_mode_btns[m].disabled = (m == _mode)
if _status:
_status.text = "模式:%s 已放 %d/%d" % [MODE_NAMES[_mode], _cells.size(), SLOTS]
func _set_mode(m: int) -> void:
_mode = clampi(m, 0, 2)
_refresh()
func _do() -> void:
if client and client.has_method("ds_refine") and not _cells.is_empty():
client.ds_refine(_mode, _cells)
func _on_result(ok: bool, sub_type: int, cell: int) -> void:
if not _status:
return
if ok:
_cells.clear()
_refresh()
_status.text = ("✔ 精炼成功(格%d" % cell) if ok else ("✘ 精炼失败(原因码 %d" % sub_type)
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-190, -220)
_root.size = Vector2(380, 440)
_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.05, 0.09, 0.98)
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)
var modes := HBoxContainer.new()
modes.position = Vector2(14, 34)
modes.add_theme_constant_override("separation", 8)
_root.add_child(modes)
for m in 3:
var b := Button.new()
b.text = MODE_NAMES[m]
b.custom_minimum_size = Vector2(112, 28)
b.pressed.connect(_set_mode.bind(m))
modes.add_child(b)
_mode_btns.append(b)
var grid := VBoxContainer.new()
grid.position = Vector2(16, 74)
grid.add_theme_constant_override("separation", 2)
_root.add_child(grid)
for i in SLOTS:
var l := Label.new()
l.add_theme_font_size_override("font_size", 12)
grid.add_child(l)
_slot_labels.append(l)
_status = Label.new()
_status.position = Vector2(16, 366)
_root.add_child(_status)
var row := HBoxContainer.new()
row.position = Vector2(16, 394)
row.add_theme_constant_override("separation", 12)
_root.add_child(row)
var go := Button.new()
go.text = "执行"
go.custom_minimum_size = Vector2(150, 30)
go.pressed.connect(_do)
row.add_child(go)
var clr := Button.new()
clr.text = "清空"
clr.custom_minimum_size = Vector2(90, 30)
clr.pressed.connect(func(): _cells.clear(); _refresh())
row.add_child(clr)
var cancel := Button.new()
cancel.text = "关闭"
cancel.custom_minimum_size = Vector2(90, 30)
cancel.pressed.connect(func(): _root.visible = false)
row.add_child(cancel)
_refresh()
+1
View File
@@ -0,0 +1 @@
uid://ce0wnqedo0ga7
+307
View File
@@ -0,0 +1,307 @@
# EquipModel (P2) —— 装备变化 → Metin2Model 部件切换。
#
# var em := preload("res://ui/equip_model.gd").new()
# add_child(em)
# em.setup(m2client, item_list, model_getter, assets_root, race)
#
# 监听 M2Client.inventory_changed(window == EQUIPMENT),读 get_equipment()
# WEAR_WEAPON(4) -> model.weapon_gr2 = item_list.model(vnum) 解析后的真实路径
# WEAR_SHIELD(10)-> model.shield_gr2(左手刚体挂点,同 weapon)
# WEAR_HEAD(1) -> 有模型的头盔 = 覆盖发型槽(Metin2 约定:头防替换头发)
# WEAR_BODY(0) -> race_spec.shape(armor_shape_of(vnum)) -> model.gr2_path (+ 换肤)
# armor_model_map[vnum] 若给了则优先
#
# 时装(costume):Metin2 不发独立的 costume 槽包,服务器把 costume 的 vnum 直接写进
# 主角实体的 parts[] 数组(ARMOR/HAIR)。所以每个渲染槽优先取 parts[],为 0 才回退
# get_equipment()。这样 costume「自动」生效,也是渲染别的玩家用的同一条路。
extends Node
const RaceSpec = preload("res://ui/race_spec.gd")
const WEAR_BODY := 0
const WEAR_HEAD := 1
const WEAR_WEAPON := 4
const WEAR_SHIELD := 10
# CHR_EQUIPPART_*parts[] 下标)
const PART_ARMOR := 0
const PART_WEAPON := 1
const PART_HEAD := 2
const PART_HAIR := 3
const CHR_EQUIPPART_HAIR := 3 # 兼容旧引用
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
var client: Node
var item_list: RefCounted # ItemList
var proto: Node # Metin2Proto(有的话 armor shape 走 item_proto values[3]
var assets_root := ""
var race := -1
var _model_getter: Callable
var _spec: RefCounted # RaceSpec(惰性)
var _spec_tried := false
var _last_weapon_vnum := -1
var _last_body_vnum := -1
var _last_head_vnum := -1
var _last_shield_vnum := -1
var _last_hair_part := -1
var main_getter: Callable = Callable() # func() -> int:主角 vid(取 parts 用)
# armor vnum -> race .msm 的 shape index。默认:有 proto 用 item_proto values[3]
# (对齐客户端 __ArmorVnumToShapeSHAPE_VALUE_SLOT_INDEX=3),否则回退 =vnum。应用方可覆写。
var armor_shape_of: Callable = func(vnum: int) -> int: return _armor_shape_default(vnum)
# 应用方可注入:vnum -> 身体 gr2 路径(优先于 race_spec
var armor_model_map := {}
func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "",
race_index := -1) -> void:
client = m2client
item_list = il
_model_getter = model_getter
assets_root = assets
race = race_index
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(_on_inv_changed)
if client and client.has_signal("entity_main_set"):
client.entity_main_set.connect(func(v):
if race < 0 and client.has_method("get_entity"):
race = int(client.get_entity(v).get("race", -1))
refresh())
# GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 改了主角 parts(换装 / 时装)-> 全刷
if client and client.has_signal("entity_info"):
client.entity_info.connect(func(v, _d):
if main_getter.is_valid() and int(main_getter.call()) == v:
refresh())
func set_race(r: int) -> void:
race = r
_spec = null
_spec_tried = false
func _on_inv_changed(window: int, _cell: int) -> void:
if window == 2: # mtnet::WINDOW_EQUIPMENT
refresh()
func refresh() -> void:
var model: Node = _model_getter.call() if _model_getter.is_valid() else null
if model == null or client == null:
return
var eq: Array = client.get_equipment()
if eq.size() <= WEAR_WEAPON:
return
var parts := _parts() # 主角 parts[](时装 / 别的玩家用);空 -> 全回退 eq
# 武器:parts[WEAPON] 优先,为 0 回退 WEAR_WEAPON
var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON)
if wpn_vnum != _last_weapon_vnum:
_last_weapon_vnum = wpn_vnum
model.set("weapon_gr2", _resolve_weapon(wpn_vnum))
if eq.size() > WEAR_SHIELD:
var sh_vnum := int(eq[WEAR_SHIELD].get("vnum", 0))
if sh_vnum != _last_shield_vnum:
_last_shield_vnum = sh_vnum
model.set("shield_gr2", _resolve_weapon(sh_vnum))
# 身体:parts[ARMOR] 优先(= 普通盔甲或时装),为 0 回退 WEAR_BODY
var body_vnum := _eff(parts, PART_ARMOR, eq, WEAR_BODY)
if body_vnum != _last_body_vnum:
_last_body_vnum = body_vnum
_apply_body(model, body_vnum)
# 头:parts[HEAD] 优先,为 0 回退 WEAR_HEAD;有模型的头盔覆盖发型槽,否则用发型
var head_vnum := _eff(parts, PART_HEAD, eq, WEAR_HEAD)
if head_vnum != _last_head_vnum:
_last_head_vnum = head_vnum
_apply_head_or_hair(model, head_vnum)
# 有效 vnum:主角 parts[part_idx] 非 0 就用它(时装 / 远端玩家),否则用装备槽
func _eff(parts: Array, part_idx: int, eq: Array, wear_slot: int) -> int:
if parts.size() > part_idx and int(parts[part_idx]) != 0:
return int(parts[part_idx])
return int(eq[wear_slot].get("vnum", 0)) if eq.size() > wear_slot else 0
func _parts() -> Array:
if not main_getter.is_valid() or client == null or not client.has_method("get_entity"):
return []
var vid: int = main_getter.call()
if vid == 0:
return []
var p = client.get_entity(vid).get("parts", [])
return p if p is Array else []
func _apply_head_or_hair(model: Node, head_vnum: int) -> void:
# 头盔有模型 -> 用它替换发型(Metin2:头防走 hair 槽)
if head_vnum != 0:
var hm := _resolve_weapon(head_vnum) # item_list.model 解析,复用武器解析器
if hm != "":
model.set("hair_gr2", hm)
model.set("hair_skin", "")
return
# 没头盔(或头盔无模型)-> 回到角色发型
_apply_hair_from_parts(model, true)
func _apply_hair_from_parts(model: Node, force := false) -> void:
if model == null:
return
var hp := _hair_part()
if hp < 0 or (hp == _last_hair_part and not force):
return
_last_hair_part = hp
# 时装假发 / 大编号 -> parts[HAIR] 是 item vnum,走 item_list.model(同头盔)
if hp >= 100 and item_list:
var wig := _resolve_weapon(hp)
if wig != "":
model.set("hair_gr2", wig)
model.set("hair_skin", "")
return
# 小编号 -> race .msm 的 hair shape index
var e := _spec_hair(hp)
if e.is_empty():
return
var m := _resolve_spec_asset(e, String(e.get("model", "")))
var ts := _resolve_spec_asset(e, String(e.get("target_skin", "")))
if m != "":
model.set("hair_gr2", m)
if ts != "":
model.set("hair_skin", ts)
func _hair_part() -> int:
if not main_getter.is_valid() or client == null or not client.has_method("get_entity"):
return -1
var vid: int = main_getter.call()
if vid == 0:
return -1
var e: Dictionary = client.get_entity(vid)
var parts = e.get("parts", [])
if parts is Array and parts.size() > CHR_EQUIPPART_HAIR:
return int(parts[CHR_EQUIPPART_HAIR])
return -1
func set_hair(index: int) -> void:
var model: Node = _model_getter.call() if _model_getter.is_valid() else null
if model == null:
return
var e := _spec_hair(index)
if e.is_empty():
return
var m := _resolve_spec_asset(e, String(e.get("model", "")))
var ts := _resolve_spec_asset(e, String(e.get("target_skin", "")))
if m != "":
model.set("hair_gr2", m)
if ts != "":
model.set("hair_skin", ts)
# --- body ------------------------------------------------------------------
# armor vnum -> item_proto values[3](非 0);否则 =vnum(对齐 __ArmorVnumToShape
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:
return int(v[3])
return vnum
func _armor_specular(vnum: int) -> int:
if proto and proto.has_method("item"):
return int(proto.item(vnum).get("specular", 0))
return 0
func _apply_body(model: Node, vnum: int) -> void:
# 强化等级越高越亮(PARITY §2.7fSpecular = bSpecular / 100
var sp := _armor_specular(vnum)
if sp > 0:
model.set("specular_power", sp / 100.0)
if armor_model_map.has(vnum):
model.set("gr2_path", String(armor_model_map[vnum]))
return
var shape_idx: int = armor_shape_of.call(vnum) if armor_shape_of.is_valid() else vnum
var e := _spec_shape(shape_idx)
if e.is_empty():
return # 没有对应 shape:保持当前身体
var body := _resolve_spec_asset(e, String(e.get("model", "")))
if body != "":
model.set("gr2_path", body)
# 换肤(同模型不同 TargetSkin):覆盖 surface 0
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"):
var t := _resolve_spec_asset(e, tgt)
if t != "":
model.call("set_surface_texture", 0, t)
func _spec_shape(idx: int) -> Dictionary:
_ensure_spec()
return _spec.shape(idx) if _spec else {}
func _spec_hair(idx: int) -> Dictionary:
_ensure_spec()
return _spec.hair(idx) if _spec else {}
func _ensure_spec() -> void:
if _spec_tried:
return
_spec_tried = true
if race < 0 or assets_root == "":
return
var cls: String = CLASS_OF[race & 3]
for rel in ["PC/ymir work/pc/%s/%s.msm" % [cls, cls],
"pc2/ymir work/pc2/%s/%s.msm" % [cls, cls],
"season1/season1/pc/%s.msm" % cls]:
var p := _resolve_asset(rel)
if p != "":
var rs := RaceSpec.new()
if rs.load_file(p):
_spec = rs
return
# --- 路径解析 ------------------------------------------------------------
# 用 race_spec 的一条 shape/hair 记录 + 一个相对文件(model / *_skin)解析绝对路径。
# 依次试: 规格文件目录/<rel> · PathName(去盘符)+扫散包 · 按 basename 扫 pc/<cls>/ 与 pc2/<cls>/
func _resolve_spec_asset(e: Dictionary, rel: String) -> String:
if rel == "":
return ""
var sdir := String(e.get("spec_dir", ""))
if sdir != "":
var p := sdir.path_join(rel)
if FileAccess.file_exists(p):
return p
var byp := _resolve_asset(String(e.get("path", "")) + rel)
if byp != "":
return byp
# 按 basename 在 pc/<cls>/ 和 pc2/<cls>/ 下找
var bn := rel.get_file()
if race >= 0:
var cls: String = CLASS_OF[race & 3]
for sub in ["PC/ymir work/pc/%s/%s" % [cls, bn], "pc2/ymir work/pc2/%s/%s" % [cls, bn]]:
var q := _resolve_asset(sub)
if q != "":
return q
return ""
func _resolve_weapon(vnum: int) -> String:
if vnum == 0 or item_list == null:
return ""
var vpath: String = item_list.model(vnum)
if vpath == "":
var base := (vnum / 10) * 10
vpath = "d:/ymir work/item/weapon/%05d.gr2" % base
return _resolve_asset(vpath)
# "d:/ymir work/..." 或散包相对路径 -> <assets>/(<pack>/)...
func _resolve_asset(vpath: String) -> String:
if vpath == "" or assets_root == "":
return ""
var rel := vpath.replace("\\", "/")
if rel.length() >= 2 and rel[1] == ":":
rel = rel.substr(2)
rel = rel.lstrip("/")
var direct := assets_root.path_join(rel)
if FileAccess.file_exists(direct):
return direct
var da := DirAccess.open(assets_root)
if da:
for sub in da.get_directories():
var cand := assets_root.path_join(sub).path_join(rel)
if FileAccess.file_exists(cand):
return cand
return ""
+1
View File
@@ -0,0 +1 @@
uid://eol27bnby30t
+150
View File
@@ -0,0 +1,150 @@
# ExchangeUI (P8) —— 交易 / 换货窗。
#
# var xu := preload("res://ui/exchange_ui.gd").new()
# add_child(xu)
# xu.setup(m2client, canvas_parent, proto) # proto 可空
#
# 两栏:我方 / 对方,各列道具行 + 金币。底部:金币输入 + [放金币]、[接受]、[取消]。
# `exchange_changed` 刷新(active=false 时自动关闭)。
# inventory_ui 在交易开着时右键道具 → 调 xu.offer(win, cell)。
extends Node
var client: Node
var proto: Node
var _root: Control
var _self_box: VBoxContainer
var _peer_box: VBoxContainer
var _self_gold: Label
var _peer_gold: Label
var _accept_btn: Button
var _gold_input: LineEdit
var _next_display := 0
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
proto = proto_node
_build(parent)
if client.has_signal("exchange_changed"):
client.exchange_changed.connect(refresh)
func is_open() -> bool:
return _root != null and _root.visible
func offer(inv_window: int, inv_cell: int) -> void:
if is_open():
client.exchange_add_item(inv_window, inv_cell, _next_display)
_next_display = (_next_display + 1) % 12
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
var n := String(d.get("locale_name", d.get("name", "")))
if n != "":
return n
return "#%d" % vnum
func refresh() -> void:
if client == null:
return
var x: Dictionary = client.get_exchange()
if not x.get("active", false):
_root.visible = false
_next_display = 0
return
_root.visible = true
_fill(_self_box, x.get("self_items", []))
_fill(_peer_box, x.get("peer_items", []))
_self_gold.text = "金币: %d" % int(x.get("self_gold", 0))
_peer_gold.text = "金币: %d" % int(x.get("peer_gold", 0))
var me: bool = x.get("self_accept", false)
var peer: bool = x.get("peer_accept", false)
_accept_btn.text = "已接受 ✓" if me else "接受"
_accept_btn.modulate = Color(0.5, 1, 0.5) if me else Color(1, 1, 1)
_root.get_node("PeerAccept").text = "对方: 已接受" if peer else "对方: 未接受"
func _fill(box: VBoxContainer, rows: Array) -> void:
for c in box.get_children():
c.queue_free()
for r in rows:
var l := Label.new()
l.text = "%s ×%d" % [_name_of(int(r.get("vnum", 0))), int(r.get("count", 1))]
l.add_theme_font_size_override("font_size", 12)
box.add_child(l)
func _on_put_gold() -> void:
var g := int(_gold_input.text)
if g > 0:
client.exchange_add_gold(g)
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-220, -180)
_root.size = Vector2(440, 360)
_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.07, 0.08, 0.1, 0.97)
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)
var sl := Label.new()
sl.text = "我方"
sl.position = Vector2(20, 34)
_root.add_child(sl)
_self_box = VBoxContainer.new()
_self_box.position = Vector2(20, 56)
_self_box.custom_minimum_size = Vector2(200, 0)
_root.add_child(_self_box)
_self_gold = Label.new()
_self_gold.text = "金币: 0"
_self_gold.position = Vector2(20, 250)
_root.add_child(_self_gold)
var pl := Label.new()
pl.text = "对方"
pl.position = Vector2(240, 34)
_root.add_child(pl)
_peer_box = VBoxContainer.new()
_peer_box.position = Vector2(240, 56)
_peer_box.custom_minimum_size = Vector2(180, 0)
_root.add_child(_peer_box)
_peer_gold = Label.new()
_peer_gold.text = "金币: 0"
_peer_gold.position = Vector2(240, 250)
_root.add_child(_peer_gold)
var pa := Label.new()
pa.name = "PeerAccept"
pa.text = "对方: 未接受"
pa.position = Vector2(240, 272)
pa.add_theme_font_size_override("font_size", 12)
_root.add_child(pa)
var bottom := HBoxContainer.new()
bottom.position = Vector2(20, 300)
_root.add_child(bottom)
_gold_input = LineEdit.new()
_gold_input.placeholder_text = "金币"
_gold_input.custom_minimum_size = Vector2(90, 0)
bottom.add_child(_gold_input)
var put := Button.new()
put.text = "放金币"
put.pressed.connect(_on_put_gold)
bottom.add_child(put)
_accept_btn = Button.new()
_accept_btn.text = "接受"
_accept_btn.pressed.connect(func() -> void: client.exchange_accept())
bottom.add_child(_accept_btn)
var cancel := Button.new()
cancel.text = "取消"
cancel.pressed.connect(func() -> void: client.exchange_cancel())
bottom.add_child(cancel)
+1
View File
@@ -0,0 +1 @@
uid://dupx028in7ymk
+107
View File
@@ -0,0 +1,107 @@
# FriendUI (P8) —— 好友 / 私聊名单窗(M 键)。
#
# var fu := preload("res://ui/friend_ui.gd").new()
# add_child(fu)
# fu.setup(m2client, canvas_parent)
# fu.toggle() # M 键
#
# 每行 = 在线点 + 名字 + [删除]。底部输入框 + [添加好友]。
# `friends_changed` 刷新。双击某行 → emit `whisper_to(name)`chat_ui 可接)。
extends Node
signal whisper_to(name: String)
var client: Node
var _root: Control
var _list: VBoxContainer
var _name_edit: LineEdit
func setup(m2client: Node, parent: Node) -> void:
client = m2client
_build(parent)
if client.has_signal("friends_changed"):
client.friends_changed.connect(refresh)
func is_open() -> bool:
return _root != null and _root.visible
func toggle() -> void:
_root.visible = not _root.visible
if _root.visible:
refresh()
func refresh() -> void:
if not is_open() or client == null:
return
for c in _list.get_children():
c.queue_free()
var friends: Array = client.get_friends()
if friends.is_empty():
var e := Label.new()
e.text = "(好友列表为空)"
e.add_theme_font_size_override("font_size", 11)
_list.add_child(e)
return
for f in friends:
_list.add_child(_row(f))
func _row(f: Dictionary) -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(260, 0)
var dot := Label.new()
dot.text = ""
dot.modulate = Color(0.4, 0.9, 0.4) if f.get("online", false) else Color(0.4, 0.4, 0.4)
row.add_child(dot)
var nm := Button.new()
nm.text = String(f.get("name", "?"))
nm.flat = true
nm.custom_minimum_size = Vector2(180, 0)
nm.pressed.connect(func() -> void: whisper_to.emit(String(f.get("name", ""))))
row.add_child(nm)
var del := Button.new()
del.text = "×"
del.pressed.connect(func() -> void: client.remove_friend(String(f.get("name", ""))))
row.add_child(del)
return row
func _on_add() -> void:
var nm := _name_edit.text.strip_edges()
if nm != "":
client.add_friend(nm)
_name_edit.clear()
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-150, -180)
_root.size = Vector2(300, 360)
_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.07, 0.08, 0.1, 0.96)
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)
_list = VBoxContainer.new()
_list.position = Vector2(12, 34)
_list.add_theme_constant_override("separation", 3)
_root.add_child(_list)
var bottom := HBoxContainer.new()
bottom.position = Vector2(12, 320)
bottom.custom_minimum_size = Vector2(276, 0)
_root.add_child(bottom)
_name_edit = LineEdit.new()
_name_edit.placeholder_text = "角色名"
_name_edit.custom_minimum_size = Vector2(190, 0)
bottom.add_child(_name_edit)
var add_btn := Button.new()
add_btn.text = "添加"
add_btn.pressed.connect(_on_add)
bottom.add_child(add_btn)
+1
View File
@@ -0,0 +1 @@
uid://d0hn8nlem82an
+193
View File
@@ -0,0 +1,193 @@
# GameOptionUI (P11) —— 游戏设置窗(1:1 迁移 `assets/root/uigameoption.py` `OptionDialog`)。
#
# var go := preload("res://ui/game_option_ui.gd").new()
# add_child(go)
# go.setup(ui_manager, m2client, assets_root)
# go.toggle() # 由 system_menu_ui 的 game_option_button 打开
#
# 布局走真 `assets/uiscript/uiscript/gameoptiondialog.py`。绑定逐字对照 uigameoption.py
# block_{exchange,party,guild,whisper,friend,party_request}_button (toggle) →
# `/setblockmode <mask ^ bit>`EBlockAction 位:1<<0..1<<5)—— 真聊天命令
# pvp_{peace,revenge,guild,free} (radio) → `/pkmode {0,1,4,2}` —— 真聊天命令
# name_color / target_board / view_chat / always_show_name / show_damage / salestext (radio)
# —— 客户端显示开关,持久化到 user://system_option.cfg [gameopt];渲染侧钩子待补。
extends Node
# Packet.h EBlockAction
const BLOCK_BITS := {
"block_exchange_button": 1 << 0,
"block_party_button": 1 << 1,
"block_guild_button": 1 << 2,
"block_whisper_button": 1 << 3,
"block_friend_button": 1 << 4,
"block_party_request_button": 1 << 5,
}
# uigameoption: peace /pkmode 0, revenge /pkmode 1, free /pkmode 2, guild /pkmode 4
const PK_CMD := {"pvp_peace": 0, "pvp_revenge": 1, "pvp_free": 2, "pvp_guild": 4}
# 客户端显示开关:{radio 名 -> [cfg key, 该按钮代表的值]}
const DISPLAY_RADIOS := {
"name_color_normal": ["name_color", 0], "name_color_empire": ["name_color", 1],
"target_board_no_view": ["target_board", 0], "target_board_view": ["target_board", 1],
"view_chat_on_button": ["view_chat", 1], "view_chat_off_button": ["view_chat", 0],
"always_show_name_on_button": ["always_show_name", 1], "always_show_name_off_button": ["always_show_name", 0],
"show_damage_on_button": ["show_damage", 1], "show_damage_off_button": ["show_damage", 0],
"salestext_on_button": ["salestext", 1], "salestext_off_button": ["salestext", 0],
}
const RADIO_GROUPS := [
["name_color_normal", "name_color_empire"],
["target_board_no_view", "target_board_view"],
["view_chat_on_button", "view_chat_off_button"],
["always_show_name_on_button", "always_show_name_off_button"],
["show_damage_on_button", "show_damage_off_button"],
["salestext_on_button", "salestext_off_button"],
]
const LABELS := {
"titlename": "游戏设置", "name_color": "名字颜色", "target_board": "目标框",
"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": "看他国",
"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": "组队申请",
"view_chat_on_button": "", "view_chat_off_button": "",
"always_show_name_on_button": "", "always_show_name_off_button": "",
"show_damage_on_button": "", "show_damage_off_button": "",
"salestext_on_button": "", "salestext_off_button": "",
}
const CFG_PATH := "user://system_option.cfg"
var ui: CanvasLayer
var client: Node
var assets_root := ""
var uiscript_dir := ""
var _win: Dictionary = {}
var _cfg := ConfigFile.new()
var _block_mode := 0 # blockMode(本地跟踪;服务器回包同步待补)
var _display := {} # cfg [gameopt] 快照
func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
ui = ui_manager
client = m2client
assets_root = assets
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")
_cfg.load(CFG_PATH)
for k in ["name_color", "target_board", "view_chat", "always_show_name", "show_damage", "salestext"]:
_display[k] = int(_cfg.get_value("gameopt", k, 1 if k in ["view_chat", "always_show_name", "show_damage"] else 0))
_block_mode = int(_cfg.get_value("gameopt", "block_mode", 0))
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
if is_open():
ui.close(_win["root"])
_win = {}
func open() -> void:
if is_open():
return
var path := uiscript_dir.path_join("gameoptiondialog.py")
if not FileAccess.file_exists(path):
push_warning("GameOptionUI: no gameoptiondialog.py at " + path)
return
_win = ui.open_script(path, assets_root)
if not is_open():
return
_relabel()
_wire()
_sync()
func _node(nm: String) -> Control:
if _win.is_empty():
return null
var n = _win.get("nodes", {}).get(nm, null)
return n if n is Control else null
func _relabel() -> void:
for nm in LABELS:
var n := _node(nm)
if n and n.has_method("set_text"):
n.set_text(LABELS[nm])
func _wire() -> void:
# 屏蔽 toggle → /setblockmode
for nm in BLOCK_BITS:
var b := _node(nm)
if b is BaseButton:
b.toggle_mode = true
var bit: int = BLOCK_BITS[nm]
b.pressed.connect(func(): _toggle_block(bit))
# PK radio → /pkmode
_bind_radio(PK_CMD.keys(), func(nm):
if client and client.has_method("say"):
client.say(0, "/pkmode %d" % PK_CMD[nm])) # CHAT_TYPE_TALKING
# 显示开关 radio → 持久化
for group in RADIO_GROUPS:
_bind_radio(group, func(nm):
var spec: Array = DISPLAY_RADIOS[nm]
_display[spec[0]] = int(spec[1])
_save())
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 := {}
for nm: String in names:
boxes[nm] = _node(nm)
for nm: String in names:
var b = boxes[nm]
if not (b is BaseButton):
continue
b.toggle_mode = true
var picked: String = nm
b.pressed.connect(func():
for other: String in names:
if boxes[other] is BaseButton:
boxes[other].set_pressed_no_signal(other == picked)
on_pick.call(picked))
func _toggle_block(bit: int) -> void:
_block_mode ^= bit
if client and client.has_method("say"):
client.say(0, "/setblockmode %d" % _block_mode)
_cfg.set_value("gameopt", "block_mode", _block_mode)
_cfg.save(CFG_PATH)
_sync_block()
# 开窗时把状态回填到控件
func _sync() -> void:
_sync_block()
for group in RADIO_GROUPS:
var key: String = DISPLAY_RADIOS[group[0]][0]
var cur := int(_display.get(key, 0))
for nm in group:
var b := _node(nm)
if b is BaseButton:
b.set_pressed_no_signal(int(DISPLAY_RADIOS[nm][1]) == cur)
func _sync_block() -> void:
for nm in BLOCK_BITS:
var b := _node(nm)
if b is BaseButton:
b.set_pressed_no_signal((_block_mode & int(BLOCK_BITS[nm])) != 0)
func _save() -> void:
for k in _display:
_cfg.set_value("gameopt", k, int(_display[k]))
_cfg.set_value("gameopt", "block_mode", _block_mode)
_cfg.save(CFG_PATH)
+1
View File
@@ -0,0 +1 @@
uid://ubb2dufk08vo
+125
View File
@@ -0,0 +1,125 @@
# GroundItems (P2) —— 世界里的掉落物:GC_ITEM_GROUND_ADD/DEL → 小物体 + 名条 + 拾取。
#
# var gi := preload("res://ui/ground_items.gd").new()
# add_child(gi)
# gi.setup(m2client, mount_node, player_getter, proto, item_list)
# # 按拾取键时: gi.try_pickup() -> 捡最近的一个(<PICKUP_RANGE m
#
# M2Client.ground_item_added(Dict{vid,vnum,pos,owner}) / ground_item_removed(vid) 驱动。
extends Node
const PICKUP_RANGE := 3.0
var client: Node
var mount: Node3D
var proto: Node
var item_list: RefCounted
var _player_getter: Callable
var _by_vid := {} # vid -> Node3D
func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
proto_node: Node = null, il: RefCounted = null) -> void:
client = m2client
mount = mount_node
proto = proto_node
item_list = il
_player_getter = player_getter
if client.has_signal("ground_item_added"):
client.ground_item_added.connect(_on_added)
if client.has_signal("ground_item_removed"):
client.ground_item_removed.connect(_on_removed)
# 已在场的(重连)
if client.has_method("get_ground_items"):
for d in client.get_ground_items():
_on_added(d)
func _on_added(d: Dictionary) -> void:
var vid := int(d.get("vid", 0))
if vid == 0:
return
var vnum := int(d.get("vnum", 0))
var owner := String(d.get("owner", ""))
if _by_vid.has(vid):
var existing: Node3D = _by_vid[vid]
if is_instance_valid(existing):
(existing.get_node("tag") as Label3D).text = _tag_text(vnum, owner)
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)
var tag := Label3D.new()
tag.name = "tag"
tag.text = _tag_text(vnum, owner)
tag.position.y = 0.75
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.pixel_size = 0.005
tag.modulate = Color(1.0, 0.9, 0.5)
node.add_child(tag)
node.set_meta("vid", vid)
mount.add_child(node)
_by_vid[vid] = node
func _on_removed(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n:
_by_vid.erase(vid)
n.queue_free()
func _process(dt: float) -> void:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p == null:
return
var t := Time.get_ticks_msec() / 1000.0
for vid in _by_vid:
var n: Node3D = _by_vid[vid]
if is_instance_valid(n):
var m := n.get_child(0) as MeshInstance3D
if m:
m.rotation.y = t * 2.0
# 近了名条高亮
var near := p.global_position.distance_to(n.global_position) <= PICKUP_RANGE
(n.get_node("tag") as Label3D).modulate = Color(0.4, 1.0, 0.4) if near else Color(1.0, 0.9, 0.5)
# 捡最近的一个(范围内)。返回捡的 vid,0 = 没有。
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 best_vid := 0
var best_d := PICKUP_RANGE
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)
if d < best_d:
best_d = d
best_vid = int(vid)
if best_vid != 0:
client.pickup_item(best_vid)
return best_vid
func _name_for(vnum: int) -> String:
if proto:
var pd: Dictionary = proto.item(vnum)
if not pd.is_empty():
return String(pd.get("locale_name", pd.get("name", "item %d" % vnum)))
if item_list and item_list.has(vnum):
return item_list.type_of(vnum)
return "item %d" % vnum
func _tag_text(vnum: int, owner: String) -> String:
var text := _name_for(vnum)
return "%s (%s)" % [text, owner] if not owner.is_empty() else text
+1
View File
@@ -0,0 +1 @@
uid://8x4b3cb2okdc
+314
View File
@@ -0,0 +1,314 @@
# GuildUI (P8/M4) —— 公会窗(G 键):成员 / 技能 / 公会战 三页。
#
# var gu := preload("res://ui/guild_ui.gd").new()
# add_child(gu)
# gu.setup(m2client, canvas_parent, skill_table) # skill_table 可空(出公会技能名)
# gu.toggle() # G 键
#
# `guild_changed` / `guild_skill_changed` / `guild_war_changed` 刷新,`guild_marks_ready`
# 到时刷会徽图。⬜ 等级页、日志页。
extends Node
const TABS := ["成员", "技能", "公会战"]
# EGuildWarState (wire.h)
const WAR_STATE_NAMES := ["", "已宣战", "被拒", "收到宣战", "待开始", "取消", "交战中", "结束"]
var client: Node
var skill_table: RefCounted # SkillTable
# 返回一张 Image(16×12,或任意会被转换/缩放)用作会徽上传源;null = 不显示上传按钮。
var mark_image_provider: Callable
var _root: Control
var _info: Label
var _mark: TextureRect
var _tab := 0
var _tab_btns: Array[Button] = []
var _pages: Array[VBoxContainer] = []
var _war_name_edit: LineEdit
var _upload_status: Label
func setup(m2client: Node, parent: Node, table: RefCounted = null) -> void:
client = m2client
skill_table = table
_build(parent)
for sig in ["guild_changed", "guild_skill_changed", "guild_war_changed"]:
if client.has_signal(sig):
client.connect(sig, refresh)
if client.has_signal("guild_marks_ready"):
client.guild_marks_ready.connect(func(_n): refresh())
if client.has_signal("guild_mark_uploaded"):
client.guild_mark_uploaded.connect(_on_mark_uploaded)
func is_open() -> bool:
return _root != null and _root.visible
func toggle() -> void:
_root.visible = not _root.visible
if _root.visible:
refresh()
func _set_tab(i: int) -> void:
_tab = i
for t in _pages.size():
_pages[t].visible = (t == i)
_tab_btns[t].disabled = (t == i)
refresh()
func refresh() -> void:
if not is_open() or client == null:
return
var g: Dictionary = client.get_guild()
var in_guild: bool = g.get("in_guild", false)
_refresh_mark(int(g.get("id", 0)) if in_guild else 0)
if not in_guild:
_info.text = "(未加入公会)"
for p in _pages:
for c in p.get_children():
c.queue_free()
return
_info.text = "%s Lv.%d 成员 %d/%d 资金 %d" % [
g.get("name", "?"), int(g.get("level", 0)),
int(g.get("member_count", 0)), int(g.get("max_member_count", 0)),
int(g.get("gold", 0))]
match _tab:
0: _fill_members()
1: _fill_skills()
2: _fill_war(int(g.get("id", 0)))
func _fill_members() -> void:
var page := _pages[0]
for c in page.get_children():
c.queue_free()
# 会徽上传(需要配了 mark server + 提供了图源)
var srv: Dictionary = client.get_mark_server() if client.has_method("get_mark_server") else {}
if mark_image_provider.is_valid() and int(srv.get("port", 0)) > 0:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
page.add_child(row)
var ub := Button.new()
ub.text = "上传会徽"
ub.custom_minimum_size = Vector2(96, 26)
ub.pressed.connect(_upload_mark)
row.add_child(ub)
_upload_status = Label.new()
_upload_status.add_theme_font_size_override("font_size", 11)
_upload_status.modulate = Color(0.7, 0.8, 0.7)
row.add_child(_upload_status)
var grades: Array = client.get_guild_grades()
var members: Array = client.get_guild_members()
members.sort_custom(func(a, b): return int(a.get("grade", 99)) < int(b.get("grade", 99)))
for m in members:
var gi := int(m.get("grade", 0))
var gname := ""
if gi >= 1 and gi <= grades.size():
gname = String(grades[gi - 1].get("name", ""))
var row := Label.new()
row.add_theme_font_size_override("font_size", 12)
row.text = "%s Lv.%d [%s] 贡献 %d%s" % [
String(m.get("name", "?")), int(m.get("level", 0)),
gname if gname != "" else str(gi),
int(m.get("offer", 0)),
"" if m.get("general", false) else ""]
page.add_child(row)
func _guild_skill_rows() -> Array:
# skill_table 的 GUILD 分类给 id(升序);按顺序配 levels[i]。返回 [{id, name}]。
var out := []
if skill_table and skill_table.has_method("for_category"):
for id in skill_table.for_category("GUILD"):
out.append({"id": int(id), "name": skill_table.name_of(int(id))})
return out
func _fill_skills() -> void:
var page := _pages[1]
for c in page.get_children():
c.queue_free()
var sk: Dictionary = client.get_guild_skill()
if not sk.get("valid", false):
var l := Label.new()
l.text = "(还没收到公会技能数据)"
page.add_child(l)
return
var head := Label.new()
head.add_theme_font_size_override("font_size", 12)
head.modulate = Color(0.8, 0.9, 1.0)
head.text = "技能点 %d 公会点 %d / %d" % [
int(sk.get("skill_point", 0)), int(sk.get("guild_point", 0)), int(sk.get("max_guild_point", 0))]
page.add_child(head)
var levels: Array = sk.get("levels", [])
var meta := _guild_skill_rows()
for i in levels.size():
var lv := int(levels[i])
var nm := "公会技能 %d" % i
var sid := -1
if i < meta.size():
nm = String(meta[i].get("name", nm))
sid = int(meta[i].get("id", -1))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
page.add_child(row)
var lbl := Label.new()
lbl.add_theme_font_size_override("font_size", 12)
lbl.custom_minimum_size = Vector2(240, 0)
lbl.text = "%s Lv %d" % [nm, lv]
row.add_child(lbl)
if sid >= 0 and lv > 0:
var btn := Button.new()
btn.text = "施放"
btn.custom_minimum_size = Vector2(56, 24)
btn.pressed.connect(func(): client.use_guild_skill(sid, 0))
row.add_child(btn)
func _fill_war(my_guild_id: int) -> void:
var page := _pages[2]
for c in page.get_children():
c.queue_free()
var cur: Dictionary = client.get_guild_war()
var st := int(cur.get("state", 0))
var cur_lbl := Label.new()
cur_lbl.add_theme_font_size_override("font_size", 12)
cur_lbl.modulate = Color(1.0, 0.8, 0.7)
if st == 0:
cur_lbl.text = "当前无公会战"
else:
var on := String(cur.get("opp_name", ""))
cur_lbl.text = "%s %s" % [
on if on != "" else "#%d" % int(cur.get("opp_guild_id", 0)),
WAR_STATE_NAMES[st] if st < WAR_STATE_NAMES.size() else str(st)]
page.add_child(cur_lbl)
# 宣战输入
var decl := HBoxContainer.new()
decl.add_theme_constant_override("separation", 6)
page.add_child(decl)
_war_name_edit = LineEdit.new()
_war_name_edit.placeholder_text = "对方公会名"
_war_name_edit.custom_minimum_size = Vector2(200, 26)
decl.add_child(_war_name_edit)
var db := Button.new()
db.text = "宣战"
db.custom_minimum_size = Vector2(60, 26)
db.pressed.connect(func():
var nm := _war_name_edit.text.strip_edges()
if nm != "":
client.declare_guild_war(nm))
decl.add_child(db)
var hdr := Label.new()
hdr.add_theme_font_size_override("font_size", 12)
hdr.text = "— 进行中的公会战 —"
page.add_child(hdr)
var wars: Array = client.get_guild_wars()
if wars.is_empty():
var none := Label.new()
none.text = "(无)"
page.add_child(none)
for wpair in wars:
var sn := String(wpair.get("src_name", ""))
var dn := String(wpair.get("dst_name", ""))
var row := Label.new()
row.add_theme_font_size_override("font_size", 12)
var mark_self := " ◀我方" if int(wpair.get("src", 0)) == my_guild_id or int(wpair.get("dst", 0)) == my_guild_id else ""
row.text = "%s vs %s%s" % [
sn if sn != "" else "#%d" % int(wpair.get("src", 0)),
dn if dn != "" else "#%d" % int(wpair.get("dst", 0)),
mark_self]
page.add_child(row)
func _upload_mark() -> void:
var g: Dictionary = client.get_guild()
if not g.get("in_guild", false) or not mark_image_provider.is_valid():
return
var img = mark_image_provider.call()
if img == null:
if _upload_status:
_upload_status.text = "没有会徽图"
return
var srv: Dictionary = client.get_mark_server()
var ok: bool = client.upload_guild_mark(
String(srv.get("host", "")), int(srv.get("port", 0)), int(g.get("id", 0)), img)
if _upload_status:
_upload_status.text = "上传中…" if ok else "上传发起失败"
func _on_mark_uploaded(ok: bool) -> void:
if _upload_status:
_upload_status.text = "✔ 已上传(等服务器刷新)" if ok else "✘ 上传失败"
# 公会 id 变了就重新取会徽图(16x12,放大 3x 显示)。
func _refresh_mark(guild_id: int) -> void:
if _mark == null:
return
if guild_id == 0 or client == null or not client.has_method("get_guild_mark_image"):
_mark.texture = null
_mark.visible = false
return
var img: Image = client.get_guild_mark_image(guild_id)
if img == null:
_mark.texture = null
_mark.visible = false
return
_mark.texture = ImageTexture.create_from_image(img)
_mark.visible = true
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-190, -210)
_root.size = Vector2(380, 420)
_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.07, 0.08, 0.1, 0.97)
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)
_mark = TextureRect.new()
_mark.position = Vector2(320, 6)
_mark.custom_minimum_size = Vector2(48, 36) # 16x12 放大 3x
_mark.size = Vector2(48, 36)
_mark.stretch_mode = TextureRect.STRETCH_SCALE
_mark.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_mark.visible = false
_root.add_child(_mark)
_info = Label.new()
_info.position = Vector2(12, 32)
_info.add_theme_font_size_override("font_size", 12)
_info.modulate = Color(0.9, 0.85, 0.6)
_root.add_child(_info)
var tabrow := HBoxContainer.new()
tabrow.position = Vector2(12, 54)
tabrow.add_theme_constant_override("separation", 4)
_root.add_child(tabrow)
for i in TABS.size():
var b := Button.new()
b.text = TABS[i]
b.custom_minimum_size = Vector2(84, 26)
b.pressed.connect(_set_tab.bind(i))
tabrow.add_child(b)
_tab_btns.append(b)
var sc := ScrollContainer.new()
sc.position = Vector2(12, 86)
sc.custom_minimum_size = Vector2(356, 322)
_root.add_child(sc)
var stack := Control.new()
stack.custom_minimum_size = Vector2(350, 0)
sc.add_child(stack)
for i in TABS.size():
var page := VBoxContainer.new()
page.custom_minimum_size = Vector2(350, 0)
page.add_theme_constant_override("separation", 3)
page.visible = (i == 0)
stack.add_child(page)
_pages.append(page)
_tab_btns[0].disabled = true
+1
View File
@@ -0,0 +1 @@
uid://cy0ui47ok7xx7
+403
View File
@@ -0,0 +1,403 @@
# InventoryUI (P2) —— 背包 + 装备窗(走 P1 uiscript 装载器)。
#
# var inv := preload("res://ui/inventory_ui.gd").new()
# add_child(inv)
# inv.setup(ui_manager, m2client, proto, assets_root)
# inv.toggle() # I 键
#
# 从 `assets/uiscript/inventorywindow.py` 建窗,用 M2Client.get_inventory/get_equipment
# 填格子,Metin2Proto 出名字,icon/item/<vnum>.tga 出图标。右键=use_item
# 拖到别的格=move_item。inventory_changed 信号来时刷新。
#
# 格子编号约定(uiscript):0..89 背包,90+ 装备(wear = index-90)。
# 新装备 / 腰带没有可靠的旧 uiscript 布局时,在背包右侧生成扩展面板;发包仍
# 使用原客户端的 INVENTORY 全局 cell90+wear / 152+belt cell)。
extends Node
# 右键点道具时发出(window, cell 为 wire 坐标)。P8 的商店 / 交易 / 仓库窗接它。
signal item_context(window: int, cell: int)
# 若设置:右键道具时先调它 (window, cell) -> bool;返回 true 表示已处理,不再 use_item。
var context_consumer: Callable = Callable()
const UiAssets = preload("res://ui/ui_assets.gd")
const EQUIP_BASE := 90 # uiscript EQUIPMENT_START_INDEX
const BELT_BASE := 200 # 仅 UI 内部索引,避免与旧 slot_index 相撞
const BELT_SLOT_START := 152 # GameType.c_Belt_Inventory_Slot_Start
const BELT_CELL_COUNT := 16
const WEAR_COUNT := 24
const WEAR_BELT := 23
const WINDOW_INVENTORY := 1 # mtnet::WINDOW_*
const BELT_RULES := [1, 2, 4, 6, 3, 3, 4, 6, 5, 5, 5, 6, 7, 7, 7, 7]
var ui: CanvasLayer # UiManager
var client: Node # M2Client
var proto: Node # Metin2Proto
var item_list: RefCounted # ItemList(可空;给了就用它的 icon 路径)
var assets_root := ""
var uiscript_dir := ""
var _win: Dictionary = {} # { root, nodes }
var _cells := {} # ui_index:int -> Panel
var _drag_from := -1
var _combine_from := -1 # Shift 选中的“使用到物品”来源格
var _hint: Label
func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets: String,
il: RefCounted = null) -> void:
ui = ui_manager
client = m2client
proto = proto_node
item_list = il
assets_root = assets
uiscript_dir = assets.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets.path_join("uiscript")
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(_on_inv_changed)
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
if is_open():
ui.close(_win["root"])
_win = {}
_cells.clear()
_combine_from = -1
_hint = null
func open() -> void:
if is_open():
return
var path := uiscript_dir.path_join("inventorywindow.py")
if not FileAccess.file_exists(path):
push_warning("InventoryUI: no inventorywindow.py at " + path)
return
_win = ui.open_script(path, assets_root)
_index_cells(_win["root"])
_add_extended_cells(_win["root"])
_wire_cells()
refresh()
# --- 填充 ---------------------------------------------------------------
func refresh() -> void:
if not is_open() or client == null:
return
for idx in _cells:
_clear_cell(_cells[idx])
for d in client.get_inventory():
var cell: Panel = _cells.get(int(d["cell"]), null)
if cell:
_fill_cell(cell, int(d["vnum"]), int(d["count"]))
var eq: Array = client.get_equipment()
for i in eq.size():
var it: Dictionary = eq[i]
var cell: Panel = _cells.get(EQUIP_BASE + i, null)
if cell and int(it.get("vnum", 0)) != 0:
_fill_cell(cell, int(it["vnum"]), int(it.get("count", 1)))
for d in client.get_belt_inventory():
var cell: Panel = _cells.get(BELT_BASE + int(d["cell"]), null)
if cell:
_fill_cell(cell, int(d["vnum"]), int(d.get("count", 1)))
_apply_belt_locks(eq)
func _on_inv_changed(_window: int, _cell: int) -> void:
refresh()
func _fill_cell(cell: Panel, vnum: int, count: int) -> void:
_clear_cell(cell)
cell.set_meta("vnum", vnum)
cell.set_meta("count", count)
var name := "item %d" % vnum
var itype := 0
if proto:
var pd: Dictionary = proto.item(vnum)
if not pd.is_empty():
name = String(pd.get("locale_name", pd.get("name", name)))
itype = int(pd.get("type", 0))
cell.tooltip_text = "%s\n#%d%s" % [name, vnum, (" x%d" % count) if count > 1 else ""]
var tex := _icon(vnum)
if tex:
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_SCALE
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(tr)
else:
var lb := Label.new()
lb.name = "icon"
lb.text = name.substr(0, 6)
lb.add_theme_font_size_override("font_size", 9)
lb.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(lb)
if count > 1:
var cnt := Label.new()
cnt.name = "count"
cnt.text = str(count)
cnt.position = Vector2(2, 16)
cnt.add_theme_font_size_override("font_size", 9)
cnt.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(cnt)
func _clear_cell(cell: Panel) -> void:
cell.set_meta("vnum", 0)
cell.set_meta("count", 0)
cell.tooltip_text = ""
for c in cell.get_children():
c.queue_free()
func _icon(vnum: int) -> Texture2D:
var rel := ""
if item_list and item_list.has(vnum):
rel = item_list.icon(vnum)
if rel == "":
rel = "icon/item/%05d.tga" % ((vnum / 10) * 10) # basevnum 约定
return UiAssets.load_tex(assets_root, rel)
# --- 交互(右键用 / 拖动移动)---------------------------------------------
func _index_cells(root: Node) -> void:
_cells.clear()
for n in root.find_children("slot_*", "Panel", true, false):
if n.has_meta("slot_index"):
_cells[int(n.get_meta("slot_index"))] = n
func _wire_cells() -> void:
for idx in _cells:
var cell: Panel = _cells[idx]
cell.gui_input.connect(func(e: InputEvent): _on_cell_input(int(idx), e))
func _add_extended_cells(root: Control) -> void:
# 旧 inventorywindow.py 只排出前 11 个传统装备位。剩余能力 / 时装 /
# 戒指 / 腰带与 4x4 腰带背包在同一扩展面板中保持可拖放。
var panel := Panel.new()
panel.name = "extended_equipment"
panel.position = Vector2(root.size.x + 8, 0)
panel.size = Vector2(204, 460)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.04, 0.05, 0.07, 0.92)
bg.border_color = Color(0.45, 0.38, 0.2, 0.9)
bg.set_border_width_all(1)
panel.add_theme_stylebox_override("panel", bg)
root.add_child(panel)
_add_grid(panel, "扩展装备", 11, WEAR_COUNT, EQUIP_BASE, Vector2(10, 30))
_add_grid(panel, "腰带背包", 0, BELT_CELL_COUNT, BELT_BASE, Vector2(10, 242))
_hint = Label.new()
_hint.position = Vector2(10, 430)
_hint.size = Vector2(184, 28)
_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_hint.add_theme_font_size_override("font_size", 10)
_hint.text = "Shift+左键:使用到物品\nCtrl+右键:赠与目标;Alt+右键:按数量丢弃"
panel.add_child(_hint)
func _add_grid(parent: Panel, title_text: String, first: int, count: int, base: int, pos: Vector2) -> void:
var title := Label.new()
title.text = title_text
title.position = pos - Vector2(0, 22)
title.add_theme_font_size_override("font_size", 13)
parent.add_child(title)
var grid := GridContainer.new()
grid.columns = 4
grid.position = pos
grid.add_theme_constant_override("h_separation", 4)
grid.add_theme_constant_override("v_separation", 4)
parent.add_child(grid)
for local in count:
var cell := Panel.new()
cell.name = "slot_%d" % (base + first + local)
cell.custom_minimum_size = Vector2(42, 42)
cell.set_meta("slot_index", base + first + local)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.13, 0.16, 0.95)
style.border_color = Color(0.35, 0.32, 0.22, 0.9)
style.set_border_width_all(1)
cell.add_theme_stylebox_override("panel", style)
grid.add_child(cell)
_cells[base + first + local] = cell
func _apply_belt_locks(eq: Array) -> void:
var grade := 0
if eq.size() > WEAR_BELT:
var belt: Dictionary = eq[WEAR_BELT]
if int(belt.get("vnum", 0)) != 0 and proto:
var data: Dictionary = proto.item(int(belt["vnum"]))
var values: Array = data.get("values", [])
if not values.is_empty():
grade = int(values[0])
for local in BELT_CELL_COUNT:
var cell: Panel = _cells.get(BELT_BASE + local, null)
if cell == null:
continue
var locked: bool = grade < int(BELT_RULES[local])
cell.set_meta("locked", locked)
cell.modulate = Color(0.45, 0.45, 0.45, 0.9) if locked else Color.WHITE
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE if locked else Control.MOUSE_FILTER_STOP
if locked and int(cell.get_meta("vnum", 0)) == 0:
cell.tooltip_text = "需要腰带等级 %d" % BELT_RULES[local]
func _on_cell_input(ui_idx: int, e: InputEvent) -> void:
if not (e is InputEventMouseButton):
return
var cell: Panel = _cells[ui_idx]
if bool(cell.get_meta("locked", false)):
return
var vnum := int(cell.get_meta("vnum", 0))
if e.button_index == MOUSE_BUTTON_RIGHT and e.pressed and vnum != 0:
if e.shift_pressed:
_begin_combine(ui_idx)
return
if e.ctrl_pressed:
give_to_target(ui_idx)
return
if e.alt_pressed:
drop_count(ui_idx)
return
var w := _to_wire(ui_idx)
item_context.emit(w[0], w[1])
if context_consumer.is_valid() and bool(context_consumer.call(w[0], w[1])):
return
use(ui_idx)
elif e.button_index == MOUSE_BUTTON_LEFT:
if e.pressed and vnum != 0:
if e.shift_pressed:
_begin_combine(ui_idx)
return
if _combine_from != -1:
if _combine_from != ui_idx:
_use_to_item(_combine_from, ui_idx)
return
_drag_from = ui_idx
elif not e.pressed and _drag_from != -1:
var over := _cell_under_mouse()
if over != -1 and over != _drag_from:
move_to(_drag_from, over, 1)
_drag_from = -1
# 把 ui 格子 from 的物品挪到 ui 格子 to(发 CG_ITEM_MOVE
func move_to(from_ui: int, to_ui: int, count := 1) -> bool:
if client == null:
return false
var a := _to_wire(from_ui)
var b := _to_wire(to_ui)
return client.move_item(a[0], a[1], b[0], b[1], count)
# 用掉 ui 格子里的物品(发 CG_ITEM_USE
func use(ui_idx: int) -> bool:
if client == null:
return false
var w := _to_wire(ui_idx)
return client.use_item(w[0], w[1])
# Shift+左键(或 Shift+右键)选来源,再左键另一个非空格。
func _begin_combine(ui_idx: int) -> void:
if _combine_from == ui_idx:
_combine_from = -1
_set_hint("已取消物品对物品")
return
_combine_from = ui_idx
_drag_from = -1
_set_hint("请选择目标物品(左键);再次 Shift 点击来源格可取消")
func _use_to_item(source_ui: int, target_ui: int) -> bool:
var target: Panel = _cells.get(target_ui, null)
if target == null or int(target.get_meta("vnum", 0)) == 0:
_set_hint("目标格必须有物品")
return false
var source := _to_wire(source_ui)
var dest := _to_wire(target_ui)
_combine_from = -1
_set_hint("已发送物品对物品请求")
return client != null and client.use_item_to_item(source[0], source[1], dest[0], dest[1])
# Ctrl+右键把物品赠与当前选中的实体;数量由用户确认。
func give_to_target(ui_idx: int) -> void:
if client == null or not client.has_method("get_target"):
return
var target: Dictionary = client.get_target()
var target_vid := int(target.get("vid", 0))
if target_vid == 0:
_set_hint("请先选中要赠与的角色")
return
var wire := _to_wire(ui_idx)
_ask_count("赠与数量", _item_count(ui_idx), func(amount: int):
client.give_item(target_vid, wire[0], wire[1], amount))
# Alt+右键丢弃;非金币物品走 CG_ITEM_DROP2,精确保留用户填写的数量。
func drop_count(ui_idx: int) -> void:
if client == null:
return
var wire := _to_wire(ui_idx)
_ask_count("丢弃数量", _item_count(ui_idx), func(amount: int):
client.drop_item_count(wire[0], wire[1], 0, amount))
func _item_count(ui_idx: int) -> int:
var cell: Panel = _cells.get(ui_idx, null)
return max(1, int(cell.get_meta("count", 1))) if cell else 1
func _ask_count(title_text: String, max_count: int, on_confirm: Callable) -> void:
if ui == null:
return
var dialog := Panel.new()
dialog.position = Vector2(760, 420)
dialog.size = Vector2(240, 132)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.05, 0.06, 0.09, 0.97)
bg.border_color = Color(0.7, 0.55, 0.22, 0.9)
bg.set_border_width_all(1)
dialog.add_theme_stylebox_override("panel", bg)
var title := Label.new()
title.text = title_text
title.position = Vector2(12, 10)
dialog.add_child(title)
var spin := SpinBox.new()
spin.min_value = 1
spin.max_value = max(1, max_count)
spin.value = 1
spin.step = 1
spin.allow_greater = false
spin.position = Vector2(12, 42)
spin.size = Vector2(216, 28)
dialog.add_child(spin)
var ok := Button.new()
ok.text = "确认"
ok.position = Vector2(76, 88)
ok.pressed.connect(func():
var amount := int(spin.value)
ui.close(dialog)
on_confirm.call(amount))
dialog.add_child(ok)
var cancel := Button.new()
cancel.text = "取消"
cancel.position = Vector2(142, 88)
cancel.pressed.connect(func(): ui.close(dialog))
dialog.add_child(cancel)
ui.open(dialog, true)
func _set_hint(text: String) -> void:
if is_instance_valid(_hint):
_hint.text = text
func _cell_under_mouse() -> int:
for idx in _cells:
var cell: Panel = _cells[idx]
if cell.get_global_rect().has_point(cell.get_global_mouse_position()):
return int(idx)
return -1
# ui 格子编号 -> (window, cell)
func _to_wire(ui_idx: int) -> Array:
if ui_idx >= BELT_BASE:
return [WINDOW_INVENTORY, BELT_SLOT_START + ui_idx - BELT_BASE]
if ui_idx >= EQUIP_BASE:
return [WINDOW_INVENTORY, ui_idx]
return [WINDOW_INVENTORY, ui_idx]
+1
View File
@@ -0,0 +1 @@
uid://b244uiisl84sk
+55
View File
@@ -0,0 +1,55 @@
# ItemList (P2) —— 读 `locale/locale/common/item_list.txt`vnum → icon / model 路径。
#
# 格式(TSV,客户端 GameLib/ItemManager.cpp __LoadItemList):
# vnum <TAB> TYPE <TAB> iconpath <TAB> [modelpath]
# 例: 19 WEAPON icon/item/00010.tga d:/ymir work/item/weapon/00010.gr2
#
# var il := preload("res://ui/item_list.gd").new()
# il.load_file("<assets>/locale/locale/common/item_list.txt")
# il.icon(19) # "icon/item/00010.tga"
# il.model(19) # "d:/ymir work/item/weapon/00010.gr2" ("" if none)
extends RefCounted
var _by_vnum := {} # vnum:int -> { type, icon, model }
var count := 0
func load_file(path: String) -> bool:
_by_vnum.clear()
count = 0
if not FileAccess.file_exists(path):
return false
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return false
while not f.eof_reached():
var line := f.get_line()
if line.strip_edges() == "":
continue
var t := line.split("\t", false)
if t.size() < 3:
continue
var vnum := int(t[0])
if vnum == 0:
continue
_by_vnum[vnum] = {
"type": t[1].strip_edges(),
"icon": t[2].strip_edges(),
"model": t[3].strip_edges() if t.size() >= 4 else "",
}
count = _by_vnum.size()
return count > 0
func has(vnum: int) -> bool:
return _by_vnum.has(vnum)
func icon(vnum: int) -> String:
var d: Dictionary = _by_vnum.get(vnum, {})
return String(d.get("icon", ""))
func model(vnum: int) -> String:
var d: Dictionary = _by_vnum.get(vnum, {})
return String(d.get("model", ""))
func type_of(vnum: int) -> String:
var d: Dictionary = _by_vnum.get(vnum, {})
return String(d.get("type", ""))
+1
View File
@@ -0,0 +1 @@
uid://blbd4u5qn03fm
+83
View File
@@ -0,0 +1,83 @@
# LoadingScreen (P10) —— 相位驱动的读取遮罩。
#
# var ls := preload("res://ui/loading_screen.gd").new()
# add_child(ls)
# ls.setup(m2client) # 接 phase_changed;也可手动 show_for("...") / hide()
#
# 只有 PHASE = "loading"(载入地图)才铺全屏遮罩;login / select / game 都隐藏
# —— select 阶段要露出选人界面,login 阶段登录框自己显示进度。
extends CanvasLayer
const HINTS := {
"loading": "载入地图…",
}
var client: Node
var _panel: ColorRect
var _label: Label
var _spinner: Label
var _bar: ProgressBar
var _spin := 0.0
func setup(m2client: Node = null) -> void:
client = m2client
layer = 60
_build()
if client and client.has_signal("phase_changed"):
client.phase_changed.connect(_on_phase)
if client and client.has_signal("disconnected"):
client.disconnected.connect(func(_r): hide_screen())
func _on_phase(phase: String) -> void:
if phase.to_lower() == "loading":
show_for(String(HINTS.get("loading", "载入中…")))
else:
hide_screen() # login / select / game —— 让位给对应界面
func show_for(text: String) -> void:
_label.text = text
_bar.visible = false
_panel.visible = true
func set_progress(frac: float) -> void:
_bar.visible = true
_bar.value = clampf(frac, 0.0, 1.0) * 100.0
func hide_screen() -> void:
_panel.visible = false
func is_showing() -> bool:
return _panel.visible
func _process(dt: float) -> void:
if _panel.visible:
_spin += dt * 4.0
_spinner.text = ["", "", "", ""][int(_spin) % 4]
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.visible = false
add_child(_panel)
var box := VBoxContainer.new()
box.set_anchors_preset(Control.PRESET_CENTER)
box.position = Vector2(-120, -50)
box.custom_minimum_size = Vector2(240, 0)
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 14)
_panel.add_child(box)
_spinner = Label.new()
_spinner.text = ""
_spinner.add_theme_font_size_override("font_size", 36)
_spinner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_spinner)
_label = Label.new()
_label.text = "载入中…"
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_label)
_bar = ProgressBar.new()
_bar.custom_minimum_size = Vector2(240, 12)
_bar.show_percentage = false
_bar.visible = false
box.add_child(_bar)
+1
View File
@@ -0,0 +1 @@
uid://b6okrhdjyyuu4
+51
View File
@@ -0,0 +1,51 @@
# LoveUI — GC_LOVER_INFO / GC_LOVE_POINT_UPDATE 的常驻情侣状态。
extends Node
var client: Node
var _root: Panel
var _name: Label
var _bar: ProgressBar
func setup(m2client: Node, parent: Node) -> void:
client = m2client
_build(parent)
if client.has_signal("lover_changed"):
client.lover_changed.connect(func(_lover: Dictionary): refresh())
refresh()
func refresh() -> void:
if client == null or not client.has_method("get_lover"):
return
var lover: Dictionary = client.get_lover()
var valid := bool(lover.get("valid", false))
_root.visible = valid
if not valid:
return
_name.text = "" + String(lover.get("name", ""))
_bar.value = clampi(int(lover.get("love_point", 0)), 0, 100)
_bar.tooltip_text = "爱意值:%d%%" % int(_bar.value)
func _build(parent: Node) -> void:
_root = Panel.new()
_root.name = "LoveStatus"
_root.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_root.position = Vector2(-238, 28)
_root.size = Vector2(220, 48)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.14, 0.05, 0.10, 0.88)
bg.border_color = Color(0.95, 0.35, 0.55, 0.9)
bg.set_border_width_all(1)
bg.set_corner_radius_all(5)
_root.add_theme_stylebox_override("panel", bg)
parent.add_child(_root)
_name = Label.new()
_name.position = Vector2(9, 5)
_name.add_theme_font_size_override("font_size", 13)
_name.modulate = Color(1.0, 0.72, 0.82)
_root.add_child(_name)
_bar = ProgressBar.new()
_bar.position = Vector2(9, 27)
_bar.size = Vector2(202, 13)
_bar.max_value = 100
_bar.show_percentage = false
_root.add_child(_bar)
+1
View File
@@ -0,0 +1 @@
uid://bctp408qvphx6
+104
View File
@@ -0,0 +1,104 @@
# MallUI (첫 버전) —— 道具商城仓库窗(창고몰 / item-mall storage)。
#
# var mu := preload("res://ui/mall_ui.gd").new()
# add_child(mu)
# mu.setup(m2client, canvas_parent, proto) # proto 可空
#
# `mall_opened` → 显示;`mall_changed` → 刷新。列出商城道具 + [取出](放进背包第一个空格)。
extends Node
var client: Node
var proto: Node
var _root: Control
var _list: VBoxContainer
var _title: Label
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
proto = proto_node
_build(parent)
if client.has_signal("mall_opened"):
client.mall_opened.connect(func(_s): refresh())
if client.has_signal("mall_changed"):
client.mall_changed.connect(refresh)
func is_open() -> bool:
return _root != null and _root.visible
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
var n := String(d.get("locale_name", d.get("name", "")))
if n != "":
return n
return "#%d" % vnum
func _first_free_inv() -> int:
var used := {}
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
return 0
func refresh() -> void:
if client == null or _root == null:
return
_root.visible = client.is_mall_open()
if not _root.visible:
return
_title.text = "道具商城仓库(%d 格)" % client.get_mall_size()
for c in _list.get_children():
c.queue_free()
var items: Array = client.get_mall_items()
if items.is_empty():
var e := Label.new()
e.text = "(空)"
_list.add_child(e)
return
for it in items:
_list.add_child(_row(it))
func _row(it: Dictionary) -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(300, 0)
var nm := Label.new()
nm.text = "%s ×%d" % [_name_of(int(it.get("vnum", 0))), int(it.get("count", 1))]
nm.custom_minimum_size = Vector2(220, 0)
nm.add_theme_font_size_override("font_size", 12)
row.add_child(nm)
var out := Button.new()
out.text = "取出"
var cell := int(it.get("cell", 0))
out.pressed.connect(func() -> void: client.mall_checkout(cell, 1, _first_free_inv()))
row.add_child(out)
return row
func _build(parent: Node) -> void:
_root = Panel.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(140, -180)
_root.custom_minimum_size = Vector2(340, 360)
_root.size = Vector2(340, 360)
_root.visible = false
parent.add_child(_root)
var box := VBoxContainer.new()
box.position = Vector2(16, 14)
box.custom_minimum_size = Vector2(308, 0)
box.add_theme_constant_override("separation", 6)
_root.add_child(box)
_title = Label.new()
_title.text = "道具商城仓库"
_title.add_theme_font_size_override("font_size", 16)
box.add_child(_title)
var sc := ScrollContainer.new()
sc.custom_minimum_size = Vector2(308, 280)
box.add_child(sc)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 4)
sc.add_child(_list)
var close := Button.new()
close.text = "关闭"
close.pressed.connect(func() -> void: _root.visible = false)
box.add_child(close)
+1
View File
@@ -0,0 +1 @@
uid://bqlipbe67jyy6
+157
View File
@@ -0,0 +1,157 @@
# Minimap (P9) —— 右上角圆形小地图。北朝上。
#
# var mm := preload("res://ui/minimap.gd").new()
# add_child(mm)
# mm.setup(m2client, canvas_parent, func() -> Node3D: return player)
#
# 画:底圆 + 外环 + 玩家箭头 + 实体蓝点 + 怪红点 + NPC 黄点 + 传送青点 + 任务标记橙星 +
# 观战者紫色菱形 + 公会领地边框;支持 SetScale / 鼠标滚轮缩放。
# 世界→图:Δ = blip.xz - player.xz,按 scale 转像素,(+x 右, -z 上),超出半径夹到边缘。
extends Node
const RADIUS := 70.0
const SCALE := 0.25 # 像素/米(越大越放大)
const NPC_WARP_TYPE := 2 # NPCMark.type: 2 = 传送
const MIN_SCALE := 0.10
const MAX_SCALE := 1.25
var client: Node
var player_getter: Callable
var _root: Control
var _view: Control
var _ch_label: Label
var _scale := SCALE
func setup(m2client: Node, parent: Node, get_player: Callable) -> void:
client = m2client
player_getter = get_player
_build(parent)
if client and client.has_signal("channel_changed"):
client.channel_changed.connect(func(c): _ch_label.text = "CH %d" % c)
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_root.position = Vector2(-2 * RADIUS - 16, 12)
_root.size = Vector2(2 * RADIUS, 2 * RADIUS + 16)
parent.add_child(_root)
_view = Control.new()
_view.size = Vector2(2 * RADIUS, 2 * RADIUS)
_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
_view.draw.connect(_draw_map)
_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.text = "CH -"
_root.add_child(_ch_label)
func _process(_dt: float) -> void:
if _view:
_view.queue_redraw()
# Matches CPythonMiniMap::SetScale: callers may provide pixels per world metre.
# The wheel gesture is intentionally local to the minimap so world controls do
# not change when the pointer is elsewhere.
func set_scale(pixels_per_meter: float) -> void:
_scale = clampf(pixels_per_meter, MIN_SCALE, MAX_SCALE)
if _view:
_view.queue_redraw()
func get_scale() -> float:
return _scale
func _on_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
set_scale(_scale * 1.15)
_root.accept_event()
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
set_scale(_scale / 1.15)
_root.accept_event()
func _player() -> Node3D:
return player_getter.call() if player_getter.is_valid() else null
func _to_map(world: Vector3, origin: Vector3) -> Vector2:
var d := world - origin
# screen: +x right, +y down. world z = north -> screen up (y). world +x = east -> right.
var p := Vector2(d.x, d.z) * _scale
if p.length() > RADIUS - 4.0:
p = p.normalized() * (RADIUS - 4.0)
return Vector2(RADIUS, RADIUS) + p
func _draw_map() -> void:
var c := Vector2(RADIUS, RADIUS)
_view.draw_circle(c, RADIUS, Color(0.05, 0.06, 0.08, 0.82))
_view.draw_arc(c, RADIUS - 1.5, 0, TAU, 48, Color(0.6, 0.6, 0.7, 0.7), 2.0)
var pl := _player()
if pl == null or client == null:
return
var origin: Vector3 = pl.global_position
var party_vids := {}
if client.has_method("get_party"):
for member in client.get_party():
var pvid := int(member.get("vid", 0))
if pvid != 0:
party_vids[pvid] = bool(member.get("leader", false))
# 实体:怪红、其它蓝
if client.has_method("get_entities"):
for e in client.get_entities():
if bool(e.get("is_main", false)):
continue
var pos: Vector3 = e.get("pos", Vector3.ZERO)
var vid := int(e.get("vid", 0))
var map_pos := _to_map(MapCoord.to_world(pos), origin)
var col := Color(0.35, 1.0, 0.55) if party_vids.has(vid) \
else Color(0.4, 0.6, 1.0) if int(e.get("ch_type", 0)) != 2 else Color(1.0, 0.35, 0.3)
_view.draw_circle(map_pos, 2.5, col)
if bool(party_vids.get(vid, false)):
_view.draw_arc(map_pos, 4.5, 0.0, TAU, 16, Color(1.0, 0.9, 0.25, 0.9), 1.0)
# NPC 标记:传送青、其它黄
if client.has_method("get_npc_marks"):
for m in client.get_npc_marks():
var col := Color(0.3, 0.9, 0.9) if int(m.get("type", 0)) == NPC_WARP_TYPE else Color(1.0, 0.85, 0.2)
_view.draw_circle(_to_map(MapCoord.to_world(m.get("pos", Vector3.ZERO)), origin), 3.0, col)
# 观战者:原版在小地图上显示独立的观察点(紫色菱形)。
if client.has_method("get_observers"):
for o in client.get_observers():
var op := _to_map(MapCoord.to_world(o.get("pos", Vector3.ZERO)), origin)
var diamond := PackedVector2Array([op + Vector2(0, -4), op + Vector2(4, 0),
op + Vector2(0, 4), op + Vector2(-4, 0)])
_view.draw_colored_polygon(diamond, Color(0.8, 0.35, 1.0, 0.95))
# 公会领地:GC_LAND_LIST 的全局厘米矩形,在小地图上画细边框;
# guild_id=0 使用中性青色,其余领地使用金色。
if client.has_method("get_land_areas"):
for a in client.get_land_areas():
var x := float(a.get("x", 0))
var y := float(a.get("y", 0))
var w := float(a.get("width", 0))
var h := float(a.get("height", 0))
var p0 := _to_map(MapCoord.to_world(Vector3(x * 0.01, 0, -y * 0.01)), origin)
var p1 := _to_map(MapCoord.to_world(Vector3((x + w) * 0.01, 0, -(y + h) * 0.01)), origin)
var rect := Rect2(Vector2(min(p0.x, p1.x), min(p0.y, p1.y)),
Vector2(abs(p1.x - p0.x), abs(p1.y - p0.y)))
var col := Color(0.3, 0.9, 0.9, 0.75) if int(a.get("guild_id", 0)) == 0 \
else Color(1.0, 0.75, 0.2, 0.85)
_view.draw_rect(rect, col, false, 1.0)
# 任务标记:橙星(小方块代替)
if client.has_method("get_world_markers"):
for m in client.get_world_markers():
var mp := _to_map(MapCoord.to_world(m.get("pos", Vector3.ZERO)), origin)
_view.draw_rect(Rect2(mp - Vector2(3, 3), Vector2(6, 6)), Color(1.0, 0.6, 0.1))
# 玩家箭头(朝向)
var yaw: float = pl.rotation.y
var fwd := Vector2(sin(yaw), cos(yaw)) # world +? -> map;与 _to_map 的 (x,-z) 对齐
var tip := c + fwd * 8.0
var left := c + fwd.rotated(deg_to_rad(140)) * 6.0
var right := c + fwd.rotated(deg_to_rad(-140)) * 6.0
_view.draw_colored_polygon(PackedVector2Array([tip, left, right]), Color(1, 1, 1))
+1
View File
@@ -0,0 +1 @@
uid://nknmvwygsuda
+209
View File
@@ -0,0 +1,209 @@
# MobView (真机字节校准收尾) —— 怪 / NPC 的真模型:Metin2Model + Metin2AnimPlayer。
#
# var mv := preload("res://ui/mob_view.gd").new()
# if mv.build(assets_root, proto, race): # race = mob_proto vnumGC_CHARACTER_ADD.race
# ...
# mv.set_anim_state("run") # wait/run/attack/damage/dead/skill/emotion
#
# 目录:<assets>/*/ymir work/{monster,npc}/<mob_proto.name>/
# <name>.gr2 = 基础网格,<name>.dds = 贴图,motlist.txt = 动作索引表:
# GENERAL WAIT 00.msa 65 (组 动作名 文件 权重)
extends Node3D
# net_world 的状态名 -> motlist.txt 的动作名(按优先级取第一个存在的)
const STATE_MOTIONS := {
"wait": ["WAIT", "WAIT1"],
"walk": ["WALK", "RUN"],
"run": ["RUN", "WALK"],
"attack": ["NORMAL_ATTACK", "NORMAL_ATTACK1", "SPECIAL_1"],
"combo": ["NORMAL_ATTACK1", "NORMAL_ATTACK"],
"skill": ["SPECIAL_1", "SKILL", "NORMAL_ATTACK"],
"damage": ["FRONT_DAMAGE", "DAMAGE", "BACK_DAMAGE"],
"dead": ["FRONT_DEAD", "DEAD", "BACK_DEAD"],
"emotion": ["WAIT"],
}
var model: Node # Metin2Model
var anim: Node # Metin2AnimPlayer
var _dir := ""
var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径
var _state := ""
# root/npclist.txt: vnum -> 模型代号(bear_brown / tiger_big / stray_dog…)。
# 翻译版 mob_proto 把 szName 也本地化了("Brown Bear"),解不出目录,必须靠这张表。
static var _npclist: Dictionary = {}
static var _npclist_loaded := false
static func _load_npclist(assets_root: String) -> void:
if _npclist_loaded:
return
_npclist_loaded = true
for rel in ["root/npclist.txt", "npclist.txt", "locale/npclist.txt"]:
var p := assets_root.path_join(rel)
if not FileAccess.file_exists(p):
continue
var f := FileAccess.open(p, FileAccess.READ)
while f and not f.eof_reached():
var line := f.get_line().strip_edges()
if line == "":
continue
var parts := line.split("\t", false)
if parts.size() < 2:
parts = line.split(" ", false)
if parts.size() < 2:
continue
var vnum := int(parts[0])
var code := String(parts[1]).strip_edges()
if vnum > 0 and code != "":
_npclist[vnum] = code
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
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
if pump.is_valid(): pump.call()
model = ClassDB.instantiate("Metin2Model")
model.name = "Metin2Model"
model.set("texture_dir", _dir)
model.set("gr2_path", gr2) # 重:解 gr2 + 建网格
if pump.is_valid(): pump.call()
add_child(model)
_load_motlist()
if ClassDB.class_exists("Metin2AnimPlayer"):
anim = ClassDB.instantiate("Metin2AnimPlayer")
anim.set("model_path", NodePath("../Metin2Model"))
anim.set("blend_time", 0.15)
add_child(anim)
set_anim_state("wait")
CharShadow.attach(self, 1.6) # 怪脚印大一点
return true
func set_anim_state(s: String) -> void:
if s == _state or anim == null:
return
_state = s
var msa := _motion_for(s)
if msa == "":
return
anim.set("loop", s in ["wait", "walk", "run"])
anim.set("anim_path", msa)
# 供 net_world._on_info 用(如果它调 set_display_name
func set_display_name(_n: String) -> void:
pass
# --- 内部 -------------------------------------------------------------------
func _folder_candidates(proto: Node, race: int) -> Array:
if proto == null or not proto.has_method("mob"):
return []
var m: Dictionary = proto.mob(race)
if m.is_empty():
return []
var out: Array = []
var raws: Array[String] = [String(m.get("name", "")), String(m.get("locale_name", ""))]
for raw in raws:
var s: String = raw.strip_edges().to_lower()
if s == "":
continue
for c in [s.replace(" ", "_"), s.replace(" ", ""), s.replace(" ", "-")]:
if c != "" and not out.has(c):
out.append(c)
return out
func _motion_for(state: String) -> String:
for mo: String in STATE_MOTIONS.get(state, []):
if _motions.has(mo):
return _motions[mo]
# 兜底:目录里有 <mo>.msa 直接用
for mo: String in STATE_MOTIONS.get(state, []):
var p := _dir.path_join(mo.to_lower() + ".msa")
if FileAccess.file_exists(p):
return p
return _motions.get("WAIT", "")
func _load_motlist() -> void:
var ml := _dir.path_join("motlist.txt")
if not FileAccess.file_exists(ml):
return
var f := FileAccess.open(ml, FileAccess.READ)
while f and not f.eof_reached():
var parts := f.get_line().strip_edges().split(" ", false)
if parts.size() >= 3:
var motion := String(parts[1])
var file := String(parts[2])
var abs := _dir.path_join(file)
if FileAccess.file_exists(abs):
_motions[motion] = abs
func _pick_base_gr2() -> String:
var da := DirAccess.open(_dir)
if da == null:
return ""
for fn in da.get_files():
if not fn.ends_with(".gr2"):
continue
if fn.contains("_lod_"):
continue
# 动作文件通常是纯数字命名(00.gr2 / 20_1.gr2
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 ""
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
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
return ""
+1
View File
@@ -0,0 +1 @@
uid://d06ypjevle2jf
+263
View File
@@ -0,0 +1,263 @@
# PartyUI (P8/P11) —— 组队成员信息板(1:1 迁移 `assets/root/uiparty.py` 的
# `PartyMemberInfoBoard` + `PartyMemberInfoWindow`;布局照 `partymemberinfoboard.py` 的
# 每员 106×36 条:StateButton(角色图标) + NameSlot + Gauge(HP) + 附加图标行)。
#
# var pu := preload("res://ui/party_ui.gd").new()
# add_child(pu)
# pu.setup(m2client, canvas_parent, dialogs) # dialogs 可空
#
# 每员一条 strip:角色状态按钮(队长可点 → 分配角色 / 踢人)、名字(+★队长)、HP 条、
# 附加效果格(affects[7] 非零 → 小图标 + tooltip)。整条点击 → 选中该员 vid。
# 顶部:EXP 分配开关(均分 / 不均分 → party_set_distribute+ 组队治疗(party_use_skill)。
# `party_changed` 刷新;`party_invite_ask(leader_pid)` → 确认框 → party_answer。
extends Node
# PythonPlayer.h EPartyRole
const ROLE_NORMAL := 0
const ROLE_ATTACKER := 2
const ROLE_TANKER := 3
const ROLE_BUFFER := 4
const ROLE_SKILL_MASTER := 5
const ROLE_BERSERKER := 6
const ROLE_DEFENDER := 7
const ROLE_LABEL := {
0: "", 1: "", 2: "", 3: "", 4: "", 5: "", 6: "", 7: "",
}
const ROLE_MENU := [
[ROLE_NORMAL, "普通"], [ROLE_ATTACKER, "攻击"], [ROLE_TANKER, "坦克"],
[ROLE_BERSERKER, "狂战"], [ROLE_BUFFER, "辅助"], [ROLE_SKILL_MASTER, "宗师"],
[ROLE_DEFENDER, "防御"],
]
# Packet.h EPartyExpDistributionType
const EXP_NON_PARITY := 0 # 不均分(按贡献)
const EXP_PARITY := 1 # 均分
# uiparty.PartyMemberInfoBoard.PARTY_SKILL_*
const PARTY_SKILL_HEAL := 1
# affects[7] 槽位含义(暂定,按 partymemberinfoboard.py 图标顺序)
const AFFECT_LABEL := ["经验", "攻击", "防御", "辅助", "宗师", "时间", "回复"]
var client: Node
var dialogs: Node
var _root: Control
var _list: VBoxContainer
var _dist_btn: Button
var _role_popup: Control = null
func setup(m2client: Node, parent: Node, dlg: Node = null) -> void:
client = m2client
dialogs = dlg
_build(parent)
if client.has_signal("party_changed"):
client.party_changed.connect(refresh)
if client.has_signal("party_invite_ask"):
client.party_invite_ask.connect(_on_invite)
if client.has_signal("vitals_changed"):
client.vitals_changed.connect(func(_v): if is_open(): refresh())
refresh()
func is_open() -> bool:
return _root != null and _root.visible
# --- 로컬 플레이어가 파티장인가 ---
func _local_is_leader() -> bool:
if client == null or not client.has_method("get_main_vid"):
return false
var my_vid := int(client.get_main_vid())
for m in client.get_party():
if int(m.get("vid", 0)) == my_vid:
return bool(m.get("leader", false))
return false
# --- 새로고침 ------------------------------------------------------------
func refresh() -> void:
if client == null or not client.has_method("get_party"):
return
_dismiss_role_popup()
for c in _list.get_children():
_list.remove_child(c)
c.queue_free()
var members: Array = client.get_party()
_root.visible = not members.is_empty()
if members.is_empty():
return
_refresh_dist_btn()
for m in members:
_list.add_child(_strip(m))
func _refresh_dist_btn() -> void:
if not is_instance_valid(_dist_btn):
return
var mode := int(client.get_party_distribute_mode()) if client.has_method("get_party_distribute_mode") else 0
_dist_btn.text = "EXP:均分" if mode == EXP_PARITY else "EXP:不均分"
_dist_btn.disabled = not _local_is_leader()
# --- 한 명 strip ------------------------------------------------------
func _strip(m: Dictionary) -> Control:
var pid := int(m.get("pid", 0))
var vid := int(m.get("vid", 0))
var is_leader := bool(m.get("leader", false))
var role := int(m.get("state", 0)) & 0x7F
var strip := PanelContainer.new()
strip.custom_minimum_size = Vector2(206, 40)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 4)
strip.add_child(row)
# StateButton —— 角色图标(队长可点)
var state_btn := Button.new()
state_btn.custom_minimum_size = Vector2(22, 34)
state_btn.text = "" if is_leader else ROLE_LABEL.get(role, "")
state_btn.tooltip_text = "队长" if is_leader else ("角色:%s" % ROLE_LABEL.get(role, ""))
state_btn.disabled = not _local_is_leader()
state_btn.pressed.connect(func() -> void: _open_role_popup(state_btn, pid, role))
row.add_child(state_btn)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 1)
col.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(col)
# NameSlot / NamePrint
var name_lbl := Button.new()
name_lbl.flat = true
name_lbl.alignment = HORIZONTAL_ALIGNMENT_LEFT
name_lbl.text = ("" if is_leader else "") + String(m.get("name", "?"))
name_lbl.add_theme_font_size_override("font_size", 12)
name_lbl.pressed.connect(func() -> void: _select_member(vid))
col.add_child(name_lbl)
# Gauge (HP)
var bar := ProgressBar.new()
bar.min_value = 0
bar.max_value = 100
bar.value = int(m.get("hp_pct", 0))
bar.show_percentage = false
bar.custom_minimum_size = Vector2(180, 8)
col.add_child(bar)
# 附가효과 아이콘 행
var affects: Array = m.get("affects", [])
var chips := HBoxContainer.new()
chips.add_theme_constant_override("separation", 2)
col.add_child(chips)
for i in affects.size():
var v := int(affects[i])
if v == 0:
continue
var chip := Label.new()
chip.text = AFFECT_LABEL[i] if i < AFFECT_LABEL.size() else "?%d" % i
chip.add_theme_font_size_override("font_size", 9)
chip.add_theme_color_override("font_color", Color(0.7, 0.9, 1.0))
chip.tooltip_text = "%s +%d" % [chip.text, v]
chips.add_child(chip)
return strip
func _select_member(vid: int) -> void:
if vid <= 0 or client == null:
return
if client.has_method("set_target"):
client.set_target(vid)
elif client.has_method("target"):
client.target(vid)
# --- 角色分配 팝업 (uiparty.__ShowStateButton + OnSelectState / OnExpel) ---
func _open_role_popup(anchor: Control, pid: int, cur_role: int) -> void:
_dismiss_role_popup()
if not _local_is_leader():
return
var pop := VBoxContainer.new()
pop.add_theme_constant_override("separation", 2)
var bg := PanelContainer.new()
bg.add_child(pop)
for entry in ROLE_MENU:
var r: int = entry[0]
var b := Button.new()
b.text = entry[1] + ("" if r == cur_role else "")
b.custom_minimum_size = Vector2(84, 22)
b.pressed.connect(func() -> void:
if r == ROLE_NORMAL:
# OnSelectState(-1): 清掉当前角色
client.party_set_state(pid, cur_role, false)
else:
client.party_set_state(pid, r, true)
_dismiss_role_popup())
pop.add_child(b)
var expel := Button.new()
expel.text = "踢出队伍"
expel.custom_minimum_size = Vector2(84, 22)
expel.add_theme_color_override("font_color", Color(1, 0.5, 0.5))
expel.pressed.connect(func() -> void:
client.party_leave(pid) # OnExpel -> SendPartyRemovePacket
_dismiss_role_popup())
pop.add_child(expel)
bg.position = anchor.get_global_position() + Vector2(24, 0)
_root.get_parent().add_child(bg)
_role_popup = bg
func _dismiss_role_popup() -> void:
if is_instance_valid(_role_popup):
_role_popup.queue_free()
_role_popup = null
# --- 邀请确认 ---------------------------------------------------------
func _on_invite(leader_pid: int) -> void:
var accept := func() -> void: client.party_answer(leader_pid, true)
var decline := func() -> void: client.party_answer(leader_pid, false)
if dialogs and dialogs.has_method("confirm"):
dialogs.confirm("有人邀请你加入队伍,接受?", accept, decline)
else:
accept.call()
# --- EXP 분배 / 组队治疗 --------------------------------------------
func _toggle_distribute() -> void:
if client == null or not _local_is_leader():
return
var mode := int(client.get_party_distribute_mode()) if client.has_method("get_party_distribute_mode") else 0
client.party_set_distribute(EXP_NON_PARITY if mode == EXP_PARITY else EXP_PARITY)
func _party_heal() -> void:
if client and client.has_method("party_use_skill"):
client.party_use_skill(PARTY_SKILL_HEAL, 0) # OnPartyUseSkill
# --- 레이아웃 ------------------------------------------------------------
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_TOP_LEFT)
_root.position = Vector2(16, 150)
_root.size = Vector2(226, 320)
_root.visible = false
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.07, 0.09, 0.85)
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
var header := HBoxContainer.new()
header.position = Vector2(8, 6)
header.add_theme_constant_override("separation", 6)
_root.add_child(header)
_dist_btn = Button.new()
_dist_btn.text = "EXP:均分"
_dist_btn.add_theme_font_size_override("font_size", 11)
_dist_btn.pressed.connect(_toggle_distribute)
header.add_child(_dist_btn)
var heal := Button.new()
heal.text = "组队治疗"
heal.add_theme_font_size_override("font_size", 11)
heal.pressed.connect(_party_heal)
header.add_child(heal)
_list = VBoxContainer.new()
_list.position = Vector2(8, 32)
_list.add_theme_constant_override("separation", 4)
_root.add_child(_list)
+1
View File
@@ -0,0 +1 @@
uid://d3654hrkbg1u0
+98
View File
@@ -0,0 +1,98 @@
# PlayerView (P2) —— 本地玩家的真模型:Metin2Model + Metin2AnimPlayer。
#
# var pv := preload("res://ui/player_view.gd").new()
# pv.build(assets_root, race) # race 0..7GC_MAIN_CHARACTER.race
# 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)`。
extends Node3D
# race -> (class, 是否女). playersettingmodule: 0=WAR_M 1=ASN_W 2=SURA_M 3=SHA_W
# 4=WAR_W 5=ASN_M 6=SURA_W 7=SHA_M
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
const FEMALE_RACES := [1, 3, 4, 6]
var model: Node # Metin2Model
var anim: Node # Metin2AnimPlayer
var motion_dir := ""
var _state := ""
var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin", "specular_power"]
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
func build(assets_root: String, race: int, pump := Callable()) -> bool:
if not ClassDB.class_exists("Metin2Model"):
return false
var cls: String = CLASS_OF[race & 3]
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls
var base := _resolve_dir(assets_root, pc_dir)
if base == "":
base = _resolve_dir(assets_root, "PC/ymir work/pc/%s" % cls) # 兜底用男模
if base == "":
return false
motion_dir = base.path_join("general")
model = ClassDB.instantiate("Metin2Model")
model.name = "Metin2Model"
model.set("texture_dir", base)
if pump.is_valid(): pump.call()
var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)])
if body != "":
model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD
if pump.is_valid(): pump.call()
var hair := _first_existing([base.path_join("hair/hair_1_1.gr2"), base.path_join("hair/hair_01.gr2")])
if hair != "":
model.set("hair_gr2", hair) # 重:解 hair gr2 + 折进网格
if pump.is_valid(): pump.call()
add_child(model)
if ClassDB.class_exists("Metin2AnimPlayer"):
anim = ClassDB.instantiate("Metin2AnimPlayer")
anim.set("model_path", NodePath("../Metin2Model"))
anim.set("loop", true)
anim.set("blend_time", 0.15)
add_child(anim)
if pump.is_valid(): pump.call()
set_anim_state("wait") # 重:解 .msa
if pump.is_valid(): pump.call()
CharShadow.attach(self, 1.3) # 脚下接触阴影 + 强制 cast_shadow
return true
func set_anim_state(s: String) -> void:
if s == _state or anim == null or motion_dir == "":
return
_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):
anim.set("loop", s in ["wait", "walk", "run"])
anim.set("anim_path", msa)
func _set(prop: StringName, val: Variant) -> bool:
if String(prop) in _forward and model:
model.set(prop, val)
return true
return false
# --- 路径解析 -------------------------------------------------------------
func _resolve_dir(assets_root: String, rel: String) -> String:
var d := assets_root.path_join(rel)
if DirAccess.dir_exists_absolute(d):
return d
var da := DirAccess.open(assets_root)
if da:
for sub in da.get_directories():
var c := assets_root.path_join(sub).path_join(rel)
if DirAccess.dir_exists_absolute(c):
return c
return ""
func _first_existing(paths: Array) -> String:
for p: String in paths:
if FileAccess.file_exists(p):
return p
return ""
+1
View File
@@ -0,0 +1 @@
uid://nm24uhdtjn4d
+331
View File
@@ -0,0 +1,331 @@
# PrivateShopUI —— 개인상점 개설창 (CG_MYSHOP)1:1 迁移 `assets/root/uiprivateshopbuilder.py`
# 的 `PrivateShopBuilder`(布局走真 `assets/uiscript/uiscript/privateshopbuilder.py`)。
#
# var pu := preload("res://ui/private_shop_ui.gd").new()
# add_child(pu)
# pu.setup(m2client, ui_manager, proto, assets_root)
# pu.toggle()
#
# 布局:`privateshopbuilder.py`board + TitleBar + NameLine + ItemSlot 5×8=40 grid + Ok/Close)。
# 交互 = uiprivateshopbuilder.itemStock:左侧(挂在窗右边的附加面板)列背包候选,点一件
# “拿起”,再点右侧 40 格的空格 → 价格输入(`uiCommon.MoneyInputDialog`)→ 落位
# `AddPrivateShopItemStock` + `itemStock[targetSlot]=(src)`);点已占用格 → 撤下
# `OnSelectItemSlot` / `DelPrivateShopItemStock`)。OkButton(`BuildPrivateShop`)stock 按
# 格号排序、`display_pos = 格号`、上限 39`PRIVATE_SHOP_ITEM_MAX_NUM`)打包成
# `M2Client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])`
# = `TPacketCGMyShop` + `TShopItemTable`×N)。철수 `close_private_shop()` = SHOP_CG_END。
extends Node
const PRIVATE_SHOP_ITEM_MAX := 39 # PRIVATE_SHOP_ITEM_MAX_NUM (TPacketCGMyShop::bCount 上限)
const SIGN_MAX := 25 # NameLine input_limit
var client: Node
var ui: CanvasLayer # UiManager
var proto: Node
var assets_root := ""
var uiscript_dir := ""
var _win: Dictionary = {}
var _sign: LineEdit
var _inv_list: VBoxContainer
var _cells: Dictionary = {} # 格号:int -> Panel
var _status: Label
var _stock: Dictionary = {} # 格号:int -> {cell, vnum, count, price}
var _picked = null # {cell, vnum, count} 等待落位
var _price_dialog: Control = null
func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, assets := "") -> void:
client = m2client
ui = ui_manager
proto = proto_node
assets_root = assets
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")
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(func(_w, _c): if is_open(): _refresh())
# --- open / close --------------------------------------------------
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
_dismiss_price_dialog()
if is_open():
ui.close(_win["root"])
_win = {}
_cells.clear()
_stock.clear()
_picked = null
func open() -> void:
if is_open():
return
var path := uiscript_dir.path_join("privateshopbuilder.py")
if not FileAccess.file_exists(path):
push_warning("PrivateShopUI: no privateshopbuilder.py at " + path)
return
_stock.clear()
_picked = null
_win = ui.open_script(path, assets_root)
if not is_open():
return
var root: Control = _win["root"]
_index_cells(root)
_overlay_name_edit(root)
_add_inv_panel(root)
_wire_buttons(root)
_refresh()
func _node(nm: String) -> Control:
if _win.is_empty():
return null
var n = _win.get("nodes", {}).get(nm, null)
return n if n is Control else null
func _index_cells(root: Node) -> void:
_cells.clear()
for n in root.find_children("slot_*", "Panel", true, false):
if not n.has_meta("slot_index"):
continue
var slot := int(n.get_meta("slot_index"))
_cells[slot] = n
var idx := slot
n.gui_input.connect(func(e: InputEvent) -> void:
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
_on_slot_clicked(idx))
# NameLine 在 uiscript 里是 textLabel)—— 盖一个 LineEdit 上去
func _overlay_name_edit(root: Control) -> void:
var name_line := _node("NameLine")
_sign = LineEdit.new()
_sign.max_length = SIGN_MAX
_sign.placeholder_text = "상점 이름"
if name_line:
name_line.visible = false
var slot := name_line.get_parent()
_sign.position = name_line.position
_sign.size = Vector2(maxf(name_line.size.x, 150), 18)
(slot if slot is Control else root).add_child(_sign)
else:
_sign.position = Vector2(13, 35)
_sign.size = Vector2(157, 18)
root.add_child(_sign)
func _wire_buttons(root: Control) -> void:
var ok := _node("OkButton")
if ok is BaseButton:
ok.pressed.connect(_ok)
var cl := _node("CloseButton")
if cl is BaseButton:
cl.pressed.connect(close)
var tb := _node("TitleBar")
if tb:
for b in tb.find_children("*", "BaseButton", true, false):
b.pressed.connect(close)
# 원 클라는 메인 인벤토리에서 드래그 —— 여기선 창 오른쪽에 후보 패널을 붙임
func _add_inv_panel(root: Control) -> void:
var panel := Panel.new()
panel.name = "inv_candidates"
panel.position = Vector2(root.size.x + 6, 0)
panel.size = Vector2(220, root.size.y)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.06, 0.06, 0.09, 0.95)
bg.border_color = Color(0.4, 0.36, 0.24, 0.9)
bg.set_border_width_all(1)
panel.add_theme_stylebox_override("panel", bg)
root.add_child(panel)
var title := Label.new()
title.text = "배낭 (클릭 → 집기)"
title.position = Vector2(10, 8)
title.add_theme_font_size_override("font_size", 12)
panel.add_child(title)
var sc := ScrollContainer.new()
sc.position = Vector2(8, 30)
sc.size = Vector2(204, root.size.y - 66)
panel.add_child(sc)
_inv_list = VBoxContainer.new()
_inv_list.add_theme_constant_override("separation", 3)
sc.add_child(_inv_list)
_status = Label.new()
_status.position = Vector2(10, root.size.y - 30)
_status.add_theme_font_size_override("font_size", 11)
_status.add_theme_color_override("font_color", Color(1, 0.9, 0.5))
panel.add_child(_status)
# --- 명칭 --------------------------------------------------------------
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
var n := String(d.get("locale_name", d.get("name", "")))
if n != "":
return n
return "#%d" % vnum
# --- 새로고침 --------------------------------------------------------
func _refresh() -> void:
_refresh_inv()
_refresh_grid()
func _refresh_inv() -> void:
if not is_instance_valid(_inv_list):
return
for c in _inv_list.get_children():
_inv_list.remove_child(c)
c.queue_free()
var used := {}
for s in _stock.values():
used[int(s["cell"])] = true
var inv: Array = client.get_inventory() if client and client.has_method("get_inventory") else []
var shown := 0
for it in inv:
var cell := int(it.get("cell", 0))
if used.has(cell):
continue
var vnum := int(it.get("vnum", 0))
var count := int(it.get("count", 1))
var b := Button.new()
b.text = "%s ×%d" % [_name_of(vnum), count]
b.alignment = HORIZONTAL_ALIGNMENT_LEFT
b.custom_minimum_size = Vector2(196, 22)
b.toggle_mode = true
var entry := {"cell": cell, "vnum": vnum, "count": count}
b.pressed.connect(func() -> void: _pick(entry, b))
if _picked != null and int(_picked["cell"]) == cell:
b.button_pressed = true
_inv_list.add_child(b)
shown += 1
if shown == 0:
var l := Label.new()
l.text = "(배낭이 비어 있음)"
l.add_theme_font_size_override("font_size", 12)
_inv_list.add_child(l)
func _refresh_grid() -> void:
for slot in _cells.keys():
var cell: Panel = _cells[slot]
for ch in cell.get_children():
cell.remove_child(ch)
ch.queue_free()
cell.tooltip_text = ""
if _stock.has(slot):
var s: Dictionary = _stock[slot]
var nm := Label.new()
nm.text = _name_of(int(s["vnum"])).substr(0, 4)
nm.add_theme_font_size_override("font_size", 8)
nm.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(nm)
var pr := Label.new()
pr.text = str(int(s["price"]))
pr.position = Vector2(1, 18)
pr.add_theme_font_size_override("font_size", 8)
pr.add_theme_color_override("font_color", Color(0.95, 0.85, 0.5))
pr.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(pr)
cell.tooltip_text = "%s ×%d\n%d" % [_name_of(int(s["vnum"])), int(s["count"]), int(s["price"])]
cell.modulate = Color(1, 1, 1)
else:
cell.modulate = Color(0.72, 0.72, 0.82) if _picked != null else Color(1, 1, 1)
# --- 상호작용 --------------------------------------------------------
func _pick(entry: Dictionary, _btn: Button) -> void:
if _picked != null and int(_picked["cell"]) == int(entry["cell"]):
_picked = null
else:
_picked = entry
_refresh_grid()
func _on_slot_clicked(slot: int) -> void:
if _stock.has(slot):
_stock.erase(slot)
if is_instance_valid(_status):
_status.text = ""
_refresh()
return
if _picked == null:
return
_ask_price(slot)
func _ask_price(slot: int) -> void:
_dismiss_price_dialog()
var dlg := Panel.new()
dlg.set_anchors_preset(Control.PRESET_CENTER)
dlg.position = Vector2(-120, -70)
dlg.size = Vector2(240, 128)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.06, 0.06, 0.09, 0.98)
sb.border_color = Color(0.7, 0.55, 0.22, 0.9)
sb.set_border_width_all(1)
dlg.add_theme_stylebox_override("panel", sb)
var t := Label.new()
t.text = "판매 가격"
t.position = Vector2(12, 10)
dlg.add_child(t)
var spin := SpinBox.new()
spin.min_value = 1
spin.max_value = 2000000000
spin.step = 1
spin.value = int(_stock.get(slot, {}).get("price", 1))
spin.position = Vector2(12, 40)
spin.size = Vector2(216, 28)
dlg.add_child(spin)
var ok := Button.new()
ok.text = "확인"
ok.position = Vector2(60, 86)
ok.pressed.connect(func() -> void: _place(slot, int(spin.value)))
dlg.add_child(ok)
var cancel := Button.new()
cancel.text = "취소"
cancel.position = Vector2(130, 86)
cancel.pressed.connect(_dismiss_price_dialog)
dlg.add_child(cancel)
_win["root"].get_parent().add_child(dlg)
_price_dialog = dlg
func _dismiss_price_dialog() -> void:
if is_instance_valid(_price_dialog):
_price_dialog.queue_free()
_price_dialog = null
func _place(slot: int, price: int) -> void:
if price <= 0 or _picked == null:
_dismiss_price_dialog()
return
_stock[slot] = {"cell": int(_picked["cell"]), "vnum": int(_picked["vnum"]),
"count": int(_picked["count"]), "price": price}
_picked = null
_dismiss_price_dialog()
_refresh()
func _ok() -> void:
if _stock.is_empty():
if is_instance_valid(_status):
_status.text = "판매할 아이템을 올리세요"
return
var slots := _stock.keys()
slots.sort()
var items: Array = []
for slot in slots:
if items.size() >= PRIVATE_SHOP_ITEM_MAX:
break
var s: Dictionary = _stock[slot]
items.append({"vnum": s["vnum"], "count": s["count"], "inv_cell": s["cell"],
"price": s["price"], "display_pos": slot})
if client and client.has_method("open_private_shop"):
client.open_private_shop(_sign.text.strip_edges(), items)
close()
func _close_shop() -> void:
if client and client.has_method("close_private_shop"):
client.close_private_shop()
+1
View File
@@ -0,0 +1 @@
uid://3gl3s2r0g74h
+226
View File
@@ -0,0 +1,226 @@
# QuestDialog (P7) —— 渲染 GC_SCRIPT 的 NPC 对话(EventManager 脚本,客户端只渲染)。
#
# var qd := preload("res://ui/quest_dialog.gd").new()
# add_child(qd)
# qd.setup(m2client, canvas_parent)
#
# 脚本格式(`CPythonEventManager`):纯文本 + `[命令 arg(值)]` token 交错。
# [ENTER] 换行
# [CLEAR] 清空
# [NEXT] / [DONE] 「继续 / 关闭」按钮 -> M2Client.script_answer(255)
# [QUESTION arg("是") arg("否")] 选项按钮 -> script_answer(0..N-1)
# [LETTER value("...")] 追加文本(旧写法)
# [INPUT] 显示文本输入框,提交 -> M2Client.quest_input()
# [SELECT_ITEM] 要求从背包选一颗魔石 -> 发 select_item_requested 信号(打开 SelectItemUI
# [CONFIRM_WAIT] 等:本版忽略
#
# quest_confirm_askGC_QUEST_CONFIRM)也在这里弹 是/否 -> quest_confirm(yes, pid)。
extends Node
signal opened()
signal closed()
# EventManager EVENT_TYPE_SELECT_ITEM —— 对应 interfacemodule.BINARY_OpenSelectItemWindow
signal select_item_requested()
var client: Node
var _root: Control
var _text: RichTextLabel
var _btnrow: VBoxContainer
var _confirm_pid := 0
func setup(m2client: Node, parent: Node) -> void:
client = m2client
set_process_unhandled_input(true)
_build(parent)
if client.has_signal("script_dialog"):
client.script_dialog.connect(_on_script)
if client.has_signal("quest_confirm_ask"):
client.quest_confirm_ask.connect(_on_confirm)
func is_open() -> bool:
return _root != null and _root.visible
# --- 收 GC_SCRIPT ------------------------------------------------------
func _on_script(_skin: int, text: String) -> void:
var parsed := parse_script(text)
if parsed.body.strip_edges() == "" and parsed.choices.is_empty():
close()
return
_text.text = parsed.body
_fill_buttons(parsed.choices, parsed.has_next, parsed.has_input)
_root.visible = true
opened.emit()
if parsed.has_select_item:
select_item_requested.emit()
func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void:
_confirm_pid = request_pid
_text.text = msg
_fill_confirm()
_root.visible = true
opened.emit()
func close() -> void:
if _root:
_root.visible = false
closed.emit()
func _unhandled_input(event: InputEvent) -> void:
if not is_open() or not (event is InputEventKey):
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 client and client.has_method("quest_cancel"):
client.quest_cancel()
close()
get_viewport().set_input_as_handled()
# --- 脚本解析 --------------------------------------------------------
# -> { body: String, choices: [String], has_next: bool }
func parse_script(src: String) -> Dictionary:
var body := ""
var choices := []
var has_next := false
var has_input := false
var has_select_item := false
var i := 0
var n := src.length()
while i < n:
var c := src[i]
if c == "[":
var close_i := src.find("]", i)
if close_i == -1:
body += src.substr(i)
break
var tok := src.substr(i + 1, close_i - i - 1).strip_edges()
i = close_i + 1
var name := tok.split(" ", false)[0].to_upper() if tok != "" else ""
match name:
"ENTER":
body += "\n"
"CLEAR", "CLEAR_TEXT":
body = ""
"NEXT", "DONE":
has_next = true
"QUESTION":
for m in _rx_args(tok):
choices.append(m)
"INPUT":
has_input = true
"SELECT_ITEM":
has_select_item = true
"LETTER":
var vv := _rx_args(tok)
if vv.size() > 0:
body += vv[0]
_:
pass # COLOR/DELAY/WAIT/IMAGE/... 忽略
else:
body += c
i += 1
return {"body": body.strip_edges(), "choices": choices, "has_next": has_next,
"has_input": has_input, "has_select_item": has_select_item}
# 抓 tok 里所有 arg("...") / value("...") 的字符串
func _rx_args(tok: String) -> Array:
var out := []
var i := 0
while true:
var q1 := tok.find('"', i)
if q1 == -1:
break
var q2 := tok.find('"', q1 + 1)
if q2 == -1:
break
out.append(tok.substr(q1 + 1, q2 - q1 - 1))
i = q2 + 1
return out
# --- UI --------------------------------------------------------------
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-230, -160)
_root.size = Vector2(460, 300)
_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.08, 0.07, 0.06, 0.97)
sb.border_color = Color(0.5, 0.42, 0.3)
sb.set_border_width_all(1)
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
_text = RichTextLabel.new()
_text.bbcode_enabled = true
_text.position = Vector2(16, 14)
_text.size = Vector2(428, 200)
_text.add_theme_font_size_override("normal_font_size", 13)
_root.add_child(_text)
_btnrow = VBoxContainer.new()
_btnrow.position = Vector2(16, 222)
_btnrow.custom_minimum_size = Vector2(428, 0)
_btnrow.add_theme_constant_override("separation", 4)
_root.add_child(_btnrow)
func _clear_buttons() -> void:
for c in _btnrow.get_children():
c.queue_free()
func _fill_buttons(choices: Array, has_next: bool, has_input := false) -> void:
_clear_buttons()
if has_input:
var input := LineEdit.new()
input.placeholder_text = "请输入"
input.custom_minimum_size = Vector2(428, 28)
_btnrow.add_child(input)
var submit := _mkbtn("提交")
submit.pressed.connect(func(): _submit_input(input))
input.text_submitted.connect(func(_text): _submit_input(input))
return
if choices.is_empty():
var b := _mkbtn("继续" if has_next else "关闭")
b.pressed.connect(func():
client.script_answer(255)
close())
return
for i in choices.size():
var idx := i
var b := _mkbtn(String(choices[i]))
b.pressed.connect(func():
client.script_answer(idx)
close())
func _submit_input(input: LineEdit) -> void:
if client and client.has_method("quest_input") and client.quest_input(input.text):
close()
func _fill_confirm() -> void:
_clear_buttons()
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
_btnrow.add_child(row)
for spec in [["接受", true], ["拒绝", false]]:
var yes: bool = spec[1]
var b := Button.new()
b.text = spec[0]
b.custom_minimum_size = Vector2(120, 30)
b.pressed.connect(func():
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)
_btnrow.add_child(b)
return b
+1
View File
@@ -0,0 +1 @@
uid://8irhkh75qgra
+90
View File
@@ -0,0 +1,90 @@
# QuestLog (P7) —— 任务日志窗:GC_QUEST_INFO 的条目列表。
#
# var ql := preload("res://ui/quest_log.gd").new()
# add_child(ql)
# ql.setup(m2client, canvas_parent)
# ql.toggle() # J 键
#
# 每条: 图标(占位) + 标题 + counter「名: 值」 + clock「名: 值」。
# `quest_info` 信号来时刷新。空标题的条目视为「已完成 / 移除」。
extends Node
var client: Node
var _root: Control
var _list: VBoxContainer
func setup(m2client: Node, parent: Node) -> void:
client = m2client
_build(parent)
if client.has_signal("quest_info"):
client.quest_info.connect(func(_i): refresh())
func is_open() -> bool:
return _root != null and _root.visible
func toggle() -> void:
_root.visible = not _root.visible
if _root.visible:
refresh()
func refresh() -> void:
if not is_open() or client == null:
return
for c in _list.get_children():
c.queue_free()
var quests: Array = client.get_quests()
if quests.is_empty():
var e := Label.new()
e.text = "(无进行中的任务)"
e.add_theme_font_size_override("font_size", 12)
_list.add_child(e)
return
for q in quests:
if String(q.get("title", "")).strip_edges() == "":
continue
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 1)
var t := Label.new()
t.text = "" + String(q.get("title", ""))
t.add_theme_font_size_override("font_size", 13)
box.add_child(t)
var cn := String(q.get("counter_name", ""))
if cn != "":
var cl := Label.new()
cl.text = " %s: %d" % [cn, int(q.get("counter_value", 0))]
cl.add_theme_font_size_override("font_size", 11)
cl.modulate = Color(0.8, 0.85, 0.7)
box.add_child(cl)
var kn := String(q.get("clock_name", ""))
if kn != "":
var kl := Label.new()
kl.text = " %s: %d" % [kn, int(q.get("clock_value", 0))]
kl.add_theme_font_size_override("font_size", 11)
kl.modulate = Color(0.7, 0.8, 0.9)
box.add_child(kl)
_list.add_child(box)
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_root.position = Vector2(-320, 60)
_root.size = Vector2(300, 360)
_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.07, 0.08, 0.1, 0.95)
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)
_list = VBoxContainer.new()
_list.position = Vector2(12, 34)
_list.custom_minimum_size = Vector2(276, 0)
_list.add_theme_constant_override("separation", 8)
_root.add_child(_list)
+1
View File
@@ -0,0 +1 @@
uid://dw1hym64mtt6g
+272
View File
@@ -0,0 +1,272 @@
# Quickbar (P6) —— 快捷栏:36 槽(4 页 × 9 格),数字键 1-9 施放技能 / 用物品。
#
# var qb := preload("res://ui/quickbar.gd").new()
# add_child(qb)
# qb.setup(m2client, skill_table, canvas_parent, player_getter)
# qb.assign(0, "skill", 1) # 当前页 0 号格放技能 id 1
# qb.assign(1, "item", 5) # 当前页 1 号格放背包 5 格的药水
# # 输入: game_scene 里 KEY_1..KEY_9 -> qb.activate(n)F1..F4 切页
#
# 技能施放:先发送 M2Client.use_skill(skill_id, target_vid),再同步
# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)(朝向 = 玩家 yaw)。
# 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。
extends Node
signal skill_activated(skill_id: int) # game_scene 接它播技能特效
const CM := 100.0
const DEFAULT_CD := 2.0
const SLOTS_PER_PAGE := 9
const PAGE_COUNT := 4
const SLOT_COUNT := SLOTS_PER_PAGE * PAGE_COUNT
var client: Node
var table: RefCounted # SkillTable
var _player_getter: Callable
var _root: Control
var _slots := [] # 当前页 UI[{btn, cd, lbl}]
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
var _page := 0
var _move_from := -1
func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable) -> void:
client = m2client
table = skill_table
_player_getter = player_getter
for _i in SLOT_COUNT:
_state.append({"kind": "", "id": 0, "cd_end": 0.0})
_build(parent)
if client.has_signal("skill_cooldown_end"):
client.skill_cooldown_end.connect(_on_cd_end)
if client.has_signal("quickslots_changed"):
client.quickslots_changed.connect(restore_from_server)
restore_from_server()
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
# 服务器 type:1 道具 / 2 技能 / 3 命令 / 4 表情;ref = 道具格 / 技能 id。
func restore_from_server() -> void:
if client == null or not client.has_method("get_quickslots"):
return
var restored := []
for _i in SLOT_COUNT:
restored.append({"kind": "", "id": 0, "cd_end": 0.0})
for qs in client.get_quickslots():
var pos := int(qs.get("pos", -1))
if pos < 0 or pos >= SLOT_COUNT:
continue
match int(qs.get("type", 0)):
1: restored[pos] = {"kind": "item", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
2: restored[pos] = {"kind": "skill", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
_state = restored
_refresh_page()
func assign(slot: int, kind: String, id: int, persist := true) -> void:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return
var global := _global_slot(slot)
_state[global] = {"kind": kind, "id": id, "cd_end": 0.0}
if persist and client and client.has_method("quickslot_add"):
var type := 2 if kind == "skill" else 1 if kind == "item" else 0
if type == 0 or not client.quickslot_add(global, type, id):
return
_refresh_slot(slot)
func activate(slot: int) -> void:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return
var global := _global_slot(slot)
var s: Dictionary = _state[global]
if s.kind == "" or s.id == 0:
return
if _now() < s.cd_end:
return
if s.kind == "skill":
var mi: int = table.motion_idx_of(s.id) if table else 0
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
var yaw := 0.0
var xy := Vector2.ZERO
if p:
yaw = fposmod(90.0 - rad_to_deg(p.rotation.y), 360.0)
xy = Vector2(p.position.x * CM, -p.position.z * CM)
var target_vid := 0
if client.has_method("get_target"):
target_vid = int(client.get_target().get("vid", 0))
# use_skill is the authoritative server intent. Keep the CG_MOVE skill
# state packet as a separate visual/action-state sync, just like the
# original client did.
var intent_sent := true
if client.has_method("use_skill"):
intent_sent = client.use_skill(int(s.id), target_vid)
if intent_sent and client.cast_skill(mi, yaw, int(xy.x), int(xy.y)):
s.cd_end = _now() + _skill_cooldown(int(s.id))
_state[global] = s
skill_activated.emit(int(s.id))
elif s.kind == "item":
client.use_item(1, s.id) # WINDOW_INVENTORY
func set_page(page: int) -> void:
if page < 0 or page >= PAGE_COUNT or page == _page:
return
_page = page
_move_from = -1
_refresh_page()
func _global_slot(local_slot: int) -> int:
return _page * SLOTS_PER_PAGE + local_slot
func _clear(slot: int) -> void:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return
var global := _global_slot(slot)
if _state[global].kind == "":
return
if client and client.has_method("quickslot_del") and not client.quickslot_del(global):
return
_state[global] = {"kind": "", "id": 0, "cd_end": 0.0}
_refresh_slot(slot)
func _swap(local_a: int, local_b: int) -> void:
var a := _global_slot(local_a)
var b := _global_slot(local_b)
if client and client.has_method("quickslot_swap") and not client.quickslot_swap(a, b):
return
var tmp: Dictionary = _state[a]
_state[a] = _state[b]
_state[b] = tmp
_refresh_slot(local_a)
_refresh_slot(local_b)
# --- UI ---------------------------------------------------------------
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_root.position = Vector2(-9 * 23, -54)
parent.add_child(_root)
var prev := Button.new()
prev.text = ""
prev.position = Vector2(-24, 7)
prev.pressed.connect(func(): set_page(posmod(_page - 1, PAGE_COUNT)))
_root.add_child(prev)
var next := Button.new()
next.text = ""
next.position = Vector2(9 * 44 + 2, 7)
next.pressed.connect(func(): set_page(posmod(_page + 1, PAGE_COUNT)))
_root.add_child(next)
var page_label := Label.new()
page_label.name = "page"
page_label.position = Vector2(9 * 44 + 7, 38)
page_label.add_theme_font_size_override("font_size", 9)
_root.add_child(page_label)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 4)
_root.add_child(row)
for i in SLOTS_PER_PAGE:
var slot := Panel.new()
slot.custom_minimum_size = Vector2(40, 40)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.1, 0.11, 0.14, 0.9)
sb.border_color = Color(0.35, 0.32, 0.26)
sb.set_border_width_all(1)
slot.add_theme_stylebox_override("panel", sb)
var num := Label.new()
num.text = str(i + 1)
num.position = Vector2(3, 1)
num.add_theme_font_size_override("font_size", 10)
slot.add_child(num)
var lbl := Label.new()
lbl.name = "lbl"
lbl.position = Vector2(3, 15)
lbl.add_theme_font_size_override("font_size", 9)
slot.add_child(lbl)
var cd := ColorRect.new()
cd.name = "cd"
cd.color = Color(0, 0, 0, 0.55)
cd.set_anchors_preset(Control.PRESET_FULL_RECT)
cd.visible = false
slot.add_child(cd)
var idx := i
slot.gui_input.connect(func(e: InputEvent):
if e is InputEventMouseButton and e.pressed:
if e.button_index == MOUSE_BUTTON_LEFT:
_try_drop(idx)
elif e.button_index == MOUSE_BUTTON_RIGHT:
_clear(idx))
row.add_child(slot)
_slots.append({"btn": slot, "cd": cd, "lbl": lbl})
_refresh_page()
func _try_drop(slot: int) -> void:
# skill_ui 把待拖技能放在 drag_skill_id;这里落点接
var sk_ui := _find_skill_ui()
if sk_ui and sk_ui.drag_skill_id != 0:
assign(slot, "skill", sk_ui.drag_skill_id)
sk_ui.drag_skill_id = 0
_move_from = -1
return
if _move_from >= 0 and _move_from != slot:
_swap(_move_from, slot)
_move_from = -1
return
if _state[_global_slot(slot)].kind != "":
_move_from = slot
func _find_skill_ui() -> Node:
var tree := get_tree()
if tree == null or tree.root == null:
return null
for n in tree.root.find_children("*", "Node", true, false):
if n.get_script() and n.has_method("set_job") and "drag_skill_id" in n:
return n
return null
func _refresh_slot(slot: int) -> void:
if slot < 0 or slot >= _slots.size():
return
var s: Dictionary = _state[_global_slot(slot)]
var txt := ""
if s.kind == "skill" and table:
txt = table.name_of(s.id).substr(0, 5)
elif s.kind == "item":
txt = "#%d" % s.id
_slots[slot].lbl.text = txt
func _refresh_page() -> void:
if _root and _root.has_node("page"):
_root.get_node("page").text = "F%d" % (_page + 1)
for slot in _slots.size():
_refresh_slot(slot)
func _on_cd_end(skill_id: int) -> void:
for i in _state.size():
var s: Dictionary = _state[i]
if s.kind == "skill" and s.id == skill_id:
s.cd_end = 0.0
_state[i] = s
func _process(_dt: float) -> void:
var t := _now()
for slot in _slots.size():
var s: Dictionary = _state[_global_slot(slot)]
var on_cd: bool = t < s.cd_end
_slots[slot].cd.visible = on_cd
if on_cd:
var duration := _skill_cooldown(int(s.id))
var frac: float = clampf((s.cd_end - t) / duration, 0.0, 1.0)
_slots[slot].cd.anchor_top = 1.0 - frac
func _skill_cooldown(skill_id: int) -> float:
if table and table.has_method("cooldown_of"):
var level := 0
if client and client.has_method("get_skills"):
for skill in client.get_skills():
if int(skill.get("id", 0)) == skill_id:
level = int(skill.get("level", 0))
break
var configured := float(table.cooldown_of(skill_id, level))
if configured > 0.0:
return configured
return DEFAULT_CD
func _now() -> float:
return Time.get_ticks_msec() / 1000.0
+1
View File
@@ -0,0 +1 @@
uid://cunyyq6g5bs6m
+154
View File
@@ -0,0 +1,154 @@
# RaceSpec (P2) —— 解析角色种族规格文件 `<race>.msm`(文本树,非二进制 mesh)。
#
# 客户端 GameLib/RaceDataFile.cpp `CRaceData::LoadRaceData`。格式是 Metin2 的
# CTextFileLoader 花括号树:
# ScriptType RaceDataScript
# BaseModelFileName "D:/YMIR WORK/pc/warrior/warrior_novice.GR2"
# Group ShapeData {
# PathName "d:/ymir Work/pc/warrior/"
# ShapeDataCount 26
# Group ShapeData00 { ShapeIndex 0 Model "warrior_novice.gr2"
# SourceSkin "..dds" TargetSkin "..dds" }
# ...
# }
# Group HairData { ... 同结构,HairIndex/Model/SourceSkin/TargetSkin ... }
#
# var rs := preload("res://ui/race_spec.gd").new()
# rs.load_file("<assets>/PC/ymir work/pc/warrior/warrior.msm") # 或 season1/pc/warrior.msm
# rs.shape(9) # { model:"warrior_cheongrin.gr2", source_skin:"..", target_skin:"..", path:"d:/ymir Work/pc/warrior/" }
# rs.hair(3) # 同上
extends RefCounted
var base_model := ""
var shape_path := ""
var hair_path := ""
var shapes := {} # shape_index:int -> { model, source_skin, target_skin }
var hairs := {} # hair_index:int -> { model, source_skin, target_skin }
var _dir := "" # 规格文件所在目录(资产实际就在这旁边)
func load_file(path: String) -> bool:
shapes.clear()
hairs.clear()
if not FileAccess.file_exists(path):
return false
_dir = path.get_base_dir()
var toks := _tokenize(FileAccess.get_file_as_string(path))
var i := [0]
var tree := _parse_block(toks, i, true) # 顶层当一个隐式 block
base_model = _s(tree.get("BaseModelFileName", ""))
var sd: Dictionary = tree.get("__group_ShapeData", {})
shape_path = _s(sd.get("PathName", ""))
for k in sd:
if String(k).begins_with("__group_ShapeData") and sd[k] is Dictionary:
var g: Dictionary = sd[k]
if g.has("ShapeIndex"):
shapes[int(g["ShapeIndex"])] = _entry(g)
var hd: Dictionary = tree.get("__group_HairData", {})
hair_path = _s(hd.get("PathName", ""))
for k in hd:
if String(k).begins_with("__group_HairData") and hd[k] is Dictionary:
var g: Dictionary = hd[k]
if g.has("HairIndex"):
hairs[int(g["HairIndex"])] = _entry(g)
return shapes.size() > 0
func shape(idx: int) -> Dictionary:
var e: Dictionary = shapes.get(idx, {})
if e.is_empty():
return {}
e = e.duplicate()
e["path"] = shape_path
e["spec_dir"] = _dir
return e
func hair(idx: int) -> Dictionary:
var e: Dictionary = hairs.get(idx, {})
if e.is_empty():
return {}
e = e.duplicate()
e["path"] = hair_path
e["spec_dir"] = _dir
return e
# --- internals --------------------------------------------------------
func _entry(g: Dictionary) -> Dictionary:
return {
"model": _s(g.get("Model", g.get("local_model", ""))).strip_edges(),
"source_skin": _s(g.get("SourceSkin", "")).strip_edges(),
"target_skin": _s(g.get("TargetSkin", "")).strip_edges(),
}
func _s(v) -> String:
return v if v is String else str(v)
# 词法:按空白切;"..." 是一个 token{ } 单独 token# / // 到行尾注释
func _tokenize(src: String) -> Array:
var out := []
var i := 0
var n := src.length()
while i < n:
var c := src[i]
if c in [" ", "\t", "\r", "\n"]:
i += 1
elif c == '"':
var j := i + 1
var s := ""
while j < n and src[j] != '"':
s += src[j]
j += 1
out.append("" + s) # 前缀标记「这是带引号的字符串值」
i = j + 1
elif c == "{" or c == "}":
out.append(c)
i += 1
elif c == "#" or (c == "/" and i + 1 < n and src[i + 1] == "/"):
while i < n and src[i] != "\n":
i += 1
else:
var j := i
while j < n and not (src[j] in [" ", "\t", "\r", "\n", "{", "}", '"']):
j += 1
out.append(src.substr(i, j - i))
i = j
return out
# 解析一个 { } 块(或 top=true 的隐式顶层)为 Dictionary。
# KEY VALUE -> d[KEY] = VALUE
# Group NAME { ... } -> d["__group_NAME"] = <block dict>
func _parse_block(toks: Array, i: Array, top := false) -> Dictionary:
var d := {}
var guard := 0
while i[0] < toks.size():
guard += 1
if guard > 100000:
break
var t = toks[i[0]]
if t == "}":
i[0] += 1
return d
if t is String and t == "Group":
i[0] += 1
var name := String(toks[i[0]]) if i[0] < toks.size() else "?"
i[0] += 1
if i[0] < toks.size() and toks[i[0]] == "{":
i[0] += 1
d["__group_" + name] = _parse_block(toks, i)
continue
if t == "{":
i[0] += 1
continue
# KEY VALUE
var key: String = str(_tokval(t))
i[0] += 1
if i[0] < toks.size() and toks[i[0]] != "}" and toks[i[0]] != "{":
d[key] = _tokval(toks[i[0]])
i[0] += 1
if top and i[0] >= toks.size():
break
return d
func _tokval(t):
if t is Dictionary and t.has("str"):
return t["str"]
return str(t)
+1
View File
@@ -0,0 +1 @@
uid://bp7yu41d0cuja
+106
View File
@@ -0,0 +1,106 @@
# ReconnectUI (P10) —— 断线遮罩 + 自动重连倒计时 + 手动重连。
#
# var rc := preload("res://ui/reconnect_ui.gd").new()
# add_child(rc)
# rc.setup(m2client)
#
# `disconnected(reason)` → 显示遮罩 + "N 秒后自动重连";到点调 client.reconnect()。
# `entered_game` 或任何非 offline 的 phase_changed → 隐藏。
# 按钮:立即重连 / 停止(不再自动)。
extends CanvasLayer
const AUTO_DELAY := 5.0
const MAX_TRIES := 5
var client: Node
var _panel: ColorRect
var _msg: Label
var _count: Label
var _left := 0.0
var _tries := 0
var _auto := true
var _armed := false
func setup(m2client: Node) -> void:
client = m2client
layer = 70
_build()
if client.has_signal("disconnected"):
client.disconnected.connect(_on_disconnected)
if client.has_signal("entered_game"):
client.entered_game.connect(func(): _dismiss())
if client.has_signal("phase_changed"):
client.phase_changed.connect(func(p):
if String(p).to_lower() not in ["offline", "", "close"]:
_dismiss())
func is_showing() -> bool:
return _panel.visible
func _on_disconnected(reason: String) -> void:
_msg.text = "连接断开:%s" % reason
_panel.visible = true
_armed = true
_tries = 0
_left = AUTO_DELAY
func _dismiss() -> void:
_panel.visible = false
_armed = false
func _retry_now() -> void:
if client == null or not client.has_method("reconnect"):
return
_tries += 1
_count.text = "重连中…(第 %d 次)" % _tries
_left = AUTO_DELAY
var ok: bool = client.reconnect()
if not ok:
_count.text = "重连失败,%0.0f 秒后再试" % AUTO_DELAY
func _process(dt: float) -> void:
if not _panel.visible or not _armed:
return
if not _auto:
_count.text = "自动重连已停止"
return
if _tries >= MAX_TRIES:
_count.text = "已重试 %d 次,仍失败" % _tries
return
_left -= dt
_count.text = "%0.0f 秒后自动重连(%d/%d" % [maxf(_left, 0.0), _tries, MAX_TRIES]
if _left <= 0.0:
_retry_now()
func _build() -> void:
_panel = ColorRect.new()
_panel.color = Color(0.05, 0.02, 0.02, 0.92)
_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
_panel.visible = false
add_child(_panel)
var box := VBoxContainer.new()
box.set_anchors_preset(Control.PRESET_CENTER)
box.position = Vector2(-160, -70)
box.custom_minimum_size = Vector2(320, 0)
box.add_theme_constant_override("separation", 12)
_panel.add_child(box)
_msg = Label.new()
_msg.text = "连接断开"
_msg.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_msg.add_theme_font_size_override("font_size", 16)
box.add_child(_msg)
_count = Label.new()
_count.text = ""
_count.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_count)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_child(row)
var now_btn := Button.new()
now_btn.text = "立即重连"
now_btn.pressed.connect(_retry_now)
row.add_child(now_btn)
var stop_btn := Button.new()
stop_btn.text = "停止"
stop_btn.pressed.connect(func(): _auto = false)
row.add_child(stop_btn)
+1
View File
@@ -0,0 +1 @@
uid://db2sxruqo6v3j
+94
View File
@@ -0,0 +1,94 @@
# RefineUI (M4) —— 精炼 / 强化对话框:`refine_ask` 来时弹。
#
# 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)`[取消] 关闭。
extends Node
var client: Node
var proto: Node
var _root: Control
var _text: RichTextLabel
var _cur := {}
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
proto = proto_node
_build(parent)
if client.has_signal("refine_ask"):
client.refine_ask.connect(_on_ask)
func is_open() -> bool:
return _root != null and _root.visible
func _name_of(vnum: int) -> String:
if vnum == 0:
return "-"
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
var n := String(d.get("locale_name", d.get("name", "")))
if n != "":
return n
return "#%d" % vnum
func _on_ask(info: Dictionary) -> void:
_cur = info
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("材料:")
for m in mats:
lines.append(" %s ×%d" % [_name_of(int(m.get("vnum", 0))), int(m.get("count", 1))])
_text.text = "\n".join(lines)
_root.visible = true
func _do_refine() -> 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
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.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.09, 0.07, 0.05, 0.98)
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)
_text = RichTextLabel.new()
_text.bbcode_enabled = true
_text.position = Vector2(14, 34)
_text.custom_minimum_size = Vector2(292, 160)
_text.size = Vector2(292, 160)
_root.add_child(_text)
var row := HBoxContainer.new()
row.position = Vector2(14, 208)
row.add_theme_constant_override("separation", 12)
_root.add_child(row)
var ok := Button.new()
ok.text = "精炼"
ok.custom_minimum_size = Vector2(120, 30)
ok.pressed.connect(_do_refine)
row.add_child(ok)
var cancel := Button.new()
cancel.text = "取消"
cancel.custom_minimum_size = Vector2(120, 30)
cancel.pressed.connect(func(): _root.visible = false)
row.add_child(cancel)
+1
View File
@@ -0,0 +1 @@
uid://dfnssxwhbe1qb
+122
View File
@@ -0,0 +1,122 @@
# SafeboxUI (P8) —— 仓库窗。
#
# var bu := preload("res://ui/safebox_ui.gd").new()
# add_child(bu)
# bu.setup(m2client, canvas_parent, proto) # proto 可空
#
# `safebox_changed` 刷新(size>0 → 打开)。列出仓库道具 + [取出]。
# inventory_ui 在仓库开着时右键道具 → 调 bu.deposit(win, cell)。
# 取出/存入用第一个空位(简版:deposit 用 safe_pos = 下一个空格;checkout 用道具 cell)。
extends Node
var client: Node
var proto: Node
var _root: Control
var _list: VBoxContainer
var _gold: Label
var _title: Label
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
proto = proto_node
_build(parent)
if client.has_signal("safebox_changed"):
client.safebox_changed.connect(refresh)
func is_open() -> bool:
return _root != null and _root.visible
func deposit(inv_window: int, inv_cell: int) -> void:
if is_open():
client.safebox_checkin(_next_free_slot(), inv_window, inv_cell)
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
for i in range(cap):
if not used.has(i):
return i
return 0
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
var n := String(d.get("locale_name", d.get("name", "")))
if n != "":
return n
return "#%d" % vnum
func refresh() -> void:
if client == null:
return
_root.visible = client.is_safebox_open()
if not _root.visible:
return
_title.text = "仓库(%d 页)" % client.get_safebox_size()
_gold.text = "仓库金币: %d" % client.get_safebox_gold()
for c in _list.get_children():
c.queue_free()
var items: Array = client.get_safebox_items()
if items.is_empty():
var e := Label.new()
e.text = "(空)"
_list.add_child(e)
return
for it in items:
_list.add_child(_row(it))
func _row(it: Dictionary) -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(300, 0)
var nm := Label.new()
nm.text = "%s ×%d" % [_name_of(int(it.get("vnum", 0))), int(it.get("count", 1))]
nm.custom_minimum_size = Vector2(220, 0)
nm.add_theme_font_size_override("font_size", 12)
row.add_child(nm)
var out := Button.new()
out.text = "取出"
var cell := int(it.get("cell", 0))
out.pressed.connect(func() -> void: client.safebox_checkout(cell, 1, _first_free_inv()))
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
for i in range(90):
if not used.has(i):
return i
return 0
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.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)
_title = Label.new()
_title.text = "仓库"
_title.position = Vector2(12, 8)
_root.add_child(_title)
_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)
_list = VBoxContainer.new()
_list.position = Vector2(12, 54)
_list.add_theme_constant_override("separation", 4)
_root.add_child(_list)
+1
View File
@@ -0,0 +1 @@
uid://dqnnrie05cy1p
+217
View File
@@ -0,0 +1,217 @@
# SelectItemUI (P11) —— 选魔石窗(1:1 迁移 `assets/root/uiselectitem.py`)。
#
# var si := preload("res://ui/select_item_ui.gd").new()
# add_child(si)
# si.setup(ui_manager, m2client, proto, assets_root, item_list)
# si.open() # 由 quest_dialog.select_item_requested 触发
#
# 布局走 `assets/uiscript/uiscript/selectitemwindow.py`UiScript → UiBuild)。
# 逐字对照 uiselectitem.py.RefreshSlot:遍历背包前 `INVENTORY_PAGE_SIZE*2` 格,
# 只保留 `item.IsMetin`proto.type == ITEM_TYPE_METIN 10)且 `GetItemGrade <= 2`
# (= 物品内部名最后一位数字,非数字按 0)的物品,最多 54 个。
# 点格子 -> `net.SendSelectItemPacket(inventoryCell)`= M2Client.script_select_item+ 关窗;
# 关闭 / ExitButton -> `net.SendSelectItemPacket(0)` + 关窗(uiselectitem.Close)。
extends Node
const UiAssets = preload("res://ui/ui_assets.gd")
const ITEM_TYPE_METIN := 10 # GameLib/ItemData.h EItemType
const INVENTORY_PAGE_SIZE := 45 # player.INVENTORY_PAGE_SIZE
const MAX_ITEMS := 54 # uiselectitem: slotPos > 54 -> break
const WINDOW_INVENTORY := 1
var ui: CanvasLayer # UiManager
var client: Node # M2Client
var proto: Node # Metin2Proto
var item_list: RefCounted # ItemListDB(可空)
var assets_root := ""
var uiscript_dir := ""
var _win: Dictionary = {} # { root, nodes }
var _cells := {} # slot_index:int -> Panel
var _slot_to_inv := {} # 选择窗格号 -> 背包格号(inventorySlotPosDict
var _sent := false # 已发过一次 select(避免关窗再补发 0)
func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets := "",
il: RefCounted = null) -> void:
ui = ui_manager
client = m2client
proto = proto_node
item_list = il
assets_root = assets
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")
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(func(_w, _c): if is_open(): _refresh())
# --- open / close ----------------------------------------------------
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func toggle() -> void:
if is_open(): close()
else: open()
# uiselectitem.Close: net.SendSelectItemPacket(0) 再隐藏
func close() -> void:
if is_open():
if not _sent and client and client.has_method("script_select_item"):
client.script_select_item(0)
ui.close(_win["root"])
_win = {}
_cells.clear()
_slot_to_inv.clear()
func open() -> void:
if is_open():
_refresh()
return
var path := uiscript_dir.path_join("selectitemwindow.py")
if not FileAccess.file_exists(path):
push_warning("SelectItemUI: no selectitemwindow.py at " + path)
return
_sent = false
_win = ui.open_script(path, assets_root)
if not is_open():
return
_index_cells(_win["root"])
_wire()
_refresh()
func _node(nm: String) -> Control:
if _win.is_empty():
return null
var n = _win.get("nodes", {}).get(nm, null)
return n if n is Control else null
func _index_cells(root: Node) -> void:
_cells.clear()
for n in root.find_children("slot_*", "Panel", true, false):
if n.has_meta("slot_index"):
_cells[int(n.get_meta("slot_index"))] = n
func _wire() -> void:
for idx in _cells:
var cell: Panel = _cells[idx]
cell.gui_input.connect(func(e: InputEvent): _on_cell_input(int(idx), e))
var exit_btn := _node("ExitButton")
if exit_btn is BaseButton:
exit_btn.pressed.connect(close)
var tb := _node("TitleBar")
if tb:
for b in tb.find_children("*", "BaseButton", true, false):
b.pressed.connect(close)
# --- fill (uiselectitem.RefreshSlot) --------------------------------
func _item_grade(vnum: int) -> int:
# player.GetItemGrade: 物品内部名最后一位数字(非数字 -> 0)
var nm := ""
if proto:
var pd: Dictionary = proto.item(vnum)
nm = String(pd.get("name", ""))
if nm == "":
return 0
var last := nm.substr(nm.length() - 1, 1)
return int(last) if last.is_valid_int() else 0
func _is_metin(vnum: int) -> bool:
if proto == null:
return false
var pd: Dictionary = proto.item(vnum)
return int(pd.get("type", 0)) == ITEM_TYPE_METIN
func _refresh() -> void:
if not is_open() or client == null:
return
for idx in _cells:
_clear_cell(_cells[idx])
_slot_to_inv.clear()
# 背包按格号索引,只看前 INVENTORY_PAGE_SIZE*2 格(uiselectitem 的 range
var by_cell := {}
for d in client.get_inventory():
by_cell[int(d["cell"])] = d
var slot_pos := 0
for i in range(INVENTORY_PAGE_SIZE * 2):
var d = by_cell.get(i, null)
if d == null:
continue
var vnum := int(d["vnum"])
if vnum == 0 or not _is_metin(vnum):
continue
if _item_grade(vnum) > 2:
continue
_slot_to_inv[slot_pos] = i
var cell: Panel = _cells.get(slot_pos, null)
if cell:
_fill_cell(cell, vnum, int(d.get("count", 1)))
slot_pos += 1
if slot_pos > MAX_ITEMS:
break
func _fill_cell(cell: Panel, vnum: int, count: int) -> void:
_clear_cell(cell)
cell.set_meta("vnum", vnum)
var nm := "item %d" % vnum
if proto:
var pd: Dictionary = proto.item(vnum)
if not pd.is_empty():
nm = String(pd.get("locale_name", pd.get("name", nm)))
cell.tooltip_text = "%s\n#%d" % [nm, vnum]
var tex := _icon(vnum)
if tex:
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_SCALE
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(tr)
else:
var lb := Label.new()
lb.name = "icon"
lb.text = nm.substr(0, 6)
lb.add_theme_font_size_override("font_size", 9)
lb.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(lb)
if count > 1:
var cnt := Label.new()
cnt.name = "count"
cnt.text = str(count)
cnt.position = Vector2(2, 16)
cnt.add_theme_font_size_override("font_size", 9)
cnt.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(cnt)
func _clear_cell(cell: Panel) -> void:
cell.set_meta("vnum", 0)
cell.tooltip_text = ""
for c in cell.get_children():
c.queue_free()
func _icon(vnum: int) -> Texture2D:
var rel := ""
if item_list and item_list.has(vnum):
rel = item_list.icon(vnum)
if rel == "":
rel = "icon/item/%05d.tga" % ((vnum / 10) * 10)
return UiAssets.load_tex(assets_root, rel)
# --- select (uiselectitem.SelectItemSlot) --------------------------
func _on_cell_input(slot_pos: int, e: InputEvent) -> void:
if not (e is InputEventMouseButton) or e.button_index != MOUSE_BUTTON_LEFT or not e.pressed:
return
if not _slot_to_inv.has(slot_pos):
return
var inv_cell := int(_slot_to_inv[slot_pos])
if client and client.has_method("script_select_item"):
client.script_select_item(inv_cell) # = net.SendSelectItemPacket(inventorySlotPos)
_sent = true
close()
+1
View File
@@ -0,0 +1 @@
uid://lro8chcsmtmk
+175
View File
@@ -0,0 +1,175 @@
# ShopUI (P8) —— NPC 商店窗。
#
# var su := preload("res://ui/shop_ui.gd").new()
# add_child(su)
# su.setup(m2client, canvas_parent, proto, item_list) # proto / item_list 可空
#
# `shop_opened(vid)` → 打开并列出货物(名字 + 价格 + [买])。
# `shop_closed` → 关闭。`shop_error(kind)` → 顶部红字提示。
# 卖:inventory_ui 在商店开着时右键道具 → 调 shop_ui.sell(cell)。
extends Node
const SHOP_SLOT_COUNT := 40 # shop.SHOP_SLOT_COUNT (== SHOP_HOST_ITEM_MAX_NUM)
var client: Node
var proto: Node
var item_list # ItemListDB (RefCounted)
var _root: Control
var _tabbar: HBoxContainer
var _list: VBoxContainer
var _err: Label
var _active_tab := 0
func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCounted = null) -> void:
client = m2client
proto = proto_node
item_list = ilist
_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_error"):
client.shop_error.connect(_on_error)
func is_open() -> bool:
return _root != null and _root.visible
func open() -> void:
_root.visible = true
_err.text = ""
_active_tab = 0
refresh()
func _close() -> void:
_root.visible = false
func close_and_leave() -> void:
client.shop_close()
_close()
func sell(inv_cell: int, count: int = 1) -> void:
if is_open():
client.shop_sell(inv_cell, count)
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
var n := String(d.get("locale_name", d.get("name", "")))
if n != "":
return n
return "#%d" % vnum
func refresh() -> void:
if not is_open() or client == null:
return
for c in _tabbar.get_children():
_tabbar.remove_child(c)
c.queue_free()
for c in _list.get_children():
_list.remove_child(c)
c.queue_free()
# START_EX 商店有多个货架(shop.GetTabCount);普通 START 商店 tabs 只 1 个。
var tabs: Array = []
if client.has_method("get_shop"):
tabs = client.get_shop().get("tabs", [])
if tabs.is_empty():
# 兼容没有 get_shop 的旧桩
tabs = [{"name": "", "items": client.get_shop_items()}]
_active_tab = clampi(_active_tab, 0, tabs.size() - 1)
if tabs.size() > 1:
_tabbar.visible = true
for i in tabs.size():
var tb := Button.new()
tb.toggle_mode = true
var tn := String(tabs[i].get("name", ""))
tb.text = tn if tn != "" else "货架 %d" % (i + 1)
tb.button_pressed = (i == _active_tab)
var idx := i
tb.pressed.connect(func() -> void:
_active_tab = idx
refresh())
_tabbar.add_child(tb)
else:
_tabbar.visible = false
var items: Array = tabs[_active_tab].get("items", [])
if items.is_empty():
var e := Label.new()
e.text = "(无货物)"
_list.add_child(e)
return
# uishop.py: 买位置 = tabIdx * SHOP_SLOT_COUNT + slotPos
var base := _active_tab * SHOP_SLOT_COUNT
for it in items:
_list.add_child(_row(it, base))
func _row(it: Dictionary, pos_base := 0) -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(320, 0)
var nm := Label.new()
nm.text = _name_of(int(it.get("vnum", 0)))
nm.custom_minimum_size = Vector2(170, 0)
nm.add_theme_font_size_override("font_size", 12)
row.add_child(nm)
var pr := Label.new()
pr.text = "%d" % int(it.get("price", 0))
pr.custom_minimum_size = Vector2(90, 0)
pr.modulate = Color(0.95, 0.85, 0.5)
row.add_child(pr)
var buy := Button.new()
buy.text = ""
var pos := pos_base + int(it.get("pos", 0))
buy.pressed.connect(func() -> void: client.shop_buy(pos, 1))
row.add_child(buy)
return row
func _on_error(kind: String) -> void:
var tbl := {
"NOT_ENOUGH_MONEY": "金币不足",
"SOLDOUT": "已售罄",
"INVENTORY_FULL": "背包已满",
"INVALID_POS": "位置无效",
}
_err.text = str(tbl.get(kind, kind))
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-190, -200)
_root.size = Vector2(380, 400)
_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.08, 0.07, 0.06, 0.97)
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)
_err = Label.new()
_err.position = Vector2(60, 10)
_err.modulate = Color(1, 0.4, 0.4)
_err.add_theme_font_size_override("font_size", 12)
_root.add_child(_err)
_tabbar = HBoxContainer.new()
_tabbar.position = Vector2(12, 30)
_tabbar.add_theme_constant_override("separation", 4)
_tabbar.visible = false
_root.add_child(_tabbar)
_list = VBoxContainer.new()
_list.position = Vector2(12, 58)
_list.add_theme_constant_override("separation", 4)
_root.add_child(_list)
var close_btn := Button.new()
close_btn.text = "离开"
close_btn.position = Vector2(300, 360)
close_btn.pressed.connect(close_and_leave)
_root.add_child(close_btn)
+1
View File
@@ -0,0 +1 @@
uid://cojt0r4bhsq5p
+168
View File
@@ -0,0 +1,168 @@
# SkillTable (P6) —— 读 `locale/locale/<lang>/skilldesc.txt`skill id → 名/职业/动作。
#
# 列(TSV1-indexedPythonSkill.cpp __LoadSkillDesc):
# 1 id · 2 JOB(WARRIOR/ASSASSIN/SURA/SHAMAN/SUPPORT/HORSE/GUILD) · 3-5 name/name2/name3
# 6-9 desc1-4 · 10 (空) · 11 attributes(ATTACK_SKILL|PASSIVE|TOGGLE|STANDING_SKILL|…)
# 12 weapon_limit(SWORD|BOW|…) · 13 motion_name · 14 motion_idx · 15 grade_count · 16+ 公式
#
# var st := preload("res://ui/skill_table.gd").new()
# st.load_file("<assets>/locale/locale/en/skilldesc.txt")
# st.entry(1) # { id, job, name, …, attrs, motion, motion_idx, grades, cooltime }
# st.for_job("WARRIOR") # 主动职业技能 [id, …]
# st.for_category("SUPPORT") # 辅助 / 被动树
# st.is_passive(121) # true
extends RefCounted
var _by_id := {}
var count := 0
const JOB_DIR := {
"WARRIOR": "warrior",
"ASSASSIN": "assassin",
"SURA": "sura",
"SHAMAN": "shaman",
}
func load_file(path: String) -> bool:
_by_id.clear()
count = 0
if not FileAccess.file_exists(path):
return false
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return false
while not f.eof_reached():
var line := f.get_line()
if line.strip_edges() == "":
continue
var t := line.split("\t")
if t.size() < 14:
continue
var id := int(t[0])
if id == 0:
continue
# 0-indexed: id=0 job=1 name=2..4 desc=5..8 (9 空) attrs=10 weapon=11
# motion_name=12 motion_idx=13 grades=14
var entry := {
"id": id,
"job": t[1].strip_edges(),
"name": t[2].strip_edges(),
"name2": t[3].strip_edges(),
"name3": t[4].strip_edges(),
"attrs": t[10].strip_edges() if t.size() > 10 else "",
"weapon": t[11].strip_edges() if t.size() > 11 else "",
"motion": t[12].strip_edges() if t.size() > 12 else "",
"motion_idx": int(t[13]) if t.size() > 13 else 0,
"grades": int(t[14]) if t.size() > 14 else 1,
}
_load_cooltime(entry)
_by_id[id] = entry
count = _by_id.size()
return count > 0
func has(id: int) -> bool:
return _by_id.has(id)
func entry(id: int) -> Dictionary:
return _by_id.get(id, {})
func name_of(id: int) -> String:
return String(_by_id.get(id, {}).get("name", "skill %d" % id))
func motion_idx_of(id: int) -> int:
return int(_by_id.get(id, {}).get("motion_idx", 0))
func _attrs(id: int) -> String:
return String(_by_id.get(id, {}).get("attrs", ""))
func is_attack(id: int) -> bool:
return _attrs(id).contains("ATTACK_SKILL")
# 原版只有弓类飞行攻击在动作事件中发送 CG_SHOOT;把 weapon_limit 保留下来,
# 让上层可以只为远程技能安排 shoot,而不会给近战技能误发该包。
func is_ranged(id: int) -> bool:
return String(_by_id.get(id, {}).get("weapon", "")).to_upper().contains("BOW")
func is_passive(id: int) -> bool:
return _attrs(id).contains("PASSIVE")
func is_toggle(id: int) -> bool:
return _attrs(id).contains("TOGGLE")
func can_level_up(id: int) -> bool:
return _by_id.has(id) and not _attrs(id).contains("CANNOT_LEVEL_UP")
# 读取对应 .msk 中的 CoolTimeFormula 和 MaxLevel。原客户端用同一份公式
# 按技能效率百分比计算本地冷却;这里在发送后进行同样的本地预测,GC_SKILL_COOLTIME_END
# 仍是服务器的提前解锁信号。
func _load_cooltime(entry: Dictionary) -> void:
var job_dir := String(JOB_DIR.get(String(entry.get("job", "")), ""))
var motion := String(entry.get("motion", ""))
if job_dir == "" or motion == "":
return
var path := AssetRoot.path().path_join("PC/ymir work/pc/%s/skill/%s.msk" % [job_dir, motion])
if not FileAccess.file_exists(path):
return
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return
# .msk 常含 CP949/CP1252 本地化文本;只扫描 ASCII 配置行,不能把整份
# 文件当 UTF-8 逐行读,否则 Godot 会为无关文案输出解码警告。
var bytes := file.get_buffer(file.get_length())
var formula := _ascii_field(bytes, "CoolTimeFormula")
if formula != "":
entry["cooltime_formula"] = formula.trim_prefix("\"").trim_suffix("\"")
var max_level := _ascii_field(bytes, "MaxLevel")
if max_level != "":
entry["max_level"] = max(1, int(max_level.trim_prefix("\"").trim_suffix("\"")))
func _ascii_field(bytes: PackedByteArray, field: String) -> String:
var needle := field.to_ascii_buffer()
if needle.is_empty():
return ""
for start in range(bytes.size() - needle.size() + 1):
var found := true
for offset in needle.size():
if bytes[start + offset] != needle[offset]:
found = false
break
if not found:
continue
var out := PackedByteArray()
var i := start + needle.size()
while i < bytes.size() and bytes[i] != 10 and bytes[i] != 13:
if bytes[i] >= 32 and bytes[i] <= 126:
out.append(bytes[i])
i += 1
return out.get_string_from_ascii().strip_edges()
return ""
func cooldown_of(id: int, level: int) -> float:
var data := entry(id)
var formula := String(data.get("cooltime_formula", ""))
if formula == "":
return 0.0
var max_level: int = max(1, int(data.get("max_level", 20)))
var skill_point: float = clampf(float(level) / float(max_level), 0.0, 1.0)
var expression := Expression.new()
if expression.parse(formula, ["SkillPoint"]) != OK:
return 0.0
var value: Variant = expression.execute([skill_point])
if expression.has_execute_failed() or not (value is float or value is int):
return 0.0
return maxf(0.0, float(value))
# JOB 列的分类:主动树是 WARRIOR/ASSASSIN/SURA/SHAMAN;辅助/被动树是 SUPPORT;还有 HORSE / GUILD
func category_of(id: int) -> String:
return String(_by_id.get(id, {}).get("job", ""))
func for_job(job: String) -> Array:
return for_category(job)
func for_category(cat: String) -> Array:
var out := []
for id in _by_id:
if _by_id[id].get("job", "") == cat:
out.append(id)
out.sort()
return out
+1
View File
@@ -0,0 +1 @@
uid://c5exefafglr65
+167
View File
@@ -0,0 +1,167 @@
# SkillUI (P6) —— 技能窗:主动 / 辅助(被动·支援) / 坐骑 三个分类页 + 等级 + 加点。
#
# var sk := preload("res://ui/skill_ui.gd").new()
# add_child(sk)
# sk.setup(m2client, skill_table, ui_manager)
# sk.set_job("WARRIOR")
# sk.toggle() # K 键
#
# 每行:名字 + Lv X(/M/G/P) + [](被动 / CANNOT_LEVEL_UP 无加点)+ [被动]/[切换] 标签。
# 点名字 -> drag_skill_idquickbar 落点读)。`skills_changed` 刷新。
extends Node
const TABS := ["主动", "辅助", "坐骑"]
const MASTER_SUFFIX := ["", " M", " G", " P"] # master_type 0..3
var client: Node
var table: RefCounted # SkillTable
var ui: CanvasLayer # UiManager
var job := "WARRIOR"
var drag_skill_id := 0
var _tab := 0
var _win: Control
var _rows := {} # skill_id -> {lv, up}
func setup(m2client: Node, skill_table: RefCounted, ui_manager: CanvasLayer) -> void:
client = m2client
table = skill_table
ui = ui_manager
if client.has_signal("skills_changed"):
client.skills_changed.connect(refresh)
func set_job(j: String) -> void:
job = j
if is_open():
_rebuild()
func is_open() -> bool:
return _win != null and is_instance_valid(_win)
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
if is_open():
ui.close(_win)
_win = null
_rows.clear()
func _cat_key() -> String:
match _tab:
1: return "SUPPORT"
2: return "HORSE"
_: return job
func open() -> void:
if is_open() or table == null:
return
_win = Control.new()
_win.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
_win.position = Vector2(-300, -240)
_win.size = Vector2(290, 470)
_win.set_meta("is_titlebar", true)
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.08, 0.09, 0.12, 0.95)
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_win.add_child(panel)
var title := Label.new()
title.text = "技能"
title.position = Vector2(12, 8)
_win.add_child(title)
# 分类页签
var tabrow := HBoxContainer.new()
tabrow.name = "tabs"
tabrow.position = Vector2(10, 30)
_win.add_child(tabrow)
for i in TABS.size():
var b := Button.new()
b.text = TABS[i]
b.toggle_mode = true
b.button_pressed = (i == _tab)
var ti: int = i
b.pressed.connect(func(): _switch_tab(ti))
tabrow.add_child(b)
var vb := VBoxContainer.new()
vb.name = "list"
vb.position = Vector2(10, 60)
vb.custom_minimum_size = Vector2(270, 0)
vb.add_theme_constant_override("separation", 2)
_win.add_child(vb)
ui.open(_win)
_rebuild()
func _switch_tab(i: int) -> void:
_tab = i
if is_open():
var tabrow: HBoxContainer = _win.get_node("tabs")
for j in tabrow.get_child_count():
(tabrow.get_child(j) as Button).button_pressed = (j == i)
_rebuild()
func _rebuild() -> void:
var vb: VBoxContainer = _win.get_node("list")
for c in vb.get_children():
c.queue_free()
_rows.clear()
var ids: Array = table.for_category(_cat_key())
if ids.is_empty():
var e := Label.new()
e.text = "(无)"
vb.add_child(e)
return
for id in ids:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(268, 26)
var nm := Label.new()
nm.text = table.name_of(id)
nm.custom_minimum_size = Vector2(140, 0)
nm.add_theme_font_size_override("font_size", 12)
row.add_child(nm)
var lv := Label.new()
lv.name = "lv"
lv.custom_minimum_size = Vector2(58, 0)
lv.add_theme_font_size_override("font_size", 12)
row.add_child(lv)
var tag := Label.new()
tag.custom_minimum_size = Vector2(40, 0)
tag.add_theme_font_size_override("font_size", 10)
if table.is_passive(id):
tag.text = "被动"
tag.modulate = Color(0.6, 0.8, 1.0)
elif table.is_toggle(id):
tag.text = "切换"
tag.modulate = Color(0.9, 0.8, 0.5)
row.add_child(tag)
var sid: int = id
if table.can_level_up(id) and not table.is_passive(id):
var up := Button.new()
up.text = ""
up.custom_minimum_size = Vector2(28, 22)
up.pressed.connect(func(): client.skill_up(sid))
row.add_child(up)
_rows[id] = {"lv": lv, "up": up}
else:
_rows[id] = {"lv": lv, "up": null}
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)
vb.add_child(row)
refresh()
func refresh() -> void:
if not is_open():
return
var lv := {}
var ms := {}
for s in client.get_skills():
lv[int(s["id"])] = int(s["level"])
ms[int(s["id"])] = int(s.get("master", 0))
for id in _rows:
var lvl: int = lv.get(id, 0)
var m: int = clampi(ms.get(id, 0), 0, 3)
_rows[id]["lv"].text = "Lv %d%s" % [lvl, MASTER_SUFFIX[m]]
+1
View File
@@ -0,0 +1 @@
uid://db3diqdsl7isa
+122
View File
@@ -0,0 +1,122 @@
# SystemMenuUI (P11) —— ESC 系统菜单(1:1 迁移 `assets/root/uisystem.py` `SystemDialog`
# 的 `__LoadSystemMenu_Default`;布局走真 `assets/uiscript/uiscript/systemdialog.py`)。
#
# var sm := preload("res://ui/system_menu_ui.gd").new()
# add_child(sm)
# sm.setup(ui_manager, m2client, assets_root, system_option_ui, game_option_ui)
# sm.toggle() # ESC(无其它窗打开时)
#
# 按钮绑定逐字对照 uisystem.py
# help_button → OpenHelpWindow(暂无帮助窗 → 提示)
# mall_button → net.SendChatPacket("/in_game_mall")
# system_option_button → uiSystemOption.OptionDialogsystem_option_ui
# game_option_button → uiGameOption.OptionDialoggame_option_ui
# change_button → net.ExitGame() = SendChatPacket("/phase_select")
# logout_button → net.LogOutGame() = SendChatPacket("/logout")
# exit_button → net.ExitApplication() = 退出进程
# cancel_button / 标题栏 → 关闭
extends Node
const LABELS := {
"help_button": "帮助", "mall_button": "商城", "system_option_button": "系统设置",
"game_option_button": "游戏设置", "change_button": "选择角色", "logout_button": "登出",
"exit_button": "退出游戏", "cancel_button": "取消",
}
var ui: CanvasLayer
var client: Node
var assets_root := ""
var uiscript_dir := ""
var system_option_ui: Node
var game_option_ui: Node
var toast: Callable = Callable() # 可选:func(msg: String) 显示提示
var _win: Dictionary = {}
func setup(ui_manager: CanvasLayer, m2client: Node, assets := "",
sys_opt: Node = null, game_opt: Node = null) -> void:
ui = ui_manager
client = m2client
system_option_ui = sys_opt
game_option_ui = game_opt
assets_root = assets
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")
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
if is_open():
ui.close(_win["root"])
_win = {}
func open() -> void:
if is_open():
return
var path := uiscript_dir.path_join("systemdialog.py")
if not FileAccess.file_exists(path):
push_warning("SystemMenuUI: no systemdialog.py at " + path)
return
_win = ui.open_script(path, assets_root, true) # modal
if not is_open():
return
_relabel()
_wire()
func _node(nm: String) -> Control:
if _win.is_empty():
return null
var n = _win.get("nodes", {}).get(nm, null)
return n if n is Control else null
func _relabel() -> void:
for nm in LABELS:
var n := _node(nm)
if n and n.has_method("set_text"):
n.set_text(LABELS[nm])
func _btn(nm: String, cb: Callable) -> void:
var b := _node(nm)
if b is BaseButton:
b.pressed.connect(cb)
func _wire() -> void:
_btn("help_button", func():
if toast.is_valid():
toast.call("帮助窗暂未实现")
)
_btn("mall_button", func():
if client and client.has_method("say"):
client.say(0, "/in_game_mall") # net.SendChatPacket("/in_game_mall")
close())
_btn("system_option_button", func():
close()
if system_option_ui and system_option_ui.has_method("open"):
system_option_ui.open())
_btn("game_option_button", func():
close()
if game_option_ui and game_option_ui.has_method("open"):
game_option_ui.open())
_btn("change_button", func():
if client and client.has_method("say"):
client.say(0, "/phase_select") # net.ExitGame()
close())
_btn("logout_button", func():
if client and client.has_method("say"):
client.say(0, "/logout") # net.LogOutGame()
close())
_btn("exit_button", func(): get_tree().quit())
_btn("cancel_button", close)
var board := _node("board")
if board:
for x in board.find_children("*", "BaseButton", true, false):
if x.get_meta("is_titlebar", false):
x.pressed.connect(close)
+1
View File
@@ -0,0 +1 @@
uid://b2djf1pu8ad6n
+245
View File
@@ -0,0 +1,245 @@
# SystemOptionUI (P11) —— 系统设置窗(1:1 迁移 `assets/root/uisystemoption.py` `OptionDialog`)。
#
# var so := preload("res://ui/system_option_ui.gd").new()
# add_child(so)
# so.setup(ui_manager, assets_root, audio_node, func(): return cam, func(): return env)
# so.toggle() # ESC(无其它窗打开时)
#
# 布局走真 `assets/uiscript/uiscript/systemoptiondialog.py`UiScript → UiBuild)。
# 控件绑定逐字对照 uisystemoption.py
# music_volume_controller → snd.SetMusicVolume + systemSetting.SetMusicVolume
# sound_volume_controller → snd.SetSoundVolume + systemSetting.SetSoundVolume
# camera_short / camera_long → systemSetting 镜头最大距离(近/远)
# fog_level0 / 1 / 2 (浓/中/淡) → systemSetting.SetFogLevel(0..2)
# tiling_cpu / tiling_gpu / apply → 原为 CPU/GPU 分块渲染,Godot 渲染器无对应,保留为占位
# 设置持久化到 `user://system_option.cfg`= 原 systemSetting 的配置文件)。
extends Node
const CFG_PATH := "user://system_option.cfg"
const CAMERA_MAX := [11.0, 20.0] # 近 / 远(game_camera.max_dist
const FOG_DENSITY := [0.055, 0.018, 0.004] # 浓 / 中 / 淡(Environment.fog_density
# uiScriptLocale 未接全局 Locale 时的中文兜底
const LABELS := {
"titlename": "系统设置", "music_name": "背景音乐", "sound_name": "音效",
"bgm_button": "更换", "bgm_file": "默认主题", "camera_mode": "镜头距离",
"camera_short": "", "camera_long": "", "fog_mode": "",
"fog_level0": "", "fog_level1": "", "fog_level2": "",
"tiling_mode": "图形内存", "tiling_cpu": "CPU", "tiling_gpu": "GPU", "tiling_apply": "应用",
}
var ui: CanvasLayer
var assets_root := ""
var uiscript_dir := ""
var audio: Node
var _cam_get: Callable = Callable()
var _env_get: Callable = Callable()
var _win: Dictionary = {}
var _cfg := ConfigFile.new()
# --- 状态(= systemSetting 值)---
var music_volume := 0.6
var sound_volume := 0.9
var camera_mode := 1 # 0 近, 1 远
var fog_level := 1 # 0 浓, 1 中, 2 淡
var tiling_mode := 1 # 0 CPU, 1 GPU(占位)
func setup(ui_manager: CanvasLayer, assets: String, audio_node: Node = null,
cam_getter := Callable(), env_getter := Callable()) -> void:
ui = ui_manager
assets_root = assets
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")
audio = audio_node
_cam_get = cam_getter
_env_get = env_getter
_load_cfg()
_apply_all() # 持久化的设置在开窗前就生效
# --- open / close ---------------------------------------------------
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
if is_open():
ui.close(_win["root"])
_win = {}
func open() -> void:
if is_open():
return
var path := uiscript_dir.path_join("systemoptiondialog.py")
if not FileAccess.file_exists(path):
push_warning("SystemOptionUI: no systemoptiondialog.py at " + path)
return
_win = ui.open_script(path, assets_root)
if not is_open():
return
_relabel()
_wire()
_sync_controls()
func _node(nm: String) -> Control:
if _win.is_empty():
return null
var n = _win.get("nodes", {}).get(nm, null)
return n if n is Control else null
# uiScriptLocale 兜底:把 `<KEY>` 文本换成中文
func _relabel() -> void:
for nm in LABELS:
var n := _node(nm)
if n and n.has_method("set_text"):
n.set_text(LABELS[nm])
# --- wiring (uisystemoption.__Load) ---------------------------------
func _wire() -> void:
var mv := _node("music_volume_controller")
if mv is Range:
mv.min_value = 0.0
mv.max_value = 1.0
mv.step = 0.01
mv.value = music_volume
mv.value_changed.connect(_on_music_volume)
var sv := _node("sound_volume_controller")
if sv is Range:
sv.min_value = 0.0
sv.max_value = 1.0
sv.step = 0.01
sv.value = sound_volume
sv.value_changed.connect(_on_sound_volume)
_bind_radio(["camera_short", "camera_long"], func(i):
camera_mode = i
_apply_camera()
_save())
_bind_radio(["fog_level0", "fog_level1", "fog_level2"], func(i):
fog_level = i
_apply_fog()
_save())
_bind_radio(["tiling_cpu", "tiling_gpu"], func(i):
tiling_mode = i
_save())
var apply_btn := _node("tiling_apply")
if apply_btn is BaseButton:
apply_btn.pressed.connect(func():
# 原 __OnClickTilingApplyButton 会重启客户端应用 CPU/GPU 分块;
# Godot 渲染器无此项,仅记录选择。
_save())
var bgm_btn := _node("bgm_button")
if bgm_btn is BaseButton:
bgm_btn.disabled = true # 换 BGM 需音乐文件选择器,暂缺
func _bind_radio(names: Array, on_pick: Callable) -> void:
var boxes: Array = []
for nm in names:
boxes.append(_node(nm))
for i in boxes.size():
var b = boxes[i]
if not (b is BaseButton):
continue
b.toggle_mode = true
var idx := i
b.pressed.connect(func():
for j in boxes.size():
if boxes[j] is BaseButton:
boxes[j].set_pressed_no_signal(j == idx)
on_pick.call(idx))
# 把当前状态回填到控件(开窗时)
func _sync_controls() -> void:
_set_radio(["camera_short", "camera_long"], camera_mode)
_set_radio(["fog_level0", "fog_level1", "fog_level2"], fog_level)
_set_radio(["tiling_cpu", "tiling_gpu"], tiling_mode)
func _set_radio(names: Array, active: int) -> void:
for i in names.size():
var b = _node(names[i])
if b is BaseButton:
b.set_pressed_no_signal(i == active)
# --- change handlers ----------------------------------------------------
func _on_music_volume(v: float) -> void:
music_volume = clampf(v, 0.0, 1.0)
_apply_music()
_save()
func _on_sound_volume(v: float) -> void:
sound_volume = clampf(v, 0.0, 1.0)
_apply_sound()
_save()
# --- apply to engine --------------------------------------------------
func _apply_all() -> void:
_apply_music()
_apply_sound()
_apply_camera()
_apply_fog()
func _apply_music() -> void:
if audio == null:
return
if "master_bgm" in audio:
audio.master_bgm = music_volume
# 已在播放的 BGM 声道即时跟随
if "_bgm" in audio and "_bgm_cur" in audio:
var pl = audio._bgm[audio._bgm_cur]
if is_instance_valid(pl):
pl.volume_db = linear_to_db(maxf(music_volume, 0.0001))
func _apply_sound() -> void:
if audio and "master_sfx" in audio:
audio.master_sfx = sound_volume # play_ui / play_at 每次读取
func _apply_camera() -> void:
if not _cam_get.is_valid():
return
var c = _cam_get.call()
if c == null:
return
var m: float = CAMERA_MAX[clampi(camera_mode, 0, 1)]
if "max_dist" in c:
c.max_dist = m
if "dist" in c:
c.dist = minf(c.dist, m)
func _apply_fog() -> void:
if not _env_get.is_valid():
return
var e = _env_get.call()
if not (e is Environment):
return
e.fog_enabled = true
e.fog_density = FOG_DENSITY[clampi(fog_level, 0, 2)]
# --- persistence (= systemSetting 配置文件) --------------------------
func _load_cfg() -> void:
if _cfg.load(CFG_PATH) != OK:
return
music_volume = float(_cfg.get_value("audio", "music_volume", music_volume))
sound_volume = float(_cfg.get_value("audio", "sound_volume", sound_volume))
camera_mode = int(_cfg.get_value("video", "camera_mode", camera_mode))
fog_level = int(_cfg.get_value("video", "fog_level", fog_level))
tiling_mode = int(_cfg.get_value("video", "tiling_mode", tiling_mode))
func _save() -> void:
_cfg.set_value("audio", "music_volume", music_volume)
_cfg.set_value("audio", "sound_volume", sound_volume)
_cfg.set_value("video", "camera_mode", camera_mode)
_cfg.set_value("video", "fog_level", fog_level)
_cfg.set_value("video", "tiling_mode", tiling_mode)
_cfg.save(CFG_PATH)
+1
View File
@@ -0,0 +1 @@
uid://cs2tl51xyym8q
+110
View File
@@ -0,0 +1,110 @@
# UiAssets (P1) —— 解析 uiscript 里的图片路径(`.sub` / `.tga` / `.png` / `.jpg`)。
#
# 路径形如 `d:/ymir work/ui/public/middle_button_01.sub`。解析:
# 1) 去掉盘符前缀
# 2) 依次试: <assets>/<rel> · <assets>/<pack>/<rel>(散包目录)
# 3) `.sub` = 子图描述(title/image/left/top/right/bottom)→ 载入其 image + 裁剪成 AtlasTexture
# `.dds` 走 Metin2World.load_ddsC++ dxt 解码)运行时解成 Image。
extends RefCounted
static var _cache := {}
static var _dds_helper: Object = null
# DDS 运行时解码:走 Metin2World.load_ddsC++ 里的 dxt.cppDXT1/3/5 + BGRA8)。
# Godot 无原生运行时 DDS 解码,UI 里的 select.dds / 职业名图都得走这条路。
static func load_dds_image(path: String) -> Image:
if path == "" or not FileAccess.file_exists(path):
return null
if not ClassDB.class_exists("Metin2World"):
return null
if _dds_helper == null or not is_instance_valid(_dds_helper):
_dds_helper = ClassDB.instantiate("Metin2World")
var img = _dds_helper.call("load_dds", path)
return img if img is Image else null
static func load_tex(assets_root: String, vpath: String) -> Texture2D:
if vpath == "" or assets_root == "":
return null
var key := assets_root + "|" + vpath
if _cache.has(key):
return _cache[key]
var tex: Texture2D = _load_uncached(assets_root, vpath)
_cache[key] = tex
return tex
static func _strip_drive(p: String) -> String:
var s := p.replace("\\", "/")
if s.length() >= 2 and s[1] == ":":
s = s.substr(2)
return s.lstrip("/")
static func _resolve(assets_root: String, rel: String) -> String:
var direct := assets_root.path_join(rel)
if FileAccess.file_exists(direct):
return direct
# 散包:<assets>/<sub>/<rel>
var da := DirAccess.open(assets_root)
if da:
for sub in da.get_directories():
var cand := assets_root.path_join(sub).path_join(rel)
if FileAccess.file_exists(cand):
return cand
# 大小写不敏感兜底:<assets>/**/ymir work/ui/... —— 只按 basename 找
return ""
static func _load_uncached(assets_root: String, vpath: String) -> Texture2D:
var rel := _strip_drive(vpath)
var real := _resolve(assets_root, rel)
if real == "":
# 试把 .sub 换成 .tga / .png
for ext: String in [".tga", ".png", ".jpg"]:
real = _resolve(assets_root, rel.get_basename() + ext)
if real != "":
break
if real == "":
return null
if real.get_extension().to_lower() == "sub":
return _load_sub(real)
return _load_image_file(real)
static func _load_image_file(path: String) -> Texture2D:
var ext := path.get_extension().to_lower()
if ext == "dds":
var di := load_dds_image(path)
return ImageTexture.create_from_image(di) if di != null else null
var img := Image.new()
if img.load(path) != OK:
return null
return ImageTexture.create_from_image(img)
static func _load_sub(path: String) -> Texture2D:
var txt := FileAccess.get_file_as_string(path)
var image_name := ""
var l := 0
var t := 0
var r := -1
var b := -1
for line in txt.split("\n"):
var parts := line.strip_edges().split(" ", false)
if parts.size() < 2:
continue
match parts[0]:
"image": image_name = parts[1].strip_edges().trim_prefix('"').trim_suffix('"')
"left": l = int(parts[1])
"top": t = int(parts[1])
"right": r = int(parts[1])
"bottom": b = int(parts[1])
if image_name == "":
return null
var img_path := path.get_base_dir().path_join(image_name)
if not FileAccess.file_exists(img_path):
img_path = path.get_base_dir().path_join(image_name.get_basename() + ".tga")
var base := _load_image_file(img_path)
if base == null:
return null
if r <= l or b <= t:
return base
var at := AtlasTexture.new()
at.atlas = base
at.region = Rect2(l, t, r - l, b - t)
return at
+1
View File
@@ -0,0 +1 @@
uid://cdmq2ac3w0su6
+233
View File
@@ -0,0 +1,233 @@
# UiBuild (P1) —— uiscript 嵌套 Dictionary → Godot Control 树。
#
# var spec := UiScript.new().parse_file(path)
# var r := UiBuild.build(spec, assets_root)
# add_child(r.root)
# r.nodes["accept"].pressed.connect(...) # 按 uiscript 里的 "name" 取节点
#
# 覆盖 uiscript 最常用的 ~18 种 type;其它降级为 Control / Button / TextureRect。
# board/thinboard 的九宫格用 ui_kit(真实 ymir 贴图,缺失则 StyleBoxFlat 兜底)。
extends RefCounted
const UiKit = preload("res://ui_kit.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
# spec: 顶层 window dict。返回 { root: Control, nodes: {name: Control} }
static func build(spec: Dictionary, assets_root: String) -> Dictionary:
var ctx := {"assets": assets_root, "nodes": {}}
var root := _make(spec, ctx, Vector2i.ZERO)
if root == null:
root = Control.new()
return {"root": root, "nodes": ctx["nodes"]}
# --- recursion ---------------------------------------------------------
static func _i(spec: Dictionary, key: String, def := 0) -> int:
var v = spec.get(key, def)
return int(v) if (v is int or v is float or v is bool or v is String) else def
static func _str(v) -> String:
if v == null:
return ""
return v if v is String else str(v)
static func _make(spec: Dictionary, ctx: Dictionary, parent_size: Vector2i) -> Control:
var type := _str(spec.get("type", "window"))
var w := _i(spec, "width", 0)
var h := _i(spec, "height", 0)
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)))
if w > 0:
node.custom_minimum_size = Vector2(w, h)
node.size = Vector2(w, h)
# 位置:x/y + horizontal_align / vertical_align(相对父)
var x := _i(spec, "x", 0)
var y := _i(spec, "y", 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
"bottom": y = parent_size.y - h - y
node.position = Vector2(x, y)
var nm := _str(spec.get("name", ""))
if nm != "":
ctx["nodes"][nm] = node
var self_size := Vector2i(w, h) if w > 0 else parent_size
for child in spec.get("children", []):
if child is Dictionary:
var c := _make(child, ctx, self_size)
if c:
node.add_child(c)
return node
static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: Vector2i) -> Control:
var assets: String = ctx["assets"]
match type:
"window", "box":
var c := Control.new()
c.mouse_filter = Control.MOUSE_FILTER_PASS
return c
"board", "thinboard", "board_with_titlebar":
var p := _board(assets, "thinboard" if type == "thinboard" else "board", sz)
if type == "board_with_titlebar" and spec.has("title"):
var tl := Label.new()
tl.name = "TitleText"
tl.text = _str(spec.get("title", ""))
tl.position = Vector2(12, 6)
p.add_child(tl)
p.set_meta("titlebar_h", 24)
return p
"titlebar":
var t := Panel.new()
t.set_meta("is_titlebar", true)
return t
"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")):
"center": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
"right": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
return lbl
"button", "toggle_button":
var b := Button.new()
b.text = _str(spec.get("text", ""))
b.toggle_mode = (type == "toggle_button")
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", "")))
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))
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", "")))
ir.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
ir.stretch_mode = TextureRect.STRETCH_SCALE if type == "expanded_image" \
else TextureRect.STRETCH_KEEP
ir.mouse_filter = Control.MOUSE_FILTER_IGNORE
return ir
"editline":
var e := LineEdit.new()
e.text = _str(spec.get("text", ""))
e.secret = bool(spec.get("secret", false))
if spec.has("input_limit"):
e.max_length = int(spec["input_limit"])
return e
"line":
var ln := ColorRect.new()
ln.color = Color(1, 1, 1, 0.15)
if _i(spec, "height", 0) == 0:
ln.custom_minimum_size = Vector2(_i(spec, "width", 100), 1)
return ln
"listbox", "candidate_list":
return ItemList.new()
"gauge", "bar", "horizontalbar":
var pb := ProgressBar.new()
pb.show_percentage = false
pb.value = 100.0
return pb
"grid_table", "slotbar", "slot":
# Metin2 物品格容器:有 start_index/x_count 或显式 "slot" 元组 -> 建子格;
# 否则 grid_table 退化成普通 GridContainer。
var cells := _slot_cells(spec)
if cells.is_empty() and type == "grid_table":
var g := GridContainer.new()
g.columns = maxi(1, _i(spec, "x_count", _i(spec, "column", 1)))
return g
var s := Control.new()
s.set_meta("is_slot_container", true)
for cd in cells:
var cell := Panel.new()
cell.name = "slot_%d" % int(cd["index"])
cell.position = Vector2(cd["x"], cd["y"])
cell.custom_minimum_size = Vector2(cd["w"], cd["h"])
cell.size = Vector2(cd["w"], cd["h"])
cell.set_meta("slot_index", int(cd["index"]))
cell.mouse_filter = Control.MOUSE_FILTER_STOP
s.add_child(cell)
return s
"sliderbar":
return HSlider.new()
"scrollbar", "thin_scrollbar":
return VScrollBar.new()
_:
return Control.new()
# --- helpers ---------------------------------------------------------
static func _board(assets: String, prefix: String, sz: Vector2i) -> Panel:
var p := Panel.new()
var np: NinePatchRect = null
if assets != "":
np = UiKit.board(assets, prefix, 32, 128)
if np and np.texture:
np.set_anchors_preset(Control.PRESET_FULL_RECT)
np.mouse_filter = Control.MOUSE_FILTER_IGNORE
p.add_child(np)
p.get_child(0).show_behind_parent = true
else:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.10, 0.11, 0.14, 0.92)
sb.border_color = Color(0.35, 0.32, 0.26)
sb.set_border_width_all(1)
sb.set_corner_radius_all(3)
p.add_theme_stylebox_override("panel", sb)
return p
# slot 容器的子格:显式 "slot" 元组,或 start_index/x_count/y_count/x_step/y_step 网格。
static func _slot_cells(spec: Dictionary) -> Array:
var cells := []
if spec.get("slot", null) is Array:
for sd in spec["slot"]:
if sd is Dictionary:
cells.append({"index": _i(sd, "index"), "x": _i(sd, "x"), "y": _i(sd, "y"),
"w": _i(sd, "width", 32), "h": _i(sd, "height", 32)})
elif spec.has("x_count"):
var si := _i(spec, "start_index", 0)
var xc := maxi(1, _i(spec, "x_count", 1))
var yc := maxi(1, _i(spec, "y_count", 1))
var xs := _i(spec, "x_step", 32)
var ys := _i(spec, "y_step", 32)
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})
return cells
static func _sb_tex(t: Texture2D) -> StyleBoxTexture:
var sb := StyleBoxTexture.new()
sb.texture = t
return sb
static func _argb(v: int) -> Color:
# 0xAARRGGBB
var a := (v >> 24) & 0xff
var r := (v >> 16) & 0xff
var g := (v >> 8) & 0xff
var b := v & 0xff
if a == 0:
a = 255
return Color8(r, g, b, a)
static func _safe_name(s: String) -> String:
var out := s
for bad: String in [".", ":", "@", "/", "%", " "]:
out = out.replace(bad, "_")
return out if out != "" else "node"
+1
View File
@@ -0,0 +1 @@
uid://dxoqp78836s5p
+134
View File
@@ -0,0 +1,134 @@
# UiManager (P1) —— 窗口栈:打开 / 关闭 / 置顶 / ESC 关顶层 / modal 变暗 / 标题栏拖动。
#
# var ui := preload("res://ui/ui_manager.gd").new()
# add_child(ui)
# ui.locale = func(k): return Locale.t(k)
# var w := ui.open_script("res://../assets/uiscript/uiscript/systemdialog.py", assets_root)
# w.nodes["close_button"].pressed.connect(func(): ui.close(w.root))
#
# open_script 返回 { root: Control, nodes: {name: Control} }(同 UiBuild.build)。
extends CanvasLayer
const UiScript = preload("res://ui/uiscript.gd")
const UiBuild = preload("res://ui/ui_build.gd")
signal window_opened(root: Control)
signal window_closed(root: Control)
var locale: Callable
var screen := Vector2i(1920, 1080)
var _root: Control # 全屏容器
var _dim: ColorRect # modal 变暗层
var _stack: Array[Control] = []
var _drag_win: Control = null
var _drag_from := Vector2.ZERO
func _ready() -> void:
layer = 10
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_root)
_dim = ColorRect.new()
_dim.color = Color(0, 0, 0, 0.45)
_dim.set_anchors_preset(Control.PRESET_FULL_RECT)
_dim.visible = false
_root.add_child(_dim)
set_process_unhandled_input(true)
# --- open / close --------------------------------------------------------
func build_script(path: String, assets_root: String) -> Dictionary:
var us := UiScript.new()
us.screen = screen
if locale.is_valid():
us.locale = locale
var spec := us.parse_file(path)
if spec.is_empty():
push_warning("UiManager: parse failed (%s): %s" % [path, us.last_error])
return {"root": Control.new(), "nodes": {}}
return UiBuild.build(spec, assets_root)
# 解析 + 构建 + 打开,返回 { root, nodes }
func open_script(path: String, assets_root: String, modal := false) -> Dictionary:
var r := build_script(path, assets_root)
open(r.root, modal)
return r
func open(win: Control, modal := false) -> void:
if win.get_parent() == null:
_root.add_child(win)
win.set_meta("modal", modal)
_wire_drag(win)
_stack.erase(win)
_stack.append(win)
_restack()
window_opened.emit(win)
func close(win: Control) -> void:
if win == null:
return
_stack.erase(win)
if win.get_parent():
win.get_parent().remove_child(win)
win.queue_free()
_restack()
window_closed.emit(win)
func close_top() -> bool:
if _stack.is_empty():
return false
close(_stack[-1])
return true
func top() -> Control:
return _stack[-1] if not _stack.is_empty() else null
func _restack() -> void:
var any_modal := false
for i in _stack.size():
var w := _stack[i]
w.move_to_front()
if bool(w.get_meta("modal", false)):
any_modal = true
_dim.visible = any_modal
if any_modal:
# 变暗层紧贴在最顶层 modal 之下
_dim.move_to_front()
_stack[-1].move_to_front()
# --- ESC ---------------------------------------------------------------
func _unhandled_input(e: InputEvent) -> void:
if e is InputEventKey and e.pressed and e.keycode == KEY_ESCAPE:
if close_top():
get_viewport().set_input_as_handled()
# --- 标题栏拖动 --------------------------------------------------------
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))
func _find_drag_handle(win: Control) -> Control:
# 优先 titlebar;否则 board_with_titlebar 的顶部条;否则整个 window 根
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
func _on_handle_input(win: Control, ev: InputEvent) -> void:
if ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT:
if ev.pressed:
_drag_win = win
_drag_from = win.get_global_mouse_position() - win.position
open(win) # 置顶
else:
_drag_win = null
elif ev is InputEventMouseMotion and _drag_win == win:
win.position = win.get_global_mouse_position() - _drag_from
+1
View File
@@ -0,0 +1 @@
uid://cyw533ww50yve
+327
View File
@@ -0,0 +1,327 @@
# UiScript (P1) —— 解析原始客户端的 `assets/uiscript/*.py` 布局脚本为嵌套 Dictionary。
#
# 这些文件是 Python **dict 字面量**,用到的子集很小:
# 语句: import X(忽略)· NAME = <expr>(文件常量)· window = {...}
# 表达式:str/int/float · NAME · NAME.ATTR · (e) · 元组 (e,e) · 列表 [e,e] ·
# dict {k:v} · 一元 - · 二元 + - * /
# 注释: # 和 ## 到行尾
#
# 名字解析顺序:文件常量 → {SCREEN_WIDTH, SCREEN_HEIGHT} → uiScriptLocale.<X>
# (交给 locale 回调)→ 未知则 0 / ""。除法按浮点算(调用方按需取整)。
#
# 用法:
# var us := preload("res://ui/uiscript.gd").new()
# us.screen = Vector2i(1920, 1080)
# us.locale = func(key): return Locale.t(key) # 可选
# var tree: Dictionary = us.parse_file("res://../assets/uiscript/uiscript/popupdialog.py")
# # tree = { name, type?, x, y, width, height, children:[...], ... }
extends RefCounted
var screen := Vector2i(1920, 1080)
var locale: Callable # func(key: String) -> String
var last_error := ""
var _consts := {} # 文件顶层 NAME = expr
var _s := "" # 源文本
var _p := 0 # 游标
# --- public ---------------------------------------------------------------
func parse_file(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
last_error = "no such file: " + path
return {}
return parse_text(FileAccess.get_file_as_string(path))
func parse_text(src: String) -> Dictionary:
last_error = ""
_consts = {"SCREEN_WIDTH": screen.x, "SCREEN_HEIGHT": screen.y}
_s = _strip_comments(src)
_p = 0
var window := {}
while true:
_skip_ws()
if _p >= _s.length():
break
if _match_kw("import"):
_skip_to_newline()
continue
var name := _read_ident()
if name == "":
# 可能是散落的括号/逗号;跳一个字符防死循环
_p += 1
continue
_skip_ws()
if _p < _s.length() and _s[_p] == "=":
_p += 1
var val = _parse_expr()
if name == "window":
window = val if val is Dictionary else {}
else:
_consts[name] = val
else:
_skip_to_newline()
if window.is_empty() and last_error == "":
last_error = "no `window = {...}` found"
return window
# --- lexing helpers -----------------------------------------------------
func _strip_comments(src: String) -> String:
var out := ""
var in_str := false
var q := ""
var i := 0
while i < src.length():
var c := src[i]
if in_str:
out += c
if c == "\\" and i + 1 < src.length():
out += src[i + 1]
i += 2
continue
if c == q:
in_str = false
i += 1
continue
if c == '"' or c == "'":
in_str = true
q = c
out += c
elif c == "#":
while i < src.length() and src[i] != "\n":
i += 1
continue
else:
out += c
i += 1
return out
func _skip_ws() -> void:
while _p < _s.length() and _s[_p] in [" ", "\t", "\r", "\n", ","]:
_p += 1
func _skip_to_newline() -> void:
while _p < _s.length() and _s[_p] != "\n":
_p += 1
func _match_kw(kw: String) -> bool:
_skip_ws()
if _s.substr(_p, kw.length()) == kw:
var after := _p + kw.length()
if after >= _s.length() or not _is_ident_char(_s[after]):
_p = after
return true
return false
func _is_ident_char(c: String) -> bool:
return c == "_" or (c >= "a" and c <= "z") or (c >= "A" and c <= "Z") or (c >= "0" and c <= "9")
func _read_ident() -> String:
_skip_ws()
var start := _p
while _p < _s.length() and _is_ident_char(_s[_p]):
_p += 1
return _s.substr(start, _p - start)
# --- expression parser (precedence: + - then * /) ----------------------
func _parse_expr():
var lhs = _parse_term()
while _p < _s.length():
_skip_ws_no_comma()
if _p >= _s.length():
break
var c := _s[_p]
if c == "+" or c == "-":
_p += 1
var rhs = _parse_term()
if c == "+" and (lhs is String or rhs is String):
lhs = _str_or(lhs) + _str_or(rhs)
elif c == "+":
lhs = _num(lhs) + _num(rhs)
else:
lhs = _num(lhs) - _num(rhs)
elif c == "%":
# Python 字符串格式化 "fmt" % (args):取 fmt,丢掉格式参数
_p += 1
_parse_term()
elif c == '"' or c == "'":
# Python 相邻字符串字面量隐式拼接: "a" "b"
lhs = _str_or(lhs) + _str_or(_parse_atom())
else:
break
return lhs
func _parse_term():
var lhs = _parse_atom()
while true:
_skip_ws_no_comma()
if _p < _s.length() and (_s[_p] == "*" or _s[_p] == "/"):
var op := _s[_p]
_p += 1
var rhs = _parse_atom()
if op == "*":
lhs = _num(lhs) * _num(rhs)
else:
lhs = _num(lhs) / _num(rhs) if _num(rhs) != 0.0 else 0.0
else:
break
return lhs
func _skip_ws_no_comma() -> void:
while _p < _s.length() and _s[_p] in [" ", "\t", "\r", "\n"]:
_p += 1
func _parse_atom():
_skip_ws_no_comma()
if _p >= _s.length():
return 0
var c := _s[_p]
if c == "-":
_p += 1
return -_num(_parse_atom())
if c == '"' or c == "'":
return _parse_string()
if c == "{":
return _parse_dict()
if c == "[":
return _parse_seq("[", "]")
if c == "(":
return _parse_seq("(", ")") # 元组或加括号的表达式,_parse_seq 统一处理
if c == "_" or (c >= "A" and c <= "Z") or (c >= "a" and c <= "z"):
return _parse_name()
if (c >= "0" and c <= "9") or c == ".":
return _parse_number()
_p += 1
return 0
func _parse_string():
var q := _s[_p]
_p += 1
var out := ""
while _p < _s.length() and _s[_p] != q:
if _s[_p] == "\\" and _p + 1 < _s.length():
var n := _s[_p + 1]
out += "\n" if n == "n" else ("\t" if n == "t" else n)
_p += 2
continue
out += _s[_p]
_p += 1
_p += 1 # closing quote
return out
func _parse_number():
var start := _p
var is_float := false
while _p < _s.length() and ((_s[_p] >= "0" and _s[_p] <= "9") or _s[_p] == "." or _s[_p] == "x" \
or (_s[_p] >= "a" and _s[_p] <= "f") or (_s[_p] >= "A" and _s[_p] <= "F")):
if _s[_p] == ".":
is_float = true
_p += 1
var tok := _s.substr(start, _p - start)
if tok.begins_with("0x") or tok.begins_with("0X"):
return tok.hex_to_int()
return float(tok) if is_float else int(tok)
func _parse_name():
var name := _read_ident()
# 属性链:uiScriptLocale.KEY / X.Y
while _p < _s.length() and _s[_p] == ".":
_p += 1
var attr := _read_ident()
if name == "uiScriptLocale":
return _resolve_locale(attr)
name += "." + attr
if _consts.has(name):
return _consts[name]
if name == "True":
return true
if name == "False":
return false
if name == "None":
return null
# 未知名字:数值上下文当 0,字符串上下文当 "";这里返回 0_str_or 会兜
return 0
func _resolve_locale(key: String):
if locale.is_valid():
var v = locale.call(key)
if v != null and String(v) != "":
return String(v)
return "<" + key + ">"
func _parse_dict() -> Dictionary:
var d := {}
_p += 1 # {
var guard := 0
while _p < _s.length():
guard += 1
if guard > 4000:
last_error = "dict parse runaway"
break
_skip_ws()
if _p >= _s.length() or _s[_p] == "}":
_p += 1
break
var before := _p
var key = _parse_atom()
_skip_ws()
if _p < _s.length() and _s[_p] == ":":
_p += 1
var val = _parse_expr()
d[_key(key)] = val
elif _p == before:
_p += 1 # 没进展也没 ':',强制前进防死循环
# 否则:孤立 token(相邻字符串续行残留等)——丢弃,继续
_skip_ws()
return d
func _key(k) -> String:
if k is String:
return k
if k is int or k is float:
return str(k)
return "_"
# 统一处理 ( ) 和 [ ]:单元素且无尾逗号的 (x) 退化成 x,否则是数组。
func _parse_seq(open: String, close: String):
_p += 1 # opening
var items := []
var had_comma := false
var guard := 0
while _p < _s.length():
guard += 1
if guard > 4000:
break
_skip_ws_no_comma()
if _p >= _s.length() or _s[_p] == close:
_p += 1
break
if _s[_p] == ",":
had_comma = true
_p += 1
continue
var before := _p
items.append(_parse_expr())
if _p == before:
_p += 1
if open == "(" and items.size() == 1 and not had_comma:
return items[0]
return items
# --- coercion ----------------------------------------------------------
func _num(v) -> float:
if v is float:
return v
if v is int:
return float(v)
if v is bool:
return 1.0 if v else 0.0
return 0.0
func _str_or(v) -> String:
if v is String:
return v
return str(v)
+1
View File
@@ -0,0 +1 @@
uid://e2wkbja3qvh7
+103
View File
@@ -0,0 +1,103 @@
# ViewEquipmentUI — GC_VIEW_EQUIP 的装备查看窗。
# 服务器推送某个角色的 11 个传统可见装备位后自动刷新并置顶。
extends Node
const WEAR_NAMES := ["身体", "头部", "鞋子", "护腕", "武器", "项链", "耳环", "盾牌", "饰品 1", "饰品 2", "箭矢"]
var client: Node
var ui: CanvasLayer
var proto: Node
var item_list: RefCounted
var _window: Control
func setup(m2client: Node, manager: CanvasLayer, proto_node: Node = null, il: RefCounted = null) -> void:
client = m2client
ui = manager
proto = proto_node
item_list = il
if client.has_signal("view_equipment"):
client.view_equipment.connect(show_for)
func show_for(vid: int) -> void:
if client == null or not client.has_method("get_view_equipment"):
return
var equipment: Array = client.get_view_equipment(vid)
if equipment.is_empty():
return
if is_instance_valid(_window):
ui.close(_window)
_window = _build_window(vid, equipment)
ui.open(_window)
func _build_window(vid: int, equipment: Array) -> Control:
var win := Panel.new()
win.name = "ViewEquipment"
win.position = Vector2(1280, 180)
win.size = Vector2(280, 390)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.05, 0.06, 0.09, 0.94)
bg.border_color = Color(0.65, 0.5, 0.2, 0.9)
bg.set_border_width_all(1)
bg.set_corner_radius_all(5)
win.add_theme_stylebox_override("panel", bg)
var title := Label.new()
title.position = Vector2(12, 8)
title.size = Vector2(220, 26)
title.text = "%s 的装备" % _character_name(vid)
title.add_theme_font_size_override("font_size", 16)
win.add_child(title)
var close := Button.new()
close.text = "×"
close.position = Vector2(238, 4)
close.size = Vector2(34, 30)
close.pressed.connect(func():
if is_instance_valid(_window):
ui.close(_window)
_window = null)
win.add_child(close)
var list := VBoxContainer.new()
list.position = Vector2(12, 42)
list.size = Vector2(256, 336)
list.add_theme_constant_override("separation", 3)
win.add_child(list)
for d in equipment:
var wear := int(d.get("wear", 0))
var line := Label.new()
line.text = "%s: %s" % [_wear_name(wear), _item_name(int(d.get("vnum", 0)), int(d.get("count", 0)))]
line.tooltip_text = _item_tooltip(d)
line.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
list.add_child(line)
return win
func _character_name(vid: int) -> String:
if client.has_method("get_entity"):
var entity: Dictionary = client.get_entity(vid)
if not entity.is_empty() and not String(entity.get("name", "")).is_empty():
return String(entity["name"])
return "角色 #%d" % vid
func _wear_name(wear: int) -> String:
return WEAR_NAMES[wear] if wear >= 0 and wear < WEAR_NAMES.size() else "装备 %d" % wear
func _item_name(vnum: int, count: int) -> String:
if vnum == 0:
return "(空)"
var name := "物品 #%d" % vnum
if proto:
var pd: Dictionary = proto.item(vnum)
if not pd.is_empty():
name = String(pd.get("locale_name", pd.get("name", name)))
elif item_list and item_list.has(vnum):
name = item_list.type_of(vnum)
return "%s x%d" % [name, count] if count > 1 else name
func _item_tooltip(d: Dictionary) -> String:
var attrs: Array = d.get("attrs", [])
if attrs.is_empty():
return ""
var lines := []
for attr in attrs:
lines.append("属性 %d: %+d" % [int(attr.get("type", 0)), int(attr.get("value", 0))])
return "\n".join(lines)
+1
View File
@@ -0,0 +1 @@
uid://b05l7vag5x0hd