extends Path3D const STEAM_DISTANCE_STAT: String = "stat_distance_km" const DISTANCE_KM_PER_UNIT: float = 0.01 @export_group("Train") ##train speed @export var train_speed: float = 6.0 ##if true the train is in motion @export var is_inmotion: bool = true ##the model for the locomotive @export var train_model: PackedScene ##array of wagon scenes @export var wagon_pool: Resource ##if true the number of wagons is random from 1 to wagon_count @export var wagon_random_number: bool = true ##if wagon_random_number = true is used as max wagons @export_range(0, 32, 1, "or_greater") var wagon_count: int = 3 ##the distance between two wagons @export var wagon_gap: float = 0.4 ##the scale to calculate the distance of the bounds @export_range(0.1, 2.0, 0.05, "or_greater") var wagon_spacing_scale: float = 0.5 ##override spacing using this value @export var wagon_spacing_override: float = 0.0 ##distance of the rail axes @export var axes_distance: float = 3.0 ##discance between rails @export var rail_distance: float = 1.2 ##current camera @export var cameras: Node3D ##swing of the train during the ran @export_range(0.0, 1.0) var basic_swing: float = 0.1 @export_group("Rails Bulding") ##distance between sleepers of the rails @export var sleepers_distance: float = 1.0 ##nodel for the sleeper of the rail @export var sleepers_model: PackedScene @export_group("Manual Controls") ##max speed @export var speed_max: float = 15.0 ##min speed @export var speed_min: float = -5.0 ##acceleration @export var manual_acceleration: float = 5.0 @export_group("Train Stops") ##if true the train stop the ran on stops @export var enable_stops: bool = true ##time of stop @export var stop_time: float = 4.0 ##distance when the train start to brake @export var brake_distance: float = 15.0 ##restart time @export var restart_time: float = 3.0 ##scene for fireworks @export var fireworks_scene: PackedScene ##array of colors for fireworks @export var fireworks_colors: Array[Color] = [ Color(1.0, 0.2, 0.2), # Rosso vivo Color(0.2, 1.0, 0.2), # Verde lime Color(0.3, 0.5, 1.0), # Blu cielo Color(1.0, 0.8, 0.1), # Oro Color(0.8, 0.2, 1.0), # Viola Color(0.1, 1.0, 0.9) # Ciano ] var train_instance: Node3D var train_progress: float = 0.0 var swing_time: float = 0.0 var curve_roll: float = 0.0 var curve_pitch: float = 0.0 var stop_offset: Array[float] = [] var stops_position: Array[Vector3] = [] var stop_ongoing: bool = false var next_stop_index: int = 0 var stop_multiply: float = 1.0 var is_restarting: bool = false var tween_restart: Tween var environment_config: EnvironmentConfig var wagon_instances: Array[Node3D] = [] var wagon_progress_offsets: Array[float] = [] var _pending_steam_distance_km: float = 0.0 var _initial_is_inmotion: bool = true var _is_photo_mode_active: bool = false func _ready() -> void: randomize() GameState.on_enable_photo_mode_request.connect(func(): _is_photo_mode_active = true) GameState.on_disable_photo_mode_request.connect(func(): _is_photo_mode_active = false) _cache_environment_config() _initial_is_inmotion = is_inmotion if curve != null and curve.get_baked_length() > 0: build_rails() build_train() _plan_stops() _apply_initial_train_start() _apply_train_start_delay() else: print("WARNING: Draw Path3D for rails!") func _cache_environment_config() -> void: var parent_node: Node = get_parent() if parent_node != null: var day_night_node := parent_node.get_node_or_null("DayNight") as EnvironmentManagerRoot if day_night_node != null: environment_config = day_night_node.environment_config return var current_scene: Node = get_tree().current_scene if current_scene == null: return for child in current_scene.get_children(): var environment_manager := child as EnvironmentManagerRoot if environment_manager != null: environment_config = environment_manager.environment_config return func _apply_initial_train_start() -> void: if train_instance == null or curve == null: return var total_length: float = curve.get_baked_length() if total_length <= 0.0: return #start from a random position if environment_config != null and environment_config.train_start_from_random_position: train_progress = randf() * total_length _update_next_stop_index_from_progress() elif environment_config != null and not stop_offset.is_empty(): #start from a specific stop var stop_index: int = clampi(environment_config.train_start_stop_index, 0, stop_offset.size() - 1) train_progress = stop_offset[stop_index] _set_next_stop_index_from_current(stop_index) else: train_progress = wrapf(train_progress, 0.0, total_length) _update_next_stop_index_from_progress() _snap_train_to_progress() func _apply_train_start_delay() -> void: if not _initial_is_inmotion: return var start_delay_seconds: float = 0.0 if environment_config != null: start_delay_seconds = maxf(environment_config.train_start_delay_seconds, 0.0) if start_delay_seconds <= 0.0: return is_inmotion = false await get_tree().create_timer(start_delay_seconds).timeout if not is_inside_tree(): return is_inmotion = _initial_is_inmotion func _set_next_stop_index_from_current(current_stop_index: int) -> void: if stop_offset.is_empty(): next_stop_index = 0 return if train_speed >= 0.0: next_stop_index = (current_stop_index + 1) % stop_offset.size() else: next_stop_index = current_stop_index - 1 if next_stop_index < 0: next_stop_index = stop_offset.size() - 1 func _update_next_stop_index_from_progress() -> void: if stop_offset.is_empty(): next_stop_index = 0 return if train_speed >= 0.0: next_stop_index = 0 for i in range(stop_offset.size()): if stop_offset[i] > train_progress + 0.001: next_stop_index = i return return next_stop_index = stop_offset.size() - 1 for i in range(stop_offset.size() - 1, -1, -1): if stop_offset[i] < train_progress - 0.001: next_stop_index = i return func _snap_train_to_progress() -> void: if train_instance == null or curve == null: return var total_length: float = curve.get_baked_length() if total_length <= 0.0: return train_progress = wrapf(train_progress, 0.0, total_length) var center_position: Vector3 = to_global(curve.sample_baked(train_progress, true)) var prog_forward: float = wrapf(train_progress + 2.0, 0.0, total_length) var forward_position: Vector3 = to_global(curve.sample_baked(prog_forward, true)) train_instance.global_position = center_position if center_position.distance_to(forward_position) > 0.01: train_instance.look_at(forward_position, Vector3.UP) train_instance.rotate_y(PI) if cameras: cameras.global_position = center_position cameras.global_basis = train_instance.global_basis _snap_wagons_to_progress(total_length) func build_rails() -> void: var mat_wood = StandardMaterial3D.new() mat_wood.albedo_color = Color(0.35, 0.2, 0.1) var mat_iron = StandardMaterial3D.new() mat_iron.albedo_color = Color(0.6, 0.6, 0.65) mat_iron.metallic = 0.8 var mesh_sleeper = BoxMesh.new() mesh_sleeper.size = Vector3(rail_distance + 0.6, 0.1, 0.3) var mesh_rail = BoxMesh.new() mesh_rail.size = Vector3(0.1, 0.15, sleepers_distance + 0.05) var total_length = curve.get_baked_length() var piece_number = int(total_length / sleepers_distance) for i in range(piece_number): var distance = i * sleepers_distance var rail_piece = Node3D.new() add_child(rail_piece) var local_pos = curve.sample_baked(distance, true) var distance_forward = distance + 0.1 var local_pos_forward = Vector3.ZERO if distance_forward > total_length: var pos_back = curve.sample_baked(distance - 0.1, true) local_pos_forward = local_pos + (local_pos - pos_back) else: local_pos_forward = curve.sample_baked(distance_forward, true) rail_piece.position = local_pos var global_pos = to_global(local_pos) var global_pos_forward = to_global(local_pos_forward) if global_pos.distance_to(global_pos_forward) > 0.001: rail_piece.look_at(global_pos_forward, Vector3.UP) if sleepers_model != null: var sleeper_custom = sleepers_model.instantiate() rail_piece.add_child(sleeper_custom) else: var sleeper = MeshInstance3D.new() sleeper.mesh = mesh_sleeper sleeper.material_override = mat_wood rail_piece.add_child(sleeper) var rail_sx = MeshInstance3D.new() rail_sx.mesh = mesh_rail rail_sx.material_override = mat_iron rail_sx.position = Vector3(-rail_distance / 2.0, 0.1, 0) rail_piece.add_child(rail_sx) var rail_dx = MeshInstance3D.new() rail_dx.mesh = mesh_rail rail_dx.material_override = mat_iron rail_dx.position = Vector3(rail_distance / 2.0, 0.1, 0) rail_piece.add_child(rail_dx) func _plan_stops() -> void: var current_stops = [] for child in get_children(): if child is Marker3D: var offset = curve.get_closest_offset(child.position) current_stops.append({ "offset": offset, "position": child.global_position }) current_stops.sort_custom(func(a, b): return a["offset"] < b["offset"]) for data in current_stops: stop_offset.append(data["offset"]) stops_position.append(data["position"]) func _input(event: InputEvent) -> void: if _is_photo_mode_active: return if event is InputEventKey and event.pressed and not event.echo: if event.keycode == KEY_T: _goto_next_stop() if event.is_action_pressed("train_horn"): UIEvents.toot_toot.emit(true) #elif event.keycode == KEY_F: #_test_manual_fireworks() func _test_manual_fireworks() -> void: if fireworks_scene == null or train_instance == null: return var firework_number = randi_range(5, 8) for i in range(firework_number): var fire_root = fireworks_scene.instantiate() get_tree().current_scene.add_child(fire_root) var offset_x = randf_range(-6.0, 6.0) var offset_z = randf_range(-6.0, 6.0) fire_root.global_position = train_instance.global_position + Vector3(offset_x, 0.5, offset_z) if fire_root.has_method("set_color"): fire_root.set_color(fireworks_colors.pick_random()) if fire_root.has_method("turn_on"): fire_root.turn_on() await get_tree().create_timer(randf_range(0.2, 0.6)).timeout func _goto_next_stop() -> void: if stop_offset.size() == 0 or stop_ongoing or is_restarting: return var target_offset = stop_offset[next_stop_index] train_progress = target_offset _execute_stop(train_speed >= 0) func _physics_process(delta: float) -> void: input_controls_management(delta) train_move(delta) func input_controls_management(delta: float) -> void: if not is_inmotion or _is_photo_mode_active: return if Input.is_action_pressed("train_speed_up"): train_speed += manual_acceleration * delta elif Input.is_action_pressed("train_speed_down"): train_speed -= manual_acceleration * delta elif Input.is_action_pressed("train_stop"): train_speed = 0 train_speed = clamp(train_speed, speed_min, speed_max) func train_move(delta: float) -> void: if is_inmotion and train_instance and curve: var total_length = curve.get_baked_length() if total_length <= 0: return if not stop_ongoing: var last_progress = train_progress if enable_stops and stop_offset.size() > 0 and not is_restarting: var target_offset = stop_offset[next_stop_index] var dist = target_offset - train_progress dist = wrapf(dist + total_length / 2.0, 0.0, total_length) - total_length / 2.0 if abs(dist) < brake_distance and sign(dist) == sign(train_speed): var t = abs(dist) / brake_distance stop_multiply = max(smoothstep(0.0, 1.0, t), 0.05) else: stop_multiply = 1.0 var current_speed = train_speed * stop_multiply train_progress += current_speed * delta _track_steam_distance(abs(current_speed) * delta) if enable_stops and stop_offset.size() > 0: var target_offset = stop_offset[next_stop_index] var exceeded_forward = (current_speed > 0 and last_progress < target_offset and train_progress >= target_offset) var exceeded_back = (current_speed < 0 and last_progress > target_offset and train_progress <= target_offset) if exceeded_forward or exceeded_back: train_progress = target_offset _execute_stop(current_speed > 0) if train_progress > total_length: train_progress -= total_length if stop_offset.size() > 0: next_stop_index = 0 elif train_progress < 0: train_progress += total_length if stop_offset.size() > 0: next_stop_index = stop_offset.size() - 1 var center_position = to_global(curve.sample_baked(train_progress, true)) var prog_back = wrapf(train_progress - 2.0, 0.0, total_length) var back_position = to_global(curve.sample_baked(prog_back, true)) var prog_forward = wrapf(train_progress + 2.0, 0.0, total_length) var forward_position = to_global(curve.sample_baked(prog_forward, true)) if center_position.distance_to(forward_position) > 0.01: train_instance.global_position = center_position train_instance.look_at(forward_position, Vector3.UP) train_instance.rotate_y(PI) if cameras: cameras.global_position = center_position cameras.global_basis = train_instance.global_basis _snap_wagons_to_progress(total_length) var real_speed = abs(train_speed * stop_multiply) var speed_factor = clamp(real_speed / max(speed_max, 0.001), 0.0, 1.0) var dir_prev = (center_position - back_position).normalized() var dir_next = (forward_position - center_position).normalized() var signed_curve = dir_prev.cross(dir_next).y var curve_amount = clamp(abs(signed_curve) * 8.0, 0.0, 1.0) curve_roll = lerp(curve_roll, (-signed_curve * 0.08) * speed_factor, delta * 4.0) curve_pitch = lerp(curve_pitch, curve_amount * 0.012 * speed_factor, delta * 4.0) if not stop_ongoing: swing_time += delta * real_speed * (2.0 + curve_amount) var amplitude_z = 0.006 * basic_swing var amplitude_x = 0.004 * basic_swing var curve_sway = sin(swing_time * 1.35) * 0.01 * curve_amount * speed_factor train_instance.rotation.z += sin(swing_time) * amplitude_z train_instance.rotation.x += cos(swing_time * 0.8) * amplitude_x train_instance.rotation.z += curve_roll + curve_sway train_instance.rotation.x += curve_pitch else: train_instance.rotation.z += curve_roll train_instance.rotation.x += curve_pitch #Calculate train distance for steam stats func _track_steam_distance(distance_units: float) -> void: if distance_units <= 0.0: return # Always track the global distance in our save file (for UI and persistency) GameState.save_data.total_distance_km += distance_units * DISTANCE_KM_PER_UNIT # Steam specific logic if not SteamManager.is_on_steam: return _pending_steam_distance_km += distance_units * DISTANCE_KM_PER_UNIT var whole_km: int = int(floor(_pending_steam_distance_km)) if whole_km <= 0: return _pending_steam_distance_km -= float(whole_km) var total_distance_km: int = StatsManager.get_int(STEAM_DISTANCE_STAT) + whole_km StatsManager.set_int(STEAM_DISTANCE_STAT, total_distance_km) _check_distance_achievements(total_distance_km) StatsManager.store() #Check achievements func _check_distance_achievements(total_distance_km: int) -> void: if total_distance_km >= 100: AchievementManager.unlock("ACH_DISTANCE_100") if total_distance_km >= 200: AchievementManager.unlock("ACH_DISTANCE_200") func _execute_stop(go_forward: bool = true) -> void: stop_ongoing = true stop_multiply = 0.0 var current_stop_index = next_stop_index if go_forward: next_stop_index += 1 if next_stop_index >= stop_offset.size(): next_stop_index = 0 else: next_stop_index -= 1 if next_stop_index < 0: next_stop_index = stop_offset.size() - 1 if fireworks_scene != null: var marker_position = stops_position[current_stop_index] var firework_number = randi_range(5, 8) for i in range(firework_number): var fire_root = fireworks_scene.instantiate() get_tree().current_scene.add_child(fire_root) var offset_x = randf_range(-5.0, 5.0) var offset_z = randf_range(-5.0, 5.0) fire_root.global_position = marker_position + Vector3(offset_x, 0.5, offset_z) if fire_root.has_method("set_color"): fire_root.set_color(fireworks_colors.pick_random()) if fire_root.has_method("turn_on"): fire_root.turn_on() await get_tree().create_timer(randf_range(0.3, 0.8)).timeout await get_tree().create_timer(stop_time).timeout stop_ongoing = false is_restarting = true if tween_restart and tween_restart.is_valid(): tween_restart.kill() tween_restart = create_tween() tween_restart.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) tween_restart.tween_property(self, "stop_multiply", 1.0, restart_time) tween_restart.tween_callback(func(): is_restarting = false) func build_train() -> void: if train_model != null: train_instance = train_model.instantiate() add_child(train_instance) else: build_default_train() var wagons = wagon_count if wagon_random_number: wagons = randi_range(1, wagon_count) build_train_wagons(wagons) func build_train_wagons(wagon_number: int) -> void: _clear_train_wagons() if wagon_number <= 0 or wagon_pool == null or not "available_wagons" in wagon_pool: return if wagon_pool.available_wagons.is_empty(): return for i in range(wagon_number): var wagon_scene: PackedScene = wagon_pool.available_wagons.pick_random() if wagon_scene == null: continue var wagon: Node3D = wagon_scene.instantiate() as Node3D if wagon == null: push_warning("Wagon scene root must be a Node3D.") continue add_child(wagon) wagon_instances.append(wagon) _update_wagon_progress_offsets() _snap_wagons_to_progress() func build_default_train() -> void: train_instance = Node3D.new() add_child(train_instance) var mat_body = StandardMaterial3D.new() mat_body.albedo_color = Color(0.8, 0.15, 0.15) var mat_glass = StandardMaterial3D.new() mat_glass.albedo_color = Color(0.2, 0.8, 1.0) var train_base = MeshInstance3D.new() var mesh_base = BoxMesh.new() mesh_base.size = Vector3(1.4, 0.8, 3.0) train_base.mesh = mesh_base train_base.material_override = mat_body train_base.position = Vector3(0, 0.6, 0) train_instance.add_child(train_base) var cabin = MeshInstance3D.new() var mesh_cabin = BoxMesh.new() mesh_cabin.size = Vector3(1.4, 1.0, 1.2) cabin.mesh = mesh_cabin cabin.material_override = mat_glass cabin.position = Vector3(0, 1.5, -0.8) train_instance.add_child(cabin) var stack = MeshInstance3D.new() var mesh_stack = CylinderMesh.new() mesh_stack.top_radius = 0.2 mesh_stack.bottom_radius = 0.3 mesh_stack.height = 0.8 stack.mesh = mesh_stack stack.material_override = mat_body stack.position = Vector3(0, 1.2, 1.0) train_instance.add_child(stack) func _clear_train_wagons() -> void: for wagon in wagon_instances: if is_instance_valid(wagon): wagon.queue_free() wagon_instances.clear() wagon_progress_offsets.clear() func _update_wagon_progress_offsets() -> void: wagon_progress_offsets.clear() if train_instance == null or wagon_instances.is_empty(): return var train_length: float = _get_vehicle_length(train_instance) var previous_length: float = train_length var accumulated_distance: float = 0.0 for wagon in wagon_instances: var wagon_length: float = _get_vehicle_length(wagon) if wagon_spacing_override > 0.0: accumulated_distance += wagon_spacing_override + wagon_gap else: var detected_spacing: float = previous_length * 0.5 + wagon_length * 0.5 accumulated_distance += detected_spacing * wagon_spacing_scale + wagon_gap wagon_progress_offsets.append(accumulated_distance) previous_length = wagon_length func _snap_wagons_to_progress(total_length: float = 0.0) -> void: if curve == null or wagon_instances.is_empty(): return if total_length <= 0.0: total_length = curve.get_baked_length() if total_length <= 0.0: return for i in range(wagon_instances.size()): var wagon: Node3D = wagon_instances[i] if not is_instance_valid(wagon): continue var wagon_offset: float = float(i + 1) * 7.0 if i < wagon_progress_offsets.size(): wagon_offset = wagon_progress_offsets[i] var wagon_progress: float = train_progress - wagon_offset var vehicle_progress: float = wrapf(wagon_progress, 0.0, total_length) var center_position: Vector3 = to_global(curve.sample_baked(vehicle_progress, true)) var prog_forward: float = wrapf(vehicle_progress + 2.0, 0.0, total_length) var forward_position: Vector3 = to_global(curve.sample_baked(prog_forward, true)) wagon.global_position = center_position if center_position.distance_to(forward_position) > 0.01: wagon.look_at(forward_position, Vector3.UP) wagon.rotate_y(PI) func _get_vehicle_length(vehicle: Node3D) -> float: var bounds: AABB = _get_node_local_bounds(vehicle, vehicle) if bounds.size == Vector3.ZERO: return 7.0 return maxf(bounds.size.z, 0.1) func _get_node_local_bounds(root: Node3D, node: Node) -> AABB: var result: AABB = AABB() var has_bounds: bool = false var mesh_instance := node as MeshInstance3D if mesh_instance != null and mesh_instance.mesh != null: var mesh_bounds: AABB = mesh_instance.get_aabb() var mesh_transform: Transform3D = root.global_transform.affine_inverse() * mesh_instance.global_transform for corner in _get_aabb_corners(mesh_bounds): var local_point: Vector3 = mesh_transform * corner if has_bounds: result = result.expand(local_point) else: result = AABB(local_point, Vector3.ZERO) has_bounds = true for child in node.get_children(): var child_bounds: AABB = _get_node_local_bounds(root, child) if child_bounds.size == Vector3.ZERO: continue if has_bounds: result = result.merge(child_bounds) else: result = child_bounds has_bounds = true return result func _get_aabb_corners(bounds: AABB) -> Array[Vector3]: var start: Vector3 = bounds.position var end: Vector3 = bounds.end return [ Vector3(start.x, start.y, start.z), Vector3(end.x, start.y, start.z), Vector3(start.x, end.y, start.z), Vector3(end.x, end.y, start.z), Vector3(start.x, start.y, end.z), Vector3(end.x, start.y, end.z), Vector3(start.x, end.y, end.z), Vector3(end.x, end.y, end.z), ]