Files
mtgodot-poc/project/ui/minimap.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

379 lines
15 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
signal map_pressed
const RADIUS := 70.0
const SCALE := 0.25 # 像素/米(越大越放大)
# CPythonMiniMap 的 waypoint 夹边留边:c_fMiniMapWindowRadius = min(w,h)/2 - 9.0f
# m2dev PythonMiniMap.cpp:441)。信标越出此半径时,标记贴到圆周并改画方向箭头。
const SIGNAL_RIM_INSET := 9.0
const NPC_WARP_TYPE := 2 # NPCMark.type: 2 = 传送
const MIN_SCALE := 0.10
const MAX_SCALE := 1.25
const OBSERVER_ADD := 0
const OBSERVER_REMOVE := 1
const OBSERVER_MOVE := 2
const OBSERVER_INTERPOLATION_MS := 1000.0
var client: Node
var player_getter: Callable
var _root: Control
var _view: Control
var _ch_label: Label
var _coord_label: Label
var _btn_zoom_in: Button
var _btn_zoom_out: Button
var _btn_atlas: Button
var _scale := SCALE
var _signal_points: Array[Vector2] = []
var _observer_tracks: Dictionary = {}
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)
if client and client.has_signal("observer_event"):
client.observer_event.connect(_on_observer_event)
if client and client.has_signal("world_reset"):
client.world_reset.connect(_clear_observers)
_sync_observers_snapshot()
func _build(parent: Node) -> void:
_root = Control.new()
_root.name = "MiniMap"
_root.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_root.position = Vector2(-2 * RADIUS - 16, 12)
_root.size = Vector2(2 * RADIUS + 16, 2 * RADIUS + 38)
parent.add_child(_root)
_view = Control.new()
_view.size = Vector2(2 * RADIUS, 2 * RADIUS)
_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
_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(4, 2 * RADIUS + 2)
_ch_label.add_theme_font_size_override("font_size", 10)
_ch_label.text = "CH -"
_root.add_child(_ch_label)
# 40250 uiminimap.py:440 实时坐标 "(X, Y)"
_coord_label = Label.new()
_coord_label.position = Vector2(46, 2 * RADIUS + 2)
_coord_label.size = Vector2(90, 16)
_coord_label.add_theme_font_size_override("font_size", 10)
_coord_label.text = "(0, 0)"
_root.add_child(_coord_label)
# 40250 uiminimap.py: ScaleUp / ScaleDown / Atlas
_btn_zoom_in = Button.new()
_btn_zoom_in.name = "ScaleUpButton"
_btn_zoom_in.position = Vector2(2 * RADIUS - 2, 2 * RADIUS - 38)
_btn_zoom_in.size = Vector2(18, 18)
_btn_zoom_in.text = "+"
_btn_zoom_in.add_theme_font_size_override("font_size", 11)
_btn_zoom_in.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_btn_zoom_in.pressed.connect(func(): set_scale(_scale + 0.05))
_root.add_child(_btn_zoom_in)
_btn_zoom_out = Button.new()
_btn_zoom_out.name = "ScaleDownButton"
_btn_zoom_out.position = Vector2(2 * RADIUS - 2, 2 * RADIUS - 18)
_btn_zoom_out.size = Vector2(18, 18)
_btn_zoom_out.text = "-"
_btn_zoom_out.add_theme_font_size_override("font_size", 11)
_btn_zoom_out.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_btn_zoom_out.pressed.connect(func(): set_scale(_scale - 0.05))
_root.add_child(_btn_zoom_out)
_btn_atlas = Button.new()
_btn_atlas.name = "AtlasButton"
_btn_atlas.position = Vector2(2 * RADIUS - 2, 2 * RADIUS + 2)
_btn_atlas.size = Vector2(18, 18)
_btn_atlas.text = "M"
_btn_atlas.add_theme_font_size_override("font_size", 10)
_btn_atlas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_btn_atlas.pressed.connect(func(): map_pressed.emit())
_root.add_child(_btn_atlas)
func _process(_dt: float) -> void:
if _coord_label:
var pl := _player()
if pl:
var pos: Vector3 = pl.global_position if pl.is_inside_tree() else pl.position
# 40250 uiminimap.py: (x // 100, y // 100)
_coord_label.text = "(%d, %d)" % [int(pos.x), int(-pos.z)]
if _view:
_view.queue_redraw()
# 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
# Reparent the same live minimap view into the mobile top-right HUD. The
# drawing/data path stays shared; only the presentation container changes.
func mount_mobile(host: Control) -> void:
if host == null or _root == null:
return
var old_parent := _root.get_parent()
if old_parent:
old_parent.remove_child(_root)
host.add_child(_root)
_root.set_anchors_preset(Control.PRESET_TOP_LEFT)
_root.position = Vector2(0, 0)
_root.size = Vector2(140, 156)
_root.scale = Vector2(0.64, 0.64)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
func restore_desktop_layout() -> void:
if _root == null:
return
_root.scale = Vector2.ONE
_root.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_root.position = Vector2(-2 * RADIUS - 16, 12)
_root.size = Vector2(2 * RADIUS, 2 * RADIUS + 16)
# CPythonMiniMap::AddSignalPoint stores quest-script waypoints separately from
# server target markers. EventManager supplies absolute server centimetres.
func add_signal_point(x: float, y: float) -> void:
_signal_points.append(Vector2(x, y))
if _view:
_view.queue_redraw()
func clear_signal_points() -> void:
_signal_points.clear()
if _view:
_view.queue_redraw()
func signal_point_count() -> int:
return _signal_points.size()
# 1:1 复刻 m2dev CPythonMiniMap::Render 的 waypoint 夹边分支(PythonMiniMap.cpp:445-462):
# fDistanceFromCenter = sqrt(dx*dx + dy*dy)
# if fDistanceFromCenter >= radius:
# fRadian = atan2f(dy, dx); dx = radius*cos; dy = radius*sin
# 返回 {clamped, offset(相对圆心的像素偏移), angle(指向真实目标的弧度)}。
static func signal_marker_geometry(delta_px: Vector2, rim_radius: float) -> Dictionary:
var angle := atan2(delta_px.y, delta_px.x)
if delta_px.length() >= rim_radius:
return {
"clamped": true,
"offset": Vector2(rim_radius * cos(angle), rim_radius * sin(angle)),
"angle": angle,
}
return {"clamped": false, "offset": delta_px, "angle": angle}
func _signal_offset(world: Vector3, origin: Vector3) -> Dictionary:
var d := world - origin
# 与 _to_map 相同的世界→图轴向:world +x = 东(右),world z = 北(上)。
var delta_px := Vector2(d.x, d.z) * _scale
return signal_marker_geometry(delta_px, RADIUS - SIGNAL_RIM_INSET)
# CPythonMiniMap::AddObserver / MoveObserver / RemoveObserver keep observer
# positions separate from ordinary character instances. The reference client
# interpolates each move over one second, so a packet burst does not make the
# purple observer marker teleport across the minimap.
func _observer_track(pos: Vector3, now_ms: int) -> Dictionary:
return {"cur": pos, "src": pos, "dst": pos, "started_ms": now_ms}
func _set_observer_track(vid: int, pos: Vector3) -> void:
_observer_tracks[vid] = _observer_track(pos, Time.get_ticks_msec())
func _on_observer_event(kind: int, vid: int, pos: Vector2) -> void:
var net_pos := Vector3(pos.x, 0.0, pos.y)
match kind:
OBSERVER_ADD:
# AddObserver resets an existing observer to the packet position too.
_set_observer_track(vid, net_pos)
OBSERVER_REMOVE:
_observer_tracks.erase(vid)
OBSERVER_MOVE:
# Match MoveObserver: a move without a prior add is ignored. The
# snapshot sync below covers observers present before setup.
if not _observer_tracks.has(vid):
return
var cur := _observer_position(vid, net_pos)
var track: Dictionary = _observer_tracks[vid]
track["src"] = cur
track["dst"] = net_pos
track["cur"] = cur
track["started_ms"] = Time.get_ticks_msec()
_observer_tracks[vid] = track
func _clear_observers() -> void:
_observer_tracks.clear()
func _sync_observers_snapshot() -> void:
if client == null or not client.has_method("get_observers"):
return
# This seeds observers that arrived before the minimap connected. Once an
# observer_event stream exists, its transitions own the track's destination.
var has_events := client.has_signal("observer_event")
for o in client.get_observers():
var vid := int(o.get("vid", 0))
if vid == 0:
continue
var pos: Vector3 = o.get("pos", Vector3.ZERO)
if not has_events:
_set_observer_track(vid, pos)
elif not _observer_tracks.has(vid):
_set_observer_track(vid, pos)
func _observer_position(vid: int, fallback: Vector3 = Vector3.ZERO) -> Vector3:
if not _observer_tracks.has(vid):
_set_observer_track(vid, fallback)
var track: Dictionary = _observer_tracks[vid]
var src: Vector3 = track.get("src", fallback)
var dst: Vector3 = track.get("dst", src)
var started_ms := float(track.get("started_ms", Time.get_ticks_msec()))
var f := clampf((float(Time.get_ticks_msec()) - started_ms) / OBSERVER_INTERPOLATION_MS, 0.0, 1.0)
var cur := src.lerp(dst, f)
track["cur"] = cur
_observer_tracks[vid] = track
return cur
func _on_gui_input(event: InputEvent) -> void:
if event is InputEventScreenTouch and event.pressed:
map_pressed.emit()
_root.accept_event()
return
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
map_pressed.emit()
_root.accept_event()
return
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
# 圆周方向箭头(对应 __RenderTargetMark 的边缘指示;本工程无 .sub 资源,用三角形代替)。
func _draw_signal_arrow(pos: Vector2, angle: float) -> void:
var dir := Vector2(cos(angle), sin(angle))
var perp := Vector2(-dir.y, dir.x)
var tip := pos + dir * 7.0
var base := pos - dir * 3.0
_view.draw_colored_polygon(
PackedVector2Array([tip, base + perp * 4.0, base - perp * 4.0]),
Color(1.0, 0.6, 0.1, 0.95))
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"):
_sync_observers_snapshot()
for o in client.get_observers():
var vid := int(o.get("vid", 0))
var observer_world := _observer_position(vid, o.get("pos", Vector3.ZERO))
var op := _to_map(MapCoord.to_world(observer_world), 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)
# 任务标记(EventManager quest signals,本地 waypoint,不是 GC_TARGET):
# 圆内画橙星;越出小地图半径则贴到圆周画指向目标的方向箭头(世界箭头 / 屏幕边缘指示)。
for signal_point in _signal_points:
var signal_pos := MapCoord.to_world(Vector3(signal_point.x * 0.01, 0, -signal_point.y * 0.01))
var geo := _signal_offset(signal_pos, origin)
var smp: Vector2 = c + geo["offset"]
if geo["clamped"]:
_draw_signal_arrow(smp, geo["angle"])
else:
_view.draw_circle(smp, 5.0, Color(1.0, 0.75, 0.15, 0.95))
_view.draw_arc(smp, 8.0, 0.0, TAU, 16, Color(1.0, 0.4, 0.1, 0.9), 1.5)
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))