extends Area3D
## Teleporter — either switches levels or moves within the same level.
## If target_map is set, triggers a full level change via game.gd.
## Otherwise, teleports the player to destination within the current map.
@export var destination: Vector3 = Vector3.ZERO
@export var target_map: String = "" ## e.g. "res://scenes/maps/map_cyber.tscn"
## Optional label shown above teleporter. A SETTER, because object.set_prop changes
## it on a live teleporter: reading it only in _ready left the sign showing the old
## text until the next map load.
@export var label_text: String = "":
set(v):
label_text = v
if is_node_ready():
_refresh_label()
@onready var indicator: OmniLight3D = $Indicator
@onready var label_3d: Label3D = $Label3D
func _ready() -> void:
body_entered.connect(_on_body_entered)
# Visual distinction: orange for level switch, cyan for local teleport
if target_map != "":
indicator.light_color = Color(1.0, 0.5, 0.0) # orange
else:
indicator.light_color = Color(0.0, 0.8, 1.0) # cyan
_refresh_label()
if snap_to_ground and not Engine.is_editor_hint():
_snap_origin = global_position
## STAND ON THE GROUND (2026-10-06). Authored pads were 117 m in the air (map_mountain),
## 12.7 m (map_geiss), 11.8 m inside rock (map_geiss_terraces), 10 cm into the floor
## (map_test) — gate_teleporter_ground. The pad resolves its ground the way an arrival
## does (ArrivalGround: physics rays, lift out of rock), re-snapping every SNAP_EVERY s
## to the best ground found until that ground is FINAL (finest LOD, own collision) —
## a pad far from the camera only gets there when someone walks near it. Resolved from
## the AUTHORED point every time, never from where the last snap put it.
@export var snap_to_ground: bool = true
const SNAP_EVERY := 0.5
const _Arrival := preload("res://scenes/player/arrival_ground.gd")
var _snap_origin: Variant = null
var _snap_t := 0.0
func _physics_process(delta: float) -> void:
if _snap_origin == null:
return
_snap_t -= delta
if _snap_t > 0.0:
return
_snap_t = SNAP_EVERY
var excl: Array[RID] = []
for c in find_children("*", "CollisionObject3D", true, false):
excl.append((c as CollisionObject3D).get_rid())
var r: Dictionary = _Arrival.resolve(get_world_3d().direct_space_state, _snap_origin, true,
excl, _Arrival.find_terrain(self))
if r["found"]:
global_position = r["ground"]
if r["ready"]:
_snap_origin = null # final: stop polling
func _refresh_label() -> void:
if label_text != "":
label_3d.text = label_text
elif target_map != "":
label_3d.text = target_map.get_file().get_basename().replace("map_", "").capitalize()
else:
label_3d.text = "Teleport"
func _on_body_entered(body: Node3D) -> void:
if not body is CharacterBody3D:
return
# Only the local player triggers level changes
if not body.is_multiplayer_authority():
return
if target_map != "":
var game := get_tree().root.get_node_or_null("Game")
if game and game.has_method("change_map"):
game.change_map(target_map, destination)
else:
if body.has_method("arrive"):
body.call("arrive", destination) # held until the ground there is real
else:
body.global_position = destination
body.velocity = Vector3.ZERO
if "grapple_hooked" in body:
body.grapple_hooked = false