Captures the uncommitted parallel-development work (increments 46-49) on the
40250 classic client port, folded into docs/CLIENT-GAP.md + CLIENT-GAP-FIX.md.
Increment 49 (W0 interface-freeze batch, Phase 1 prerequisite) lands the
cross-workflow shared-file scaffolding so the 4 Phase 1 workflows can run in
isolated worktrees without contention:
- entity_store.{h,cpp}: Entity gains empire/affect_flags/owner_vid/state_flags;
new mut_spawn_full() (single-shot two-packet merge, §2.3), mut_ownership()
(§2.5), mut_map_bgm() + take_bgm_dirty()/bgm_name()/bgm_volume() (§9.1),
drain_dirty() (§2.1/§2.5 bare-field-update queue); mut_char_info() now stores
empire; reset_for_map_change() clears m_dirty.
- m2_client.cpp: new bgm_changed(name, volume) signal; classic + m2dev pump
loops drain drain_dirty() -> entity_info and take_bgm_dirty() -> bgm_changed;
entity_dict() exposes the 4 new keys.
- classic/classic_parser.{h,cpp}: GC_MAIN_CHARACTER3_BGM /
GC_MAIN_CHARACTER4_BGM_VOL route bgm_name/bgm_volume to mut_map_bgm() (was
discarded); new m_pending_actor staging map (W1 fills §2.2 merge logic).
- project/bgm_director.gd (new) + game_scene.gd: BGM consumption split out of
net_world.gd so W2/W4 don't collide; §8.6/§4.8 keybind convergence (digits
1-4 -> quickslots 0-3, F1-F4 -> quickslots 4-7, Ctrl+1..9 -> _emote()).
Tests: cmake --build build clean; ctest 16/16; 15 GDScript regressions green
(netbridge, gamescene, netplay, p9, p10, p2b, p8, system_menu_ui, skill,
combat_fx, skill_fx, player_motion, char_status_ui, chat, inventory), no skips.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
251 lines
8.7 KiB
GDScript
251 lines
8.7 KiB
GDScript
# 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 reload_map(metin_world: Node, name := "") -> void:
|
||
world = metin_world
|
||
if name != "":
|
||
map_name = name
|
||
if is_open():
|
||
close()
|
||
open()
|
||
|
||
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))
|