# 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))