Procedural Terrain Toy #1

Closed
m.rossi wants to merge 9 commits from proc-terrain into develop
30 changed files with 1749 additions and 29 deletions

View File

@@ -0,0 +1,461 @@
---
name: godot-gdscript
description: "Develops GDScript code for Godot 4.6 projects. Use when writing, editing, or debugging GDScript files, creating nodes, signals, exports, or any Godot game development task."
---
# Godot 4.6 GDScript Development
Write idiomatic GDScript for Godot 4.6 following these conventions and language features.
## File Structure
```gdscript
@tool # Optional: runs in editor
@icon("res://path/to/icon.svg") # Optional: custom icon
class_name MyClass # Optional: registers global class
extends BaseClass # Required (defaults to RefCounted)
## Signals
signal health_changed(old_value, new_value)
## Enums
enum State { IDLE, RUNNING, JUMPING }
## Constants
const MAX_SPEED = 100.0
## Exports (inspector properties)
@export var speed: float = 50.0
@export_range(0, 100) var health: int = 100
## Public variables
var velocity: Vector2 = Vector2.ZERO
## Private variables (convention: prefix with _)
var _internal_state: int = 0
## @onready variables (initialized in _ready)
@onready var sprite: Sprite2D = $Sprite2D
## Static variables
static var instance_count: int = 0
## Built-in virtual methods
func _init():
pass
func _ready():
pass
func _process(delta: float) -> void:
pass
func _physics_process(delta: float) -> void:
pass
## Public methods
func take_damage(amount: int) -> void:
pass
## Private methods
func _calculate_something() -> float:
return 0.0
## Static methods
static func get_instance_count() -> int:
return instance_count
```
## Type System
### Type Hints
```gdscript
var my_int: int = 5
var my_string: String = "hello"
var my_node: Node2D = null
var inferred := Vector2(1, 2) # Type inferred as Vector2
func my_func(param: int, optional: String = "default") -> bool:
return true
```
### Typed Arrays and Dictionaries
```gdscript
var int_array: Array[int] = [1, 2, 3]
var node_array: Array[Node] = []
var typed_dict: Dictionary[String, int] = {"a": 1, "b": 2}
```
### Casting
```gdscript
var node := $Sprite as Sprite2D # Returns null if wrong type
if node is Sprite2D:
node.texture = some_texture
```
## Common Patterns
### Signals
```gdscript
signal damage_taken(amount: int)
func take_damage(amount: int) -> void:
health -= amount
damage_taken.emit(amount)
# Connecting signals
func _ready():
$Button.pressed.connect(_on_button_pressed)
# With bound arguments
$Enemy.died.connect(_on_enemy_died.bind(enemy_name))
func _on_button_pressed():
pass
```
### Properties (Setters/Getters)
```gdscript
var health: int = 100:
get:
return health
set(value):
health = clamp(value, 0, max_health)
health_changed.emit(health)
```
### Export Annotations
```gdscript
@export var speed: float = 10.0
@export_range(0, 100, 1) var health: int = 100
@export_enum("Walk", "Run", "Jump") var movement: int
@export_file("*.png") var texture_path: String
@export_dir var save_directory: String
@export_node_path("Sprite2D", "Sprite3D") var sprite_path: NodePath
@export_flags("Fire", "Water", "Earth", "Wind") var elements: int
@export_group("Movement")
@export var walk_speed: float
@export var run_speed: float
@export_subgroup("Physics")
@export var gravity: float
```
### Node References
```gdscript
@onready var player := $Player as CharacterBody2D
@onready var unique_node := %UniqueNodeName # Scene-unique node
# Get nodes dynamically
var child = get_node("Path/To/Child")
var parent = get_parent()
```
### Awaiting Signals/Coroutines
```gdscript
func async_operation() -> void:
await get_tree().create_timer(1.0).timeout
print("1 second passed")
var result = await some_async_func()
print(result)
```
### Match Statements
```gdscript
match state:
State.IDLE:
play_idle_animation()
State.RUNNING:
play_run_animation()
_:
push_warning("Unknown state")
# Pattern matching with guards
match point:
[var x, var y] when x == y:
print("On diagonal")
[var x, var y]:
print("Point: ", x, ", ", y)
```
### Lambda Functions
```gdscript
var double = func(x): return x * 2
print(double.call(5)) # 10
# With type hints
var add: Callable = func(a: int, b: int) -> int: return a + b
```
### Variadic Functions (4.5+)
```gdscript
func log_values(prefix: String, ...values: Array) -> void:
for v in values:
print(prefix, v)
```
## Built-in Types Reference
| Type | Description |
|------|-------------|
| `null` | Empty value |
| `bool` | `true` or `false` |
| `int` | 64-bit integer |
| `float` | 64-bit float |
| `String` | Unicode string |
| `StringName` | Interned string (fast comparison) |
| `NodePath` | Path to node/property |
| `Vector2/Vector2i` | 2D vector |
| `Vector3/Vector3i` | 3D vector |
| `Rect2` | 2D rectangle |
| `Transform2D/Transform3D` | Transformation matrix |
| `Color` | RGBA color |
| `Array` | Dynamic array |
| `Dictionary` | Key-value map |
| `Callable` | Function reference |
| `Signal` | Signal reference |
## Memory Management
- `RefCounted` subclasses: Automatically freed when no references
- `Node` subclasses: Must call `queue_free()` or `free()`
- Use `weakref()` to avoid reference cycles
```gdscript
var weak = weakref(some_object)
if weak.get_ref():
weak.get_ref().do_something()
if is_instance_valid(node):
node.queue_free()
```
## Abstract Classes (4.5+)
```gdscript
@abstract
class_name BaseEnemy
extends CharacterBody2D
@abstract func attack() -> void
class Zombie extends BaseEnemy:
func attack() -> void:
# Must implement abstract method
melee_attack()
```
## Style Guide
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| File names | snake_case | `yaml_parser.gd` |
| Class names | PascalCase | `class_name YAMLParser` |
| Node names | PascalCase | `Camera3D`, `Player` |
| Functions | snake_case | `func load_level():` |
| Variables | snake_case | `var particle_effect` |
| Signals | snake_case (past tense) | `signal door_opened` |
| Constants | CONSTANT_CASE | `const MAX_SPEED = 200` |
| Enum names | PascalCase | `enum Element` |
| Enum members | CONSTANT_CASE | `{EARTH, WATER, AIR}` |
### Private Members
Prepend underscore for private/virtual methods and variables:
```gdscript
var _internal_state: int = 0
func _calculate_damage() -> int:
return 0
```
### Code Order
Organize scripts in this order:
1. `@tool` (if needed)
2. `@icon`
3. `class_name`
4. `extends`
5. `## Documentation comments`
6. Signals
7. Enums
8. Constants
9. `@export` variables
10. Public variables
11. Private variables
12. `@onready` variables
13. Virtual methods (`_init`, `_ready`, `_process`, etc.)
14. Public methods
15. Private methods
16. Inner classes
### Formatting Rules
**Indentation**: Use tabs, not spaces.
**Blank lines**:
- Two blank lines between functions/class definitions
- One blank line to separate logical sections within functions
**Line length**: Keep under 100 characters (prefer 80).
**Comments**:
```gdscript
# Regular comment (space after #)
## Documentation comment
#var disabled_code # No space for commented code
#region Region Name
#endregion
```
**Multiline statements** - Use parentheses, put `and`/`or` at line start:
```gdscript
if (
is_valid
and has_permission
and not is_disabled
):
do_something()
```
**Enums** - One item per line:
```gdscript
enum State {
IDLE,
RUNNING,
JUMPING,
}
```
## Static Typing
Static typing catches errors at compile time, improves autocompletion, and enables performance optimizations.
### Basic Syntax
```gdscript
# Variables
var health: int = 100
var velocity: Vector2 = Vector2.ZERO
var player: CharacterBody2D
# Type inference with :=
var speed := 10.0 # Inferred as float
var position := Vector2.ZERO # Inferred as Vector2
# Constants (type optional but recommended for arrays)
const MAX_SPEED: float = 200.0
const ITEMS: Array[String] = ["sword", "shield"]
# Function parameters and return types
func calculate_damage(base: int, multiplier: float) -> int:
return int(base * multiplier)
# Void return type
func apply_damage(amount: int) -> void:
health -= amount
```
### Typed Collections
```gdscript
# Typed arrays
var enemies: Array[Enemy] = []
var scores: Array[int] = [10, 20, 30]
# Typed dictionaries (Godot 4.4+)
var inventory: Dictionary[String, int] = {"gold": 100}
var stats: Dictionary[StringName, float] = {}
# Typed for loops
for enemy: Enemy in enemies:
enemy.take_damage(10)
for name: String in ["Alice", "Bob"]:
print(name)
```
### Type Casting
```gdscript
# Safe casting with 'as' (returns null if wrong type)
var sprite := $Sprite as Sprite2D
if sprite:
sprite.texture = new_texture
# Type checking with 'is'
if node is CharacterBody2D:
var body: CharacterBody2D = node
body.move_and_slide()
# Casting node references for safe lines
@onready var timer := $Timer as Timer
@onready var player := $Player as CharacterBody2D
```
### Safe vs Unsafe Lines
The editor marks lines green (safe) or grey (unsafe):
```gdscript
# Safe - type is known
var timer: Timer = $Timer as Timer
# Unsafe - type could fail at runtime
var timer = $Timer # No type info
```
### Typed Global Scope Methods
Use typed versions for better performance:
```gdscript
# Prefer typed versions
var node: Node = instance_from_id(id) as Node # Safe
var clamped: int = clampi(value, 0, 100) # Typed int clamp
var lerped: float = lerpf(0.0, 1.0, 0.5) # Typed float lerp
# Typed math functions
var abs_val: int = absi(-5)
var max_val: float = maxf(a, b)
var min_val: int = mini(a, b)
```
### Safe Property/Method Access
```gdscript
# Unsafe - warnings if property doesn't exist on type
func process_node(node: Node2D) -> void:
if "custom_property" in node:
node.custom_property = 10 # UNSAFE_PROPERTY_ACCESS warning
# Safe - cast first, then access
func process_node_safe(node: Node2D) -> void:
if node is MyScript:
var typed: MyScript = node as MyScript
typed.custom_property = 10 # Safe
```
### Limitations
```gdscript
# Cannot type individual array/dict elements inline
var arr = [1, "two", 3.0] # Mixed types - can't specify
# Nested typed collections not supported
var nested: Array[Array[int]] # ERROR - not allowed
# Use this instead
var nested: Array[Array] = [[1, 2], [3, 4]]
```
### Recommended Editor Settings
Enable in **Project Settings > Debug > GDScript**:
- `UNTYPED_DECLARATION` - Warn on untyped variables
- `INFERRED_DECLARATION` - Prefer explicit over inferred types
- `UNSAFE_PROPERTY_ACCESS` - Warn on unsafe property access
- `UNSAFE_METHOD_ACCESS` - Warn on unsafe method calls
Enable in **Editor Settings > Text Editor > Completion**:
- **Add Type Hints** - Auto-add types on completion
## Best Practices
1. **Use static typing** - Improves performance and catches errors
2. **Prefer `@onready`** - For node references instead of `_ready()` assignments
3. **Use signals** - For decoupled communication between nodes
4. **Avoid `get_node()` in `_process()`** - Cache references with `@onready`
5. **Use `StringName` for frequent comparisons** - `&"my_string"`
6. **Group exports** - Use `@export_group` and `@export_subgroup`
7. **Document with `##`** - Double hash for documentation comments
8. **Declare locals near first use** - Don't declare at top of function
9. **Use past tense for signals** - `health_depleted`, `door_opened`
10. **Match file name to class** - `YAMLParser``yaml_parser.gd`

View File

@@ -0,0 +1,3 @@
[folding]
sections_unfolded=PackedStringArray()

View File

@@ -0,0 +1,3 @@
[folding]
sections_unfolded=PackedStringArray()

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

View File

@@ -0,0 +1,100 @@
# Terrain Control System Specification
## Summary
Provide artists with deterministic, in-editor control over procedural terrain generation by
introducing control layers (biomes, height profiles, masks, slope limits) and hard scene
constraints (bel vedere stations and rail splines). The generator must combine these inputs
to produce a terrain mesh and mask/biome outputs suitable for materials and prop placement.
## Goals
- Allow artists to explicitly control biomes, height profiles, masks, and slope limits.
- Ensure rails and stations act as hard constraints that shape/flatten the terrain.
- Support a procedural rail spline (Path3D + Curve3D) with known points near stations.
- Provide real-time preview and regeneration in-editor.
- Expose data for props placement (density/exclusion by biome/slope/rail distance).
## Non-Goals
- Final art-quality shader/material authoring.
- Runtime streaming or terrain LOD.
- Full track generation algorithm (assumed to exist elsewhere).
## Inputs
### Scene Inputs (Hard Constraints)
- Bel vedere stations: placed as scene nodes (Node3D).
- Rail spline: Path3D with Curve3D (procedural). Only points near stations are known.
### Artist Inputs (Control Layers)
- Biome map (Texture2D): defines biome IDs/weights across terrain.
- Height profile (Curve): maps base noise height to final height per biome.
- Mask layers (Texture2D array): painted/assigned regions (forest, village, rock, etc.).
- Slope limit map (Texture2D): limits maximum slope per region.
## Data Model
### TerrainControlLayers (Resource)
- biome_map: Texture2D
- height_profile: Curve
- slope_limit_map: Texture2D
- mask_layers: Array[Texture2D]
- mask_names: Array[String]
- resolution: Vector2i (shared texture resolution for all layers)
### TerrainGenerator (Node)
- control_layers: TerrainControlLayers
- station_paths: Array[NodePath]
- rail_paths: Array[NodePath]
- rail_influence_radius: float
- station_influence_radius: float
- station_flatten_height: float
- rail_flatten_strength: float
- biome_preview_mode: enum (None, Biome, Slope, MaskIndex)
## Workflow
1. Artist places bel vedere station nodes in the scene.
2. Rail spline is generated as Path3D with Curve3D (known points near stations).
3. Artist assigns/creates a TerrainControlLayers resource.
4. Artist paints biome map and mask layers in-editor and sets height/slope controls.
5. TerrainGenerator regenerates and previews terrain with rail/station constraints.
## Generation Pipeline
1. Base height: sample noise and scale by max_height.
2. Apply height profile: evaluate Curve to remap height.
3. Apply island/edge mask if enabled.
4. Sample biome map and masks to modulate height and material blending.
5. Apply slope limits by clamping local height gradients per pixel.
6. Apply rail constraints:
- Compute distance from vertex to nearest point on rail spline.
- Within rail_influence_radius, flatten height toward rail height.
- Enforce maximum slope along rail direction.
7. Apply station constraints:
- Within station_influence_radius, flatten to station height.
- Optionally expand a platform/yard mask for props and paths.
8. Output: mesh + per-vertex data for materials (biome/mask indices) and props.
## Rail Spline Handling
- Rail spline is Path3D with Curve3D and may be partially defined near stations.
- The generator samples the spline at fixed intervals to build a distance field.
- If only station-adjacent points exist, interpolate curve points between stations.
## Editor Tooling
- Terrain Control Painter:
- Paint biome IDs, slope limits, and masks directly on terrain.
- Toggle view modes (biome/slope/mask overlays).
- Provide brush size, hardness, and opacity.
- Preview toggle:
- Regenerate terrain on parameter changes in the editor.
## Props Integration
- Provide maps to props system:
- biome_id, slope_limit, rail_distance, station_distance, mask values.
- Placement rules can use these to control density and exclusions.
## Definition of Done
- Artists can paint biomes/masks and see results in-editor.
- Terrain respects rail and station constraints.
- Slope limits and height profiles affect terrain as expected.
- Outputs for props placement are available and consistent.
## Open Questions
- How is rail height defined relative to terrain (absolute Y or curve metadata)?
- Should station flattening use station Y or a per-station override value?
- Expected texture resolution for control layers?

BIN
proc-terrain/biomes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,4 @@
[gd_resource type="FastNoiseLite" format=3 uid="uid://clpxlmv87brc7"]
[resource]
seed = 42

View File

@@ -0,0 +1,16 @@
[gd_resource type="ShaderMaterial" format=3 uid="uid://4a05tpi1tu14"]
[ext_resource type="Shader" uid="uid://d4b4lfd2r51iu" path="res://proc-terrain/triplanar_slope.gdshader" id="1_shader"]
[ext_resource type="Texture2D" uid="uid://cfffdsmmgils3" path="res://proc-terrain/Dirt_baseColor.tga" id="2_50hjp"]
[ext_resource type="Texture2D" uid="uid://dq7id4wwphvm6" path="res://proc-terrain/Grass_baseColor.tga" id="3_8s6t3"]
[resource]
render_priority = 0
shader = ExtResource("1_shader")
shader_parameter/texture_top = ExtResource("3_8s6t3")
shader_parameter/texture_side = ExtResource("2_50hjp")
shader_parameter/blend_sharpness = 5.0
shader_parameter/slope_threshold = 0.7
shader_parameter/uv_scale = Vector3(0.1, 0.1, 0.1)
shader_parameter/texture_top_tiling = Vector2(2, 2)
shader_parameter/texture_side_tiling = Vector2(1, 1)

View File

@@ -0,0 +1,4 @@
[gd_resource type="StandardMaterial3D" format=3 uid="uid://40g5lvx2hw5k"]
[resource]
vertex_color_use_as_albedo = true

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="TerrainControlLayers" format=3 uid="uid://diftpul4fiqwa"]
[ext_resource type="Texture2D" uid="uid://cdx6jsbu8586x" path="res://proc-terrain/biomes.png" id="1_1o45a"]
[ext_resource type="Script" uid="uid://dn0dmf6vj6ybp" path="res://proc-terrain/terrain_control_layers.gd" id="1_2pv2t"]
[ext_resource type="Texture2D" uid="uid://b8dd3au482ax" path="res://proc-terrain/slop_limit_map.png" id="3_3iw73"]
[sub_resource type="Curve" id="Curve_2pv2t"]
_data = [Vector2(0, 0), 0.0, 0.0, 0, 0, Vector2(0.48395061, 1), 0.0, 0.0, 0, 0, Vector2(0.99814117, 0.004347801), 0.0, 0.0, 0, 0]
point_count = 3
[resource]
script = ExtResource("1_2pv2t")
resolution = Vector2i(1024, 1024)
biome_map = ExtResource("1_1o45a")
height_profile = SubResource("Curve_2pv2t")
slope_limit_map = ExtResource("3_3iw73")

View File

@@ -0,0 +1,110 @@
@tool
class_name TerrainControlLayers
extends Resource
## Resource containing artist-controlled terrain layers for procedural generation.
## Includes biome maps, height profiles, masks, and slope limits.
signal layers_changed
@export var resolution: Vector2i = Vector2i(512, 512):
set(value):
resolution = value
emit_changed()
layers_changed.emit()
@export_group("Biome Control")
@export var biome_map: Texture2D:
set(value):
biome_map = value
emit_changed()
layers_changed.emit()
@export var height_profile: Curve:
set(value):
if height_profile and height_profile.changed.is_connected(_on_curve_changed):
height_profile.changed.disconnect(_on_curve_changed)
height_profile = value
if height_profile:
height_profile.changed.connect(_on_curve_changed)
emit_changed()
layers_changed.emit()
@export_group("Slope Control")
@export var slope_limit_map: Texture2D:
set(value):
slope_limit_map = value
emit_changed()
layers_changed.emit()
@export_range(0.0, 90.0) var default_slope_limit: float = 45.0:
set(value):
default_slope_limit = value
emit_changed()
layers_changed.emit()
@export_group("Mask Layers")
@export var mask_layers: Array[Texture2D] = []:
set(value):
mask_layers = value
emit_changed()
layers_changed.emit()
@export var mask_names: Array[String] = []:
set(value):
mask_names = value
emit_changed()
layers_changed.emit()
func _on_curve_changed() -> void:
emit_changed()
layers_changed.emit()
func get_biome_value(uv: Vector2) -> Color:
if not biome_map:
return Color.WHITE
var img := biome_map.get_image()
if not img:
return Color.WHITE
var px := clampi(int(uv.x * img.get_width()), 0, img.get_width() - 1)
var py := clampi(int(uv.y * img.get_height()), 0, img.get_height() - 1)
return img.get_pixel(px, py)
func get_slope_limit(uv: Vector2) -> float:
if not slope_limit_map:
return default_slope_limit
var img := slope_limit_map.get_image()
if not img:
return default_slope_limit
var px := clampi(int(uv.x * img.get_width()), 0, img.get_width() - 1)
var py := clampi(int(uv.y * img.get_height()), 0, img.get_height() - 1)
return img.get_pixel(px, py).r * 90.0
func get_height_profile_value(normalized_height: float) -> float:
if not height_profile:
return normalized_height
return height_profile.sample(clampf(normalized_height, 0.0, 1.0))
func get_mask_value(mask_index: int, uv: Vector2) -> float:
if mask_index < 0 or mask_index >= mask_layers.size():
return 0.0
var tex := mask_layers[mask_index]
if not tex:
return 0.0
var img := tex.get_image()
if not img:
return 0.0
var px := clampi(int(uv.x * img.get_width()), 0, img.get_width() - 1)
var py := clampi(int(uv.y * img.get_height()), 0, img.get_height() - 1)
return img.get_pixel(px, py).r
func get_mask_name(mask_index: int) -> String:
if mask_index < 0 or mask_index >= mask_names.size():
return "mask_%d" % mask_index
return mask_names[mask_index]

View File

@@ -0,0 +1 @@
uid://dn0dmf6vj6ybp

View File

@@ -0,0 +1,507 @@
@tool
class_name TerrainGenerator
extends MeshInstance3D
enum PreviewMode { NONE, BIOME, SLOPE, MASK_INDEX, RAIL_DISTANCE, STATION_DISTANCE }
signal terrain_generated
signal props_data_ready(props_data: TerrainPropsData)
@export var noise_setting: FastNoiseLite:
set(value):
if noise_setting and noise_setting.changed.is_connected(_on_settings_changed):
noise_setting.changed.disconnect(_on_settings_changed)
noise_setting = value
if noise_setting:
noise_setting.changed.connect(_on_settings_changed)
_request_update()
@export var material_setting: Material:
set(value):
material_setting = value
if mesh:
mesh.surface_set_material(0, material_setting)
@export_group("Terrain Size")
@export var terrain_size: Vector2 = Vector2(200, 200):
set(value):
terrain_size = value
_request_update()
@export_range(2, 512) var subdivision: int = 128:
set(value):
subdivision = value
_request_update()
@export var max_height: float = 50.0:
set(value):
max_height = value
_request_update()
@export_group("Island Mask")
@export var island_mask: bool = true:
set(value):
island_mask = value
_request_update()
@export_range(0.0, 1.0) var mask_falloff_start: float = 0.5:
set(value):
mask_falloff_start = value
_request_update()
@export_range(0.1, 2.0) var mask_power: float = 1.5:
set(value):
mask_power = value
_request_update()
@export_group("Control Layers")
@export var control_layers: TerrainControlLayers:
set(value):
if control_layers and control_layers.layers_changed.is_connected(_on_settings_changed):
control_layers.layers_changed.disconnect(_on_settings_changed)
control_layers = value
if control_layers:
control_layers.layers_changed.connect(_on_settings_changed)
_request_update()
@export_group("Rail Constraints")
@export var rail_paths: Array[NodePath] = []:
set(value):
rail_paths = value
_request_update()
@export var rail_influence_radius: float = 10.0:
set(value):
rail_influence_radius = value
_request_update()
@export var rail_flatten_strength: float = 1.0:
set(value):
rail_flatten_strength = value
_request_update()
@export_range(0.0, 45.0) var rail_max_slope: float = 5.0:
set(value):
rail_max_slope = value
_request_update()
@export_group("Station Constraints")
@export var station_paths: Array[NodePath] = []:
set(value):
station_paths = value
_request_update()
@export var station_influence_radius: float = 25.0:
set(value):
station_influence_radius = value
_request_update()
@export var station_flatten_height: float = 0.0:
set(value):
station_flatten_height = value
_request_update()
@export var use_station_y_as_height: bool = true:
set(value):
use_station_y_as_height = value
_request_update()
@export_group("Preview")
@export var preview_mode: PreviewMode = PreviewMode.NONE:
set(value):
preview_mode = value
_request_update()
@export var preview_mask_index: int = 0:
set(value):
preview_mask_index = value
if preview_mode == PreviewMode.MASK_INDEX:
_request_update()
@export_group("Actions")
@export var regenerate: bool = false:
set(_value):
generate_terrain()
@export var bake_collision: bool = false:
set(_value):
_bake_collision()
var _update_queued: bool = false
var _rail_spline_cache: Array[Curve3D] = []
var _station_positions: Array[Vector3] = []
var _props_data: TerrainPropsData
func _ready() -> void:
if noise_setting:
if not noise_setting.changed.is_connected(_on_settings_changed):
noise_setting.changed.connect(_on_settings_changed)
generate_terrain()
func _on_settings_changed() -> void:
_request_update()
func _request_update() -> void:
if _update_queued:
return
_update_queued = true
call_deferred("_do_update")
func _do_update() -> void:
_update_queued = false
generate_terrain()
func generate_terrain() -> void:
if not noise_setting:
return
_cache_constraints()
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var step_x: float = terrain_size.x / float(subdivision - 1)
var step_z: float = terrain_size.y / float(subdivision - 1)
var half_x: float = terrain_size.x / 2.0
var half_z: float = terrain_size.y / 2.0
var heights: Array[float] = []
var biome_ids: Array[int] = []
var slope_values: Array[float] = []
var rail_distances: Array[float] = []
var station_distances: Array[float] = []
var mask_values: Array[Array] = []
heights.resize(subdivision * subdivision)
biome_ids.resize(subdivision * subdivision)
slope_values.resize(subdivision * subdivision)
rail_distances.resize(subdivision * subdivision)
station_distances.resize(subdivision * subdivision)
var mask_count := 0
if control_layers:
mask_count = control_layers.mask_layers.size()
for i in range(mask_count):
var arr: Array[float] = []
arr.resize(subdivision * subdivision)
mask_values.append(arr)
# Pass 1: Generate base heights
for z in range(subdivision):
for x in range(subdivision):
var world_x: float = x * step_x - half_x
var world_z: float = z * step_z - half_z
var idx: int = z * subdivision + x
var uv := Vector2(float(x) / float(subdivision - 1), float(z) / float(subdivision - 1))
var base_noise: float = (noise_setting.get_noise_2d(world_x, world_z) + 1.0) * 0.5
var height: float = base_noise * max_height
# Apply height profile from control layers
if control_layers and control_layers.height_profile:
height = control_layers.get_height_profile_value(base_noise) * max_height
# Apply island mask
if island_mask:
height *= _calculate_island_mask(world_x, world_z, half_x, half_z)
# Sample biome
var biome_color := Color.WHITE
if control_layers:
biome_color = control_layers.get_biome_value(uv)
biome_ids[idx] = _color_to_biome_id(biome_color)
# Sample slope limit
var slope_limit := 45.0
if control_layers:
slope_limit = control_layers.get_slope_limit(uv)
slope_values[idx] = slope_limit
# Sample masks
for m in range(mask_count):
mask_values[m][idx] = control_layers.get_mask_value(m, uv)
heights[idx] = height
# Pass 2: Apply rail constraints
for z in range(subdivision):
for x in range(subdivision):
var world_x: float = x * step_x - half_x
var world_z: float = z * step_z - half_z
var idx: int = z * subdivision + x
var world_pos := Vector3(world_x, heights[idx], world_z)
var rail_result := _get_rail_constraint(world_pos)
rail_distances[idx] = rail_result.distance
if rail_result.distance < rail_influence_radius and rail_result.distance >= 0.0:
var t: float = 1.0 - (rail_result.distance / rail_influence_radius)
t = t * t * rail_flatten_strength
heights[idx] = lerpf(heights[idx], rail_result.height, t)
# Pass 3: Apply station constraints
for z in range(subdivision):
for x in range(subdivision):
var world_x: float = x * step_x - half_x
var world_z: float = z * step_z - half_z
var idx: int = z * subdivision + x
var world_pos := Vector3(world_x, heights[idx], world_z)
var station_result := _get_station_constraint(world_pos)
station_distances[idx] = station_result.distance
if station_result.distance < station_influence_radius and station_result.distance >= 0.0:
var t: float = 1.0 - (station_result.distance / station_influence_radius)
t = t * t
heights[idx] = lerpf(heights[idx], station_result.height, t)
# Pass 4: Apply slope limits
_apply_slope_limits(heights, slope_values, step_x, step_z)
# Build props data
_props_data = TerrainPropsData.new()
_props_data.resolution = Vector2i(subdivision, subdivision)
_props_data.terrain_size = terrain_size
_props_data.heights = heights
_props_data.biome_ids = biome_ids
_props_data.slope_values = slope_values
_props_data.rail_distances = rail_distances
_props_data.station_distances = station_distances
_props_data.mask_values = mask_values
# Build mesh
for z in range(subdivision - 1):
for x in range(subdivision - 1):
var idx00: int = z * subdivision + x
var idx10: int = z * subdivision + (x + 1)
var idx01: int = (z + 1) * subdivision + x
var idx11: int = (z + 1) * subdivision + (x + 1)
var x0: float = x * step_x - half_x
var x1: float = (x + 1) * step_x - half_x
var z0: float = z * step_z - half_z
var z1: float = (z + 1) * step_z - half_z
var v00 := Vector3(x0, heights[idx00], z0)
var v10 := Vector3(x1, heights[idx10], z0)
var v01 := Vector3(x0, heights[idx01], z1)
var v11 := Vector3(x1, heights[idx11], z1)
var uv00 := Vector2(x0 / terrain_size.x + 0.5, z0 / terrain_size.y + 0.5)
var uv10 := Vector2(x1 / terrain_size.x + 0.5, z0 / terrain_size.y + 0.5)
var uv01 := Vector2(x0 / terrain_size.x + 0.5, z1 / terrain_size.y + 0.5)
var uv11 := Vector2(x1 / terrain_size.x + 0.5, z1 / terrain_size.y + 0.5)
# Preview mode coloring
var c00 := _get_preview_color(idx00, biome_ids, slope_values, rail_distances, station_distances, mask_values)
var c10 := _get_preview_color(idx10, biome_ids, slope_values, rail_distances, station_distances, mask_values)
var c01 := _get_preview_color(idx01, biome_ids, slope_values, rail_distances, station_distances, mask_values)
var c11 := _get_preview_color(idx11, biome_ids, slope_values, rail_distances, station_distances, mask_values)
# Triangle 1: v00, v10, v01 (CCW winding, normal pointing up)
var normal1: Vector3 = (v01 - v00).cross(v10 - v00).normalized()
st.set_normal(normal1)
st.set_color(c00)
st.set_uv(uv00)
st.add_vertex(v00)
st.set_color(c10)
st.set_uv(uv10)
st.add_vertex(v10)
st.set_color(c01)
st.set_uv(uv01)
st.add_vertex(v01)
# Triangle 2: v10, v11, v01 (CCW winding, normal pointing up)
var normal2: Vector3 = (v01 - v10).cross(v11 - v10).normalized()
st.set_normal(normal2)
st.set_color(c10)
st.set_uv(uv10)
st.add_vertex(v10)
st.set_color(c11)
st.set_uv(uv11)
st.add_vertex(v11)
st.set_color(c01)
st.set_uv(uv01)
st.add_vertex(v01)
mesh = st.commit()
if material_setting:
mesh.surface_set_material(0, material_setting)
terrain_generated.emit()
props_data_ready.emit(_props_data)
func _cache_constraints() -> void:
_rail_spline_cache.clear()
_station_positions.clear()
for path in rail_paths:
if path.is_empty():
continue
var node := get_node_or_null(path)
if node is Path3D:
var path3d := node as Path3D
if path3d.curve:
_rail_spline_cache.append(path3d.curve)
for path in station_paths:
if path.is_empty():
continue
var node := get_node_or_null(path)
if node is Node3D:
_station_positions.append((node as Node3D).global_position)
func _get_rail_constraint(world_pos: Vector3) -> Dictionary:
var min_dist := INF
var rail_height := world_pos.y
for curve in _rail_spline_cache:
var closest := curve.get_closest_point(world_pos)
var dist := Vector2(world_pos.x - closest.x, world_pos.z - closest.z).length()
if dist < min_dist:
min_dist = dist
rail_height = closest.y
return {"distance": min_dist, "height": rail_height}
func _get_station_constraint(world_pos: Vector3) -> Dictionary:
var min_dist := INF
var target_height := station_flatten_height
for station_pos in _station_positions:
var dist := Vector2(world_pos.x - station_pos.x, world_pos.z - station_pos.z).length()
if dist < min_dist:
min_dist = dist
if use_station_y_as_height:
target_height = station_pos.y
return {"distance": min_dist, "height": target_height}
func _apply_slope_limits(heights: Array[float], slope_limits: Array[float], step_x: float, step_z: float) -> void:
var max_iterations := 10
for _iter in range(max_iterations):
var changed := false
for z in range(1, subdivision - 1):
for x in range(1, subdivision - 1):
var idx: int = z * subdivision + x
var slope_limit_rad: float = deg_to_rad(slope_limits[idx])
var max_delta_x: float = tan(slope_limit_rad) * step_x
var max_delta_z: float = tan(slope_limit_rad) * step_z
var h := heights[idx]
var h_xm := heights[idx - 1]
var h_xp := heights[idx + 1]
var h_zm := heights[idx - subdivision]
var h_zp := heights[idx + subdivision]
var target := h
if abs(h - h_xm) > max_delta_x:
target = minf(target, h_xm + max_delta_x) if h > h_xm else maxf(target, h_xm - max_delta_x)
changed = true
if abs(h - h_xp) > max_delta_x:
target = minf(target, h_xp + max_delta_x) if h > h_xp else maxf(target, h_xp - max_delta_x)
changed = true
if abs(h - h_zm) > max_delta_z:
target = minf(target, h_zm + max_delta_z) if h > h_zm else maxf(target, h_zm - max_delta_z)
changed = true
if abs(h - h_zp) > max_delta_z:
target = minf(target, h_zp + max_delta_z) if h > h_zp else maxf(target, h_zp - max_delta_z)
changed = true
heights[idx] = lerpf(h, target, 0.5)
if not changed:
break
func _color_to_biome_id(color: Color) -> int:
return int(color.r * 255.0) + int(color.g * 255.0) * 256
func _get_preview_color(idx: int, biome_ids: Array[int], slope_values: Array[float],
rail_distances: Array[float], station_distances: Array[float], mask_values: Array[Array]) -> Color:
match preview_mode:
PreviewMode.NONE:
return Color.WHITE
PreviewMode.BIOME:
var biome := biome_ids[idx]
return Color.from_hsv(fmod(float(biome) * 0.618033988749, 1.0), 0.7, 0.9)
PreviewMode.SLOPE:
var slope := slope_values[idx] / 90.0
return Color(slope, 1.0 - slope, 0.0)
PreviewMode.MASK_INDEX:
if preview_mask_index < mask_values.size():
var v: float = mask_values[preview_mask_index][idx]
return Color(v, v, v)
return Color.BLACK
PreviewMode.RAIL_DISTANCE:
var d: float = clampf(rail_distances[idx] / (rail_influence_radius * 2.0), 0.0, 1.0)
return Color(1.0 - d, 0.2, d)
PreviewMode.STATION_DISTANCE:
var d: float = clampf(station_distances[idx] / (station_influence_radius * 2.0), 0.0, 1.0)
return Color(0.2, 1.0 - d, d)
return Color.WHITE
func get_props_data() -> TerrainPropsData:
return _props_data
func _calculate_island_mask(x: float, z: float, half_x: float, half_z: float) -> float:
var nx: float = x / half_x
var nz: float = z / half_z
var dist: float = sqrt(nx * nx + nz * nz)
if dist < mask_falloff_start:
return 1.0
elif dist > 1.0:
return 0.0
else:
var t: float = (dist - mask_falloff_start) / (1.0 - mask_falloff_start)
return pow(1.0 - t, mask_power)
func _bake_collision() -> void:
if not mesh:
push_warning("TerrainGenerator: No mesh to bake collision from")
return
var static_body: StaticBody3D
var collision_shape: CollisionShape3D
for child in get_children():
if child is StaticBody3D:
static_body = child
break
if not static_body:
static_body = StaticBody3D.new()
static_body.name = "TerrainCollision"
add_child(static_body)
if Engine.is_editor_hint():
static_body.owner = get_tree().edited_scene_root
for child in static_body.get_children():
if child is CollisionShape3D:
collision_shape = child
break
if not collision_shape:
collision_shape = CollisionShape3D.new()
collision_shape.name = "CollisionShape"
static_body.add_child(collision_shape)
if Engine.is_editor_hint():
collision_shape.owner = get_tree().edited_scene_root
collision_shape.shape = mesh.create_trimesh_shape()
print("TerrainGenerator: Collision baked successfully")

View File

@@ -0,0 +1 @@
uid://brr6het2uwnp5

View File

@@ -0,0 +1,153 @@
@tool
class_name TerrainPropsData
extends RefCounted
## Data structure containing terrain information for props placement.
## Generated by TerrainGenerator after terrain generation.
var resolution: Vector2i = Vector2i(128, 128)
var terrain_size: Vector2 = Vector2(200, 200)
var heights: Array[float] = []
var biome_ids: Array[int] = []
var slope_values: Array[float] = []
var rail_distances: Array[float] = []
var station_distances: Array[float] = []
var mask_values: Array[Array] = []
func get_index(x: int, z: int) -> int:
return z * resolution.x + x
func get_uv(x: int, z: int) -> Vector2:
return Vector2(float(x) / float(resolution.x - 1), float(z) / float(resolution.y - 1))
func get_world_position(x: int, z: int) -> Vector3:
var half_x := terrain_size.x / 2.0
var half_z := terrain_size.y / 2.0
var step_x := terrain_size.x / float(resolution.x - 1)
var step_z := terrain_size.y / float(resolution.y - 1)
var idx := get_index(x, z)
return Vector3(
x * step_x - half_x,
heights[idx] if idx < heights.size() else 0.0,
z * step_z - half_z
)
func get_height(x: int, z: int) -> float:
var idx := get_index(x, z)
if idx < 0 or idx >= heights.size():
return 0.0
return heights[idx]
func get_biome_id(x: int, z: int) -> int:
var idx := get_index(x, z)
if idx < 0 or idx >= biome_ids.size():
return 0
return biome_ids[idx]
func get_slope_limit(x: int, z: int) -> float:
var idx := get_index(x, z)
if idx < 0 or idx >= slope_values.size():
return 45.0
return slope_values[idx]
func get_rail_distance(x: int, z: int) -> float:
var idx := get_index(x, z)
if idx < 0 or idx >= rail_distances.size():
return INF
return rail_distances[idx]
func get_station_distance(x: int, z: int) -> float:
var idx := get_index(x, z)
if idx < 0 or idx >= station_distances.size():
return INF
return station_distances[idx]
func get_mask_value(mask_index: int, x: int, z: int) -> float:
if mask_index < 0 or mask_index >= mask_values.size():
return 0.0
var mask_arr: Array = mask_values[mask_index]
var idx := get_index(x, z)
if idx < 0 or idx >= mask_arr.size():
return 0.0
return mask_arr[idx]
func get_calculated_slope(x: int, z: int) -> float:
if x <= 0 or x >= resolution.x - 1 or z <= 0 or z >= resolution.y - 1:
return 0.0
var step_x := terrain_size.x / float(resolution.x - 1)
var step_z := terrain_size.y / float(resolution.y - 1)
var h := get_height(x, z)
var h_xm := get_height(x - 1, z)
var h_xp := get_height(x + 1, z)
var h_zm := get_height(x, z - 1)
var h_zp := get_height(x, z + 1)
var dx := (h_xp - h_xm) / (2.0 * step_x)
var dz := (h_zp - h_zm) / (2.0 * step_z)
return rad_to_deg(atan(sqrt(dx * dx + dz * dz)))
func can_place_prop(x: int, z: int, rules: Dictionary) -> bool:
var slope := get_calculated_slope(x, z)
var rail_dist := get_rail_distance(x, z)
var station_dist := get_station_distance(x, z)
var biome := get_biome_id(x, z)
if rules.has("max_slope") and slope > rules["max_slope"]:
return false
if rules.has("min_rail_distance") and rail_dist < rules["min_rail_distance"]:
return false
if rules.has("min_station_distance") and station_dist < rules["min_station_distance"]:
return false
if rules.has("allowed_biomes") and not biome in rules["allowed_biomes"]:
return false
if rules.has("excluded_biomes") and biome in rules["excluded_biomes"]:
return false
if rules.has("required_mask"):
var mask_idx: int = rules["required_mask"]
var threshold: float = rules.get("mask_threshold", 0.5)
if get_mask_value(mask_idx, x, z) < threshold:
return false
if rules.has("excluded_mask"):
var mask_idx: int = rules["excluded_mask"]
var threshold: float = rules.get("exclude_threshold", 0.5)
if get_mask_value(mask_idx, x, z) >= threshold:
return false
return true
func get_density_multiplier(x: int, z: int, rules: Dictionary) -> float:
var multiplier := 1.0
if rules.has("slope_density_curve"):
var slope := get_calculated_slope(x, z)
var curve: Curve = rules["slope_density_curve"]
multiplier *= curve.sample(clampf(slope / 90.0, 0.0, 1.0))
if rules.has("rail_distance_curve"):
var dist := get_rail_distance(x, z)
var curve: Curve = rules["rail_distance_curve"]
var max_dist: float = rules.get("rail_distance_max", 100.0)
multiplier *= curve.sample(clampf(dist / max_dist, 0.0, 1.0))
if rules.has("density_mask"):
var mask_idx: int = rules["density_mask"]
multiplier *= get_mask_value(mask_idx, x, z)
return multiplier

View File

@@ -0,0 +1 @@
uid://csau51iw3gxam

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,50 @@
shader_type spatial;
render_mode cull_back;
group_uniforms textures;
uniform sampler2D texture_top : source_color, filter_linear_mipmap, repeat_enable;
uniform sampler2D texture_side : source_color, filter_linear_mipmap, repeat_enable;
group_uniforms blending;
uniform float blend_sharpness : hint_range(0.1, 20.0) = 5.0;
uniform float slope_threshold : hint_range(0.0, 1.0) = 0.7;
group_uniforms uv_scaling;
uniform vec3 uv_scale = vec3(0.1, 0.1, 0.1);
uniform vec2 texture_top_tiling = vec2(1.0, 1.0);
uniform vec2 texture_side_tiling = vec2(1.0, 1.0);
varying vec3 world_pos;
varying vec3 world_normal;
void vertex() {
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
}
void fragment() {
vec3 abs_normal = abs(world_normal);
vec2 uv_xz = world_pos.xz * uv_scale.x * texture_top_tiling;
vec2 uv_xy = world_pos.xy * uv_scale.y * texture_side_tiling;
vec2 uv_zy = world_pos.zy * uv_scale.z * texture_side_tiling;
vec3 weights = pow(abs_normal, vec3(blend_sharpness));
weights /= (weights.x + weights.y + weights.z);
vec4 tex_xz = texture(texture_top, uv_xz);
vec4 tex_xy = texture(texture_side, uv_xy);
vec4 tex_zy = texture(texture_side, uv_zy);
vec4 triplanar_color = tex_xz * weights.y + tex_xy * weights.z + tex_zy * weights.x;
float up_factor = dot(world_normal, vec3(0.0, 1.0, 0.0));
float slope_blend = smoothstep(slope_threshold - 0.1, slope_threshold + 0.1, up_factor);
vec4 top_color = texture(texture_top, uv_xz);
vec4 side_color = (tex_xy * weights.z + tex_zy * weights.x) / max(weights.z + weights.x, 0.001);
vec4 final_color = mix(side_color, top_color, slope_blend);
ALBEDO = final_color.rgb;
}

View File

@@ -0,0 +1 @@
uid://d4b4lfd2r51iu

View File

@@ -7,6 +7,7 @@
[ext_resource type="ArrayMesh" uid="uid://cm8un80nubjbi" path="res://resources/train kit/train-diesel-a.obj" id="3_jy5c4"] [ext_resource type="ArrayMesh" uid="uid://cm8un80nubjbi" path="res://resources/train kit/train-diesel-a.obj" id="3_jy5c4"]
[ext_resource type="Script" uid="uid://b65vwus8xxmun" path="res://scripts/traincontrol_camera.gd" id="4_jy5c4"] [ext_resource type="Script" uid="uid://b65vwus8xxmun" path="res://scripts/traincontrol_camera.gd" id="4_jy5c4"]
[ext_resource type="Script" uid="uid://df2oi5vyeak81" path="res://scripts/train_movement.gd" id="6_1lqs8"] [ext_resource type="Script" uid="uid://df2oi5vyeak81" path="res://scripts/train_movement.gd" id="6_1lqs8"]
[ext_resource type="Script" uid="uid://b5x80gpkn1swt" path="res://scripts/rail_swtich.gd" id="7_cenvf"]
[sub_resource type="PlaneMesh" id="PlaneMesh_23vqf"] [sub_resource type="PlaneMesh" id="PlaneMesh_23vqf"]
size = Vector2(100, 100) size = Vector2(100, 100)
@@ -17,10 +18,10 @@ albedo_color = Color(2.153851e-06, 0.25305444, 0.03645257, 1)
[sub_resource type="Curve3D" id="Curve3D_0b1nv"] [sub_resource type="Curve3D" id="Curve3D_0b1nv"]
closed = true closed = true
_data = { _data = {
"points": PackedVector3Array(0, 0, 0, 0, 0, 0, -41.962486, 0.03566742, -44.78871, 0, 0, 0, 0, 0, 0, -7.760188, 0.036315918, -45.60305, 0, 0, 0, 0, 0, 0, 21.603975, 0.03604126, -45.26774, 0, 0, 0, 0, 0, 0, 41.148148, 0.03127289, -39.27994, 0, 0, 0, 0, 0, 0, 44.2618, 0.02154541, -27.064838, 0, 0, 0, 0, 0, 0, 44.118095, 0.0033874512, -4.2633114, 0, 0, 0, 0, 0, 0, 26.96904, 0.003112793, -3.9279928, 0, 0, 0, 0, 0, 0, 3.4968712, 0.0079574585, -10.011598, 0, 0, 0, 0, 0, 0, -3.8321874, 0.024543762, -30.849133, 0, 0, 0, 0, 0, 0, -25.627771, 0.026069641, -32.765224, 0, 0, 0, 0, 0, 0, -31.28027, 0.015075684, -18.969343, 0, 0, 0, 0, 0, 0, -23.472174, 0.003479004, -4.4070177, 0, 0, 0, 0, 0, 0, 12.167218, -0.011207581, 14.035401, 0, 0, 0, 0, 0, 0, 34.681347, -0.013763428, 17.244862, 0, 0, 0, 0, 0, 0, 46.99225, -0.019981384, 25.052942, 0, 0, 0, 0, 0, 0, 42.72894, -0.03504944, 43.97439, 0, 0, 0, 0, 0, 0, 14.897652, -0.036231995, 45.459354, 0, 0, 0, 0, 0, 0, -23.088955, -0.034858704, 43.73488, 0, 0, 0, 0, 0, 0, -44.357616, -0.034965515, 43.878586, 0, 0, 0, 0, 0, 0, -45.603073, -0.010246277, 12.837832, 0, 0, 0, 0, 0, 0, -34.585533, 0.0079422, -10.0116005, 0, 0, 0, 0, 0, 0, -38.321926, 0.022087097, -27.78338), "points": PackedVector3Array(-2.1678085, -0.043792725, 1.658886, 2.1678085, 0.043792725, -1.658886, -41.962486, 0.03566742, -44.78871, 0, 0, 0, 0, 0, 0, -7.760188, 0.036315918, -45.60305, -6.559948, 0.007423401, -0.16356277, 6.559948, -0.007423401, 0.16356277, 21.603975, 0.03604126, -45.26774, -3.4513588, 0.124176025, -4.544277, 3.4513588, -0.124176025, 4.544277, 40.931072, 0.032211304, -40.35701, 0.5563507, 0.09750366, -3.6268482, -0.5563507, -0.09750366, 3.6268482, 45.43544, 0.021453857, -26.947992, 3.438774, 0.09018707, -3.405755, -3.438774, -0.09018707, 3.405755, 38.412643, 0.47789, -9.150471, 6.6514378, -0.022911072, 0.7331085, -6.6514378, 0.022911072, -0.7331085, 25.856459, 0.10243797, -3.7440395, 5.315757, -0.10071564, 3.6390858, -5.315757, 0.10071564, -3.6390858, 3.4968712, 0.0079574585, -10.011598, 4.9409637, -0.0754776, 2.709568, -4.9409637, 0.0754776, -2.709568, -3.8321874, 0.024543762, -30.849133, 6.360344, 0.0488739, -1.9223251, -6.360344, -0.0488739, 1.9223251, -22.311077, 0.005493164, -31.887383, -0.9378357, 0.12989807, -4.797656, 0.9378357, -0.12989807, 4.797656, -31.28027, 0.015075684, -18.969343, -7.12364, 0.096969604, -3.4738579, 7.12364, -0.096969604, 3.4738579, -23.472174, 0.003479004, -4.4070177, -9.224047, 0.08418274, -2.961853, 9.224047, -0.08418274, 2.961853, 12.167218, -0.011207581, 14.035401, -8.825874, 0.03265381, -1.0564928, 8.825874, -0.03265381, 1.0564928, 32.632145, -0.011894226, 14.993831, 0, 0, 0, 0, 0, 0, 39.98447, -0.019104004, 24.146927, 4.9879036, 0.122177124, -4.6163826, -4.9879036, -0.122177124, 4.6163826, 35.724655, 0.047676086, 33.444454, 8.1661625, 0.014076233, -0.6628723, -8.1661625, -0.014076233, 0.6628723, 11.495622, 0.0951767, 40.647045, 4.9570847, -0.058029175, 2.0650558, -4.9570847, 0.058029175, -2.0650558, -22.318954, 0.051948547, 40.504288, 4.9869003, -0.18069458, 6.610588, -4.9869003, 0.18069458, -6.610588, -37.494194, 0.080329895, 26.992659, -1.5943985, -0.19984436, 7.435757, 1.5943985, 0.19984436, -7.435757, -46.58226, 0.10346222, 8.63745, -0.4270401, -0.16287994, 6.045038, 0.4270401, 0.16287994, -6.045038, -39.930115, 0.050865173, -11.514841, 4.8086166, -0.20992279, 7.687052, -4.8086166, 0.20992279, -7.687052, -35.56163, 0.15759277, -32.8561, -0.73628235, -0.15232849, 5.656826, 0.73628235, 0.15232849, -5.656826, -45.566254, 0.602231, -39.59991),
"tilts": PackedFloat32Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) "tilts": PackedFloat32Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
} }
point_count = 22 point_count = 23
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ke0jj"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ke0jj"]
albedo_texture = ExtResource("1_ihlg3") albedo_texture = ExtResource("1_ihlg3")
@@ -31,6 +32,14 @@ uv1_offset = Vector3(0, -0.61, 0)
metallic = 1.0 metallic = 1.0
roughness = 0.25 roughness = 0.25
[sub_resource type="Curve3D" id="Curve3D_1lqs8"]
closed = true
_data = {
"points": PackedVector3Array(-6.2964935, 0.06638336, -2.3570595, 6.2964935, -0.06638336, 2.3570595, 24, 0, -36, 0.40251923, 0.1432724, -5.316846, -0.40251923, -0.1432724, 5.316846, 30.535202, 0.016418457, -19.69652, -3.0211945, 0.21754456, -8.010609, 3.0211945, -0.21754456, 8.010609, 37.792625, 0.0013275146, -0.5123644, 7.037945, 0.16593933, -6.2738285, -7.037945, -0.16593933, 6.2738285, 33.177887, 0.0013427734, 21.921793, 12.049584, -0.0362854, 1.1334801, -12.049584, 0.0362854, -1.1334801, 7.185522, 0.0013427734, 33.84686, 3.006013, -0.23966217, 8.824167, -3.006013, 0.23966217, -8.824167, -5.633688, 0.0127334595, 16.86435, 0.09129143, -0.14064789, 5.2124147, -0.09129143, 0.14064789, -5.2124147, -20.520624, 0.0013427734, -3.9991686, -6.1595325, -0.18273163, 6.8687496, 6.1595325, 0.18273163, -6.8687496, -10.095118, 0.0013427734, -27.90497),
"tilts": PackedFloat32Array(0, 0, 0, 0, 0, 0, 0, 0)
}
point_count = 8
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_jgp6u"] [sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_jgp6u"]
sky_top_color = Color(0.6262097, 0.76737565, 0.9309246, 1) sky_top_color = Color(0.6262097, 0.76737565, 0.9309246, 1)
sky_horizon_color = Color(0.82665706, 0.8808251, 0.95468724, 1) sky_horizon_color = Color(0.82665706, 0.8808251, 0.95468724, 1)
@@ -63,10 +72,10 @@ main_scene = ExtResource("2_0b1nv")
mesh = SubResource("PlaneMesh_23vqf") mesh = SubResource("PlaneMesh_23vqf")
surface_material_override/0 = SubResource("StandardMaterial3D_23vqf") surface_material_override/0 = SubResource("StandardMaterial3D_23vqf")
[node name="Rails" type="Path3D" parent="Ground" unique_id=1160818899] [node name="Rails1" type="Path3D" parent="Ground" unique_id=1160818899]
curve = SubResource("Curve3D_0b1nv") curve = SubResource("Curve3D_0b1nv")
[node name="Ballast" type="CSGPolygon3D" parent="Ground/Rails" unique_id=259312931] [node name="Ballast" type="CSGPolygon3D" parent="Ground/Rails1" unique_id=259312931]
polygon = PackedVector2Array(-1, 0, -1, 0.1, 1, 0.1, 1, 0) polygon = PackedVector2Array(-1, 0, -1, 0.1, 1, 0.1, 1, 0)
mode = 2 mode = 2
path_node = NodePath("..") path_node = NodePath("..")
@@ -81,7 +90,7 @@ path_u_distance = 1.0
path_joined = true path_joined = true
material = SubResource("StandardMaterial3D_ke0jj") material = SubResource("StandardMaterial3D_ke0jj")
[node name="LRail" type="CSGPolygon3D" parent="Ground/Rails" unique_id=477776682] [node name="LRail" type="CSGPolygon3D" parent="Ground/Rails1" unique_id=477776682]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.24889, -4.7985353, 2.2341003) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.24889, -4.7985353, 2.2341003)
polygon = PackedVector2Array(-0.5, 0, -0.5, 0.2, -0.4, 0.2, -0.4, 0) polygon = PackedVector2Array(-0.5, 0, -0.5, 0.2, -0.4, 0.2, -0.4, 0)
mode = 2 mode = 2
@@ -97,7 +106,7 @@ path_u_distance = 1.0
path_joined = true path_joined = true
material = SubResource("StandardMaterial3D_ihlg3") material = SubResource("StandardMaterial3D_ihlg3")
[node name="RRail" type="CSGPolygon3D" parent="Ground/Rails" unique_id=965437995] [node name="RRail" type="CSGPolygon3D" parent="Ground/Rails1" unique_id=965437995]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.24889, -4.7985353, 2.2341003) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.24889, -4.7985353, 2.2341003)
polygon = PackedVector2Array(0.5, 0, 0.5, 0.2, 0.4, 0.2, 0.5, 0) polygon = PackedVector2Array(0.5, 0, 0.5, 0.2, 0.4, 0.2, 0.5, 0)
mode = 2 mode = 2
@@ -113,15 +122,78 @@ path_u_distance = 1.0
path_joined = true path_joined = true
material = SubResource("StandardMaterial3D_ihlg3") material = SubResource("StandardMaterial3D_ihlg3")
[node name="TrainEngine" type="PathFollow3D" parent="Ground/Rails" unique_id=973990755] [node name="TrainEngine" type="PathFollow3D" parent="Ground/Rails1" unique_id=973990755]
transform = Transform3D(0.026897233, -2.1416474e-05, -1.1296921, 0, 1.130161, -2.141972e-05, 1.1296905, 5.0991304e-07, 0.02689727, -41.962486, 0.03566742, -44.78871) transform = Transform3D(0.6867339, -0.014397825, -0.8973005, 0, 1.130071, -0.018126711, 0.8974141, 0.01101774, 0.68664694, -41.962486, 0.03566742, -44.78871)
rotation_mode = 4 rotation_mode = 4
script = ExtResource("2_jgp6u") script = ExtResource("2_jgp6u")
[node name="Train" type="MeshInstance3D" parent="Ground/Rails/TrainEngine" unique_id=65189673] [node name="Rails2" type="Path3D" parent="Ground" unique_id=1426938039]
curve = SubResource("Curve3D_1lqs8")
[node name="Ballast" type="CSGPolygon3D" parent="Ground/Rails2" unique_id=608449521]
polygon = PackedVector2Array(-1, 0, -1, 0.1, 1, 0.1, 1, 0)
mode = 2
path_node = NodePath("..")
path_interval_type = 0
path_interval = 1.0
path_simplify_angle = 0.0
path_rotation = 2
path_rotation_accurate = false
path_local = false
path_continuous_u = true
path_u_distance = 1.0
path_joined = true
material = SubResource("StandardMaterial3D_ke0jj")
[node name="RRail" type="CSGPolygon3D" parent="Ground/Rails2" unique_id=469196308]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.24889, -4.7985353, 2.2341003)
polygon = PackedVector2Array(0.5, 0, 0.5, 0.2, 0.4, 0.2, 0.5, 0)
mode = 2
path_node = NodePath("..")
path_interval_type = 0
path_interval = 1.0
path_simplify_angle = 0.0
path_rotation = 2
path_rotation_accurate = false
path_local = false
path_continuous_u = true
path_u_distance = 1.0
path_joined = true
material = SubResource("StandardMaterial3D_ihlg3")
[node name="LRail" type="CSGPolygon3D" parent="Ground/Rails2" unique_id=1887208549]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.24889, -4.7985353, 2.2341003)
polygon = PackedVector2Array(-0.5, 0, -0.5, 0.2, -0.4, 0.2, -0.4, 0)
mode = 2
path_node = NodePath("..")
path_interval_type = 0
path_interval = 1.0
path_simplify_angle = 0.0
path_rotation = 2
path_rotation_accurate = false
path_local = false
path_continuous_u = true
path_u_distance = 1.0
path_joined = true
material = SubResource("StandardMaterial3D_ihlg3")
[node name="TrainEngine" type="PathFollow3D" parent="Ground/Rails2" unique_id=1618605131]
transform = Transform3D(-0.39616987, 0.010450672, -1.0582525, 0, 1.1301593, 0.01115706, 1.0583019, 0.003912155, -0.3961513, 24, 0, -36)
rotation_mode = 4
script = ExtResource("2_jgp6u")
[node name="Train" type="MeshInstance3D" parent="Ground" unique_id=65189673]
transform = Transform3D(0.6867333, -0.014397784, -0.89729905, 0, 1.1300678, -0.018126683, 0.8974134, 0.011017708, 0.68664587, -41.962486, 0.03566742, -44.78871)
mesh = ExtResource("3_jy5c4") mesh = ExtResource("3_jy5c4")
skeleton = NodePath("../Rails1/TrainEngine")
script = ExtResource("6_1lqs8") script = ExtResource("6_1lqs8")
[node name="RailSwtich" type="Area3D" parent="Ground" unique_id=1897237029 node_paths=PackedStringArray("rail_a", "rail_b", "switch_to_rail")]
script = ExtResource("7_cenvf")
rail_a = NodePath("../Rails1")
rail_b = NodePath("../Rails2")
switch_to_rail = NodePath("../Rails1")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1729725358] [node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1729725358]
shadow_enabled = true shadow_enabled = true
shadow_bias = 0.04 shadow_bias = 0.04
@@ -135,7 +207,8 @@ current = true
fov = 11.9 fov = 11.9
near = 0.001 near = 0.001
script = ExtResource("4_jy5c4") script = ExtResource("4_jy5c4")
train = NodePath("../Ground/Rails/TrainEngine/Train") train = NodePath("../Ground/Train")
follow_distance = 30.0
[node name="HUD" type="Control" parent="." unique_id=563257159] [node name="HUD" type="Control" parent="." unique_id=563257159]
layout_mode = 3 layout_mode = 3
@@ -155,9 +228,9 @@ label_settings = SubResource("LabelSettings_1lqs8")
[node name="camera_label" type="Label" parent="HUD" unique_id=1719494020] [node name="camera_label" type="Label" parent="HUD" unique_id=1719494020]
layout_mode = 0 layout_mode = 0
offset_left = 20.0 offset_left = 19.0
offset_top = 43.0 offset_top = 43.0
offset_right = 1280.0 offset_right = 1279.0
offset_bottom = 66.0 offset_bottom = 66.0
size_flags_vertical = 0 size_flags_vertical = 0
text = "Camera: " text = "Camera: "
@@ -165,15 +238,25 @@ label_settings = SubResource("LabelSettings_1lqs8")
[node name="controls_label" type="Label" parent="HUD" unique_id=1262113688] [node name="controls_label" type="Label" parent="HUD" unique_id=1262113688]
layout_mode = 0 layout_mode = 0
offset_left = 20.0 offset_left = 19.0
offset_top = 64.0 offset_top = 83.0
offset_right = 1280.0 offset_right = 1279.0
offset_bottom = 217.0 offset_bottom = 236.0
size_flags_vertical = 0 size_flags_vertical = 0
text = "Comandi: text = "Comandi:
\"W / S - Accelera / Rallenta\", \"W / S - Accelera / Rallenta\",
\"A / D - Ruota camera\", \"A / D - Ruota camera\",
\"SPACE - Cambia camera\", \"SPACE - Cambia camera\",
\"TAB - Cambia treno\", \"TAB\" - Cambia binario,
\"ESC - Esci\"" \"ESC - Esci\""
label_settings = SubResource("LabelSettings_1lqs8") label_settings = SubResource("LabelSettings_1lqs8")
[node name="switch_label" type="Label" parent="HUD" unique_id=462391845]
layout_mode = 0
offset_left = 19.0
offset_top = 64.0
offset_right = 1279.0
offset_bottom = 87.0
size_flags_vertical = 0
text = "Binario: "
label_settings = SubResource("LabelSettings_1lqs8")

View File

@@ -1,9 +1,9 @@
extends PathFollow3D extends PathFollow3D
class_name TrainEngine #class_name TrainEngine
#
@onready var train: MeshInstance3D = $Train #@onready var train: MeshInstance3D = $"../../Train"
#
#
func _physics_process(delta: float) -> void: #func _physics_process(delta: float) -> void:
if train: #if train:
progress += train.current_speed * delta #progress += train.current_speed * delta

17
scripts/rail_switch.gd Normal file
View File

@@ -0,0 +1,17 @@
extends Area3D
class_name RailSwitch
@export var rail_a: Path3D
@export var rail_b: Path3D
@export var switch_to_rail: Path3D # Il binario verso cui deviare
# Cambia direzione dello scambio
func toggle_switch() -> void:
if switch_to_rail == rail_a:
switch_to_rail = rail_b
else:
switch_to_rail = rail_a
func _on_body_entered(body: Node3D) -> void:
if body is Train:
body.switch_to_rail(switch_to_rail)

View File

@@ -0,0 +1 @@
uid://bhan6tvt4sow

17
scripts/rail_swtich.gd Normal file
View File

@@ -0,0 +1,17 @@
extends Area3D
@export var rail_a: Path3D
@export var rail_b: Path3D
@export var switch_to_rail: Path3D # Il binario verso cui deviare
# Cambia direzione dello scambio
func toggle_switch() -> void:
if switch_to_rail == rail_a:
switch_to_rail = rail_b
else:
switch_to_rail = rail_a
#func get_state_name() -> String:
#return "BIANRIO 1" if current_state == SwitchState.LEFT else "BINARIO 2"

View File

@@ -0,0 +1 @@
uid://b5x80gpkn1swt

View File

@@ -1,21 +1,30 @@
extends MeshInstance3D extends MeshInstance3D
class_name TrainBody
signal speed_changed(new_speed: float) signal speed_changed(new_speed: float)
signal rail_switched(old_rail: Path3D, new_rail: Path3D)
@export_group("Train") @export_group("Train")
@export var base_speed: float = 15.0 @export var base_speed: float = 15.0
@export var max_speed: float = 50.0 @export var max_speed: float = 50.0
@export var min_speed: float = 3.0 @export var min_speed: float = 3.0
@export var acceleration: float = 12.0 @export var acceleration: float = 12.0
@onready var rail_a: Path3D = $"../Rails1"
@onready var rail_b: Path3D = $"../Rails2"
var current_follower: PathFollow3D
var current_rail: Path3D
var current_speed: float = 15.0 var current_speed: float = 15.0
func _ready() -> void: func _ready() -> void:
current_speed = base_speed current_speed = base_speed
switch_to_rail(rail_a)
func _process(delta: float) -> void: func _process(delta: float) -> void:
_handle_input(delta) _handle_input(delta)
current_follower.progress += current_speed * delta
global_transform = current_follower.global_transform
func _handle_input(delta: float) -> void: func _handle_input(delta: float) -> void:
@@ -29,3 +38,35 @@ func _handle_input(delta: float) -> void:
func get_speed() -> float: func get_speed() -> float:
return current_speed return current_speed
func switch_to_rail(new_rail: Path3D) -> void:
if new_rail == current_rail:
return
current_rail = new_rail
current_follower = new_rail.get_node("TrainEngine") as PathFollow3D
var closest_offset = find_closest_point_on_path(new_rail, global_position)
current_follower.progress = closest_offset
func find_closest_point_on_path(path: Path3D, point: Vector3) -> float:
var curve = path.curve
var closest_offset = 0.0
var min_distance = INF
var length = curve.get_baked_length()
var steps = 100 # Più alto = più preciso ma più lento
for i in range(steps + 1):
var offset = (float(i) / steps) * length
# Trasforma il punto locale della curva in coordinate globali
var path_point = path.global_transform * curve.sample_baked(offset)
var dist = point.distance_to(path_point)
if dist < min_distance:
min_distance = dist
closest_offset = offset
return closest_offset

View File

@@ -3,8 +3,10 @@ extends Node3D
@onready var speed_label : Label = $HUD/speed_label @onready var speed_label : Label = $HUD/speed_label
@onready var camera_label : Label = $HUD/camera_label @onready var camera_label : Label = $HUD/camera_label
@onready var train: MeshInstance3D = $Ground/Rails/TrainEngine/Train @onready var train: MeshInstance3D = $Ground/Train
@onready var camera: TrainControlCamera = $TrainCamera @onready var camera: TrainControlCamera = $TrainCamera
@onready var rail_switch: Area3D = $Ground/RailSwtich
@onready var switch_label : Label = $HUD/switch_label
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
@@ -13,6 +15,8 @@ func _process(_delta: float) -> void:
if Input.is_action_just_pressed("ui_cancel"): if Input.is_action_just_pressed("ui_cancel"):
SceneSwitcher.switch_scene(main_scene.resource_path) SceneSwitcher.switch_scene(main_scene.resource_path)
if Input.is_action_just_pressed("change_train"):
_toggle_rail()
func _update_ui() -> void: func _update_ui() -> void:
@@ -22,3 +26,22 @@ func _update_ui() -> void:
if camera_label and camera: if camera_label and camera:
camera_label.text = "Camera: %s" % camera.get_mode_name() camera_label.text = "Camera: %s" % camera.get_mode_name()
func _toggle_rail() -> void:
if train and rail_switch:
rail_switch.toggle_switch()
train.switch_to_rail(rail_switch.switch_to_rail)
func _update_switch_status():
if not switch_label:
return
#if rail_switch:
#var state_name = rail_switch.get_state_name()
#var color = Color.YELLOW if rail_switch.current_state == rail_switch.SwitchState.LEFT else Color.CYAN
#switch_label.text = "Scambio: %s" % state_name
#switch_label.add_theme_color_override("font_color", color)
#else:
#switch_label.text = "Scambio: N/A"