extends Control ## The tutorial's guide panel and marker (docs/TUTORIAL_DESIGN.md §1): what to ## do now, top right under the clock, with a progress count; a tick when a ## step's done; a pale glowing post where to go, and an arrow with the ## distance when it's off-screen. H hides the panel. Drawn from the Guide ## autoload; hidden when no tutorial is running. const UITheme = preload("res://scripts/ui/theme.gd") var player: Node3D var _box: PanelContainer var _chapter: Label var _title: Label var _text: Label var _progress: Label var _tick: Label var _arrow: Label var _marker: Node3D var _hidden := false func setup(p: Node3D) -> void: player = p set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) mouse_filter = Control.MOUSE_FILTER_IGNORE _box = UITheme.panel() _box.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT) _box.grow_horizontal = Control.GROW_DIRECTION_BEGIN _box.offset_right = -22 _box.offset_top = 48 _box.custom_minimum_size = Vector2(340, 0) _box.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_box) var v := VBoxContainer.new() v.add_theme_constant_override("separation", 4) v.custom_minimum_size = Vector2(320, 0) _box.add_child(v) _chapter = UITheme.label("", 13, UITheme.TEXT_DIM) v.add_child(_chapter) _title = UITheme.label("", 19) v.add_child(_title) _text = UITheme.label("", 15, UITheme.TEXT_DIM) _text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _text.custom_minimum_size = Vector2(320, 0) v.add_child(_text) _progress = UITheme.label("", 15, Color(0.95, 0.82, 0.5)) v.add_child(_progress) _tick = UITheme.label("", 22, Color(0.55, 0.9, 0.45)) _tick.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP) _tick.offset_top = 90 _tick.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _tick.modulate.a = 0.0 add_child(_tick) _arrow = UITheme.label("", 20, Color(0.75, 0.95, 1.0)) _arrow.visible = false add_child(_arrow) Guide.step_started.connect(func(_i, _s): _redraw()) Guide.progressed.connect(_redraw) Guide.step_done.connect(_on_done) Guide.started.connect(_redraw) Guide.finished.connect(_on_finished) _redraw() func _unhandled_input(event: InputEvent) -> void: if Guide.active and event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_H: _hidden = not _hidden _redraw() func _redraw() -> void: var s: Dictionary = Guide.current() visible = Guide.active _box.visible = Guide.active and not _hidden and not s.is_empty() if s.is_empty(): return _chapter.text = "%s · step %d of %d (H hides)" % [Guide.chapter_title(s), Guide.index + 1, Guide.steps.size()] _title.text = String(s.title) # The hermit's words, in quotes, with his name. _text.text = ("%s: \"%s\"" % [Guide.guide_name, String(s.text)]) if not Guide.guide_name.is_empty() else String(s.text) _progress.text = Guide.progress_text() func _on_done(_i: int, s: Dictionary) -> void: _tick.text = "✓ " + String(s.title) var tw := create_tween() _tick.modulate.a = 1.0 tw.tween_interval(1.4) tw.tween_property(_tick, "modulate:a", 0.0, 0.6) func _on_finished() -> void: _box.visible = false _tick.text = "✓ Chapter done. More of the tutorial is on its way." _tick.modulate.a = 1.0 var tw := create_tween() tw.tween_interval(3.0) tw.tween_property(_tick, "modulate:a", 0.0, 1.0) func _process(_delta: float) -> void: _update_marker() ## The glowing post at the target, and the arrow at the screen edge. func _update_marker() -> void: var at: Vector3 = Guide.marker_position() if Guide.active else Vector3.INF if _marker != null and (not is_instance_valid(_marker) or _marker.get_parent() != Guide.world): if is_instance_valid(_marker): _marker.queue_free() _marker = null # (its world went: a reload, or back to the title) if _marker == null and Guide.active and Guide.world != null: _marker = make_marker() Guide.world.add_child(_marker) if _marker == null: _arrow.visible = false return _marker.visible = at != Vector3.INF if at == Vector3.INF: _arrow.visible = false return _marker.global_position = at var cam := get_viewport().get_camera_3d() if cam == null or _hidden: _arrow.visible = false return var vis := not cam.is_position_behind(at) var sp := cam.unproject_position(at + Vector3(0, 1.0, 0)) var r := get_viewport_rect() if vis and r.grow(-40).has_point(sp): _arrow.visible = false return # Off-screen: an arrow at the edge, pointing the way, with the distance. var c := r.size * 0.5 var dir := (sp - c) * (-1.0 if not vis else 1.0) if dir.length() < 1.0: dir = Vector2(0, -1) dir = dir.normalized() var edge := c + dir * (minf(c.x, c.y) - 70.0) edge = edge.clamp(Vector2(40, 40), r.size - Vector2(140, 60)) var arrows := ["→", "↘", "↓", "↙", "←", "↖", "↑", "↗"] var k := wrapi(roundi(atan2(dir.y, dir.x) / (PI / 4.0)), 0, 8) var who: Node3D = Guide.world.player if Guide.world != null else player _arrow.text = "%s %d m" % [arrows[k], roundi(who.global_position.distance_to(at))] _arrow.position = edge _arrow.visible = true ## A pale, glowing post with a ring at its foot. static func make_marker() -> Node3D: var root := Node3D.new() root.name = "GuideMarker" var m := StandardMaterial3D.new() m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA m.blend_mode = BaseMaterial3D.BLEND_MODE_ADD m.albedo_color = Color(0.55, 0.85, 1.0, 0.35) m.cull_mode = BaseMaterial3D.CULL_DISABLED var beam := MeshInstance3D.new() var cyl := CylinderMesh.new() cyl.top_radius = 0.06 cyl.bottom_radius = 0.14 cyl.height = 5.0 cyl.material = m beam.mesh = cyl beam.position = Vector3(0, 2.6, 0) beam.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF root.add_child(beam) var ring := MeshInstance3D.new() var torus := TorusMesh.new() torus.inner_radius = 0.55 torus.outer_radius = 0.7 torus.material = m ring.mesh = torus ring.position = Vector3(0, 0.05, 0) ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF root.add_child(ring) if DisplayServer.get_name() != "headless": var l := OmniLight3D.new() l.light_color = Color(0.55, 0.85, 1.0) l.light_energy = 0.6 l.omni_range = 4.0 l.position = Vector3(0, 1.0, 0) root.add_child(l) return root