327 lines
13 KiB
GDScript
327 lines
13 KiB
GDScript
# 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 _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
|
||
|
||
# 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))
|