WIP Placing 3d ShipParts

This commit is contained in:
2025-11-20 22:20:41 +01:00
parent 8fd540ddfc
commit 24ce1edb38
14 changed files with 654 additions and 86 deletions

View File

@ -0,0 +1,23 @@
[gd_resource type="Resource" script_class="StructureData" load_steps=2 format=3 uid="uid://squareplate001"]
[ext_resource type="Script" path="res://data/structure/structure_data.gd" id="1_script"]
[resource]
script = ExtResource("1_script")
piece_name = "Square Plate 1x1"
type = 1
base_mass = 10.0
health_max = 100.0
shape = "Square"
vertices = [
Vector3(-0.5, -0.5, 0.0),
Vector3(-0.5, 0.5, 0.0),
Vector3(0.5, 0.5, 0.0),
Vector3(0.5, -0.5, 0.0)
]
mounts = [
{ "position": Vector3(0, 0.5, 0), "normal": Vector3(0, 1, 0), "up": Vector3(0, 0, 1), "type": 0 },
{ "position": Vector3(0, -0.5, 0), "normal": Vector3(0, -1, 0), "up": Vector3(0, 0, 1), "type": 0 },
{ "position": Vector3(-0.5, 0, 0), "normal": Vector3(-1, 0, 0), "up": Vector3(0, 0, 1), "type": 0 },
{ "position": Vector3(0.5, 0, 0), "normal": Vector3(1, 0, 0), "up": Vector3(0, 0, 1), "type": 0 }
]

View File

@ -0,0 +1,21 @@
[gd_resource type="Resource" script_class="StructureData" load_steps=2 format=3 uid="uid://triangleplate001"]
[ext_resource type="Script" path="res://data/structure/structure_data.gd" id="1_script"]
[resource]
script = ExtResource("1_script")
piece_name = "Triangle Plate 1m"
type = 1 # PLATE
base_mass = 5.0
health_max = 100.0
shape = "Triangle"
vertices = [
Vector3(-0.5, -0.288675, 0.0),
Vector3(0.0, 0.57735, 0.0),
Vector3(0.5, -0.288675, 0.0)
]
mounts = [
{ "position": Vector3(0, -0.288, 0), "normal": Vector3(0, -1, 0), "up": Vector3(0, 0, 1), "type": 0 },
{ "position": Vector3(0.25, 0.144, 0), "normal": Vector3(0.866, 0.5, 0), "up": Vector3(0, 0, 1), "type": 0 },
{ "position": Vector3(-0.25, 0.144, 0), "normal": Vector3(-0.866, 0.5, 0), "up": Vector3(0, 0, 1), "type": 0 }
]

View File

@ -0,0 +1,62 @@
class_name StructureData extends Resource
enum PieceType {STRUT, PLATE, CONNECTOR}
@export_group("Identity")
@export var piece_name: String = "Structure"
@export var type: PieceType = PieceType.STRUT
@export var base_mass: float = 10.0
@export var health_max: float = 100.0
@export_group("Visuals & Physics")
## The mesh to display for static pieces. Leave null for procedural pieces.
@export var mesh: Mesh
## The collision shape for physics. Leave null for procedural pieces.
@export var collision_shape: Shape3D
@export_group("Procedural Parameters")
# For procedural pieces, we store parameters instead of a mesh
@export var shape: String = "Cube"
@export var vertices: Array[Vector3] = [
Vector3(1.0, 1.0, 1.0),
Vector3(-1.0, 1.0, 1.0),
Vector3(-1.0, -1.0, 1.0),
Vector3(1.0, -1.0, 1.0),
Vector3(1.0, 1.0, -1.0),
Vector3(-1.0, 1.0, -1.0),
Vector3(-1.0, -1.0, -1.0),
Vector3(1.0, -1.0, -1.0)
]
# @export var procedural_params: Dictionary = {}
@export_group("Mounts")
## Array of Dictionaries defining attachment points.
## Format: { "position": Vector3, "normal": Vector3, "up": Vector3, "type": int }
@export var mounts: Array[Dictionary] = []
# Helper to get mounts transformed into world space for snapping calculations
func get_mounts_transformed(global_transform: Transform3D) -> Array:
var world_mounts = []
for mount in mounts:
# Default to identity rotation if normal/up are missing
var normal = mount.get("normal", Vector3.BACK) # Default -Z forward
var up = mount.get("up", Vector3.UP)
world_mounts.append({
"position": global_transform * mount.get("position", Vector3.ZERO),
"normal": global_transform.basis * normal,
"up": global_transform.basis * up,
"type": mount.get("type", 0)
})
return world_mounts
# Helper to add a mount dynamically (for procedural pieces)
func add_mount(pos: Vector3, normal: Vector3, up: Vector3 = Vector3.UP, type: int = 0):
mounts.append({
"position": pos,
"normal": normal,
"up": up,
"type": type
})

View File

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

View File

@ -158,6 +158,11 @@ left_click={
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
toggle_build_mode={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":66,"key_label":0,"unicode":98,"location":0,"echo":false,"script":null)
]
}
[layer_names]

View File

@ -33,49 +33,50 @@ properties/4/path = NodePath(".:angular_velocity")
properties/4/spawn = false
properties/4/replication_mode = 0
[node name="CharacterPawn3D" type="RigidBody3D" unique_id=288275840]
[node name="CharacterPawn3D" type="RigidBody3D"]
physics_interpolation_mode = 1
top_level = true
script = ExtResource("1_4frsu")
metadata/_custom_type_script = "uid://cdmmiixa75f3x"
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=1967015232]
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("CapsuleShape3D_6vm80")
[node name="MeshInstance3D" type="MeshInstance3D" parent="." unique_id=1703183586]
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
mesh = SubResource("CapsuleMesh_6vm80")
[node name="CameraAnchor" type="Marker3D" parent="." unique_id=462168232]
[node name="CameraAnchor" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.7000000000000001, 0)
[node name="CameraPivot" type="Node3D" parent="CameraAnchor" unique_id=794640520]
[node name="CameraPivot" type="Node3D" parent="CameraAnchor"]
physics_interpolation_mode = 1
[node name="SpringArm" type="SpringArm3D" parent="CameraAnchor/CameraPivot" unique_id=1399441728]
[node name="SpringArm" type="SpringArm3D" parent="CameraAnchor/CameraPivot"]
shape = SubResource("CapsuleShape3D_673rh")
spring_length = 2.0
margin = 0.1
[node name="Camera3D" type="Camera3D" parent="CameraAnchor/CameraPivot/SpringArm" unique_id=1779046272]
[node name="Camera3D" type="Camera3D" parent="CameraAnchor/CameraPivot/SpringArm"]
far = 200000.0
[node name="GripDetector" type="Area3D" parent="." unique_id=734413990]
[node name="GripDetector" type="Area3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -1)
collision_layer = 0
collision_mask = 32768
monitorable = false
[node name="CollisionShape3D" type="CollisionShape3D" parent="GripDetector" unique_id=1939219836]
[node name="CollisionShape3D" type="CollisionShape3D" parent="GripDetector"]
shape = SubResource("SphereShape3D_gnddn")
[node name="ZeroGMovementComponent" type="Node3D" parent="." unique_id=594953523]
[node name="ZeroGMovementComponent" type="Node3D" parent="."]
script = ExtResource("4_8jhjh")
metadata/_custom_type_script = "uid://y3vo40i16ek3"
[node name="EVAMovementComponent" parent="." unique_id=1806288315 instance=ExtResource("3_gnddn")]
[node name="EVAMovementComponent" parent="." instance=ExtResource("3_gnddn")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.13939085347041424, 0.5148942200402955)
[node name="PlayerController3d" parent="." unique_id=1450011826 instance=ExtResource("4_bcy3l")]
[node name="PlayerController3d" parent="." instance=ExtResource("4_bcy3l")]
mouse_sensitivity = null
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="." unique_id=732324183]
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("SceneReplicationConfig_gnddn")

View File

@ -8,39 +8,78 @@ class_name PlayerController3D
@export var mouse_sensitivity: float = 0.002 # Radians per pixel motion
var _mouse_motion_input: Vector2 = Vector2.ZERO
# --- Builder State ---
var active_preview_piece: ProceduralPiece = null
var active_structure_data: StructureData = null
var build_mode_enabled: bool = false
# Resources
const SQUARE_RES = preload("res://data/structure/definitions/1m_square_plate.tres")
const TRIANGLE_RES = preload("res://data/structure/definitions/1m_triangle_plate.tres")
const PROCEDURAL_PIECE_SCENE = preload("res://scenes/ship/builder/pieces/procedural_piece.tscn")
class KeyInput:
var pressed: bool = false
var held: bool = false
var released: bool = false
func _init(_p: bool, _h: bool, _r: bool):
func _init(_p: bool = false, _h: bool = false, _r: bool = false):
pressed = _p
held = _h
released = _r
func _to_dict():
return {
"pressed": pressed,
"held": held,
"released": released
}
static func from_dict(dict: Dictionary) -> KeyInput:
return KeyInput.new(dict.get("pressed", false), dict.get("held", false), dict.get("released", false))
# Helper to convert to Dictionary for RPC
func to_dict() -> Dictionary:
return {"p": pressed, "h": held, "r": released}
# Helper to create from Dictionary
static func from_dict(d: Dictionary) -> KeyInput:
return KeyInput.new(d.get("p", false), d.get("h", false), d.get("r", false))
func _ready():
# If we are spawned dynamically, the owner_id might be set by GameManager.
# Fallback: assume the pawn's name is the player ID (common pattern).
# Fallback: assume the pawn's name is the player ID.
if get_parent().name.is_valid_int():
set_multiplayer_authority(int(get_parent().name))
func _unhandled_input(event: InputEvent):
# Check if THIS client is the owner of this controller
# Check if THIS client is the owner of this controller
if not is_multiplayer_authority() or not is_instance_valid(possessed_pawn):
return
# Toggle Build Mode
if event.is_action_pressed("toggle_build_mode"): # Map 'B' or similar in Project Settings
build_mode_enabled = !build_mode_enabled
if not build_mode_enabled:
_clear_preview()
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) # Ensure mouse captured when leaving
else:
_select_piece(1) # Default to square
print("Build Mode Enabled")
if not build_mode_enabled:
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
_mouse_motion_input += Vector2(event.relative.x, -event.relative.y)
return
# --- Build Mode Inputs ---
if event is InputEventKey and event.pressed:
if event.keycode == KEY_1:
print("Selected Square Piece")
_select_piece(1)
elif event.keycode == KEY_2:
_select_piece(2)
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
_place_piece()
# Allow camera look while holding right click in build mode
if event.button_index == MOUSE_BUTTON_RIGHT:
if event.pressed:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
# Handle mouse motion input directly here
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
_mouse_motion_input += Vector2(event.relative.x, -event.relative.y)
@ -49,54 +88,174 @@ func _physics_process(_delta):
# Check if THIS client is the owner
if not is_multiplayer_authority() or not is_instance_valid(possessed_pawn):
return
# STRENGTHENED CHECK: Ensure pawn is valid and inside the tree
if not is_instance_valid(possessed_pawn) or not possessed_pawn.is_inside_tree():
return
# 1. Handle Mouse Rotation
if _mouse_motion_input != Vector2.ZERO:
# Calculate yaw and pitch based on mouse movement
var sensitivity_modified_mouse_input = Vector2(_mouse_motion_input.x, _mouse_motion_input.y) * mouse_sensitivity
# Send rotation input via RPC immediately
# Send to Server (ID 1)
server_process_rotation_input.rpc_id(1, sensitivity_modified_mouse_input)
# Reset the buffer
_mouse_motion_input = Vector2.ZERO
# 2. Gather Movement Inputs (Only process movement if NOT in build mode, or perhaps allow moving while building?)
# Let's allow movement while building for now.
var move_vec = Input.get_vector("move_left_3d", "move_right_3d", "move_backward_3d", "move_forward_3d")
var roll_input = Input.get_action_strength("roll_right_3d") - Input.get_action_strength("roll_left_3d")
var vertical_input = Input.get_action_strength("move_up_3d") - Input.get_action_strength("move_down_3d")
var interact_input = KeyInput.new(Input.is_action_just_pressed("spacebar_3d"), Input.is_action_pressed("spacebar_3d"), Input.is_action_just_released("spacebar_3d"))
var interact_input = KeyInput.new(Input.is_action_just_pressed("spacebar_3d"), Input.is_action_pressed("spacebar_3d"), Input.is_action_just_released("spacebar_3d"))
var l_input = KeyInput.new(Input.is_action_just_pressed("left_click"), Input.is_action_pressed("left_click"), Input.is_action_just_released("left_click"))
var r_input = KeyInput.new(Input.is_action_just_pressed("right_click"), Input.is_action_pressed("right_click"), Input.is_action_just_released("right_click"))
# Send to Server (ID 1), converting KeyInput objects to Dictionaries
server_process_movement_input.rpc_id(1, move_vec, roll_input, vertical_input)
server_process_interaction_input.rpc_id(1, interact_input._to_dict())
server_process_clicks.rpc_id(1, l_input._to_dict(), r_input._to_dict())
server_process_interaction_input.rpc_id(1, interact_input.to_dict())
server_process_clicks.rpc_id(1, l_input.to_dict(), r_input.to_dict())
# 3. Update Builder Preview
if build_mode_enabled and active_preview_piece:
_update_preview_transform()
# --- Builder Functions ---
func _select_piece(index: int):
_clear_preview()
if index == 1:
active_structure_data = SQUARE_RES
elif index == 2:
active_structure_data = TRIANGLE_RES
if active_structure_data:
var piece = PROCEDURAL_PIECE_SCENE.instantiate()
piece.structure_data = active_structure_data
piece.is_preview = true
get_tree().current_scene.add_child(piece)
active_preview_piece = piece
func _update_preview_transform():
if not is_instance_valid(possessed_pawn): return
# 1. Cast Ray from Camera
var cam = possessed_pawn.camera
var space_state = possessed_pawn.get_world_3d().direct_space_state
var mouse_pos = get_viewport().get_mouse_position()
var from = cam.project_ray_origin(mouse_pos)
var to = from + cam.project_ray_normal(mouse_pos) * 10.0 # 10m reach
var query = PhysicsRayQueryParameters3D.create(from, to)
query.collision_mask = 1 << 14 # Layer 15 (Weld/Structure)
var result = space_state.intersect_ray(query)
if result:
var collider = result.collider
# Try to find the Module or Piece
var target_module: Module = null
if collider is StructuralPiece:
target_module = collider.get_root_module()
elif collider is Module:
target_module = collider
if target_module:
# Call Snapping Tool
var snap_transform = SnappingTool.get_best_snap_transform(
active_structure_data,
target_module,
result.position, # Ray hit position
(to - from).normalized()
)
active_preview_piece.global_transform = snap_transform
return
# Fallback: Float in front of player
var float_pos = from + cam.project_ray_normal(mouse_pos) * 3.0
active_preview_piece.global_position = float_pos
# Orient to face camera roughly
active_preview_piece.look_at(cam.global_position, Vector3.UP)
func _place_piece():
if not active_preview_piece or not active_structure_data: return
# Tell Server to spawn the real piece at this transform
server_request_place_piece.rpc_id(1, active_structure_data.resource_path, active_preview_piece.global_transform)
func _clear_preview():
if is_instance_valid(active_preview_piece):
active_preview_piece.queue_free()
active_preview_piece = null
# --- RPCs: Allow "any_peer" so clients can call this on the Server ---
@rpc("any_peer", "call_local")
func server_request_place_piece(resource_path: String, transform: Transform3D):
# Server validation logic here (distance check, cost check)
# Security: Check if sender is allowed to build
var res = load(resource_path) as StructureData
if not res: return
# Find nearby module to attach to
var query_pos = transform.origin
var module = _find_module_near_server(query_pos)
if not module:
# Logic to create a new module could go here
print("No module nearby to attach piece.")
var new_module = Module.new()
new_module.name = "Module_%d" % get_process_delta_time() # Unique name
possessed_pawn.get_parent().add_child(new_module)
new_module.global_position = query_pos
module = new_module
print("Created new module %s for piece placement." % new_module.name)
if module:
var piece = PROCEDURAL_PIECE_SCENE.instantiate()
piece.structure_data = res
module.add_child(piece)
piece.global_transform = transform
piece.owner = module # Ensure persistence
# Trigger weld logic on the new piece
piece.try_weld()
# Helper to find modules on server side (uses global overlap check)
func _find_module_near_server(pos: Vector3) -> Module:
# Simple distance check against all modules in the system data
# A better way uses a Physics Shape Query
if GameManager.current_star_system and GameManager.current_star_system.system_data:
for ship in GameManager.current_star_system.system_data.ships:
if ship is Module and ship.global_position.distance_to(pos) < 10.0: # Arbitrary range
return ship
return null
@rpc("any_peer", "call_local")
func server_process_movement_input(move: Vector2, roll: float, vertical: float):
if is_instance_valid(possessed_pawn):
# Debug: Uncomment to verify flow
# if move.length() > 0: print("Server Pawn %s Move: %s" % [owner_id, move])
possessed_pawn.set_movement_input(move, roll, vertical)
@rpc("any_peer", "call_local")
func server_process_interaction_input(interact_input: Dictionary):
func server_process_interaction_input(interact_data: Dictionary):
if is_instance_valid(possessed_pawn):
possessed_pawn.set_interaction_input(KeyInput.from_dict(interact_input))
if interact_data.has_all(["p", "h", "r"]):
possessed_pawn.set_interaction_input(KeyInput.from_dict(interact_data))
@rpc("any_peer", "call_local")
func server_process_rotation_input(input: Vector2):
if is_instance_valid(possessed_pawn):
possessed_pawn.set_rotation_input(input)
@rpc("any_peer", "call_local")
func server_process_clicks(l_action: Dictionary, r_action: Dictionary):
if is_instance_valid(possessed_pawn):
possessed_pawn.set_click_input(KeyInput.from_dict(l_action), KeyInput.from_dict(r_action))
func possess(pawn_to_control: CharacterPawn3D):
possessed_pawn = pawn_to_control
#print("PlayerController3D %d possessed: %s" % [multiplayer.get_unique_id(), possessed_pawn.name])
@rpc("any_peer", "call_local")
func server_process_clicks(l_data: Dictionary, r_data: Dictionary):
if is_instance_valid(possessed_pawn):
var l_action = KeyInput.from_dict(l_data) if l_data.has("p") else KeyInput.new()
var r_action = KeyInput.from_dict(r_data) if r_data.has("p") else KeyInput.new()
possessed_pawn.set_click_input(l_action, r_action)
# Optional: Release mouse when losing focus
func _notification(what):
@ -109,4 +268,3 @@ func _notification(what):
print("PlayerController exited tree")
NOTIFICATION_ENTER_TREE:
print("PlayerController %s entered tree" % multiplayer.get_unique_id())

View File

@ -0,0 +1,140 @@
class_name ProceduralPiece extends StructuralPiece
# For a strut: Start Point, End Point
# For a plate: 3 or 4 vertices
@export var vertices: Array[Vector3] = []:
set(value):
vertices = value
_generate_geometry()
@export var thickness: float = 0.1
func on_structure_data_update():
vertices = structure_data.vertices
if not vertices.is_empty():
_generate_geometry()
super._ready()
# Configure this piece as a simple Strut (Beam)
func configure_strut(start: Vector3, end: Vector3):
vertices = [start, end]
# Create ephemeral data for this specific instance
structure_data = StructureData.new()
structure_data.piece_name = "Strut"
structure_data.type = StructureData.PieceType.STRUT
# Define Mounts at both ends
var dir = (end - start).normalized()
# Mount 0: At Start, facing away from End (-dir)
structure_data.add_mount(start, -dir)
# Mount 1: At End, facing away from Start (+dir)
structure_data.add_mount(end, dir)
_generate_geometry()
# Configure this piece as a Plate (Triangle/Quad)
func configure_plate(points: Array[Vector3]):
vertices = points
structure_data = StructureData.new()
structure_data.piece_name = "Plate"
structure_data.type = StructureData.PieceType.PLATE
# Add Mounts along edges (simplified: midpoints of edges)
var center = Vector3.ZERO
for p in points: center += p
center /= points.size()
for i in range(points.size()):
var p1 = points[i]
var p2 = points[(i + 1) % points.size()]
var mid = (p1 + p2) / 2.0
var edge_norm = (mid - center).normalized()
structure_data.add_mount(mid, edge_norm)
_generate_geometry()
func _generate_geometry():
if vertices.is_empty(): return
print("Generating geometry for ProceduralPiece with vertices:")
print(vertices)
# Clear existing meshes
for c in get_children():
if c is MeshInstance3D or c is CollisionShape3D:
c.queue_free()
if vertices.size() == 2:
_build_strut_mesh()
elif vertices.size() >= 3:
_build_plate_mesh()
func _build_strut_mesh():
# Generate a Box or Cylinder between v[0] and v[1]
var start = vertices[0]
var end = vertices[1]
var length = start.distance_to(end)
var mid = (start + end) / 2.0
var mesh_inst = MeshInstance3D.new()
var box = BoxMesh.new()
box.size = Vector3(thickness, thickness, length)
mesh_inst.mesh = box
add_child(mesh_inst)
# Orient mesh
mesh_inst.position = mid
if length > 0.001:
mesh_inst.look_at(end, Vector3.UP)
# Collision
var col = CollisionShape3D.new()
var shape = BoxShape3D.new()
shape.size = box.size
col.shape = shape
col.position = mid
col.rotation = mesh_inst.rotation
add_child(col)
func _build_plate_mesh():
# Use SurfaceTool to build a polygon
var st = SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
# Assume points are coplanar and ordered
var normal = (vertices[1] - vertices[0]).cross(vertices[2] - vertices[0]).normalized()
# Simple Fan Triangulation (works for convex shapes)
for i in range(1, vertices.size() - 1):
st.set_normal(normal)
st.add_vertex(vertices[0])
st.set_normal(normal)
st.add_vertex(vertices[i])
st.set_normal(normal)
st.add_vertex(vertices[i + 1])
# Backface
st.set_normal(-normal)
st.add_vertex(vertices[i + 1])
st.set_normal(-normal)
st.add_vertex(vertices[i])
st.set_normal(-normal)
st.add_vertex(vertices[0])
var mesh_inst = MeshInstance3D.new()
mesh_inst.mesh = st.commit()
add_child(mesh_inst)
# Collision (Convex)
var col = CollisionShape3D.new()
var shape = ConvexPolygonShape3D.new()
shape.points = vertices
# Extrude slightly for thickness? Or just use flat collision for plates.
col.shape = shape
add_child(col)

View File

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

View File

@ -0,0 +1,7 @@
[gd_scene load_steps=2 format=3 uid="uid://bt31qetyom88q"]
[ext_resource type="Script" uid="uid://biwe3npxmykbi" path="res://scenes/ship/builder/pieces/procedural_piece.gd" id="1_33s2w"]
[node name="ProceduralPiece" type="RigidBody3D"]
script = ExtResource("1_33s2w")
metadata/_custom_type_script = "uid://wlm40n8ywr"

View File

@ -1,9 +1,28 @@
class_name StructuralPiece extends ShipPiece
# Track who we are welded to so we don't double-weld
var connected_neighbors: Array[StructuralPiece] = []
# The definition of this piece.
@export var structure_data: StructureData = null:
set(value):
structure_data = value
print("StructuralPiece assigned StructureData: %s" % structure_data.piece_name if structure_data else "null")
print(structure_data)
if structure_data:
on_structure_data_update()
var attachment_areas: Array[Area3D] = []
func on_structure_data_update():
# Placeholder: In a real implementation, this would create the MeshInstance3D
# and CollisionShape3D based on 'structure_data'.
pass
# Track who we are welde d to so we don't double-weld
var connected_neighbors: Array[StructuralPiece] = []
var mount_areas: Array[Area3D] = []
# Flag to distinguish between a preview piece (no physics) and a placed piece
var is_preview: bool = false:
set(value):
is_preview = value
_update_preview_visuals()
func _init():
base_mass = 50.0
@ -12,57 +31,83 @@ func _init():
func _ready():
super._ready()
if is_preview:
collision_layer = 0
collision_mask = 0
return
sleeping = false
can_sleep = false
# 1. Find all our attachment point areas
for child in get_children():
if child is Area3D and child.name.begins_with("AttachmentPoint"):
attachment_areas.append(child)
# Ensure they are monitoring
child.monitoring = true
child.monitorable = true
# 2. Attempt to weld to anything we are already touching (for spawning in)
# We wait one frame to let physics settle positions
_initial_weld_scan()
func _initial_weld_scan():
# Wait for the node to settle in the scene tree
await get_tree().physics_frame
# Wait one more tick for the Physics Server to calculate overlaps
await get_tree().physics_frame
# If we have data, generate the mount points
if structure_data:
_generate_mount_detectors()
_scan_and_weld_neighbors()
# Attempt to weld to anything we are already touching
# _initial_weld_scan()
# --- PUBLIC API ---
func _generate_mount_detectors():
for i in range(structure_data.mounts.size()):
var mount_def = structure_data.mounts[i]
var area = Area3D.new()
area.name = "Mount_%d" % i
add_child(area)
# Position
area.position = mount_def.get("position", Vector3.ZERO)
# Orientation: Align Area -Z with Mount Normal
var normal = mount_def.get("normal", Vector3.BACK)
var up = mount_def.get("up", Vector3.UP)
# Safety check for parallel vectors
if normal.cross(up).is_zero_approx():
up = Vector3.RIGHT if abs(normal.y) > 0.9 else Vector3.UP
if normal.length_squared() > 0.01:
area.transform.basis = Basis.looking_at(normal, up)
# Shape
var shape = CollisionShape3D.new()
var sphere = SphereShape3D.new()
sphere.radius = 0.15 # Small tolerance radius
shape.shape = sphere
area.add_child(shape)
# Collision Layers (Use Layer 15 "Weld" = bit 14)
area.collision_layer = 1 << 14
area.collision_mask = 1 << 14
area.monitoring = true
area.monitorable = true
mount_areas.append(area)
# Call this after placing a piece during runtime construction
func try_weld():
_scan_and_weld_neighbors()
# --- INTERNAL LOGIC ---
func _initial_weld_scan():
await get_tree().physics_frame
await get_tree().physics_frame
if not is_instance_valid(self): return
_scan_and_weld_neighbors()
func _scan_and_weld_neighbors():
for my_area in attachment_areas:
# Check what other attachment points this area is touching
for my_area in mount_areas:
for other_area in my_area.get_overlapping_areas():
var other_piece = other_area.get_parent()
# Validate the target
if other_piece is StructuralPiece and other_piece != self:
# Here we could check mount compatibility (types, alignment)
if not other_piece in connected_neighbors:
_create_weld_to(other_piece)
func _create_weld_to(neighbor: StructuralPiece):
# print("Welding %s to %s" % [self.name, neighbor.name])
# 1. Create the Joint
var joint = Generic6DOFJoint3D.new()
# 2. Configure as a rigid lock
_lock_joint_axis(joint)
# 3. Add to scene (Joints should generally be peers or children of the system root)
# Adding it to 'self' is fine; if 'self' dies, the joint breaks, which is correct.
add_child(joint)
@ -74,7 +119,7 @@ func _create_weld_to(neighbor: StructuralPiece):
# 5. Connect Bodies
joint.node_a = self.get_path()
joint.node_b = neighbor.get_path()
# 6. Record connection on BOTH sides
connected_neighbors.append(neighbor)
neighbor.connected_neighbors.append(self)
@ -90,10 +135,15 @@ func _lock_joint_axis(joint: Generic6DOFJoint3D):
joint.set_param_y(axis, 0.0)
joint.set_param_z(axis, 0.0)
# Handle destruction/detachment
func _update_preview_visuals():
var mesh_instance = find_child("MeshInstance3D")
if mesh_instance and mesh_instance.mesh:
var mat = StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(0.5, 0.8, 1.0, 0.5)
mesh_instance.material_override = mat
func _exit_tree():
# super._exit_tree()
# Clean up references in neighbors so they don't hold onto dead objects
for neighbor in connected_neighbors:
if is_instance_valid(neighbor):
neighbor.connected_neighbors.erase(self)

View File

@ -0,0 +1,94 @@
class_name SnappingTool extends RefCounted
const SNAP_DISTANCE = 2.0 # Meters
const SNAP_ANGLE_THRESHOLD = deg_to_rad(45.0)
## Returns a Transform3D for the 'piece_to_place' that aligns one of its mounts
## with a compatible mount on 'target_module'.
static func get_best_snap_transform(
piece_data: StructureData,
target_module: Module,
ray_origin: Vector3,
ray_direction: Vector3
) -> Transform3D:
var best_dist = INF
var best_transform = Transform3D()
var found_snap = false
# 1. Harvest all world-space mounts from the module's existing pieces.
# Optimization: The Module could cache this list.
var world_target_mounts = []
for child in target_module.get_structural_pieces():
if child is StructuralPiece and child.structure_data:
world_target_mounts.append_array(child.structure_data.get_mounts_transformed(child.global_transform))
# 2. Determine where the player is pointing (Project ray out)
var cursor_pos = ray_origin + ray_direction * 4.0 # Default 4m reach if no snap
# 3. Find the BEST pair of mounts (New Mount <-> Target Mount)
for new_mount in piece_data.mounts:
var local_mount_pos = new_mount.get("position", Vector3.ZERO)
var local_mount_norm = new_mount.get("normal", Vector3.BACK)
for target_mount in world_target_mounts:
# A. Check Distance to cursor
# We check distance between target mount and cursor to see if user is "aiming" at it
var dist_to_cursor = cursor_pos.distance_to(target_mount.position)
# Optimization: If user is too far from this target mount, skip detailed math
if dist_to_cursor > SNAP_DISTANCE: continue
# B. Check if this is the closest snap so far
if dist_to_cursor < best_dist:
# C. Calculate Orientation
# We want the New Piece's Mount Normal to oppose the Target Mount Normal
# Target Normal points OUT of the ship. New Normal points OUT of the piece.
# When connected, they point at each other (180 degrees apart).
var desired_normal = -target_mount.normal # The direction we want our mount to face
# Calculate rotation to align 'local_mount_norm' to 'desired_normal'
var rot_quat = _get_rotation_between_vectors(local_mount_norm, desired_normal)
var final_basis = Basis(rot_quat)
# D. Calculate Position
# We know where the target mount is (Target_Pos).
# We need to place the Piece Origin such that:
# Piece_Origin + (Rotated_Mount_Offset) = Target_Pos
# Therefore: Piece_Origin = Target_Pos - (Rotated_Mount_Offset)
var rotated_offset = final_basis * local_mount_pos
var final_pos = target_mount.position - rotated_offset
best_transform = Transform3D(final_basis, final_pos)
best_dist = dist_to_cursor
found_snap = true
if found_snap:
return best_transform
else:
# Fallback: Just place floating at cursor
return Transform3D(Basis(), cursor_pos)
# Helper: Calculate quaternion to rotate vector A to vector B
static func _get_rotation_between_vectors(v1: Vector3, v2: Vector3) -> Quaternion:
v1 = v1.normalized()
v2 = v2.normalized()
# If vectors are parallel (same direction)
if v1.dot(v2) > 0.999:
return Quaternion.IDENTITY
# If vectors are opposite (180 degrees)
if v1.dot(v2) < -0.999:
# We need any axis perpendicular to v1 to rotate 180 degrees around
var axis = v1.cross(Vector3.UP)
if axis.length_squared() < 0.01:
axis = v1.cross(Vector3.RIGHT)
return Quaternion(axis.normalized(), PI)
var cross = v1.cross(v2)
var dot = v1.dot(v2)
var w = sqrt(v1.length_squared() * v2.length_squared()) + dot
return Quaternion(cross.x, cross.y, cross.z, w).normalized()

View File

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

View File

@ -1,7 +1,8 @@
[gd_scene load_steps=7 format=3 uid="uid://ddfsn0rtdnfda"]
[gd_scene load_steps=8 format=3 uid="uid://ddfsn0rtdnfda"]
[ext_resource type="PackedScene" uid="uid://5noqmp8b267n" path="res://scenes/ship/components/grips/single_handhold.tscn" id="1_jlvj7"]
[ext_resource type="PackedScene" uid="uid://dvpy3urgtm62n" path="res://scenes/ship/components/hardware/spawner.tscn" id="2_jlvj7"]
[ext_resource type="PackedScene" uid="uid://7yc6a07xoccy" path="res://scenes/character/character_pawn_3d.tscn" id="3_7yxt7"]
[sub_resource type="BoxMesh" id="BoxMesh_kateb"]
size = Vector3(50, 1, 50)
@ -161,3 +162,6 @@ shape = SubResource("CylinderShape3D_nvgim")
[node name="Spawner" parent="." instance=ExtResource("2_jlvj7")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.309784, 0, -12.84836)
[node name="CharacterPawn3D" parent="." instance=ExtResource("3_7yxt7")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 17.12362689122544, -0.49999999999999645, -11.643245313710281)