Files
mtgodot-poc/project/ui/minimap.gd
T

294 lines
12 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
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 _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.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
# 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 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
# 圆周方向箭头(对应 __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))