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:
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://xm8a0g4337da
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://mvxphl3dhhlt
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://buc0fgb3nbkq6
|
||||
@@ -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")
|
||||
|
||||
# --- EPointTypes(m2dev 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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d1tgslry3nyo4
|
||||
@@ -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 喊话 · 其它 普通。
|
||||
# 消费 M2Client:chat / 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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcbcym6kbm16i
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ojp8p3ktvd2c
|
||||
@@ -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_town(CG_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
|
||||
@@ -0,0 +1 @@
|
||||
uid://crxoqgwj72u71
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtrft82p6n3br
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ce0wnqedo0ga7
|
||||
@@ -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]
|
||||
# (对齐客户端 __ArmorVnumToShape,SHAPE_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.7:fSpecular = 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 ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://eol27bnby30t
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dupx028in7ymk
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0hn8nlem82an
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ubb2dufk08vo
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://8x4b3cb2okdc
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cy0ui47ok7xx7
|
||||
@@ -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 全局 cell(90+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]
|
||||
@@ -0,0 +1 @@
|
||||
uid://b244uiisl84sk
|
||||
@@ -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", ""))
|
||||
@@ -0,0 +1 @@
|
||||
uid://blbd4u5qn03fm
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6okrhdjyyuu4
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bctp408qvphx6
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqlipbe67jyy6
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://nknmvwygsuda
|
||||
@@ -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 vnum(GC_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 ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://d06ypjevle2jf
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3654hrkbg1u0
|
||||
@@ -0,0 +1,98 @@
|
||||
# PlayerView (P2) —— 本地玩家的真模型:Metin2Model + Metin2AnimPlayer。
|
||||
#
|
||||
# var pv := preload("res://ui/player_view.gd").new()
|
||||
# pv.build(assets_root, race) # race 0..7(GC_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 ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://nm24uhdtjn4d
|
||||
@@ -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 里是 text(Label)—— 盖一个 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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://3gl3s2r0g74h
|
||||
@@ -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_ask(GC_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
|
||||
@@ -0,0 +1 @@
|
||||
uid://8irhkh75qgra
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dw1hym64mtt6g
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cunyyq6g5bs6m
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bp7yu41d0cuja
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://db2sxruqo6v3j
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfnssxwhbe1qb
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqnnrie05cy1p
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://lro8chcsmtmk
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cojt0r4bhsq5p
|
||||
@@ -0,0 +1,168 @@
|
||||
# SkillTable (P6) —— 读 `locale/locale/<lang>/skilldesc.txt`:skill id → 名/职业/动作。
|
||||
#
|
||||
# 列(TSV,1-indexed,PythonSkill.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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c5exefafglr65
|
||||
@@ -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_id(quickbar 落点读)。`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]]
|
||||
@@ -0,0 +1 @@
|
||||
uid://db3diqdsl7isa
|
||||
@@ -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.OptionDialog(system_option_ui)
|
||||
# game_option_button → uiGameOption.OptionDialog(game_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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b2djf1pu8ad6n
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cs2tl51xyym8q
|
||||
@@ -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_dds(C++ dxt 解码)运行时解成 Image。
|
||||
extends RefCounted
|
||||
|
||||
static var _cache := {}
|
||||
static var _dds_helper: Object = null
|
||||
|
||||
# DDS 运行时解码:走 Metin2World.load_dds(C++ 里的 dxt.cpp,DXT1/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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdmq2ac3w0su6
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxoqp78836s5p
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cyw533ww50yve
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://e2wkbja3qvh7
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b05l7vag5x0hd
|
||||
Reference in New Issue
Block a user