Working straight angle placement of ship pieces

This commit is contained in:
olof.pettersson
2025-11-21 18:18:10 +01:00
parent 94e33c0cba
commit 916172d0b2
12 changed files with 296 additions and 113 deletions

1
.gitignore vendored
View File

@ -1,6 +1,5 @@
# Godot 4+ specific ignores
.godot/
.vscode/
/android/
*.tmp

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"godotTools.editorPath.godot4": "./godot_engine/bin/godot.windows.editor.double.x86_64.console.exe",
"search.exclude": {
"/godot_engine": true
}
}

View File

@ -29,7 +29,6 @@ Godot requires a C++ compiler to build from source.
* Install **Xcode** from the App Store.
* Run `xcode-select --install` in the terminal.
---
## 🛠️ Setup & Compilation
@ -93,4 +92,62 @@ Import and open the `project.godot` file located in the root `ProjectMillimeters
### Troubleshooting
"No valid compilers found" (Windows): Ensure you installed the C++ Desktop Development workload in the Visual Studio Installer. Just the editor is not enough.
Jittering Objects: If objects jitter at large distances, ensure you are running the double precision binary and not a standard build.
Jittering Objects: If objects jitter at large distances, ensure you are running the double precision binary and not a standard build.
## 🛠 Compiling Custom Export Templates
This project uses features from the latest development branch of Godot (`master` branch). As a result, standard export templates downloaded from the Godot website may not be compatible. To export the project, you must compile custom export templates from the same source version used to build your editor.
### Prerequisites
Ensure you have a C++ build environment set up (SCons, Python, Visual Studio/GCC/MinGW). See the official Godot documentation on compiling for platform-specific instructions.
### 1. Build the Editor (Optional)
See above section.
### 2. Build the Export Templates
You need to build two templates: one for debug (used during development/testing) and one for release (optimized for final distribution).
**Windows:**
```PowerShell
# Debug Template (console enabled, debug tools)
scons platform=windows target=template_debug
# Release Template (optimized, no console)
scons platform=windows target=template_release
```
**Linux:**
```bash
# Debug Template
scons platform=linuxbsd target=template_debug
# Release Template
scons platform=linuxbsd target=template_release
```
### 3. Locate Output Files
After compilation, the binaries will be located in the bin/ directory of your Godot source folder.
- Debug: godot.windows.template_debug.x86_64.exe (or similar)
- Release: godot.windows.template_release.x86_64.exe (or similar)
### 4. Configure Export Presets
1. Open the project in Godot.
2. Go to Project > Export.
3. Select your export preset (e.g., Windows Desktop).
4. Under the Options tab, find the Custom Template section.
5. Set Debug to the path of your compiled template_debug binary.
6. Set Release to the path of your compiled template_release binary.
You can now export the project using your custom engine build!

1
godot_engine Submodule

Submodule godot_engine added at 8a2b782c42

13
reinit_submodules.sh Normal file
View File

@ -0,0 +1,13 @@
#!/bin/sh
set -e
git config -f .gitmodules --get-regexp '^submodule\..*\.path$' |
while read path_key local_path
do
url_key=$(echo $path_key | sed 's/\.path/.url/')
url=$(git config -f .gitmodules --get "$url_key")
git submodule add $url $local_path
done
# https://stackoverflow.com/questions/11258737/restore-git-submodules-from-gitmodules

View File

@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.cte
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false

View File

@ -12,6 +12,7 @@ var _mouse_motion_input: Vector2 = Vector2.ZERO
var active_preview_piece: ProceduralPiece = null
var active_structure_data: StructureData = null
var build_mode_enabled: bool = false
var is_snap_valid: bool = false # Track if we are currently snapped
# Resources
const SQUARE_RES = preload("res://data/structure/definitions/1m_square_plate.tres")
@ -138,44 +139,63 @@ func _select_piece(index: int):
func _update_preview_transform():
if not is_instance_valid(possessed_pawn): return
is_snap_valid = false # Reset snap state
# 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 dir = cam.project_ray_normal(mouse_pos)
# --- NEW: Use SnappingTool to perform the physics sweep ---
# This replaces the simple raycast with a thick cylinder cast
var hit_result = SnappingTool.find_snap_target(space_state, from, dir, 10.0, 0.2)
if not hit_result.is_empty():
var collider = hit_result["collider"]
var hit_pos = hit_result["position"]
var target_module: Module = null
if collider is StructuralPiece:
target_module = collider.get_root_module()
elif collider is Module:
target_module = collider
if collider is PieceMount:
# If we hit a mount, get its piece and its module, get its module
print(collider.get_parent())
if collider.get_parent() is StructuralPiece:
target_module = collider.get_parent().get_parent()
if collider.owner is Module: target_module = collider.owner
elif collider.get_parent() is Module: target_module = collider.get_parent()
if target_module:
# Call Snapping Tool
# Attempt Snap using the hit position
var snap_transform = SnappingTool.get_best_snap_transform(
active_structure_data,
target_module,
result.position, # Ray hit position
(to - from).normalized()
hit_pos, # Ray hit position
)
active_preview_piece.global_transform = snap_transform
return
print(snap_transform)
# If the transform has changed significantly from the hit pos, it means a snap occurred.
# (Simple heuristic: check distance from hit to new origin)
if snap_transform.origin.distance_to(hit_pos) < SnappingTool.SNAP_DISTANCE:
active_preview_piece.global_transform = snap_transform
is_snap_valid = true
_update_preview_color(Color.GREEN)
return
# Fallback: Float in front of player
var float_pos = from + cam.project_ray_normal(mouse_pos) * 3.0
var float_pos = from + dir * 3.0
active_preview_piece.global_position = float_pos
# Orient to face camera roughly
active_preview_piece.look_at(cam.global_position, Vector3.UP)
_update_preview_color(Color.CYAN) # Cyan = Floating
func _update_preview_color(color: Color):
if not is_instance_valid(active_preview_piece): return
var mesh_inst = active_preview_piece.find_child("MeshInstance3D")
if mesh_inst and mesh_inst.material_override:
var mat = mesh_inst.material_override as StandardMaterial3D
mat.albedo_color = Color(color.r, color.g, color.b, 0.4)
mat.emission = color
func _place_piece():
if not active_preview_piece or not active_structure_data: return
@ -206,7 +226,7 @@ func server_request_place_piece(resource_path: String, transform: Transform3D):
# 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
new_module.name = "Module_%s" % get_process_delta_time() # Unique name
possessed_pawn.get_parent().add_child(new_module)
new_module.global_position = query_pos
module = new_module
@ -224,12 +244,33 @@ func server_request_place_piece(resource_path: String, transform: Transform3D):
# 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
# Create a sphere query parameters object
var space_state = possessed_pawn.get_world_3d().direct_space_state
var params = PhysicsShapeQueryParameters3D.new()
var shape = SphereShape3D.new()
shape.radius = 5.0 # Search radius
params.shape = shape
params.transform = Transform3D(Basis(), pos)
params.collide_with_bodies = true
params.collision_mask = 0xFFFFFFFF # Check all layers, or specific module layer
var results = space_state.intersect_shape(params)
for result in results:
var collider = result["collider"]
# Case 1: We hit the Module directly (RigidBody3D)
if collider is Module:
return collider
# Case 2: We hit a StructuralPiece attached to a Module
if collider is StructuralPiece:
# StructuralPiece should have a way to get its root module
if collider.get_parent() is Module:
return collider.get_parent()
# Or if you use the helper:
# return collider.get_root_module()
return null
@rpc("any_peer", "call_local")

View File

@ -0,0 +1,12 @@
class_name PieceMount extends Area3D
# Define compatibility types (0=Universal, 1=Strut, 2=Plate, etc.)
@export var mount_type: int = 0
# You could add other metadata here (e.g., is_occupied)
var is_occupied: bool = false
func _ready():
# Ensure this is on the correct layer for snapping queries
collision_layer = 1 << 14
collision_mask = 0 # Mounts don't need to scan for things, things scan for them

View File

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

View File

@ -1,22 +1,11 @@
class_name StructuralPiece extends ShipPiece
# 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()
@export var structure_data: StructureData
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
# Track who we are welded to so we don't double-weld
var connected_neighbors: Array[StructuralPiece] = []
var mount_areas: Array[Area3D] = []
var mount_areas: Array[PieceMount] = []
# Flag to distinguish between a preview piece (no physics) and a placed piece
var is_preview: bool = false:
@ -45,14 +34,15 @@ func _ready():
_generate_mount_detectors()
# Attempt to weld to anything we are already touching
# _initial_weld_scan()
_initial_weld_scan()
func _generate_mount_detectors():
for i in range(structure_data.mounts.size()):
var mount_def = structure_data.mounts[i]
var area = Area3D.new()
var area = PieceMount.new() # Uses the class_name
area.name = "Mount_%d" % i
area.mount_type = mount_def.get("type", 0) # Set the type!
add_child(area)
# Position
@ -62,7 +52,6 @@ func _generate_mount_detectors():
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
@ -72,11 +61,11 @@ func _generate_mount_detectors():
# Shape
var shape = CollisionShape3D.new()
var sphere = SphereShape3D.new()
sphere.radius = 0.15 # Small tolerance radius
sphere.radius = 0.15
shape.shape = sphere
area.add_child(shape)
# Collision Layers (Use Layer 15 "Weld" = bit 14)
# Layer setup is handled in PieceMount._ready() now, or explicit here:
area.collision_layer = 1 << 14
area.collision_mask = 1 << 14
area.monitoring = true
@ -103,24 +92,16 @@ func _scan_and_weld_neighbors():
_create_weld_to(other_piece)
func _create_weld_to(neighbor: StructuralPiece):
# 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)
# 4. Position joint at the midpoint (visual/physics anchor)
# (Optional, but good for stability)
# Position joint halfway between pieces
joint.global_position = (self.global_position + neighbor.global_position) / 2.0
# 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)
@ -129,7 +110,6 @@ func _lock_joint_axis(joint: Generic6DOFJoint3D):
joint.set_param_x(axis, 0.0)
joint.set_param_y(axis, 0.0)
joint.set_param_z(axis, 0.0)
for axis in [Generic6DOFJoint3D.PARAM_ANGULAR_LOWER_LIMIT, Generic6DOFJoint3D.PARAM_ANGULAR_UPPER_LIMIT]:
joint.set_param_x(axis, 0.0)
joint.set_param_y(axis, 0.0)
@ -146,4 +126,4 @@ func _update_preview_visuals():
func _exit_tree():
for neighbor in connected_neighbors:
if is_instance_valid(neighbor):
neighbor.connected_neighbors.erase(self)
neighbor.connected_neighbors.erase(self)

View File

@ -3,91 +3,158 @@ class_name SnappingTool extends RefCounted
const SNAP_DISTANCE = 2.0 # Meters
const SNAP_ANGLE_THRESHOLD = deg_to_rad(45.0)
# Define the collision mask for mounts/structure.
const SNAP_COLLISION_MASK = 1 << 14
## Performs a shape cast to find the best candidate for snapping.
## Returns a Dictionary with { "position": Vector3, "normal": Vector3, "collider": Node } or null.
static func find_snap_target(
space_state: PhysicsDirectSpaceState3D,
ray_origin: Vector3,
ray_direction: Vector3,
reach_distance: float = 10.0,
radius: float = 0.2
) -> Dictionary:
var shape = SphereShape3D.new()
shape.radius = radius
var params = PhysicsShapeQueryParameters3D.new()
params.shape = shape
params.transform = Transform3D(Basis(), ray_origin)
params.motion = ray_direction * reach_distance
params.collision_mask = SNAP_COLLISION_MASK
params.collide_with_areas = true
params.collide_with_bodies = true
# 2. Cast the shape
var result = space_state.cast_motion(params)
# cast_motion returns [safe_fraction, unsafe_fraction]
# If safe_fraction is 1.0, we hit nothing.
if result[1] >= 1.0:
return {} # No hit
# 3. Get the collision details
# cast_motion doesn't return the collider, so we need to use get_rest_info
# at the collision point to find WHAT we hit.
# Calculate hit position
var hit_fraction = result[1]
var hit_pos = ray_origin + (ray_direction * reach_distance * hit_fraction)
params.transform.origin = hit_pos
params.motion = Vector3.ZERO
var intersection = space_state.intersect_shape(params, 1)
if intersection.is_empty():
return {}
var collider = intersection[0]["collider"]
# --- NEW: Filter for PieceMount class ---
# If we hit a PieceMount, we return it directly.
if collider is PieceMount:
return {
"position": hit_pos,
"collider": collider,
"mount": collider # Pass the strong reference
}
return {
"position": hit_pos,
"collider": collider
}
## 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
cursor_pos: Vector3,
target_mount_node: PieceMount = null # OPTIONAL: Specific target mount
) -> 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.
# 1. Harvest world-space mounts from the module
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
# Optimization: If we already know the target mount node (from find_snap_target), use ONLY that one.
if target_mount_node:
world_target_mounts.append(_get_mount_data_from_node(target_mount_node))
else:
# Fallback: Search all
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))
# 3. Find the BEST pair of mounts (New Mount <-> Target Mount)
# 2. Find the BEST pair of mounts
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)
# Construct the Local Transform of the NEW mount
var local_pos = new_mount.get("position", Vector3.ZERO)
var local_norm = new_mount.get("normal", Vector3.BACK)
var local_up = new_mount.get("up", Vector3.UP)
var local_type = new_mount.get("type", 0)
var mount_local_transform = Transform3D(Basis.looking_at(local_norm, local_up), local_pos)
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
# A. Type Compatibility Check
if target_mount.type != local_type:
continue
# B. Distance Check
# If we provided a specific target mount, distance is less relevant (we already aimed at it),
# but we still check to ensure the preview doesn't jump wildly if the mounts are far apart.
var dist_to_cursor = cursor_pos.distance_to(target_mount.position)
if dist_to_cursor > SNAP_DISTANCE: continue
# B. Check if this is the closest snap so far
if dist_to_cursor < best_dist:
# C. Construct Target World Transform
# We want the NEW mount to face OPPOSITE to the TARGET mount.
var target_basis = Basis.looking_at(-target_mount.normal, target_mount.up)
var target_world_transform = Transform3D(target_basis, target_mount.position)
# 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).
# D. Calculate Final Piece Transform
# Piece_World = Target_Mount_World * Mount_Local.inverse()
var desired_normal = -target_mount.normal # The direction we want our mount to face
best_transform = target_world_transform * mount_local_transform.affine_inverse()
# 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()
# Helper to extract data dictionary from a runtime PieceMount node
static func _get_mount_data_from_node(mount_node: PieceMount) -> Dictionary:
# We extract the transform from the node itself
var t = mount_node.global_transform
# Forward (Normal) is -Z, Up is +Y
var normal = -t.basis.z
var up = t.basis.y
# If vectors are parallel (same direction)
if v1.dot(v2) > 0.999:
return Quaternion.IDENTITY
# If vectors are opposite (180 degrees)
return {
"position": t.origin,
"normal": normal,
"up": up,
"type": 0 # TODO: PieceMount needs a 'type' property!
}
static func _get_rotation_between_vectors(v1: Vector3, v2: Vector3) -> Quaternion:
v1 = v1.normalized(); v2 = v2.normalized()
if v1.dot(v2) > 0.999: return Quaternion.IDENTITY
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)
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

View File

@ -98,7 +98,7 @@ shape = SubResource("CylinderShape3D_nvgim")
transform = Transform3D(-4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D8"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.016562464, 0.05784607, -0.0040130615)
transform = Transform3D(0.9999999999999997, 0, 0, 0, 0.9999999999999997, 0, 0, 0, 1, 0.09530011764134061, 0.10799497165389675, -0.025631436817796976)
mesh = SubResource("CylinderMesh_nvgim")
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D8"]
@ -164,4 +164,4 @@ shape = SubResource("CylinderShape3D_nvgim")
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)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 17.12362689122544, -0.4999999999999965, -11.64324531371028)