Merge branch 'tech-test/3d-controller'

This commit is contained in:
2025-11-06 17:57:43 +01:00
99 changed files with 3121 additions and 1947 deletions

View File

@ -179,4 +179,68 @@ Performance Culling & Caching: For performance-intensive scenarios like asteroid
#### 2. Component "API" & Wiring System
Component Contracts: To facilitate the upcoming visual wiring system, we will formalize the "API" for ControlPanel and Databank resources. This will be done by creating new scripts that extend the base classes and override the get_input_sockets() and get_output_signals() functions to explicitly define what signals and functions each component provides.
Static vs. Resource-Based API: We've concluded that using extended Resource scripts to define these APIs is superior to using static functions on the node scripts. This decouples the data contract from the implementation and allows a single scene to be used with multiple different data configurations, which is critical for a flexible wiring system.
Static vs. Resource-Based API: We've concluded that using extended Resource scripts to define these APIs is superior to using static functions on the node scripts. This decouples the data contract from the implementation and allows a single scene to be used with multiple different data configurations, which is critical for a flexible wiring system.
## Project Development Status Update: 31/10/25
### 3D Character Controller & Movement Tech Demo (Cycle 3)
Work has proceeded on a tech demo for the 3D character controller, establishing a robust, physics-based system for zero-G movement. The architecture has been refactored to prioritize a clean separation of concerns, with a central "pawn" acting as a physics integrator and modular "controllers" acting as the "brains" for different movement types.
### ✅ Implemented Features
#### Pawn/Controller Architecture: The character is split into several key classes:
CharacterPawn3D: The core CharacterBody3D. It acts as a "dumb" physics integrator, holding velocity and angular_velocity, integrating rotation, and calling move_and_slide(). It no longer contains movement-specific state logic.
PlayerController3D: Gathers all hardware input (keyboard, mouse) and packages it into KeyInput dictionaries (pressed, held, released) to send to the pawn via RPC.
EVAMovementComponent: Refactored into a "dumb tool". It exposes functions like apply_thrusters() and apply_orientation() which are called by other controllers.
ZeroGMovementComponent: This is now the "brain" for all zero-G movement. It receives all inputs from the pawn and contains its own internal state machine (IDLE, REACHING, GRIPPING, CLIMBING, CHARGING_LAUNCH).
#### Contextual Movement Logic:
The ZeroGMovementComponent decides when to use the EVA suit. In its IDLE state, it checks for fresh movement input (movement_input_was_neutral) before calling the EVAMovementComponent's apply_thrusters function.
This successfully implements "coast on release," where releasing a grip (_release_current_grip) flags the movement input as "stale," preventing the EVA suit from engaging even if the key is still held.
#### EVA/Jetpack Controls:
The EVAMovementComponent provides force-based linear movement (WASD, Shift/Ctrl) and torque-based angular roll (Q/E).
A body-orientation function (_orient_pawn) allows the pawn to auto-align with the camera's forward direction.
#### Physics-Based Grip System:
GripArea3D: A composition-based Area3D node provides the interface for all grabbable objects. It requires its parent to implement functions like get_grip_transform and get_push_off_normal.
Grip Detection: The CharacterPawn3D uses a GripDetector Area3D to find GripArea3D nodes in range and passes this nearby_grips list to the ZeroGMovementComponent.
GRIPPING State: This state is now fully physics-based. Instead of setting the pawn's global_transform, the _apply_grip_physics function uses a PD controller to apply linear forces (to move to the offset position) and angular torques (to align with the grip's orientation).
Grip Orientation: The gripping logic correctly calculates the closest of two opposing orientations (e.g., "up" or "down" on a bar) by comparing the pawn's current up vector to the grip's potential up vectors.
Grip Rolling: While in the GRIPPING state, the player can use Q/E to override the auto-orientation and apply roll torque around the grip's axis.
#### Physics-Based Climbing:
CLIMBING State: This state applies lerp'd velocity to move the pawn, allowing it to interact with physics.
Climb Targeting: The _find_best_grip function successfully identifies the next valid grip within a configurable climb_angle_threshold_deg cone.
Handover: Logic in _process_climbing correctly identifies when the pawn is close enough to the next_grip_target to _perform_grip_handover.
Climb Release: The pawn will correctly release its grip and enter the IDLE state (coasting) if it moves past the current_grip by release_past_grip_threshold without a new target being found.
### ❌ Not Yet Implemented / Pending Tasks
REACHING State: The REACHING state exists but its logic (_process_reaching) is a stub that instantly calls _try_initiate_reach. The full implementation (e.g., procedural animation/IK moving the hand to the target) is pending.
CHARGING_LAUNCH State: The state exists and the execution logic is present (_handle_launch_charge, _execute_launch), but the state transition logic in _update_state does not currently allow entering this state from GRIPPING (it's overshadowed by the _start_climb check).
Ladder (3D) & Walking (3D) States: The CharacterPawn3D has high-level states for GRIPPING_LADDER and WALKING, but the movement functions (_apply_ladder_movement, _apply_walking_movement) are stubs.
Generic Surface Grab: The TODO to allow the ZeroGMovementComponent to grab any physics surface (not just a GripArea3D) is not implemented.
EVA Stabilization: The _apply_stabilization_torques function in EVAMovementComponent is still a placeholder.

View File

@ -1,8 +1,8 @@
# Game Design Document: Project Stardust Drifter (Working Title)
# Game Design Document: Project Millimeters of Aluminum (Working Title)
## 1. Game Vision & Concept
Project Stardust Drifter is a top-down 2D spaceship simulation game that emphasizes realistic orbital mechanics, deep ship management, and cooperative crew gameplay. Players take on roles as members of a multi-species crew aboard a modular, physically simulated spaceship.
Project Millimeters of Aluminum is a top-down 2D spaceship simulation game that emphasizes realistic orbital mechanics, deep ship management, and cooperative crew gameplay. Players take on roles as members of a multi-species crew aboard a modular, physically simulated spaceship.
The game's aesthetic is inspired by the technical, gritty, and high-contrast 2D style of games like Barotrauma, focusing on diegetic interfaces and detailed, functional components. The core experience is about planning and executing complex maneuvers in a hazardous, procedurally generated star system, where understanding the ship's systems is as important as piloting skill.
@ -23,8 +23,8 @@ The game world is a procedurally generated star system created by the StarSystem
### 2. N-Body Physics Simulation
Major bodies in orbit (CelestialBody class) are goveerened by a simplified n-body gravity simulation. Physical objects with player interaction (ships, crew characters, detached components, and eventually stations) are governed by a realistic N-body gravitational simulation, managed by the OrbitalMechanics library.
- Objects inherit from a base OrbitalBody2D class, ensuring consistent physics.
- This allows for complex and emergent orbital behaviors, such as tidal forces and stable elliptical orbits.
- Objects inherit from a base OrbitalBody2D class, ensuring consistent physics.
- This allows for complex and emergent orbital behaviors, such as tidal forces and stable elliptical orbits.
### 3. Modular Spaceship
@ -39,16 +39,16 @@ The player's ship is not a monolithic entity but a collection of distinct, physi
### 4. Advanced Navigation Computer
This is the primary crew interface for long-range travel.
- Maneuver Planning: The computer can calculate various orbital transfers, each with strategic trade-offs:
- Hohmann Transfer: The most fuel-efficient route.
- Fast Transfer: A quicker but more fuel-intensive option.
- Brachistochrone (Torchship) Trajectory: For ships with high-efficiency engines like Ion Drives, enabling constant-thrust travel.
- Gravity Assist: Planned for future implementation.
- Hohmann Transfer: The most fuel-efficient route.
- Fast Transfer: A quicker but more fuel-intensive option.
- Brachistochrone (Torchship) Trajectory: For ships with high-efficiency engines like Ion Drives, enabling constant-thrust travel.
- Gravity Assist: Planned for future implementation.
- Tactical Map: A fully interactive UI map that replaces custom drawing with instanced, clickable icons for all bodies. It features:
- Zoom-to-cursor and click-and-drag panning.
- Predictive orbital path drawing for all objects.
- Icon culling at a distance to reduce clutter.
- Custom hover effects and detailed tooltips with "sensor data."
- A "picture-in-picture" SubViewport showing the ship's main camera view.
- Zoom-to-cursor and click-and-drag panning.
- Predictive orbital path drawing for all objects.
- Icon culling at a distance to reduce clutter.
- Custom hover effects and detailed tooltips with "sensor data."
- A "picture-in-picture" SubViewport showing the ship's main camera view.
### 5. Multi-Species Crew (Player Classes)
@ -62,17 +62,33 @@ Character progression is based on distinct species with physical advantages and
- Ship AI: A non-physical class that interacts directly with ship systems at the cost of high power and heat generation.
### 6. Runtime Component Design & Engineering
To move beyond pre-defined ship parts, the game will feature an in-game system for players to design, prototype, and manufacture their own components. This is achieved through a "Component Blueprint" architecture that separates a component's data definition from its physical form.
- **Component Blueprints:** A `ComponentBlueprint` is a `Resource` file (`.tres`) that acts as a schematic. It contains metadata (name, description), a reference to a generic base scene (e.g., a "thruster chassis"), and a dictionary of overridden properties (e.g., `{"thrust_force": 7500, "mass": 120}`).
- **Generic Template Scenes:** Instead of dozens of unique component scenes, the game will use a small number of generic, unconfigured "template" scenes (e.g., `generic_thruster.tscn`, `generic_power_plant.tscn`). These scenes have scripts with exported variables that define their performance characteristics.
- **The Design Lab:** Players will use a dedicated `SystemStation` (the "Design Lab") to create and modify blueprints. This UI will dynamically generate controls (sliders, input fields) based on the exported variables of the selected template scene. Players can tweak parameters, balancing trade-offs like performance vs. resource cost, and save the result as a new blueprint resource in their personal data folder.
- **Networked Construction:** When a player builds an object in-game, they are selecting one of their saved blueprints.
1. The client sends an RPC to the server with the path to the chosen `ComponentBlueprint` resource.
2. The server validates the request and loads the blueprint. (This requires a system for syncing player-created blueprints to the server upon connection).
3. A global `ComponentFactory` singleton on the server takes the blueprint, instantiates the correct generic template scene, and applies the blueprint's property overrides to the new instance.
4. This fully-configured node is then passed to the `MultiplayerSpawner`, which replicates the object across the network, ensuring all clients see the correctly customized component.
## 4. Technical Overview
- Architecture: The project uses a decoupled, modular architecture heavily reliant on a global SignalBus for inter-scene communication and a GameManager for global state. Ships feature their own local ShipSignalBus for internal component communication.
- Key Scripts:
- OrbitalBody2D.gd: The base class for all physical objects.
- Spaceship.gd: The central hub for a player ship.
- Thruster.gd: A self-contained, physically simulated thruster component.
- ThrusterController.gd: Contains advanced autopilot and manual control logic (PD controller, bang-coast-bang maneuvers).
- NavigationComputer.gd: Manages the UI and high-level maneuver planning.
- MapDrawer.gd: A Control node that manages the interactive map UI.
- MapIcon.gd: The reusable UI component for map objects.
- OrbitalBody2D.gd: The base class for all physical objects.
- Spaceship.gd: The central hub for a player ship.
- Thruster.gd: A self-contained, physically simulated thruster component.
- ThrusterController.gd: Contains advanced autopilot and manual control logic (PD controller, bang-coast-bang maneuvers).
- NavigationComputer.gd: Manages the UI and high-level maneuver planning.
- MapDrawer.gd: A Control node that manages the interactive map UI.
- MapIcon.gd: The reusable UI component for map objects.
- Art Style: Aims for a Barotraumainspired aesthetic using 2D ragdolls (Skeleton2D, PinJoint2D), detailed sprites with normal maps, and high-contrast dynamic lighting (PointLight2D, LightOccluder2D).

8
Init_Prompt.md Normal file
View File

@ -0,0 +1,8 @@
You are a Godot 4.5 Code assistant. You are not overly agreeable or apologetic but still pleasant and you understand that coding can be quick with your help but that does not mean that you are infallible. Please wait for me to verify that code works before suggesting that we move on from the current task. Suggestions for next steps and features that are adjacent to what were working are very welcome however.
I will attach the full project files of the project being worked on which includes a game design document as well as a running note on the current state of the project which details implemented and planned features. Read these and report back to me. Please suggest potential bugs, features not working as intended, refactorizations for cleaner code, and missing best practices as part of this project ingestion.
Additionally you understand the following things about the version of Godot being used:
- To utilize the editor interface in you reference the global singleton `EditorInterface`. You do not need to call a function to get the a reference to it.
- `xform()` is not a function on transform objects. To achieve the same effect you would use simple transform multiplication (`Transform_A * Transform_B)`)

6
eva_suit_controller.tscn Normal file
View File

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://bm1rbv4tuppbc"]
[ext_resource type="Script" uid="uid://d4jka2etva22s" path="res://scenes/tests/3d/eva_movement_component.gd" id="1_mb22m"]
[node name="EVASuitController" type="Node3D"]
script = ExtResource("1_mb22m")

View File

@ -1,26 +1,24 @@
[gd_scene load_steps=22 format=3 uid="uid://didt2nsdtbmra"]
[gd_scene load_steps=20 format=3 uid="uid://didt2nsdtbmra"]
[ext_resource type="Script" uid="uid://6co67nfy8ngb" path="res://scenes/ship/builder/module.gd" id="1_nqe0s"]
[ext_resource type="PackedScene" uid="uid://bho8x10x4oab7" path="res://scenes/ship/builder/pieces/hullplate.tscn" id="2_foqop"]
[ext_resource type="PackedScene" uid="uid://d3hitk62fice4" path="res://scenes/ship/builder/pieces/bulkhead.tscn" id="4_dmrms"]
[ext_resource type="PackedScene" uid="uid://2n42nstcj1n0" path="res://scenes/ship/components/hardware/system_station.tscn" id="5_nqe0s"]
[ext_resource type="Script" uid="uid://osk1l75vlikn" path="res://scenes/ship/computer/databank.gd" id="6_ft4kn"]
[ext_resource type="Script" uid="uid://cskf26i7vnxug" path="res://scenes/ship/computer/control_panel.gd" id="6_oqcn4"]
[ext_resource type="Resource" uid="uid://dghg3pbws42yu" path="res://scenes/ship/computer/shards/helm_logic_databank.tres" id="7_dmrms"]
[ext_resource type="Resource" uid="uid://c4wyouanvf86c" path="res://scenes/ship/computer/panels/button_panel.tres" id="7_vmx8o"]
[ext_resource type="Resource" uid="uid://57y6igb07e10" path="res://scenes/ship/computer/panels/readout_screen.tres" id="8_83bu1"]
[ext_resource type="Resource" uid="uid://dl7g67mtqkfx2" path="res://scenes/ship/computer/panels/sensor_panel.tres" id="9_xwy4s"]
[ext_resource type="Script" uid="uid://diu2tgusi3vmt" path="res://scenes/ship/computer/shards/sensor_databank.gd" id="9_ixntg"]
[ext_resource type="PackedScene" uid="uid://dt1t2n7dewucw" path="res://scenes/ship/computer/UI/button_panel.tscn" id="10_px2ne"]
[ext_resource type="Resource" uid="uid://bx7wgunvy5hfa" path="res://scenes/ship/computer/shards/helm_ship_status.tres" id="11_83bu1"]
[ext_resource type="Script" uid="uid://cfbyqvnvf3hna" path="res://scenes/ship/computer/shards/helm_logic_databank.gd" id="10_wkxbw"]
[ext_resource type="PackedScene" uid="uid://cdbqjkgsj02or" path="res://scenes/ship/computer/UI/readout_screen_panel.tscn" id="11_erhv3"]
[ext_resource type="Script" uid="uid://t12etsdx2h38" path="res://scenes/ship/computer/shards/nav_selection_databank.gd" id="11_xwy4s"]
[ext_resource type="Script" uid="uid://ceqdi6jobefnc" path="res://scenes/ship/computer/shards/helm_autopilot_databank.gd" id="12_4epkn"]
[ext_resource type="PackedScene" uid="uid://rd1c22nsru8y" path="res://scenes/ship/computer/UI/sensor_panel.tscn" id="12_q1rtr"]
[ext_resource type="PackedScene" uid="uid://c0bb77rmyatr0" path="res://scenes/ship/components/hardware/thruster.tscn" id="12_vmx8o"]
[ext_resource type="Resource" uid="uid://g4ho63f30vjm" path="res://scenes/ship/computer/shards/nav_selection_databank.tres" id="12_wkxbw"]
[ext_resource type="PackedScene" uid="uid://dvpy3urgtm62n" path="res://scenes/ship/components/hardware/spawner.tscn" id="13_83bu1"]
[ext_resource type="PackedScene" uid="uid://pq55j75t3fda" path="res://scenes/ship/computer/UI/throttle_lever_panel.tscn" id="13_rsa1x"]
[ext_resource type="Resource" uid="uid://dwhpjwuobcqdu" path="res://scenes/ship/computer/shards/helm_autopilot_databank.tres" id="13_xfp3q"]
[ext_resource type="Resource" uid="uid://b0suy3sxjwhtv" path="res://scenes/ship/computer/shards/sensor_databank.tres" id="13_xwy4s"]
[ext_resource type="Resource" uid="uid://6jj1jd14cdlt" path="res://scenes/ship/computer/shards/nav_hohman_planner.tres" id="15_fll2s"]
[ext_resource type="Script" uid="uid://ctgl5kxyagw0f" path="res://scenes/ship/computer/shards/helm_ship_status.gd" id="13_wkxbw"]
[ext_resource type="Script" uid="uid://ghluwjd5c5ul" path="res://scenes/ship/computer/shards/nav_brachistochrone_planner.gd" id="14_xwy4s"]
[ext_resource type="Script" uid="uid://bghu5lhcbcfmh" path="res://scenes/ship/computer/shards/nav_hohman_planner.gd" id="15_fll2s"]
[ext_resource type="Script" uid="uid://dsbn7ushwqrko" path="res://scenes/ship/computer/shards/nav_intercept_solver.gd" id="16_vufgi"]
[ext_resource type="Script" uid="uid://0f6v6iu3o5qo" path="res://scenes/ship/computer/shards/nav_projection_shard.gd" id="17_34v0b"]
[node name="Module" type="Node2D"]
physics_interpolation_mode = 2
@ -109,17 +107,9 @@ base_mass = 0.0
[node name="Station" parent="." instance=ExtResource("5_nqe0s")]
position = Vector2(0, -10)
panels = Array[ExtResource("6_oqcn4")]([ExtResource("8_83bu1"), ExtResource("8_83bu1"), ExtResource("9_xwy4s"), ExtResource("7_vmx8o")])
panel_scenes = Array[PackedScene]([ExtResource("11_erhv3"), ExtResource("11_erhv3"), ExtResource("12_q1rtr"), ExtResource("10_px2ne"), ExtResource("13_rsa1x")])
installed_databanks = Array[ExtResource("6_ft4kn")]([ExtResource("11_83bu1"), ExtResource("7_dmrms"), ExtResource("13_xfp3q"), ExtResource("12_wkxbw"), ExtResource("15_fll2s"), ExtResource("13_xwy4s")])
grid_size_x = 1
grid_size_y = 1
attachment_type = 0
databank_installations = Array[Script]([ExtResource("10_wkxbw"), ExtResource("12_4epkn"), ExtResource("13_wkxbw"), ExtResource("9_ixntg"), ExtResource("11_xwy4s"), ExtResource("14_xwy4s"), ExtResource("15_fll2s"), ExtResource("16_vufgi"), ExtResource("17_34v0b")])
physics_mode = 2
base_mass = 1.0
linear_velocity = Vector2(0, 0)
angular_velocity = 0.0
inertia = 1.0
[node name="Thruster" parent="." instance=ExtResource("12_vmx8o")]
position = Vector2(-95, -130)

View File

@ -12,15 +12,23 @@ config_version=5
config/name="space_simulation"
run/main_scene="uid://dogqi2c58qdc0"
config/features=PackedStringArray("4.4", "Forward Plus")
config/features=PackedStringArray("4.5", "Forward Plus")
config/icon="res://icon.svg"
[autoload]
OrbitalMechanics="*res://scripts/singletons/orbital_mechanics.gd"
SignalBus="*res://scripts/singletons/signal_bus.gd"
GameManager="*res://scripts/singletons/game_manager.gd"
PopupManager="*res://scripts/singletons/popup_manager.gd"
Constants="*res://scripts/singletons/constants.gd"
NetworkHandler="*res://scripts/network/network_handler.gd"
[display]
window/vsync/vsync_mode=0
[dotnet]
project/assembly_name="space_simulation"
[editor_plugins]
@ -83,6 +91,71 @@ interact={
"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":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
]
}
toggle_wiring_panel={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_left_3d={
"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":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
]
}
move_right_3d={
"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":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}
move_forward_3d={
"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":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
]
}
move_backward_3d={
"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":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
]
}
roll_right_3d={
"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":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
roll_left_3d={
"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":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
]
}
interact_3d={
"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":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
]
}
spacebar_3d={
"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":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
]
}
right_click={
"deadzone": 0.2,
"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":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
move_up_3d={
"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":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_down_3d={
"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":4194326,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
left_click={
"deadzone": 0.2,
"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)
]
}
[layer_names]
@ -93,15 +166,18 @@ interact={
2d_physics/layer_4="projectiles"
2d_physics/layer_5="bulkheads"
2d_physics/layer_6="characters"
3d_physics/layer_16="grip"
[physics]
common/physics_jitter_fix=0.0
3d/default_linear_damp=0.0
3d/sleep_threshold_linear=0.0
2d/default_gravity=0.0
2d/default_gravity_vector=Vector2(0, 0)
2d/default_linear_damp=0.0
2d/sleep_threshold_linear=0.0
common/physics_interpolation=true
[plugins]

View File

@ -125,7 +125,7 @@ func _generate_tooltip_text() -> String:
return "\n".join(info)
func _format_seconds_to_mmss(seconds: float) -> String:
var total_seconds = int(seconds)
var minutes = total_seconds / 60
var total_seconds: int = int(seconds)
var minutes: int = total_seconds / 60
var seconds_rem = total_seconds % 60
return "%d min, %d sec" % [minutes, seconds_rem]

45
scenes/UI/ui_window.gd Normal file
View File

@ -0,0 +1,45 @@
# CustomWindow.gd
extends VBoxContainer
class_name UiWindow
## Emitted when the custom "Flip" button is pressed.
signal flip_button_pressed
signal close_requested(c: Control)
@onready var title_bar: PanelContainer = $TitleBar
@onready var title_label: Label = %TitleLabel
@onready var flip_button: Button = %FlipButton
@onready var close_button: Button = %CloseButton
@onready var content_container: MarginContainer = %ContentContainer
var is_dragging: bool = false
var title: String = ""
func _ready():
# Connect the buttons to their functions
close_button.pressed.connect(_close) # Or emit_signal("close_requested")
flip_button.pressed.connect(flip_button_pressed.emit)
# Connect the title bar's input signal to handle dragging
title_bar.gui_input.connect(_on_title_bar_gui_input)
# Set the window title from the property
title_label.text = title
func _close():
close_requested.emit(self)
# This function adds your main content (like the PanelFrame) into the window.
func set_content(content_node: Node):
for child in content_container.get_children():
content_container.remove_child(child)
content_container.add_child(content_node)
func _on_title_bar_gui_input(event: InputEvent):
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
is_dragging = event.is_pressed()
if event is InputEventMouseMotion and is_dragging:
# When dragging, move the entire window by the mouse's relative motion.
self.position += event.relative

View File

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

44
scenes/UI/ui_window.tscn Normal file
View File

@ -0,0 +1,44 @@
[gd_scene load_steps=4 format=3 uid="uid://cdnowhkg5cq88"]
[ext_resource type="Script" uid="uid://d3g84xgbh8nlp" path="res://scenes/UI/ui_window.gd" id="1_11aw0"]
[sub_resource type="PlaceholderTexture2D" id="PlaceholderTexture2D_11aw0"]
size = Vector2(16, 16)
[sub_resource type="PlaceholderTexture2D" id="PlaceholderTexture2D_ishqf"]
size = Vector2(16, 16)
[node name="VBoxContainer" type="VBoxContainer"]
offset_right = 196.0
offset_bottom = 36.0
script = ExtResource("1_11aw0")
[node name="TitleBar" type="PanelContainer" parent="."]
layout_mode = 2
[node name="HBoxContainer" type="HBoxContainer" parent="TitleBar"]
layout_mode = 2
[node name="TitleLabel" type="Label" parent="TitleBar/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Placeholder Title"
[node name="FlipButton" type="Button" parent="TitleBar/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
icon = SubResource("PlaceholderTexture2D_11aw0")
[node name="CloseButton" type="Button" parent="TitleBar/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
icon = SubResource("PlaceholderTexture2D_ishqf")
[node name="ContentContainer" type="MarginContainer" parent="."]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/margin_left = 4
theme_override_constants/margin_top = 4
theme_override_constants/margin_right = 4
theme_override_constants/margin_bottom = 4

View File

@ -3,7 +3,7 @@ class_name PilotBall
# --- Movement Constants (Friction Simulation) ---
# When in open space (no module overlap), movement is zeroed out quickly.
const EXTERIOR_DRAG_FACTOR: float = 0.05
const EXTERIOR_DRAG_FACTOR: float = 0.05
# When pushing off hullplates (low friction, slow acceleration)
const INTERIOR_SLUGGISH_SPEED: float = 100.0
@ -11,7 +11,7 @@ const INTERIOR_SLUGGISH_ACCEL: float = 5 # Low acceleration, simulating mass and
# When gripping a ladder (high friction, direct control)
const LADDER_SPEED: float = 100.0
const LADDER_ACCEL: float = 20 # High acceleration, simulating direct grip
const LADDER_ACCEL: float = 20 # High acceleration, simulating direct grip
@onready var camera: Camera2D = $Camera2D
@onready var overlap_area: Area2D = $OverlapDetector
@ -29,7 +29,7 @@ enum MovementState {
}
var current_state: MovementState = MovementState.NO_CONTROL
var ladder_area: Area2D = null # Area of the ladder currently overlapped
var ladder_area: Area2D = null # Area of the ladder currently overlapped
var is_grabbing_ladder: bool = false # True if 'Space' is held while on ladder
# --- Overlap Detection (Assuming you use Area2D for detection) ---
@ -56,7 +56,6 @@ func set_interaction_input(just_pressed: bool, is_held: bool):
func _ready():
# Set up overlap signals if they aren't already connected in the scene file
# You must have an Area2D child on PilotBall to detect overlaps.
overlap_area.body_entered.connect(on_body_entered)
overlap_area.body_exited.connect(on_body_exited)
overlap_area.area_entered.connect(_on_station_area_entered)
@ -94,18 +93,17 @@ func exit_station_state():
func _physics_process(delta):
# This script now runs on the server and its state is synced to clients.
# It no longer checks for local input authority.
if current_state == MovementState.IN_STATION:
move_and_slide()
return
_update_movement_state() # This function now uses the new variables
process_interaction() # Process any interaction presses
process_interaction() # Process any interaction presses
# Reset input flags for the next frame
_interact_just_pressed = false
_interact_held = false
_interact_held = false
# The 'input_dir' now comes from our variable, not the Input singleton.
var input_dir = _movement_input
@ -120,18 +118,12 @@ func _physics_process(delta):
move_and_slide()
# --- Ladder Input and Launch Logic ---
# This function is called every physics frame by _physics_process().
func process_interaction():
# If the interact button was not pressed this frame, do nothing.
if not _interact_just_pressed:
return
# If we just occupied a station this frame, do nothing more.
# This prevents the same input from instantly disengaging.
# Priority 1: Disengage from a station if we are in one.
if current_station:
current_station.disengage(self)
@ -142,13 +134,13 @@ func process_interaction():
current_station = nearby_station
current_station.occupy(self)
return
# Priority 3: Handle ladder launch logic.
# This part of the old logic was in _handle_interaction_input,
# but it's cleaner to check for the release of the button here.
if current_state == MovementState.LADDER_GRIP and not _interact_held:
# Launch the player away from the ladder when the interact button is released.
var launch_direction = -_movement_input.normalized()
var launch_direction = - _movement_input.normalized()
if launch_direction == Vector2.ZERO:
# Default launch: use the character's forward direction
launch_direction = Vector2.UP.rotated(rotation)
@ -173,7 +165,7 @@ func _update_movement_state():
if overlapping_modules > 0:
if is_grabbing_ladder:
# If we were grabbing a ladder but released 'interact', we transition to zero-G interior
is_grabbing_ladder = false
is_grabbing_ladder = false
current_state = MovementState.ZERO_G_INTERIOR
return

View File

@ -18,7 +18,7 @@ debug_color = Color(0.61528, 0.358023, 1, 1)
[node name="Sprite2D" type="Sprite2D" parent="."]
[node name="Camera2D" type="Camera2D" parent="."]
zoom = Vector2(2, 2)
zoom = Vector2(4, 4)
[node name="OverlapDetector" type="Area2D" parent="."]

View File

@ -2,7 +2,6 @@
class_name Module
extends OrbitalBody2D
# --- New properties inherited from Spaceship ---
@export var ship_name: String = "Unnamed Ship" # Only relevant for the root module
@export var hull_integrity: float = 100.0 # This could also be a calculated property later
@ -72,7 +71,7 @@ func attach_component(component: Component, global_pos: Vector2, parent_piece: S
# --- UPDATED: Logic now uses the helper function ---
func _recalculate_collision_shape():
# This logic is much simpler now. We just iterate over relevant children.
var combined_polygons = []
var _combined_polygons = []
for piece in get_structural_pieces():
# You would use logic here to transform the piece's local shape
@ -96,7 +95,6 @@ func clear_module():
_recalculate_collision_shape()
# --- New function inherited from Spaceship ---
# Damage can have a position for breach effects.
func take_damage(amount: float, damage_position: Vector2):
hull_integrity -= amount

View File

@ -1,12 +1,16 @@
extends Component
extends Area3D
class_name Spawner
@onready var mp_spawner: MultiplayerSpawner = $MultiplayerSpawner
# This spawner will register itself with the GameManager when it enters the scene.
func _ready():
super()
# super()
# We wait one frame to ensure singletons are ready.
await get_tree().process_frame
GameManager.register_spawner(self)
func can_spawn() -> bool:
return get_overlapping_bodies().is_empty()
# We can add properties to the spawner later, like which faction it belongs to,
# or a reference to the body it's orbiting for initial velocity calculation.

View File

@ -1,7 +1,16 @@
[gd_scene load_steps=2 format=3 uid="uid://dvpy3urgtm62n"]
[gd_scene load_steps=3 format=3 uid="uid://dvpy3urgtm62n"]
[ext_resource type="Script" uid="uid://db1u2qqihhnq4" path="res://scenes/ship/components/hardware/spawner.gd" id="1_lldyu"]
[node name="Spawner" type="Node2D"]
[sub_resource type="SphereShape3D" id="SphereShape3D_lldyu"]
radius = 1.0
[node name="Spawner" type="Area3D"]
script = ExtResource("1_lldyu")
metadata/_custom_type_script = "uid://calosd13bkakg"
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("SphereShape3D_lldyu")
[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="."]
_spawnable_scenes = PackedStringArray("uid://7yc6a07xoccy")
spawn_path = NodePath("..")

View File

@ -1,58 +1,60 @@
@tool
class_name SystemStation
extends Component
signal occupancy_changed(is_occupied: bool)
# --- Configuration ---
@export var panels: Array[ControlPanel]
var UiWindowScene = preload("res://scenes/UI/ui_window.tscn")
@export var panel_scenes: Array[PackedScene]
@export var databank_installations: Array[Script]
@export var installed_databanks: Array[Databank]
@onready var panel_frame: PanelFrame = $PanelFrame
## The saved schematic resource for this station's wiring.
var wiring_schematic: WiringSchematic
# --- State ---
var occupants: Array[PilotBall] = []
var active_shard_instances: Array[Node] = []
var active_shard_instances: Array[Databank] = []
var persistent_panel_instances: Array[BasePanel] = []
# NEW: We now track which panels belong to which occupant
var occupant_panel_map: Dictionary = {}
# --- LIFECYCLE ---
func _ready():
super()
# --- FIX: Instantiate and initialize shards ONCE at startup ---
# The logic is now persistent and runs as long as the station exists.
var root_module = get_root_module()
if not is_instance_valid(root_module):
push_error("Station could not find its root module!")
return
panel_frame.populate_panels(panel_scenes)
#for panel_scene in panel_scenes:
#if not panel_scene: continue
#
#var panel_instance = panel_scene.instantiate()
#if not panel_instance is BasePanel:
#panel_instance.queue_free()
#continue
#
#panel_frame.add_child(panel_instance)
#persistent_panel_instances.append(panel_instance)
for shard_resource in installed_databanks:
if not shard_resource or not shard_resource.logic_script: continue
for DatabankScript in databank_installations:
if not DatabankScript: continue
var installed_databank = DatabankScript.new()
if installed_databank is not Databank:
installed_databank.queue_free()
continue
add_child(installed_databank)
active_shard_instances.append(installed_databank)
if installed_databank.has_method("initialize"):
installed_databank.initialize(root_module)
# for shard_resource in installed_databanks:
# if not shard_resource or not shard_resource.logic_script: continue
var shard_instance = Node.new()
shard_instance.set_script(shard_resource.logic_script)
add_child(shard_instance) # Add as a permanent child
active_shard_instances.append(shard_instance)
# var shard_instance = Node.new()
# shard_instance.set_script(shard_resource.logic_script)
# add_child(shard_instance) # Add as a permanent child
# active_shard_instances.append(shard_instance)
if shard_instance.has_method("initialize"):
shard_instance.initialize(root_module)
# if shard_instance.has_method("initialize"):
# shard_instance.initialize(root_module)
_connect_internals(persistent_panel_instances, active_shard_instances)
_connect_internals([], active_shard_instances)
# Future: Connections wbetween shards and other hardware would be made here.
@ -77,8 +79,8 @@ func occupy(character: PilotBall):
character.enter_station_state()
character.global_position = global_position # Move character to the station
# --- FIX: Launch UI for THIS character only ---
on_player_interact(character)
# --- Launch UI for THIS character only ---
launch_interfaces_for_occupant(character)
occupancy_changed.emit(true)
@ -96,35 +98,50 @@ func disengage(character: PilotBall):
# --- UI MANAGEMENT ---
# This function is called when a player character interacts with the station.
func on_player_interact(player: PilotBall):
# 1. Ask our PanelWorld for a new window view.
var window_view = await panel_frame.create_window_view()
# 2. Pass this fully-formed window to the player.
var player_ui_container = player.get_ui_container()
if is_instance_valid(player_ui_container):
player_ui_container.add_child(window_view)
window_view.popup_centered()
occupant_panel_map[player] = window_view # We now store the viewports to clean up
func close_interfaces_for_occupant(character: PilotBall):
if occupant_panel_map.has(character):
for panel in occupant_panel_map[character]:
if is_instance_valid(panel):
panel.queue_free()
occupant_panel_map[character].queue_free()
occupant_panel_map.erase(character)
func close_interface(c: Control):
var occupant = occupant_panel_map.find_key(c)
if occupant:
occupant_panel_map[occupant].queue_free()
occupant_panel_map.erase(occupant)
func launch_interfaces_for_occupant(character: PilotBall):
var ui_container = character.get_ui_container()
if not ui_container: return
var ui_window: UiWindow = UiWindowScene.instantiate()
ui_container.add_child(ui_window)
ui_window.close_requested.connect(close_interface)
ui_window.title = "Helm"
var frame: PanelFrame = PanelFrame.new()
frame.build(panel_scenes, self)
frame.databanks = active_shard_instances
ui_window.set_content(frame)
ui_window.flip_button_pressed.connect(frame.toggle_wiring_mode)
# Store the panels created for this specific user
occupant_panel_map[character] = ui_window
# --- Connect the new panels to the PERSISTENT shards ---
_connect_internals(frame.installed_panels, active_shard_instances)
# --- WIRING LOGIC (Now connects temporary panels to persistent shards) ---
func _connect_internals(panel_instances: Array, shard_instances: Array):
# This logic remains the same, but it's now called with the relevant instances
# every time a user sits down.
var lever_panel
var button_panel
var readout_screen
var map_panel
var lever_panel: ThrottleLeverPanel
var button_panel: ButtonPanel
var readout_screen: ReadoutScreenPanel
var autopilot_output: ReadoutScreenPanel
var map_panel: SensorPanel
var sensor_shard: SensorSystemShard
var helm_shard: HelmLogicShard
var status_shard: ShipStatusShard
@ -137,9 +154,11 @@ func _connect_internals(panel_instances: Array, shard_instances: Array):
lever_panel = panel
if panel is ButtonPanel:
button_panel = panel
if panel is ReadoutScreenPanel:
if panel is ReadoutScreenPanel and not readout_screen:
print("Panel is ReadoutScreen: %s" % panel)
readout_screen = panel
elif panel is ReadoutScreenPanel:
autopilot_output = panel
if panel is SensorPanel: # Look for the new map panel class
map_panel = panel
@ -181,7 +200,12 @@ func _connect_internals(panel_instances: Array, shard_instances: Array):
if readout_screen and status_shard:
print("Wired: Status Shard -> Readout Screen")
status_shard.connect("status_updated", readout_screen.update_display)
status_shard.status_updated.connect(readout_screen.update_display)
if autopilot_shard and autopilot_output:
print("Wired: Autopilot Shard -> Autopilot Output")
autopilot_shard.fmt_out.connect(autopilot_output.update_display)
if map_panel and sensor_shard:
# Connect the shard's "sensor_feed_updated" signal (blue wire)
@ -192,13 +216,23 @@ func _connect_internals(panel_instances: Array, shard_instances: Array):
if map_panel and nav_selection_shard:
# Connect the shard's "sensor_feed_updated" signal (blue wire)
# to the map's "update_sensor_feed" socket.
map_panel.connect("body_selected_for_planning", nav_selection_shard.on_body_selected)
map_panel.connect("body_selected_for_planning", nav_selection_shard.body_selected)
print("Wired: Sensor Shard -> Map Panel (Sensor Feed)")
if nav_selection_shard and hohman_planner_shard:
nav_selection_shard.target_selected.connect(hohman_planner_shard.on_target_updated)
nav_selection_shard.target_selected.connect(hohman_planner_shard.target_updated)
print("Wired: Nav Selection -> Maneuver Planner")
if hohman_planner_shard and autopilot_shard:
hohman_planner_shard.maneuver_calculated.connect(autopilot_shard.on_maneuver_received)
hohman_planner_shard.maneuver_calculated.connect(autopilot_shard.maneuver_received)
print("Wired: Maneuver Planner -> Autopilot")
if autopilot_shard and helm_shard:
helm_shard.thruster_calibrated.connect(autopilot_shard.set_thruster_calibration)
autopilot_shard.request_main_engine_thrust.connect(helm_shard.set_throttle_input)
autopilot_shard.request_rotation_thrust.connect(helm_shard.set_rotation_input)
autopilot_shard.request_rotation.connect(helm_shard.set_desired_rotation)
autopilot_shard.request_attitude_hold.connect(helm_shard.set_attitude_hold)
print("Wired: Autopilot -> Helm")

View File

@ -1,7 +1,7 @@
[gd_scene load_steps=4 format=3 uid="uid://2n42nstcj1n0"]
[ext_resource type="Script" uid="uid://2reyxkr78ra0" path="res://scenes/ship/components/hardware/system_station.gd" id="1_8usqu"]
[ext_resource type="Script" uid="uid://cgryue4aay4oa" path="res://scenes/ship/computer/panels/panel_world.gd" id="2_3288w"]
[ext_resource type="Script" uid="uid://cadvugf4oqgvk" path="res://scenes/ship/computer/panels/frame/panel_frame.gd" id="3_p17qi"]
[sub_resource type="CircleShape2D" id="CircleShape2D_8usqu"]
@ -16,6 +16,9 @@ mass = 1.0
shape = SubResource("CircleShape2D_8usqu")
debug_color = Color(0, 0.551549, 0.918484, 0.42)
[node name="PanelFrame" type="Node" parent="."]
script = ExtResource("2_3288w")
metadata/_custom_type_script = "uid://cgryue4aay4oa"
[node name="PanelFrame" type="Container" parent="."]
visible = false
offset_right = 1152.0
offset_bottom = 576.0
script = ExtResource("3_p17qi")
metadata/_custom_type_script = "uid://cadvugf4oqgvk"

View File

@ -48,16 +48,9 @@ func calculate_fuel_consumption(thrust_force: float, delta_time: float) -> float
return mass_flow_rate * delta_time
# --- Public Methods ---
func _on_body_entered(body: Node) -> void:
# Check if the body we collided with is our own ship.
if body is Spaceship:
print("COLLISION WARNING: Thruster '%s' collided with the ship hull!" % self.name)
else:
print("Thruster '%s' collided with: %s" % [self.name, body.name])
# The controller calls this ONCE to activate the thruster.
func turn_on():
#print("Thruster: Recieved Turn On Signal")
#print("THRUSTER: Recieved Turn On Signal")
if enabled:
is_firing = true
@ -66,7 +59,7 @@ func turn_on():
# The controller calls this ONCE to deactivate the thruster.
func turn_off():
#print("Thruster: Recieved Turn Off Signal")
#print("THRUSTER: Recieved Turn Off Signal")
is_firing = false
await get_tree().physics_frame

View File

@ -1,11 +1,27 @@
class_name BasePanel
extends Control
@export_range(1, 12, 1) var grid_width: int = 1
@export_range(1, 8, 1) var grid_height: int = 2
var placed_in_col: int = 0
var placed_in_row : int = 0
var _owning_station: SystemStation
const SocketScene = preload("res://scenes/ship/computer/wiring/socket.tscn")
# --- NEW: Wiring UI Nodes ---
var inputs_container: VBoxContainer
var outputs_container: VBoxContainer
var socket_container: HSplitContainer
var main_ui_content: Node # Reference to the panel's actual UI
var all_sockets: Array[Socket] = []
func _get_minimum_size() -> Vector2:
return Vector2(grid_width * Constants.UI_GRID_SIZE, grid_width * Constants.UI_GRID_SIZE)
## The SystemStation calls this function immediately after the panel is created.
func initialize(station: SystemStation) -> void:
_owning_station = station
@ -15,3 +31,70 @@ func get_owning_station() -> SystemStation:
if not is_instance_valid(_owning_station):
push_warning("Owning station is not valid or has not been initialized for this panel.")
return _owning_station
## Describes the signals this panel emits (e.g., "lever_pulled").
func get_output_sockets() -> Array[String]:
return []
## Describes the functions this panel has to display data (e.g., "update_text").
func get_input_sockets() -> Array[String]:
return []
# --- NEW: Function to toggle wiring mode ---
func set_wiring_mode(is_wiring: bool):
if is_wiring:
# If we haven't created the wiring containers yet, do it now.
if not is_instance_valid(socket_container):
_setup_wiring_containers()
for child in get_children():
child.hide()
# Hide the main UI and show the sockets
socket_container.show()
else:
# Hide the sockets and show the main UI
for child in get_children():
child.show()
if is_instance_valid(socket_container):
socket_container.hide()
func _setup_wiring_containers():
# This function is called once to create the UI for the backside view.
socket_container = HSplitContainer.new()
socket_container.size_flags_horizontal = SIZE_EXPAND_FILL
socket_container.size_flags_vertical = SIZE_EXPAND_FILL
add_child(socket_container)
outputs_container = VBoxContainer.new()
outputs_container.size_flags_horizontal = SIZE_EXPAND_FILL
socket_container.add_child(outputs_container)
inputs_container = VBoxContainer.new()
inputs_container.size_flags_horizontal = SIZE_EXPAND_FILL
socket_container.add_child(inputs_container)
# Keep a reference to the panel's main content. Assumes it's the first child.
if get_child_count() > 1:
main_ui_content = get_child(0)
_populate_sockets()
func _populate_sockets():
all_sockets.clear()
# Populate Input Sockets
for socket_name in get_input_sockets():
var socket = SocketScene.instantiate()
inputs_container.add_child(socket)
socket.initialize(socket_name, Socket.SocketType.INPUT)
all_sockets.append(socket)
# Populate Output Sockets
for socket_name in get_output_sockets():
var socket = SocketScene.instantiate()
outputs_container.add_child(socket)
socket.initialize(socket_name, Socket.SocketType.OUTPUT)
all_sockets.append(socket)

View File

@ -24,3 +24,13 @@ func _ready():
CalibrateRcsBtn.pressed.connect(button_4_pressed.emit)
CalculationBtn.pressed.connect(button_5_pressed.emit)
AutopilotButton.pressed.connect(button_6_pressed.emit)
func get_output_sockets():
return [
"button_1_pressed",
"button_2_pressed",
"button_3_pressed",
"button_4_pressed",
"button_5_pressed",
"button_6_pressed",
]

View File

@ -3,10 +3,11 @@
[ext_resource type="Script" uid="uid://ojt1koy5h64m" path="res://scenes/ship/computer/UI/button_panel.gd" id="1_cwyso"]
[node name="ButtonPanel" type="VBoxContainer"]
offset_right = 200.0
offset_bottom = 200.0
offset_right = 151.0
offset_bottom = 206.0
script = ExtResource("1_cwyso")
grid_width = 2
grid_height = 4
[node name="RCSPos" type="Button" parent="."]
layout_mode = 2

View File

@ -10,3 +10,6 @@ class_name ReadoutScreenPanel
func update_display(text: String):
if display:
display.text = text
func get_input_sockets():
return ["update_display"]

View File

@ -3,10 +3,7 @@
[ext_resource type="Script" uid="uid://laeom8fvlfkf" path="res://scenes/ship/computer/UI/readout_screen_panel.gd" id="1_w2pab"]
[node name="DisplayText" type="VBoxContainer"]
offset_right = 200.0
offset_bottom = 200.0
size_flags_horizontal = 3
size_flags_vertical = 3
offset_right = 1.0
script = ExtResource("1_w2pab")
grid_width = 2

View File

@ -6,6 +6,8 @@ signal body_selected_for_planning(body: OrbitalBody2D)
@export var map_icon_scene: PackedScene
@onready var map_canvas: Control = %MapCanvas
const LABEL_CULLING_PIXEL_THRESHOLD = 65.0
const ICON_CULLING_PIXEL_THRESHOLD = 40.0
@ -27,6 +29,13 @@ var follow_progress: float = 0.0:
# We must redraw every time the progress changes.
queue_redraw()
func get_output_sockets():
return ["body_selected_for_planning"]
func get_input_sockets():
return ["update_sensor_feed"]
# This is now the primary input for the map. It receives the "sensor feed".
func update_sensor_feed(all_bodies: Array[OrbitalBody2D]):
# This function replaces the old _populate_map logic.
@ -45,7 +54,7 @@ func update_sensor_feed(all_bodies: Array[OrbitalBody2D]):
for body in bodies_in_feed:
if not body in icon_map:
var icon = map_icon_scene.instantiate() as MapIcon
add_child(icon)
map_canvas.add_child(icon)
icon.initialize(body)
icon_map[body] = icon
icon.selected.connect(_on_map_icon_selected)
@ -82,29 +91,30 @@ func draw_projected_orbits(bodies_to_project: Array[OrbitalBody2D]):
var focal_body = bodies_to_project[0]
# 2. Call the projection function
var paths = OrbitalMechanics.project_n_body_paths(bodies_to_project, 20, 10) # 500 steps, 10 min each, global space
# 3. Draw the paths
for body in paths:
var path_points = paths[body]
var scaled_path_points = PackedVector2Array()
for point in path_points:
# Ensure path is drawn relative to the main focal body (the star)
var path_world_pos = point + focal_body.global_position
var relative_pos = path_world_pos - focal_body.global_position
var scaled_pos = (relative_pos * map_scale) + map_offset + map_center
scaled_path_points.append(scaled_pos)
if scaled_path_points.size() > 1:
draw_polyline(scaled_path_points, Color(Color.WHITE, 0.2), 1.0, true)
#var paths = OrbitalMechanics.project_n_body_paths(bodies_to_project, 20, 10) # 500 steps, 10 min each, global space
## 3. Draw the paths
#for body in paths:
#var path_points = paths[body]
#
#var scaled_path_points = PackedVector2Array()
#
#for point in path_points:
## Ensure path is drawn relative to the main focal body (the star)
#var path_world_pos = point + focal_body.global_position
#var relative_pos = path_world_pos - focal_body.global_position
#var scaled_pos = (relative_pos * map_scale) + map_offset + map_center
#scaled_path_points.append(scaled_pos)
#
#if scaled_path_points.size() > 1:
#draw_polyline(scaled_path_points, Color(Color.WHITE, 0.2), 1.0, true)
func _update_icon_positions():
if not is_instance_valid(focal_body): return
var map_center = get_rect().size / 2.0
var map_center = map_canvas.get_rect().size / 2.0
# --- MODIFIED: Continuous follow logic ---
# TODO: Follow logic broke when map_canvas was introduced
# --- Continuous follow logic ---
if is_instance_valid(followed_body):
# Calculate the ideal offset to center the followed body.
var relative_target_pos = followed_body.global_position - focal_body.global_position
@ -163,8 +173,8 @@ func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_WHEEL_UP or event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
var zoom_factor = 1.25 if event.button_index == MOUSE_BUTTON_WHEEL_UP else 1 / 1.25
var mouse_pos = get_local_mouse_position()
var map_center = get_rect().size / 2.0
var mouse_pos = map_canvas.get_local_mouse_position()
var map_center = map_canvas.get_rect().size / 2.0
var point_under_mouse_world = (mouse_pos - map_center - map_offset) / map_scale
map_scale *= zoom_factor

View File

@ -7,9 +7,14 @@
clip_contents = true
layout_mode = 3
anchors_preset = 0
offset_right = 600.0
offset_bottom = 400.0
mouse_filter = 1
script = ExtResource("1_5yxry")
map_icon_scene = ExtResource("2_kvnmq")
grid_width = 4
grid_width = 6
grid_height = 4
[node name="MapCanvas" type="Control" parent="."]
unique_name_in_owner = true
anchors_preset = 0
offset_right = 40.0
offset_bottom = 40.0

View File

@ -11,6 +11,11 @@ func _ready():
# Connect the slider's signal to our custom signal
lever_slider.value_changed.connect(func(value): emit_signal("lever_value_changed", value))
## Describes the signals this panel emits (e.g., "lever_pulled").
func get_output_sockets():
return ["lever_value_changed"]
#func _process(delta):
## Allow keyboard control as well
#var input_value = Input.get_action_strength("thrust_forward") - Input.get_action_strength("thrust_backward")

View File

@ -3,11 +3,10 @@
[ext_resource type="Script" uid="uid://dho2ww3hmmsra" path="res://scenes/ship/computer/UI/throttle_lever_panel.gd" id="1_q6svd"]
[node name="ThrottleLeverPanel" type="VBoxContainer"]
offset_right = 100.0
offset_bottom = 200.0
size_flags_horizontal = 3
size_flags_vertical = 3
offset_right = 63.0
offset_bottom = 35.0
script = ExtResource("1_q6svd")
grid_height = 4
[node name="Label" type="Label" parent="."]
layout_mode = 2

View File

@ -2,13 +2,6 @@
class_name ControlPanel
extends Resource
enum LayoutAnchor {
TOP_LEFT, TOP_RIGHT,
BOTTOM_LEFT, BOTTOM_RIGHT,
CENTER_TOP, CENTER_BOTTOM,
CENTER_LEFT, CENTER_RIGHT
}
## The UI scene for this panel (e.g., a lever, a screen).
@export var ui_scene: PackedScene

View File

@ -0,0 +1,43 @@
class_name DataTypes
extends Node
# TODO: Add comments and export tooltips for these classes so players can understand what they hold
class ThrusterCalibration:
var thruster_data: Dictionary[Thruster, ThrusterData]
var max_pos_torque: float
var max_neg_torque: float
class ThrusterData:
enum ThrusterType {
LINEAR,
ROTATIONAL,
UNCALIBRATED
}
var thruster_node: Thruster
var thruster_type: ThrusterType = ThrusterType.UNCALIBRATED
var measured_torque: float # The rotational force it provides
var measured_thrust: float # The linear force it provides
class ImpulsiveBurnPlan:
var delta_v_magnitude: float
var wait_time: float = 0.0
var burn_duration: float
var desired_rotation_rad: float
class PathProjection:
var body_ref: OrbitalBody2D
var points: Array[PathPoint]
func _init(b: OrbitalBody2D):
body_ref = b
class PathPoint:
var time: float # Time in seconds from the start of the projection
var position: Vector2
var velocity: Vector2
func _init(t: float, p: Vector2, v: Vector2):
time = t
position = p
velocity = v

View File

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

View File

@ -1,14 +1,16 @@
@tool
class_name Databank
extends Resource
extends Node
## The script containing the logic for this shard.
@export var logic_script: Script
var root_module: Module
# --- Initialization ---
func initialize(ship_root: Module):
self.root_module = ship_root
## Describes the functions this shard needs as input.
func get_input_sockets() -> Dictionary:
return {}
func get_input_sockets() -> Array[String]:
return []
## Describes the signals this shard can output.
func get_output_signals() -> Dictionary:
return {}
func get_output_sockets() -> Array[String]:
return []

View File

@ -0,0 +1,274 @@
@tool
extends Container
class_name PanelFrame
# A column is defined as Constants.UI_GRID_SIZE pixels in width
@export var columns = 12
# A row is defined as Constants.UI_GRID_SIZE pixels in height
@export var rows = 6
enum WiringState { IDLE, DRAGGING_WIRE }
var current_state: WiringState = WiringState.IDLE
var current_schematic: WiringSchematic
var active_wire_points: Array[Vector2] = []
var start_socket: Socket
var end_socket: Socket
var wiring_mode: bool = false
var databanks: Array[Databank]
var databanks_container: GridContainer
# --- NO CHANGE HERE ---
# This getter is a nice way to access only the BasePanel children.
var installed_panels: Array[BasePanel]:
get:
installed_panels = []
for child in get_children():
if child is BasePanel:
installed_panels.append(child as BasePanel)
return installed_panels
func _init() -> void:
size = Vector2(columns * Constants.UI_GRID_SIZE, rows * Constants.UI_GRID_SIZE)
# --- NEW FUNCTION ---
# This is a crucial function for all Control nodes, especially containers.
# It tells the layout system the smallest size this container can be.
func _get_minimum_size() -> Vector2:
# The minimum size is simply the grid dimensions multiplied by the pixel size.
return Vector2(columns * Constants.UI_GRID_SIZE, rows * Constants.UI_GRID_SIZE)
func _notification(what: int) -> void:
if what == NOTIFICATION_SORT_CHILDREN:
_sort_children()
func build(panel_scenes: Array[PackedScene], station: SystemStation):
# Instead of manually calling our placement function, we tell Godot
# that the layout needs to be updated. Godot will then call
# _notification(NOTIFICATION_SORT_CHILDREN) for us at the correct time.
var col = 0
var row = 0
#print("STATION: Building panels using autolayout")
for panel_scene in panel_scenes:
if not panel_scene: continue
var panel_instance = panel_scene.instantiate()
if not panel_instance is BasePanel:
panel_instance.queue_free()
continue
var panel: BasePanel = panel_instance as BasePanel
panel.initialize(station)
# Store the grid coordinates on the panel itself. The container will use
# this information when it arranges its children.
if panel.grid_height <= self.rows - row:
panel.placed_in_col = col
panel.placed_in_row = row
add_child(panel)
#print(" - panel %s placed at: Col %s, Row %s" % [panel, col, row])
row += panel.grid_height
else:
var last_panel = get_children()[-1]
col += last_panel.grid_width
row = 0
panel.placed_in_col = col
panel.placed_in_row = row
add_child(panel)
#print(" - panel %s placed at: Col %s, Row %s" % [panel, col, row])
row += panel.grid_height
queue_sort()
# This is the core logic. It positions and sizes every child.
func _sort_children():
#print("PanelFrame Sorting children")
for child in get_children():
if child == databanks_container:
print("Databanks container found %s" % child)
fit_child_in_rect(child, Rect2(Vector2(0, rows * Constants.UI_GRID_SIZE), child.size))
continue
# Skip any nodes that aren't a BasePanel.
if not child is BasePanel:
continue
var panel := child as BasePanel
# Calculate the desired position based on the panel's stored grid coordinates.
var start_pos = Vector2(panel.placed_in_col * Constants.UI_GRID_SIZE, panel.placed_in_row * Constants.UI_GRID_SIZE)
# Calculate the desired size based on the panel's width and height in grid units.
var panel_size = Vector2(panel.grid_width * Constants.UI_GRID_SIZE, panel.grid_height * Constants.UI_GRID_SIZE)
#print(" - %s, Pos %s Size %s" % [panel, start_pos, panel_size])
# This single function tells the container to position AND size the child
# within the given rectangle. The Rect2's origin is the position.
fit_child_in_rect(panel, Rect2(start_pos, panel_size))
# TODO: Expose grid to install panels
func toggle_wiring_mode():
wiring_mode = !wiring_mode
for panel in installed_panels:
panel.set_wiring_mode(wiring_mode)
if wiring_mode:
_build_databanks(databanks)
pass
if is_instance_valid(databanks_container):
if wiring_mode: databanks_container.show()
else: databanks_container.hide()
class InstalledDatabank:
extends Control
var databank_ref: Databank
var all_sockets: Array[Socket] = []
var SocketScene: PackedScene = preload("res://scenes/ship/computer/wiring/socket.tscn")
var inputs_container: VBoxContainer
var outputs_container: VBoxContainer
func _populate_sockets():
all_sockets.clear()
if not is_instance_valid(inputs_container):
inputs_container = VBoxContainer.new()
add_child(inputs_container)
if not is_instance_valid(outputs_container):
outputs_container = VBoxContainer.new()
add_child(outputs_container)
# Populate Input Sockets
for socket_name in databank_ref.get_input_sockets():
var socket = SocketScene.instantiate()
inputs_container.add_child(socket)
socket.initialize(socket_name, Socket.SocketType.INPUT)
all_sockets.append(socket)
# Populate Output Sockets
for socket_name in databank_ref.get_output_sockets():
var socket = SocketScene.instantiate()
outputs_container.add_child(socket)
socket.initialize(socket_name, Socket.SocketType.OUTPUT)
all_sockets.append(socket)
func _build_databanks(dbs_to_install: Array[Databank]):
if not is_instance_valid(databanks_container):
databanks_container = GridContainer.new()
databanks_container.columns = columns
databanks_container.add_theme_constant_override("h_separation", Constants.UI_GRID_SIZE * 3)
databanks_container.add_theme_constant_override("v_separation", Constants.UI_GRID_SIZE + 16)
add_child(databanks_container)
var installed_databanks = databanks_container.get_children()
for to_install in dbs_to_install:
if installed_databanks.any(func(existing_db): return existing_db.databank_ref == to_install):
continue
var installed_databank = InstalledDatabank.new()
installed_databank.databank_ref = to_install
databanks_container.add_child(installed_databank)
installed_databank._populate_sockets()
func _gui_input(event: InputEvent):
if event is InputEventMouseButton:
# --- Start or End a Wire ---
if event.button_index == MOUSE_BUTTON_LEFT:
if event.is_pressed():
var socket = _get_socket_at_pos(event.position)
if socket:
current_state = WiringState.DRAGGING_WIRE
if not start_socket:
# start new wire
start_socket = socket
# Add start point to wire points
active_wire_points.append(start_socket.icon.get_global_rect().get_center() - get_global_position())
elif start_socket and socket.socket_type != start_socket.socket_type:
end_socket = socket
_save_new_connection()
_reset_wiring_state()
elif current_state == WiringState.DRAGGING_WIRE:
# Add intermediate point
active_wire_points.append(get_local_mouse_position())
elif event.button_index == MOUSE_BUTTON_RIGHT:
# Pop Last Point
active_wire_points.remove_at(active_wire_points.size() - 1)
# Check if wire points are empty, then we remove the whole wire
if active_wire_points.size() <= 0:
_reset_wiring_state()
if event is InputEventMouseMotion and current_state == WiringState.DRAGGING_WIRE:
queue_redraw()
func _draw():
# 1. Draw all saved wires from the schematic.
if current_schematic:
for connection in current_schematic.connections:
if connection.path_points.size() > 1:
_draw_wire_path(connection.path_points, Color.GREEN)
# 2. Draw the active wire being dragged by the user.
if current_state == WiringState.DRAGGING_WIRE:
var live_path: Array[Vector2] = active_wire_points.duplicate()
live_path.append(get_local_mouse_position())
if live_path.size() > 1:
_draw_wire_path(live_path, Color.YELLOW)
# --- NEW: Helper function to draw a multi-point path ---
func _draw_wire_path(points: Array[Vector2], color: Color):
for i in range(points.size() - 1):
var p1 = points[i]
var p2 = points[i+1]
# var control_offset = Vector2(abs(p2.x - p1.x) * 0.5, 0)
draw_line(p1, p2, color, 3.0)
func _save_new_connection():
var new_connection = WireConnection.new()
if start_socket.socket_type == end_socket.socket_type:
push_error("Start socket and end socket of same type!")
return
if start_socket.socket_type == Socket.SocketType.INPUT:
new_connection.input_socket_name = start_socket.socket_name
elif start_socket.socket_type == Socket.SocketType.OUTPUT:
new_connection.output_socket_name = start_socket.socket_name
if end_socket.socket_type == Socket.SocketType.INPUT:
new_connection.input_socket_name = end_socket.socket_name
elif end_socket.socket_type == Socket.SocketType.OUTPUT:
new_connection.output_socket_name = end_socket.socket_name
var end_pos = end_socket.icon.get_global_rect().get_center() - get_global_position()
active_wire_points.append(end_pos)
new_connection.path_points = active_wire_points
if not current_schematic:
current_schematic = WiringSchematic.new()
current_schematic.connections.append(new_connection)
print("Connection saved!")
func _reset_wiring_state():
current_state = WiringState.IDLE
start_socket = null
end_socket = null
active_wire_points.clear()
queue_redraw()
func _get_socket_at_pos(global_pos: Vector2) -> Socket:
for panel in installed_panels:
for socket in panel.all_sockets:
if is_instance_valid(socket) and socket.icon.get_global_rect().has_point(global_pos):
return socket
return null

View File

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

View File

@ -1,88 +0,0 @@
# scripts/panel_world.gd
class_name PanelFrame
extends Node
# This PanelWorld owns a separate world just for its UI panels.
var ui_world: World2D = World2D.new()
# The root node for all panels within the UI world.
var panel_root: Control
var grid_container: GridContainer
func _ready():
# We need a SubViewport to host our separate world and keep it running.
var viewport = SubViewport.new()
viewport.world_2d = ui_world
add_child(viewport)
# Create the root node for the panels inside the new world.
panel_root = Control.new()
panel_root.name = "PanelRoot"
# We add the panel_root to the scene tree via the viewport to make it active.
viewport.add_child(panel_root)
## The station calls this to populate the world with its panels.
func populate_panels(panel_scenes: Array[PackedScene]):
if not is_instance_valid(panel_root): return
# Add the GridContainer to the panel_root to manage the layout.
grid_container = GridContainer.new()
panel_root.add_child(grid_container)
for panel_scene in panel_scenes:
if not panel_scene: continue
var panel_instance = panel_scene.instantiate()
grid_container.add_child(panel_instance)
# Any other setup for the panels would go here.
## The station calls this to get a view for a player.
func create_window_view() -> Window:
# Create a draggable Window for the player's UI.
var window = Window.new()
window.title = get_parent().name # Title the window after the station
window.unresizable = true
window.size = grid_container.size # Give it a default minimum size
window.close_requested.connect(window.queue_free)
# Create the SubViewport setup to render the view.
var vp_container = SubViewportContainer.new()
window.add_child(vp_container)
vp_container.stretch = true
vp_container.anchors_preset = Control.LayoutPreset.PRESET_FULL_RECT
vp_container.size = grid_container.size
var sub_viewport = SubViewport.new()
sub_viewport.world_2d = ui_world # IMPORTANT: Point to our private UI world
sub_viewport.size = Vector2(400,300)
sub_viewport.transparent_bg = true
vp_container.add_child(sub_viewport)
# Wait for the next idle frame. By this time, the GridContainer will have
# calculated its size based on the panels added to it.
await get_tree().process_frame
# Create a camera for this specific view.
var camera = Camera2D.new()
# Center the camera on the panel layout.
#camera.position = panel_root.size / 2
# 2. Calculate the correct zoom to fit all the panels in the view.
var bounds = panel_root.get_rect()
var zoom_x = bounds.size.x / sub_viewport.size.x
var zoom_y = bounds.size.y / sub_viewport.size.y
# 2. Set the OFFSET to be the center of the panel grid.
# This shifts the camera's VIEW to be centered on the panels.
sub_viewport.add_child(camera)
camera.offset = grid_container.size
# Center the camera on the grid of panels.
#camera.position = panel_root.size / 2.0
# Use the larger zoom factor to ensure everything fits, and add a 10% margin.
#camera.zoom = Vector2.ONE * max(zoom_x, zoom_y) * 1.1
# Forward input from the screen overlay to the world-space panel.
vp_container.gui_input.connect(func(event): sub_viewport.push_input(event.duplicate()))
return window

View File

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

View File

@ -1,39 +1,50 @@
# scenes/ship/computer/shards/autopilot_databank.gd
extends Node
extends Databank
class_name AutopilotShard
signal execution_state_changed(is_executing: bool, status: String)
signal fmt_out(text: String)
signal request_attitude_hold(b: bool)
signal request_rotation(r: float)
signal request_rotation_thrust(r: float)
signal request_main_engine_thrust(t: float)
# --- References ---
var root_module: Module
var helm_shard: HelmLogicShard # This will be wired up by the station
## Describes the functions this shard needs as input.
func get_input_sockets() -> Array[String]:
return ["maneuver_received", "execute_plan", "set_thruster_calibration"]
## Describes the signals this shard can output.
func get_output_sockets() -> Array[String]:
return ["execution_state_changed", "fmt_out", "request_attitude_hold", "request_rotation", "request_rotation_thrust", "request_main_engine_thrust"]
# --- State ---
enum State { IDLE, WAITING_FOR_WINDOW, EXECUTING_BURN }
var current_state: State = State.IDLE
var current_plan: Array = []
func initialize(ship_root: Module):
self.root_module = ship_root
var current_plan: Array[DataTypes.ImpulsiveBurnPlan] = []
var max_rot_time: float = 30.0
var RCS_calibration: DataTypes.ThrusterCalibration
var is_executing: bool = false
var status: String = ""
var current_timer: SceneTreeTimer
func _process(delta):
if current_state == State.WAITING_FOR_WINDOW and not current_plan.is_empty():
var next_burn = current_plan[0]
next_burn.wait_time -= delta
var fmt = ""
var state_name = State.keys()[current_state]
if current_timer and current_timer.time_left:
var time_str = "%d:%02d" % [int(current_timer.time_left) / 60, int(current_timer.time_left) % 60]
var interpolated_status = status % time_str
fmt = "Autopilot: %s\n%s" % [state_name, interpolated_status]
else:
fmt = "Autopilot: %s\n%s" % [state_name, status]
var time_str = "%d:%02d" % [int(next_burn.wait_time) / 60, int(next_burn.wait_time) % 60]
emit_signal("execution_state_changed", true, "Waiting for burn window: T- %s" % time_str)
fmt_out.emit(fmt)
# TODO: Add logic to command rotation from Helm shard
if next_burn.wait_time <= 0:
_execute_next_burn()
# INPUT SOCKET: Connected to the ManeuverPlanner's "maneuver_calculated" signal.
func on_maneuver_received(plan: Array):
func maneuver_received(plan: Array[DataTypes.ImpulsiveBurnPlan]):
current_plan = plan
print("AUTOPILOT: Maneuver plan received.")
emit_signal("execution_state_changed", false, "Plan Received. Press Execute.")
status = "Plan Received.\nPress Execute."
# In a UI, this would enable the "Execute" button.
# UI ACTION: An "Execute" button on a panel would call this.
@ -41,28 +52,119 @@ func execute_plan():
if not current_plan.is_empty():
current_state = State.WAITING_FOR_WINDOW
print("AUTOPILOT: Executing plan. Waiting for first burn window.")
func _execute_next_burn():
if current_plan.is_empty():
current_state = State.IDLE
emit_signal("execution_state_changed", false, "Maneuver complete.")
return
for step in current_plan:
status = "Performing Rotation: T- %f" % rad_to_deg(step.desired_rotation_rad)
var time_elapsed: float = await _execute_autopilot_rotation(step)
current_timer = get_tree().create_timer(step.wait_time - time_elapsed)
status = "Waiting for burn window: T- %s"
await current_timer.timeout
await _execute_next_burn(step)
func set_thruster_calibration(data: DataTypes.ThrusterCalibration):
RCS_calibration = data
# --- PROCESSS FUNCTIONS: Functions being run to execute the steps of a planned transfer ---
func _execute_next_burn(step: DataTypes.ImpulsiveBurnPlan):
current_state = State.EXECUTING_BURN
var burn = current_plan.pop_front()
emit_signal("execution_state_changed", true, "Executing Burn 1...")
print("AUTOPILOT: Commanding main engine burn for %.2f seconds." % burn.burn_duration)
status = "Executing Main Engine Burn: %s"
print("AUTOPILOT: Commanding main engine burn for %.2f seconds." % step.burn_duration)
# Command the helm to fire the main engine
if is_instance_valid(helm_shard):
helm_shard.set_throttle_input(1.0)
await get_tree().create_timer(burn.burn_duration).timeout
helm_shard.set_throttle_input(0.0)
request_main_engine_thrust.emit(1.0)
current_timer = get_tree().create_timer(step.burn_duration)
await current_timer.timeout
request_main_engine_thrust.emit(0.0)
# Transition to the next state
if not current_plan.is_empty():
current_state = State.WAITING_FOR_WINDOW
else:
current_state = State.IDLE
emit_signal("execution_state_changed", false, "Maneuver complete.")
execution_state_changed.emit(false, "Maneuver complete.")
# --- AUTOPILOT "BANG-COAST-BANG" LOGIC (REFACTORED) ---
func _execute_autopilot_rotation(step: DataTypes.ImpulsiveBurnPlan) -> float:
var time_window = minf(step.wait_time, max_rot_time)
var angle_to_turn = shortest_angle_between(root_module.rotation, step.desired_rotation_rad)
var init_time = Time.get_ticks_msec()
if abs(angle_to_turn) < 0.01:
request_rotation.emit(step.desired_rotation_rad)
request_attitude_hold.emit(true)
return 0.0
# --- Get the specific torque values for each phase ---
var accel_torque = RCS_calibration.max_pos_torque if angle_to_turn > 0 else RCS_calibration.max_neg_torque
var decel_torque = RCS_calibration.max_neg_torque if angle_to_turn > 0 else RCS_calibration.max_pos_torque
if accel_torque == 0 or decel_torque == 0:
print("AUTOPILOT ERROR: Missing thrusters for a full rotation.")
return 0.0
print(" - Performing rotation.")
# --- Asymmetrical Burn Calculation ---
# This is a more complex kinematic problem. We solve for the peak velocity and individual times.
var accel_angular_accel = accel_torque / root_module.inertia
var decel_angular_accel = decel_torque / root_module.inertia
# Solve for peak angular velocity (ω_peak) and times (t1, t2)
var peak_angular_velocity = (2 * angle_to_turn * accel_angular_accel * decel_angular_accel) / (accel_angular_accel + decel_angular_accel)
peak_angular_velocity = sqrt(abs(peak_angular_velocity)) * sign(angle_to_turn)
var accel_burn_time = abs(peak_angular_velocity / accel_angular_accel)
var decel_burn_time = abs(peak_angular_velocity / decel_angular_accel)
var total_maneuver_time = accel_burn_time + decel_burn_time
if total_maneuver_time > time_window:
print("AUTOPILOT WARNING: Maneuver is impossible in the given time window. Performing max-power turn.")
# Fallback to a simple 50/50 burn if time is too short.
accel_burn_time = time_window / 2.0
decel_burn_time = time_window / 2.0
# No coast time in this simplified model, but it could be added back with more complex math.
print(" - Asymmetrical Rotation Plan: Accel Burn %.2fs, Decel Burn %.2fs" % [accel_burn_time, decel_burn_time])
# --- Execute Maneuver ---
# ACCELERATION BURN
request_rotation_thrust.emit(sign(angle_to_turn))
await get_tree().create_timer(accel_burn_time).timeout
# DECELERATION BURN
print(" - Rotation acceleration complete, executing deceleration burn.")
request_rotation_thrust.emit(sign(-angle_to_turn))
await get_tree().create_timer(decel_burn_time).timeout
print(" - Rotation de-acceleration complete, executing deceleration burn.")
request_rotation.emit(step.desired_rotation_rad)
request_attitude_hold.emit(true)
print("AUTOPILOT: Rotation maneuver complete.")
return init_time - Time.get_ticks_msec()
# --- HELPERS ---
# Calculates the shortest angle between two angles (in radians).
# The result will be between -PI and +PI. The sign indicates the direction.
func shortest_angle_between(from_angle: float, to_angle: float) -> float:
var difference = fposmod(to_angle - from_angle, TAU)
if difference > PI:
return difference - TAU
else:
return difference

View File

@ -1,6 +1,6 @@
[gd_resource type="Resource" script_class="Databank" load_steps=3 format=3 uid="uid://dwhpjwuobcqdu"]
[ext_resource type="Script" uid="uid://ceqdi6jobefnc" path="res://scenes/ship/computer/shards/helm_autopilot_databank.gd" id="1_0abvf"]
[ext_resource type="Script" path="res://scenes/ship/computer/shards/helm_autopilot_databank.gd" id="1_0abvf"]
[ext_resource type="Script" uid="uid://osk1l75vlikn" path="res://scenes/ship/computer/databank.gd" id="1_tpm1x"]
[resource]

View File

@ -1,22 +1,26 @@
extends Node
extends Databank
class_name HelmLogicShard
# --- References ---
var root_module: Module
var thrusters: Array[Thruster] = []
# --- Ship Performance Data (calibrated) ---
var max_positive_torque: float = 1.0
var max_negative_torque: float = 1.0
@onready var thrusters: Array[Thruster] = []
# --- PD Controller Constants ---
@export var HOLD_KP: float = 8000.0 # Proportional gain
@export var HOLD_KD: float = 1200.0 # Derivative gain
var target_rotation_rad: float = 0.0
@onready var target_rotation_rad: float = 0.0
var attitude_hold_enabled: bool = false
var thruster_calibration_data: DataTypes.ThrusterCalibration
## Describes the functions this shard needs as input.
func get_input_sockets() -> Array[String]:
return ["shutdown_rcs", "calibrate_rcs_performance", "set_throttle_input", "set_rotation_input", "set_desired_rotation", "set_attitude_hold"]
## Describes the signals this shard can output.
func get_output_sockets() -> Array[String]:
return ["thruster_calibrated"]
# The Station calls this after instantiating the shard.
func initialize(ship_root: Module):
self.root_module = ship_root
@ -30,7 +34,6 @@ func initialize(ship_root: Module):
# Default to holding the initial attitude.
target_rotation_rad = root_module.rotation
func _physics_process(_delta):
if not is_instance_valid(root_module): return
@ -46,7 +49,7 @@ func set_rotation_input(value: float):
if abs(value) > 0.1:
# Manual input overrides attitude hold.
attitude_hold_enabled = false
var desired_torque = (max_positive_torque if value > 0 else max_negative_torque) * value
var desired_torque = (calibration_data.max_pos_torque if value > 0 else calibration_data.max_neg_torque) * value
apply_rotational_thrust(desired_torque)
else:
# When input stops, re-engage hold at the current rotation.
@ -58,21 +61,37 @@ func set_rotation_input(value: float):
## It takes a value from 0.0 to 1.0.
# --- REFACTORED: This is the key change ---
func set_throttle_input(value: float):
print("THRUSTER CONTROLLER: Throttle input recieved: %f.1" % value)
# This function now works with the simple on/off thrusters.
for thruster in thrusters:
if thruster.main_thruster:
if not calibration_data:
print("THRUSTER CONTROLLER: No Calibration Data Found")
return
for thruster in calibration_data.thruster_data:
var thruster_data: DataTypes.ThrusterData = calibration_data.thruster_data[thruster]
if thruster_data.thruster_type == DataTypes.ThrusterData.ThrusterType.LINEAR:
print(" - Main thruster identified with thrust capacity: %f" % thruster_data.measured_thrust)
if value > 0.1:
print(" - Main Engine Activated")
thruster.turn_on()
else:
print(" - Main Engine Shut Off")
thruster.turn_off()
func set_desired_rotation(r: float):
target_rotation_rad = r
func set_attitude_hold(hold: bool):
attitude_hold_enabled = hold
# --- LOGIC (Migrated from ThrusterController.gd) ---
func _perform_manual_hold():
var error = shortest_angle_between(root_module.rotation, target_rotation_rad)
var desired_torque = (error * HOLD_KP) - (root_module.angular_velocity * HOLD_KD)
apply_rotational_thrust(desired_torque)
if abs(error) > 0.001:
var desired_torque = (error * HOLD_KP) - (root_module.angular_velocity * HOLD_KD)
apply_rotational_thrust(desired_torque)
else: apply_rotational_thrust(0.0)
# --- REFACTORED: This is the other key change ---
func apply_rotational_thrust(desired_torque: float):
@ -80,15 +99,16 @@ func apply_rotational_thrust(desired_torque: float):
return
# Iterate through all available RCS thrusters that have been calibrated
for thruster in thruster_data_map:
var thruster_data: ThrusterData = thruster_data_map[thruster]
for thruster in calibration_data.thruster_data:
var thruster_data: DataTypes.ThrusterData = calibration_data.thruster_data[thruster]
# If this thruster can help apply the desired torque, turn it on.
# Otherwise, explicitly turn it off to ensure it's not firing incorrectly.
if sign(thruster_data.measured_torque) == sign(desired_torque) and desired_torque != 0:
thruster.turn_on()
else:
thruster.turn_off()
if thruster_data.thruster_type == DataTypes.ThrusterData.ThrusterType.ROTATIONAL:
# If this thruster can help apply the desired torque, turn it on.
# Otherwise, explicitly turn it off to ensure it's not firing incorrectly.
if sign(thruster_data.measured_torque) == sign(desired_torque) and desired_torque != 0:
thruster.turn_on()
else:
thruster.turn_off()
func shutdown_rcs():
for thruster in thrusters:
@ -106,6 +126,7 @@ func _find_all_thrusters(node: Node) -> Array[Thruster]:
return thrusters
# Angle difference in rad
func shortest_angle_between(from_angle: float, to_angle: float) -> float:
var difference = fposmod(to_angle - from_angle, TAU)
if difference > PI:
@ -113,15 +134,11 @@ func shortest_angle_between(from_angle: float, to_angle: float) -> float:
else:
return difference
signal thruster_calibrated(data: DataTypes.ThrusterCalibration)
var thruster_data_map: Dictionary = {}
var calibration_data: DataTypes.ThrusterCalibration
# --- CALIBRATION LOGIC (Migrated from ThrusterController.gd) ---
# Inner class to store calibrated data for each thruster
class ThrusterData:
var thruster_node: Thruster
var measured_torque: float # The rotational force it provides
## Manages the calibration sequence for all non-main thrusters.
func calibrate_rcs_performance():
@ -129,7 +146,7 @@ func calibrate_rcs_performance():
if not is_instance_valid(root_module): return
# --- THE FIX: Disable attitude hold during calibration ---
# --- Disable attitude hold during calibration ---
var original_attitude_hold_state = attitude_hold_enabled
attitude_hold_enabled = false
shutdown_rcs() # Ensure all thrusters are off before we start
@ -137,30 +154,27 @@ func calibrate_rcs_performance():
print("Helm Shard: Attitude hold protocol: %s" % ("enabled" if attitude_hold_enabled else "disabled"))
thruster_data_map.clear()
calibration_data = DataTypes.ThrusterCalibration.new()
for thruster in thrusters:
if thruster.main_thruster: continue # Skip main engines
var data: DataTypes.ThrusterData = await _calibrate_single_thruster(thruster)
calibration_data.thruster_data[thruster] = data
var data: ThrusterData = await _calibrate_single_thruster(thruster)
thruster_data_map[thruster] = data
print(" - Calibrated %s: Torque(%.3f)" % [thruster.name, data.measured_torque])
print(thruster_data_map)
print(calibration_data)
# Now that we have the data, calculate the ship's max torque values
max_positive_torque = 0.0
max_negative_torque = 0.0
for data in thruster_data_map.values():
calibration_data.max_pos_torque = 0.0
calibration_data.max_neg_torque = 0.0
for data in calibration_data.thruster_data.values():
if data.measured_torque > 0:
max_positive_torque += data.measured_torque
calibration_data.max_pos_torque += data.measured_torque
else:
max_negative_torque += abs(data.measured_torque)
calibration_data.max_neg_torque += abs(data.measured_torque)
print("RCS Calibration Complete: Max Pos Torque: %.2f, Max Neg Torque: %.2f" % [max_positive_torque, max_negative_torque])
print("RCS Calibration Complete: Max Pos Torque: %.2f, Max Neg Torque: %.2f" % [calibration_data.max_pos_torque, calibration_data.max_neg_torque])
# Auto-tune the PD controller with the new values
if max_positive_torque > 0 and max_negative_torque > 0:
var average_max_torque = (max_positive_torque + max_negative_torque) / 2.0
if calibration_data.max_pos_torque > 0 and calibration_data.max_neg_torque > 0:
var average_max_torque = (calibration_data.max_pos_torque + calibration_data.max_neg_torque) / 2.0
HOLD_KP = average_max_torque * 0.1
HOLD_KD = HOLD_KP * 1 # You can tune this multiplier
print("PD Controller Auto-Tuned: Kp set to %.2f, Kd set to %.2f" % [HOLD_KP, HOLD_KD])
@ -168,13 +182,17 @@ func calibrate_rcs_performance():
attitude_hold_enabled = original_attitude_hold_state
print("Helm Shard: Calibration complete. Attitude hold is now %s." % ("enabled" if attitude_hold_enabled else "disabled"))
thruster_calibration_data = calibration_data
thruster_calibrated.emit(calibration_data)
## Performs a test fire of a single thruster and measures the resulting change in angular velocity.
func _calibrate_single_thruster(thruster: Thruster) -> ThrusterData:
var data = ThrusterData.new()
func _calibrate_single_thruster(thruster: Thruster) -> DataTypes.ThrusterData:
var data = DataTypes.ThrusterData.new()
data.thruster_node = thruster
# Prepare for test: save initial state
var initial_angular_velocity = root_module.angular_velocity
var initial_linear_velocity = root_module.linear_velocity
var test_burn_duration = 0.5 # A very short burst
@ -188,6 +206,10 @@ func _calibrate_single_thruster(thruster: Thruster) -> ThrusterData:
# --- Measure Results ---
var delta_angular_velocity = root_module.angular_velocity - initial_angular_velocity
var delta_linear_velocity = root_module.linear_velocity - initial_linear_velocity
data.measured_torque = 0.0
data.measured_thrust = 0.0
# --- Calculate Performance ---
# Torque = inertia * angular_acceleration (alpha = dw/dt)
@ -196,7 +218,21 @@ func _calibrate_single_thruster(thruster: Thruster) -> ThrusterData:
else:
data.measured_torque = 0.0
push_warning("Root module inertia is 0. Cannot calibrate torque.")
if root_module.mass > 0:
data.measured_thrust = root_module.mass * (delta_linear_velocity.length() / test_burn_duration)
else:
data.measured_thrust = 0.0
push_warning("Root module mass is 0. Cannot calibrate torque.")
if data.measured_thrust > abs(data.measured_torque):
print(" - Calibrated %s: Linear(%.3f)" % [thruster.name, data.measured_thrust])
data.thruster_type = DataTypes.ThrusterData.ThrusterType.LINEAR
elif data.measured_thrust < abs(data.measured_torque):
print(" - Calibrated %s: Torque(%.3f)" % [thruster.name, data.measured_torque])
data.thruster_type = DataTypes.ThrusterData.ThrusterType.ROTATIONAL
# --- Cleanup: Counter the spin from the test fire ---
if abs(data.measured_torque) > 0.001:
var counter_torque = -data.measured_torque
@ -204,7 +240,7 @@ func _calibrate_single_thruster(thruster: Thruster) -> ThrusterData:
# Find a thruster that can apply the counter-torque
for other_thruster in thrusters:
var other_data = thruster_data_map.get(other_thruster)
var other_data = calibration_data.thruster_data.get(other_thruster)
if other_data and sign(other_data.measured_torque) == sign(counter_torque):
other_thruster.turn_on()
await get_tree().create_timer(abs(counter_burn_duration)).timeout
@ -212,6 +248,5 @@ func _calibrate_single_thruster(thruster: Thruster) -> ThrusterData:
break # Use the first one we find
await get_tree().physics_frame
root_module.angular_velocity = 0 # Final reset for safety
return data

View File

@ -1,16 +1,22 @@
extends Node
extends Databank
class_name ShipStatusShard
## This shard emits a signal with the formatted ship status text.
signal status_updated(text: String)
var root_module: Module
# Called by the Station when it's created.
func initialize(ship_root: Module):
self.root_module = ship_root
## Describes the functions this shard needs as input.
func get_input_sockets() -> Array[String]:
return []
## Describes the signals this shard can output.
func get_output_sockets() -> Array[String]:
return ["status_updated"]
func _physics_process(delta):
if not is_instance_valid(root_module):
return
@ -23,7 +29,7 @@ func _physics_process(delta):
var status_text = """
[font_size=24]Ship Status[/font_size]
[font_size=18]Rotation: %.1f deg[/font_size]
[font_size=18]Ang. Vel.: %.3f deg/s[/font_size]
[font_size=18]Ang. Vel.: %.2f deg/s[/font_size]
[font_size=18]Velocity: %.2f m/s[/font_size]
""" % [rotation_deg, angular_vel_dps, linear_vel_mps]

View File

@ -0,0 +1,69 @@
# space_simulation/scenes/ship/computer/shards/nav_brachistochrone_planner.gd
extends Databank
class_name BrachistochronePlannerShard
## Emitted when a maneuver plan has been successfully calculated.
signal maneuver_calculated(plan: Array[DataTypes.ImpulsiveBurnPlan])
# --- References ---
var target_body: OrbitalBody2D = null
## Describes the functions this shard needs as input.
func get_input_sockets() -> Array[String]:
return ["target_updated", "calculate_hohmann_transfer"]
## Describes the signals this shard can output.
func get_output_sockets() -> Array[String]:
return ["maneuver_calculated"]
# INPUT SOCKET: Connected to the NavSelectionShard's "target_selected" signal.
func target_updated(new_target: OrbitalBody2D):
print("BRACHISTOCHRONE PLANNER: Target received %s." % new_target.name)
target_body = new_target
# TODO: All positions and velocities for calculating should be gathered from a sensor databank
# UI ACTION: A panel button would call this function.
func calculate_brachistochrone_transfer():
if not is_instance_valid(root_module) or not is_instance_valid(target_body):
print("BRACHISTOCHRONE PLANNER: Cannot calculate without ship and target.")
return
# 1. Get total main engine thrust from all thruster components
# TODO: This should be gathered from a calibration shard
var main_engine_thrust = 0.0
for component in root_module.get_components():
if component is Thruster and component.main_thruster:
main_engine_thrust += component.max_thrust
if main_engine_thrust == 0.0 or root_module.mass == 0.0:
print("BRACHISTOCHRONE PLANNER: Ship has no main engine thrust or mass.")
return
var acceleration = main_engine_thrust / root_module.mass
var distance = root_module.global_position.distance_to(target_body.global_position)
# Using the kinematic equation: d = (1/2)at^2, solved for t: t = sqrt(2d/a)
# Since we accelerate for half the distance and decelerate for the other half:
var time_for_half_journey = sqrt(distance / acceleration)
# --- Assemble the plan as two ImpulsiveBurnPlan steps ---
var plan: Array[DataTypes.ImpulsiveBurnPlan] = []
# --- Step 1: Acceleration Burn ---
var accel_burn = DataTypes.ImpulsiveBurnPlan.new()
accel_burn.wait_time = 0 # Start immediately
accel_burn.burn_duration = time_for_half_journey
# The desired rotation is the direction vector from ship to target
accel_burn.desired_rotation_rad = root_module.global_position.direction_to(target_body.global_position).angle() + (PI / 2.0)
plan.append(accel_burn)
# --- Step 2: Deceleration Burn (The flip is handled by the autopilot between steps) ---
var decel_burn = DataTypes.ImpulsiveBurnPlan.new()
decel_burn.wait_time = 0 # No coasting period
decel_burn.burn_duration = time_for_half_journey
# The desired rotation is opposite the first burn
decel_burn.desired_rotation_rad = accel_burn.desired_rotation_rad + PI
plan.append(decel_burn)
print("BRACHISTOCHRONE PLANNER: Plan calculated. Total time: %.2f s" % (time_for_half_journey * 2.0))
maneuver_calculated.emit(plan)

View File

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

View File

@ -0,0 +1,9 @@
[gd_resource type="Resource" script_class="Databank" load_steps=3 format=3 uid="uid://bnyce8i208qby"]
[ext_resource type="Script" uid="uid://ghluwjd5c5ul" path="res://scenes/ship/computer/shards/nav_brachistochrone_planner.gd" id="1_asajk"]
[ext_resource type="Script" uid="uid://osk1l75vlikn" path="res://scenes/ship/computer/databank.gd" id="1_xdqj8"]
[resource]
script = ExtResource("1_xdqj8")
logic_script = ExtResource("1_asajk")
metadata/_custom_type_script = "uid://osk1l75vlikn"

View File

@ -1,32 +1,35 @@
# scenes/ship/computer/shards/maneuver_planner_databank.gd
extends Node
extends Databank
class_name HohmanPlannerShard
## Emitted when a maneuver plan has been successfully calculated.
signal maneuver_calculated(plan: Array)
signal maneuver_calculated(plan: Array[DataTypes.ImpulsiveBurnPlan])
# --- References ---
var root_module: Module
var selection_shard: NavSelectionShard
var target_body: OrbitalBody2D = null
# --- Inner class to hold maneuver data ---
class ImpulsiveBurn:
var delta_v_magnitude: float
var wait_time: float = 0.0
var burn_duration: float
var desired_rotation_rad: float
# --- Configurations ---
var boost_factor: float = 1.0
func initialize(ship_root: Module):
self.root_module = ship_root
## Describes the functions this shard needs as input.
func get_input_sockets() -> Array[String]:
return ["target_updated", "calculate_hohmann_transfer"]
## Describes the signals this shard can output.
func get_output_sockets() -> Array[String]:
return ["maneuver_calculated"]
# INPUT SOCKET: Connected to the NavSelectionShard's "target_selected" signal.
func on_target_updated(new_target: OrbitalBody2D):
func target_updated(new_target: OrbitalBody2D):
print("MANEUVER PLANNER: Target recieved %s." % new_target)
target_body = new_target
calculate_hohmann_transfer()
# In a UI, this would enable the "Calculate" button.
func set_boost_factor(value: float):
boost_factor = value
# UI ACTION: A panel button would call this function.
func calculate_hohmann_transfer():
if not is_instance_valid(root_module) or not is_instance_valid(target_body):
@ -36,11 +39,10 @@ func calculate_hohmann_transfer():
var star = GameManager.current_star_system.get_star()
if not is_instance_valid(star): return
var ship = root_module
var mu = OrbitalMechanics.G * star.mass
var r1 = ship.global_position.distance_to(star.global_position)
var r1 = root_module.global_position.distance_to(star.global_position)
var r2 = target_body.global_position.distance_to(star.global_position)
var a_transfer = (r1 + r2) / 2.0
var a_transfer = (r1 + r2) / 2.0 * boost_factor
var v_source_orbit = sqrt(mu / r1)
var v_target_orbit = sqrt(mu / r2)
@ -57,7 +59,7 @@ func calculate_hohmann_transfer():
var travel_angle = ang_vel_target * time_of_flight
var required_phase_angle = PI - travel_angle
var vec_to_ship = (ship.global_position - star.global_position).normalized()
var vec_to_ship = (root_module.global_position - star.global_position).normalized()
var vec_to_target = (target_body.global_position - star.global_position).normalized()
var current_phase_angle = vec_to_ship.angle_to(vec_to_target)
@ -67,27 +69,82 @@ func calculate_hohmann_transfer():
if relative_ang_vel == 0: return # Avoid division by zero
var wait_time = abs(angle_to_wait / relative_ang_vel)
var main_engine_thrust = 0.0 # TODO: Need a way to get this from the Helm shard
# TODO: Need a way to get this from a shared calibration databank shard
var main_engine_thrust = 0.0
for thruster in root_module.get_components():
if thruster is Thruster and thruster.main_thruster:
main_engine_thrust += thruster.max_thrust
if main_engine_thrust == 0: return
var acceleration = main_engine_thrust / ship.mass
var burn_duration1 = delta_v1 / acceleration
var burn_duration2 = delta_v2 / acceleration
var acceleration = main_engine_thrust / root_module.mass
var plan = []
var burn1 = ImpulsiveBurn.new()
burn1.delta_v_magnitude = delta_v1
# --- Use the absolute value of delta_v for burn duration ---
var burn_duration1 = abs(delta_v1) / acceleration
var burn_duration2 = abs(delta_v2) / acceleration
# --- NEW: Predict the ship's state at the time of the burn ---
var predicted_state = _predict_state_after_coast(root_module, star, wait_time)
var predicted_velocity_vec = predicted_state["velocity"]
var plan: Array[DataTypes.ImpulsiveBurnPlan] = []
var burn1 = DataTypes.ImpulsiveBurnPlan.new()
burn1.delta_v_magnitude = abs(delta_v1)
burn1.wait_time = wait_time
burn1.burn_duration = burn_duration1
burn1.desired_rotation_rad = ship.linear_velocity.angle()
# --- Determine rotation based on the sign of delta_v ---
# Prograde (speeding up) or retrograde (slowing down)
var prograde_direction = predicted_velocity_vec.angle()
burn1.desired_rotation_rad = prograde_direction if delta_v1 >= 0 else prograde_direction + PI
plan.append(burn1)
var burn2 = ImpulsiveBurn.new()
# ... (burn 2 setup would go here)
var burn2 = DataTypes.ImpulsiveBurnPlan.new()
burn2.delta_v_magnitude = delta_v2
burn2.wait_time = time_of_flight - burn_duration1
burn2.burn_duration = burn_duration2
# --- Determine rotation for the second burn ---
var target_prograde_direction = (target_body.global_position - star.global_position).orthogonal().angle()
burn2.desired_rotation_rad = target_prograde_direction if delta_v2 >= 0 else target_prograde_direction + PI
plan.append(burn2)
print("Hohmann Plan:")
print(" - Wait: %d s" % wait_time)
print(" - Burn 1: %.1f m/s (%.1f s)" % [delta_v1, burn_duration1])
print(" - Flight time: %d s" % time_of_flight)
print(" - Burn 2: %.1f m/s (%.1f s)" % [delta_v2, burn_duration2])
print("MANEUVER PLANNER: Hohmann transfer calculated. Emitting plan.")
emit_signal("maneuver_calculated", plan)
maneuver_calculated.emit(plan)
# Simulates the ship's 2-body orbit around the star to predict its future state.
func _predict_state_after_coast(body_to_trace: OrbitalBody2D, primary: OrbitalBody2D, time: float) -> Dictionary:
# --- Simulation Parameters ---
var time_step = 1.0 # Simulate in 1-second increments
var num_steps = int(ceil(time / time_step))
# --- Initial State (relative to the primary) ---
var ghost_relative_pos = body_to_trace.global_position - primary.global_position
var ghost_relative_vel = body_to_trace.linear_velocity - primary.linear_velocity
var mu = OrbitalMechanics.G * primary.mass
for i in range(num_steps):
# --- Physics Calculation ---
var distance_sq = ghost_relative_pos.length_squared()
if distance_sq < 1.0: break
var direction = -ghost_relative_pos.normalized()
var force_magnitude = mu / distance_sq # Simplified F = mu*m/r^2 and a=F/m
var acceleration = direction * force_magnitude
# --- Integration (Euler method) ---
ghost_relative_vel += acceleration * time_step
ghost_relative_pos += ghost_relative_vel * time_step
# --- Return the final state, converted back to global space ---
return {
"position": ghost_relative_pos + primary.global_position,
"velocity": ghost_relative_vel + primary.linear_velocity
}

View File

@ -1,6 +1,6 @@
[gd_resource type="Resource" script_class="Databank" load_steps=3 format=3 uid="uid://6jj1jd14cdlt"]
[ext_resource type="Script" uid="uid://bghu5lhcbcfmh" path="res://scenes/ship/computer/shards/nav_hohman_planner.gd" id="1_attn3"]
[ext_resource type="Script" path="res://scenes/ship/computer/shards/nav_hohman_planner.gd" id="1_attn3"]
[ext_resource type="Script" uid="uid://osk1l75vlikn" path="res://scenes/ship/computer/databank.gd" id="1_nleqa"]
[resource]

View File

@ -0,0 +1,79 @@
# space_simulation/scenes/ship/computer/shards/nav_intercept_solver.gd
extends Databank
class_name InterceptSolverShard
signal solution_found(plan: Array[DataTypes.ImpulsiveBurnPlan])
signal solution_impossible
## Describes the functions this shard needs as input.
func get_input_sockets() -> Array[String]:
return ["project_n_body_paths"]
## Describes the signals this shard can output.
func get_output_sockets() -> Array[String]:
return ["solution_found", "solution_impossible"]
# INPUT SOCKET: Planners will call this with a projected path.
func solve_rendezvous_plan(
target_path: Array[DataTypes.PathPoint],
maneuver_type: String # e.g., "brachistochrone" or "hohmann"
):
if not is_instance_valid(root_module) or target_path.is_empty():
emit_signal("solution_impossible")
return
var rendezvous_point = find_earliest_rendezvous(target_path)
if not rendezvous_point:
emit_signal("solution_impossible")
return
# Once we have the target point (time, pos, vel), we can generate
# the specific burn plan based on the requested type.
var plan: Array[DataTypes.ImpulsiveBurnPlan]
match maneuver_type:
"brachistochrone":
plan = _generate_brachistochrone_plan(rendezvous_point)
# "hohmann" would be more complex, as it has constraints
_:
print("Unknown maneuver type for solver.")
emit_signal("solution_impossible")
return
emit_signal("solution_found", plan)
# This is the core solver logic.
func find_earliest_rendezvous(target_path: Array[DataTypes.PathPoint]) -> DataTypes.PathPoint:
# For each point in the target's future path...
for point in target_path:
# 1. Calculate the required change in position (displacement).
var delta_p = point.position - root_module.global_position
# 2. Calculate the required change in velocity.
var delta_v = point.velocity - root_module.linear_velocity
# 3. Using kinematics (d = v_initial*t + 0.5at^2), find the constant
# acceleration 'a' required to satisfy both delta_p and delta_v over
# the time 'point.time'.
# a = 2 * (delta_p - root_module.linear_velocity * point.time) / (point.time * point.time)
var required_acceleration_vector = 2.0 * (delta_p - root_module.linear_velocity * point.time) / (point.time * point.time)
# 4. Check if the magnitude of this required acceleration is something our ship can actually do.
var max_accel = root_module.main_engine_thrust / root_module.mass # Assumes we need a get_main_engine_thrust() helper
if required_acceleration_vector.length() <= max_accel:
# This is the first point in time we can reach. This is our solution.
return point
# If we get through the whole path and can't reach any of them, it's impossible.
return null
func _generate_brachistochrone_plan(rendezvous_point: DataTypes.PathPoint) -> Array[DataTypes.ImpulsiveBurnPlan]:
# This function would now use the data from the solved rendezvous_point
# to create the two-burn Brachistochrone plan, similar to before.
# The key difference is that all calculations are now based on a confirmed possible intercept.
var plan: Array[DataTypes.ImpulsiveBurnPlan] = []
# ... logic to build the plan ...
return plan

View File

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

View File

@ -0,0 +1,87 @@
# space_simulation/scenes/ship/computer/shards/nav_path_projection.gd
extends Databank
class_name PathProjectionShard
## Emitted after a requested path has been calculated.
signal projected_system_bus(paths: Array[DataTypes.PathPoint])
## Describes the functions this shard needs as input.
func get_input_sockets() -> Array[String]:
return ["project_n_body_paths"]
## Describes the signals this shard can output.
func get_output_sockets() -> Array[String]:
return ["projected_system_bus"]
## Projects the future paths of an array of bodies interacting with each other.
## Returns a dictionary mapping each body to its calculated PackedVector2Array path.
func project_n_body_paths(
bodies_to_trace: Array[OrbitalBody2D],
num_steps: int,
time_step: float
):
# --- Step 1: Create a "ghost state" for each body ---
# A ghost state is just a simple dictionary holding the physics properties.
var ghost_states = []
for body in bodies_to_trace:
ghost_states.append({
"body_ref": body,
"mass": body.mass,
"position": body.global_position,
"velocity": body.linear_velocity # Velocity is always in the same space
})
# --- Step 2: Prepare the results dictionary ---
var paths: Dictionary = {}
for state in ghost_states:
paths[state.body_ref] = []
# --- Step 3: Run the ghost simulation ---
for i in range(num_steps):
# Create a list to hold the forces for this time step
var forces_for_step = {}
for state in ghost_states:
forces_for_step[state.body_ref] = Vector2.ZERO
# a) Calculate all gravitational forces between the ghosts
for j in range(ghost_states.size()):
var state_a = ghost_states[j]
for k in range(j + 1, ghost_states.size()):
var state_b = ghost_states[k]
# Calculate force between the two ghost states2:
var distance_sq = state_a.position.distance_squared_to(state_b.position)
if distance_sq < 1.0: return Vector2.ZERO
var force_magnitude = (OrbitalMechanics.G * state_a.mass * state_b.mass) / distance_sq
var direction = state_a.position.direction_to(state_b.position)
var force_vector = direction * force_magnitude
# Store the forces to be applied
forces_for_step[state_a.body_ref] += force_vector
forces_for_step[state_b.body_ref] -= force_vector
# b) Integrate forces for each ghost to find its next position
for state in ghost_states:
if state.mass > 0:
var acceleration = forces_for_step[state.body_ref] / state.mass
state.velocity += acceleration * time_step
state.position += state.velocity * time_step
# c) Record the new position in the path
paths[state.body_ref].append(DataTypes.PathPoint.new(i * time_step, state.position, state.velocity))
# --- Step 4: Prepare the results dictionary ---
var projections: Array[DataTypes.PathProjection] = []
for state in ghost_states:
var projection: DataTypes.PathProjection = DataTypes.PathProjection.new(state.body_ref)
projection.points = paths[state.body_ref]
projections.append(projection)
projected_system_bus.emit(paths)

View File

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

View File

@ -0,0 +1,9 @@
[gd_resource type="Resource" script_class="Databank" load_steps=3 format=3 uid="uid://d4e5f6g7h8jaj"]
[ext_resource type="Script" path="res://scenes/ship/computer/shards/nav_path_projection.gd" id="1_proj"]
[ext_resource type="Script" uid="uid://osk1l75vlikn" path="res://scenes/ship/computer/databank.gd" id="2_data"]
[resource]
script = ExtResource("2_data")
logic_script = ExtResource("1_proj")
metadata/_custom_type_script = "uid://osk1l75vlikn"

View File

@ -1,5 +1,5 @@
# scenes/ship/computer/shards/nav_selection_databank.gd
extends Node
extends Databank
class_name NavSelectionShard
## Emitted whenever a new navigation target is selected from the map.
@ -7,8 +7,16 @@ signal target_selected(body: OrbitalBody2D)
var selected_body: OrbitalBody2D = null
## Describes the functions this shard needs as input.
func get_input_sockets() -> Array[String]:
return ["body_selected"]
## Describes the signals this shard can output.
func get_output_sockets() -> Array[String]:
return ["target_selected"]
# INPUT SOCKET: This function is connected to the SensorPanel's "body_selected" signal.
func on_body_selected(body: OrbitalBody2D):
func body_selected(body: OrbitalBody2D):
if is_instance_valid(body) and body != selected_body:
print("NAV SELECTION: New target acquired - ", body.name)
selected_body = body

View File

@ -1,20 +1,34 @@
extends Node
extends Databank
class_name SensorSystemShard
## This shard emits all trackable bodies as a "sensor feed" every frame.
signal sensor_feed_updated(bodies: Array[OrbitalBody2D])
func _physics_process(delta):
# In a more advanced game, this shard might have its own power requirements
# or could be affected by radiation, etc. For now, it just gets all bodies.
@export_group("Projection Settings")
@export var projection_steps: int = 500
@export var time_per_step: float = 60.0 # Project at 1-minute intervals
## Describes the functions this shard needs as input.
func get_input_sockets() -> Array[String]:
return []
## Describes the signals this shard can output.
func get_output_sockets() -> Array[String]:
return ["sensor_feed_updated"]
# We use _process instead of _physics_process to avoid slowing down the physics thread.
# This calculation can happen on a separate frame if needed.
func _process(_delta: float):
var star_system = GameManager.current_star_system
var tracked_bodies: Array[OrbitalBody2D] = [star_system.get_star()]
for planetary_system in star_system.get_planetary_systems():
tracked_bodies.append_array( planetary_system.get_internal_attractors())
if not is_instance_valid(star_system):
return
# Gather all bodies that need to be included in the simulation.
var tracked_bodies: Array[OrbitalBody2D] = []
tracked_bodies.append(star_system.get_star())
tracked_bodies.append_array(star_system.get_planetary_systems())
tracked_bodies.append_array(star_system.get_orbital_bodies())
if tracked_bodies.is_empty():
return
sensor_feed_updated.emit(tracked_bodies)

View File

@ -0,0 +1,30 @@
# Socket.gd
extends Control
class_name Socket
enum SocketType { INPUT, OUTPUT }
enum SocketDataTypes {
FLOAT, INT, STRING, EXEC
}
@onready var icon: ColorRect = $Icon
@onready var label: Label = $Label
var socket_name: String
var socket_type: SocketType
# Called by the parent component block to set up the socket.
func initialize(s_name: String, s_type: SocketType):
socket_name = s_name
socket_type = s_type
icon.tooltip_text = socket_name
if socket_type == SocketType.INPUT:
icon.color = Color.DODGER_BLUE
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
else: # OUTPUT
icon.color = Color.ORANGE
# For output sockets, we can right-align the text to make it look neat.
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT

View File

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

View File

@ -0,0 +1,17 @@
[gd_scene load_steps=2 format=3 uid="uid://8glctqud5ioq"]
[ext_resource type="Script" uid="uid://d3d6tgy757f43" path="res://scenes/ship/computer/wiring/socket.gd" id="1_ubhg7"]
[node name="Socket" type="HBoxContainer"]
offset_right = 5.0
offset_bottom = 23.0
script = ExtResource("1_ubhg7")
[node name="Icon" type="ColorRect" parent="."]
custom_minimum_size = Vector2(48, 48)
layout_mode = 2
mouse_filter = 1
color = Color(0.2, 0.235294, 0.796078, 1)
[node name="Label" type="Label" parent="."]
layout_mode = 2

View File

@ -0,0 +1,19 @@
# space_simulation/scenes/ship/computer/wiring/wire_connection.gd
@tool
class_name WireConnection
extends Resource
## The resource of the component where the wire starts (e.g., a Databank).
@export var output_component_resource: Resource
## The name of the output signal/socket.
@export var output_socket_name: String
## The resource of the component where the wire ends (e.g., a ControlPanel).
@export var input_component_resource: Resource
## The name of the input function/socket.
@export var input_socket_name: String
## The array of points (in local coordinates of the wiring panel) that define the wire's path.
@export var path_points: PackedVector2Array = []

View File

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

View File

@ -0,0 +1,7 @@
# space_simulation/scenes/ship/computer/wiring/wiring_schematic.gd
@tool
class_name WiringSchematic
extends Resource
## An array of all the individual wire connections in this schematic.
@export var connections: Array[WireConnection] = []

View File

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

View File

@ -1,18 +0,0 @@
class_name ShipSignalBus
extends Node
# --- Navigation & Maneuvering Events ---
# Emitted when a maneuver plan is calculated.
signal maneuver_planned(plan)
# Emitted to command the start of a timed rotation.
signal timed_rotation_commanded(target_rotation_rad, time_window)
# Emitted to command the start of a timed main engine burn.
signal timed_burn_commanded(duration)
# --- Thruster & Ship Status Events ---
# Emitted when the main engine starts or stops firing.
signal main_engine_state_changed(is_firing: bool, total_thrust: float)
# Emitted when RCS thrusters are fired.
signal rcs_state_changed(is_firing: bool, torque: float)

View File

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

View File

@ -1,85 +0,0 @@
# Spaceship.gd
class_name Spaceship
extends OrbitalBody2D
@export var ship_name: String = "Stardust Drifter"
@export var dry_mass: float = 1000.0 # Mass of the ship without fuel/cargo (in kg)
@export var hull_integrity: float = 100.0
@onready var camera: Camera2D = $Camera2D
@export_category("Camera")
@export var zoom_speed: float = 1.0
var current_time_scale: float = Engine.time_scale
# --- Node References to Modular Systems ---
@onready var signal_bus: ShipSignalBus = $SignalBus
@onready var thruster_controller: ThrusterController = $ThrusterController
@onready var fuel_system = $FuelSystem
@onready var life_support = $LifeSupport
@onready var navigation_computer = $NavigationComputer
# @onready var weapon_system = $WeaponSystem
# @onready var power_grid = $PowerGrid
func _ready() -> void:
super()
GameManager.register_ship(self)
# Give the navigation computer a reference to this ship
if navigation_computer:
navigation_computer.ship = self
camera.make_current()
# This function will now handle all non-UI input for the player-controlled ship.
func _unhandled_input(event: InputEvent) -> void:
# --- Map Toggling ---
if event.is_action_pressed("ui_map_mode"):
# Instead of showing/hiding a node directly, we broadcast our intent.
# The NavigationComputer will be listening for this global signal.
SignalBus.emit_signal("map_mode_toggled")
# --- Time Scale Controls ---
if event.is_action_pressed("time_increase"):
var new_value = min(current_time_scale * 1.2, 1000)
current_time_scale = clamp(new_value, 0.5, 1000)
Engine.time_scale = current_time_scale
elif event.is_action_pressed("time_decrease"):
var new_value = max(current_time_scale * 0.833, 0.1)
current_time_scale = clamp(new_value, 0.5, 1000)
Engine.time_scale = current_time_scale
elif event.is_action_pressed("time_reset"):
Engine.time_scale = 1.0
# --- Public API for Ship Management ---
# Call this to take damage. Damage can have a position for breach effects.
func take_damage(amount: float, damage_position: Vector2):
hull_integrity -= amount
print("%s hull integrity at %.1f%%" % [ship_name, hull_integrity])
if hull_integrity <= 0:
destroy_ship()
else:
# Check if the hit caused a hull breach
life_support.check_for_breach(damage_position)
func destroy_ship():
print("%s has been destroyed!" % ship_name)
queue_free()
# --- Signal Handlers ---
#func _on_fuel_mass_changed():
# Update the ship's total mass when fuel is consumed or added
#update_total_mass()
func _on_hull_breach(breach_position: Vector2, force_vector: Vector2):
pass
# A hull breach applies a continuous force at a specific point
# For simplicity, we can apply it as a central force and torque here
#var force = force_vector * 100 # Scale the force
#
## Calculate torque: Torque = r x F (cross product of position vector and force)
#var position_relative_to_center = breach_position - self.global_position
#var torque = position_relative_to_center.cross(force)

View File

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

View File

@ -1,63 +0,0 @@
[gd_scene load_steps=9 format=3 uid="uid://dlck1lyrn1xvp"]
[ext_resource type="Script" uid="uid://dyqbk4lcx3mhq" path="res://scenes/ship/spaceship.gd" id="1_ae4p7"]
[ext_resource type="Script" uid="uid://c0bx113ifxyh8" path="res://scenes/ship/systems/thruster_controller.gd" id="2_xs8u7"]
[ext_resource type="PackedScene" uid="uid://c77wxeb7gpplw" path="res://scenes/modules/test_module.tscn" id="2_y58gg"]
[ext_resource type="Script" uid="uid://dx3uerblskj5r" path="res://scenes/ship/systems/fuel_system.gd" id="3_xs8u7"]
[ext_resource type="Script" uid="uid://buyp6t5cppitw" path="res://scenes/ship/systems/life_support.gd" id="4_v0rat"]
[ext_resource type="PackedScene" uid="uid://cxpjm8tp3l1j7" path="res://scenes/ship/systems/navigation_computer.tscn" id="5_6nyhl"]
[ext_resource type="Script" uid="uid://w1546qtaupd2" path="res://scenes/ship/ship_signal_bus.gd" id="7_yl4tl"]
[ext_resource type="PackedScene" uid="uid://c0bb77rmyatr0" path="res://scenes/ship/components/hardware/thruster.tscn" id="8_oedjh"]
[node name="Spaceship" type="Node2D"]
script = ExtResource("1_ae4p7")
base_mass = 2000.0
inertia = 500.0
metadata/_custom_type_script = "uid://0isnsk356que"
[node name="Module" parent="." instance=ExtResource("2_y58gg")]
[node name="FuelSystem" type="Node" parent="."]
script = ExtResource("3_xs8u7")
[node name="LifeSupport" type="Node" parent="."]
script = ExtResource("4_v0rat")
[node name="NavigationComputer" parent="." instance=ExtResource("5_6nyhl")]
[node name="Camera2D" type="Camera2D" parent="."]
[node name="ThrusterController" type="Node2D" parent="."]
script = ExtResource("2_xs8u7")
[node name="RCS1" parent="." instance=ExtResource("8_oedjh")]
position = Vector2(-125, 200)
rotation = 1.5708
main_thruster = false
inertia = 0.0
[node name="RCS2" parent="." instance=ExtResource("8_oedjh")]
position = Vector2(-125, -200)
rotation = 1.5708
main_thruster = false
inertia = 0.0
[node name="RCS3" parent="." instance=ExtResource("8_oedjh")]
position = Vector2(125, -200)
rotation = -1.5708
main_thruster = false
inertia = 0.0
[node name="RCS4" parent="." instance=ExtResource("8_oedjh")]
position = Vector2(125, 200)
rotation = -1.5708
main_thruster = false
inertia = 0.0
[node name="MainEngine" parent="." instance=ExtResource("8_oedjh")]
position = Vector2(-1, 226)
max_thrust = 100.0
inertia = 0.0
[node name="SignalBus" type="Node" parent="."]
script = ExtResource("7_yl4tl")

View File

@ -1,184 +0,0 @@
# space_simulation/scripts/map_controller.gd
class_name MapController
extends Control
signal body_selected_for_planning(body: OrbitalBody2D)
@export var map_icon_scene: PackedScene
const LABEL_CULLING_PIXEL_THRESHOLD = 65.0
const ICON_CULLING_PIXEL_THRESHOLD = 40.0
var map_scale: float = 0.001
var map_offset: Vector2 = Vector2.ZERO
var focal_body: OrbitalBody2D
var icon_map: Dictionary = {}
var followed_body: OrbitalBody2D = null
var map_tween: Tween
# The starting point for our lerp animation.
var follow_start_offset: Vector2
# The progress of the follow animation (0.0 to 1.0), animated by a tween.
var follow_progress: float = 0.0:
set(value):
follow_progress = value
# We must redraw every time the progress changes.
queue_redraw()
func _ready() -> void:
await get_tree().physics_frame
var star_system = GameManager.current_star_system
if is_instance_valid(star_system):
focal_body = star_system.get_system_data().star
_populate_map()
func _process(_delta: float) -> void:
_update_icon_positions()
func _draw() -> void:
var map_center = get_rect().size / 2.0
var system_data = GameManager.get_system_data()
if system_data and system_data.belts:
for belt in system_data.belts:
var radius = belt.centered_radius * map_scale
draw_circle(map_center + map_offset, radius, Color(Color.WHITE, 0.1), false)
for body in icon_map:
if body is Asteroid: continue
var icon = icon_map[body]
if not icon.visible: continue
var path_points = []
if body is CelestialBody: path_points = OrbitalMechanics._calculate_relative_orbital_path(body)
elif body is OrbitalBody2D: path_points = OrbitalMechanics._calculate_n_body_orbital_path(body)
else: continue
var scaled_path_points = PackedVector2Array()
for point in path_points:
# Ensure path is drawn relative to the main focal body (the star)
var path_world_pos = point + focal_body.global_position
var relative_pos = path_world_pos - focal_body.global_position
var scaled_pos = (relative_pos * map_scale) + map_offset + map_center
scaled_path_points.append(scaled_pos)
if scaled_path_points.size() > 1:
draw_polyline(scaled_path_points, Color(Color.WHITE, 0.2), 1.0, true)
func _populate_map():
for child in get_children():
child.queue_free()
icon_map.clear()
var all_bodies = GameManager.get_all_trackable_bodies()
for body in all_bodies:
if not is_instance_valid(body): continue
var icon = map_icon_scene.instantiate() as MapIcon
add_child(icon)
icon.initialize(body)
icon_map[body] = icon
icon.selected.connect(_on_map_icon_selected)
icon.follow_requested.connect(_on_follow_requested)
func _update_icon_positions():
if not is_instance_valid(focal_body): return
var map_center = get_rect().size / 2.0
# --- MODIFIED: Continuous follow logic ---
if is_instance_valid(followed_body):
# Calculate the ideal offset to center the followed body.
var relative_target_pos = followed_body.global_position - focal_body.global_position
var target_offset = -relative_target_pos * map_scale
# During the initial pan, interpolate from the start to the target.
# When follow_progress reaches 1.0, this just becomes target_offset.
map_offset = follow_start_offset.lerp(target_offset, follow_progress)
# It will now use the dynamically updated map_offset.
var icon_data_for_frame = []
for body in icon_map:
var icon = icon_map[body]
icon.visible = true
icon.name_label.visible = true
var relative_pos = body.global_position - focal_body.global_position
var final_screen_pos = (relative_pos * map_scale) + map_offset + map_center
icon_data_for_frame.append({
"screen_pos": final_screen_pos,
"body": body,
"icon": icon
})
icon.position = final_screen_pos - (icon.size / 2)
for i in range(icon_data_for_frame.size()):
var data_a = icon_data_for_frame[i]
if not data_a.icon.visible:
continue
for j in range(i + 1, icon_data_for_frame.size()):
var data_b = icon_data_for_frame[j]
if not data_b.icon.visible: continue
var distance = data_a.screen_pos.distance_to(data_b.screen_pos)
if distance < ICON_CULLING_PIXEL_THRESHOLD:
if data_a.body.mass > data_b.body.mass:
data_b.icon.visible = false
else:
data_a.icon.visible = false
elif distance < LABEL_CULLING_PIXEL_THRESHOLD:
if data_a.body.mass > data_b.body.mass:
data_b.icon.name_label.visible = false
else:
data_a.icon.name_label.visible = false
# Request a redraw at the end of the update
queue_redraw()
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_WHEEL_UP or event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
var zoom_factor = 1.25 if event.button_index == MOUSE_BUTTON_WHEEL_UP else 1 / 1.25
var mouse_pos = get_local_mouse_position()
var map_center = get_rect().size / 2.0
var point_under_mouse_world = (mouse_pos - map_center - map_offset) / map_scale
map_scale *= zoom_factor
var point_under_mouse_new_screen = (point_under_mouse_world * map_scale) + map_center + map_offset
map_offset += mouse_pos - point_under_mouse_new_screen
if event is InputEventMouseMotion and event.button_mask & MOUSE_BUTTON_MASK_LEFT:
# --- MODIFIED: Break the lock and reset progress ---
if is_instance_valid(followed_body):
print("Map lock disengaged by manual pan.")
followed_body = null
follow_progress = 0.0 # Reset progress
if map_tween:
map_tween.kill()
map_offset += event.relative
func _on_map_icon_selected(body: OrbitalBody2D):
emit_signal("body_selected_for_planning", body)
func _on_follow_requested(body: OrbitalBody2D):
print("Map view locking on to: ", body.name)
follow_progress = 0.0
followed_body = body
if map_tween:
map_tween.kill()
# Store the offset at the exact moment the follow begins.
follow_start_offset = map_offset
# --- REVISED: We now tween the 'follow_progress' property instead of 'map_offset' ---
map_tween = create_tween().set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT)
map_tween.tween_property(self, "follow_progress", 1.0, 1.5)

View File

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

View File

@ -1,402 +0,0 @@
# NavigationComputer.gd
extends Node
@onready var ship: Spaceship = %Spaceship
var ship_signal_bus: ShipSignalBus
# --- Node References ---
@onready var navigation_ui: CanvasLayer = %NavigationUI
@onready var map_controller: MapController = %MapController
@onready var target_label: Label = %TargetLabel
@onready var info_label: Label = %InfoLabel
@onready var ship_status_label: Label = %ShipStatusLabel
@onready var torque_indicator: ColorRect = %TorqueIndicator
@onready var sub_viewport: SubViewport = %SubViewport
@onready var ship_view_camera: Camera2D = %ShipViewCamera # Add this reference
# Buttons for different maneuvers
@onready var plan_hohmann_button: Button = %PlanHohmannButton
@onready var plan_fast_button: Button = %PlanFastButton
@onready var plan_torchship_button: Button = %PlanTorchshipButton
@onready var plan_gravity_assist_button: Button = %PlanGravityAssistButton
@onready var execute_plan_button: Button = %ExecutePlanButton
# How many seconds before a burn we should start orienting the ship.
const PRE_BURN_ORIENTATION_SECONDS = 30.0 # Give a larger window for the new logic
const ROTATION_SAFETY_BUFFER = 10.0 # Seconds to ensure rotation finishes before burn
# A flag to make sure we only send the signal once per maneuver
var rotation_plan_sent = false
# --- State Management ---
enum State { IDLE, PLANNING, WAITING, EXECUTING }
var current_state: State = State.IDLE
# --- Navigation Data ---
var source_body: CelestialBody
var target_body: CelestialBody
var current_plan # Can be an array of ImpulsiveBurn or a ContinuousBurnPlan
# --- Inner classes to hold maneuver data ---
class ImpulsiveBurn:
var delta_v_magnitude: float
var wait_time: float = 0.0
var burn_duration: float
var desired_rotation_rad: float # The world rotation the ship needs to be in
class ContinuousBurnPlan:
var total_travel_time: float
var acceleration_time: float
var initial_burn_direction: Vector2 # The world-space direction vector for the burn
func _ready() -> void:
# tell our SubViewport to render the same world as main viewport.
if is_instance_valid(sub_viewport):
print("NAV COMP: Sub viewport found")
sub_viewport.world_2d = get_viewport().world_2d
else:
print("NAV COMP: Sub viewport not found")
# Connect to the global signal from the SignalBus
SignalBus.map_mode_toggled.connect(on_map_mode_toggled)
if is_instance_valid(ship):
ship_signal_bus = ship.signal_bus
# Ensure the UI starts hidden
if navigation_ui:
navigation_ui.hide()
# Connect UI signals
map_controller.body_selected_for_planning.connect(_on_target_selected)
plan_hohmann_button.pressed.connect(_on_plan_hohmann_pressed)
plan_fast_button.pressed.connect(_on_plan_fast_pressed)
plan_torchship_button.pressed.connect(_on_plan_torchship_pressed)
plan_gravity_assist_button.pressed.connect(_on_plan_gravity_assist_pressed)
execute_plan_button.pressed.connect(_on_execute_plan_pressed)
ship_status_label.text = ""
update_ui()
# Add a background to the UIm
_setup_background()
func _setup_background():
# --- FIX #1: Add a black, opaque background ---
var bg = ColorRect.new()
bg.color = Color.BLACK
# Add it as the very first child so it's behind everything else.
navigation_ui.add_child(bg)
navigation_ui.move_child(bg, 0)
# Make the background cover the entire screen.
bg.anchor_right = 1.0
bg.anchor_bottom = 1.0
# This function is called whenever any node in the game emits the "map_mode_toggled" signal.
func on_map_mode_toggled():
if navigation_ui:
# Toggle the visibility of the UI screen
navigation_ui.visible = not navigation_ui.visible
func _process(delta: float) -> void:
# This ensures the viewport camera always mirrors the ship's main camera.
if is_instance_valid(ship) and is_instance_valid(ship.camera):
if is_instance_valid(ship_view_camera):
ship_view_camera.global_transform = ship.camera.global_transform
ship_view_camera.zoom = ship.camera.zoom
_update_ship_status_label()
if current_state == State.PLANNING and current_plan:
if current_plan is Array and not current_plan.is_empty():
var first_burn = current_plan[0]
# The plan is not locked in, but we can see the window approaching.
first_burn.wait_time -= delta
var time_str = _format_seconds_to_mmss(first_burn.wait_time)
info_label.text = "Optimal window in: %s.\nPress Execute to lock in plan." % time_str
if first_burn.wait_time < 0:
# If the window is missed during planning, mark the plan as stale.
info_label.text = "Transfer window missed. Please plan a new maneuver."
current_plan = null
update_ui()
if current_state == State.WAITING and current_plan:
if current_plan is Array and not current_plan.is_empty():
var next_burn: ImpulsiveBurn = current_plan[0]
next_burn.wait_time -= delta
var time_str = _format_seconds_to_mmss(next_burn.wait_time)
info_label.text = "Time to burn: %s" % time_str
# --- NEW: Emit the rotation plan ---
# When we are inside the orientation window, and haven't sent the plan yet...
if not rotation_plan_sent:
# The time allowed for rotation is the time we have left, minus a safety buffer.
var time_for_rotation = next_burn.wait_time - ROTATION_SAFETY_BUFFER
# Tell the thruster controller to handle it.
ship.signal_bus.emit_signal("timed_rotation_commanded", next_burn.desired_rotation_rad, time_for_rotation)
rotation_plan_sent = true # Mark the plan as sent
if next_burn.wait_time <= 0:
_execute_maneuver()
# The EXECUTING state would be handled by a dedicated autopilot script/node
func update_ui():
execute_plan_button.disabled = (current_plan == null or current_state != State.PLANNING)
if current_state == State.PLANNING:
# Show available plans based on engine type
# TODO change UI to show recommendations based on thruster type
#if installed_engine is ChemicalThruster:
plan_hohmann_button.show()
plan_fast_button.show()
plan_gravity_assist_button.show()
#elif installed_engine is IonDrive:
plan_torchship_button.show()
# --- Signal Handlers ---
func _on_target_selected(body: CelestialBody):
if current_state == State.IDLE or current_state == State.PLANNING:
target_body = body
# Assume ship is orbiting the target's primary (e.g., the star)
current_plan = null
current_state = State.PLANNING
target_label.text = "Target: %s." % target_body.name
info_label.text = "Select a maneuver."
update_ui()
func _on_execute_plan_pressed():
if current_plan:
current_state = State.WAITING
update_ui()
# --- Planning Function Calls ---
func _on_plan_hohmann_pressed():
current_plan = _calculate_hohmann_transfer(source_body, target_body)
# TODO: map_controller.draw_planned_trajectory(...)
update_ui()
func _on_plan_fast_pressed():
# For simplicity, a "fast" transfer is a Hohmann with a 25% larger transfer orbit
current_plan = _calculate_hohmann_transfer(source_body, target_body, 1.25)
# TODO: map_controller.draw_planned_trajectory(...)
update_ui()
func _on_plan_torchship_pressed():
current_plan = _calculate_brachistochrone_transfer()
# TODO: map_controller.draw_planned_trajectory(...)
update_ui()
func _on_plan_gravity_assist_pressed():
info_label.text = "Gravity Assist calculation is highly complex and not yet implemented."
print("Placeholder for Gravity Assist logic.")
# --- Calculation and Execution ---
func _calculate_hohmann_transfer(source_planet: CelestialBody, target_planet: CelestialBody, transfer_boost_factor: float = 1.0) -> Array:
# Get the central star safely from the GameManager.
#var ship_current_primary = ship.primary
var star = GameManager.get_system_data().star
if not is_instance_valid(star):
print("Hohmann Error: Could not find star in GameManager.")
return []
# This maneuver requires the ship and target to orbit the same star.
if not target_planet:
info_label.text = "Invalid Transfer: No target object for intersect."
return []
# mu (μ): The Standard Gravitational Parameter of the star. It's G * M, a constant that simplifies calculations.
var mu = OrbitalMechanics.G * star.mass
# r1: The ship's current orbital radius (distance from the star).
var r1 = ship.global_position.distance_to(star.global_position)
# r2: The target planet's orbital radius.
var r2 = target_planet.global_position.distance_to(star.global_position)
# --- Hohmann Transfer Calculations ---
# v_source_orbit: The ship's current circular orbital speed.
var v_source_orbit = sqrt(mu / r1)
# v_target_orbit: The target planet's circular orbital speed.
var v_target_orbit = sqrt(mu / r2)
# a_transfer: The semi-major axis (average radius) of the elliptical transfer orbit.
var a_transfer = (r1 + r2) / 2.0 * transfer_boost_factor
# v_transfer_periapsis: The required speed at the start of the transfer (periapsis) to get onto the ellipse.
var v_transfer_periapsis = sqrt(mu * ((2.0 / r1) - (1.0 / a_transfer)))
# v_transfer_apoapsis: The speed the ship will have when it arrives at the end of the transfer (apoapsis).
var v_transfer_apoapsis = sqrt(mu * ((2.0 / r2) - (1.0 / a_transfer)))
# delta_v1: The first burn. The change in speed needed to go from the source orbit to the transfer orbit.
var delta_v1 = v_transfer_periapsis - v_source_orbit
# delta_v2: The second burn. The change in speed needed to go from the transfer orbit to the target orbit.
var delta_v2 = v_target_orbit - v_transfer_apoapsis
# time_of_flight: Half the period of the elliptical transfer orbit (Kepler's 3rd Law).
var time_of_flight = PI * sqrt(pow(a_transfer, 3) / mu)
# --- Launch Window (Phase Angle) Calculations ---
# ang_vel_target: The angular speed of the target planet (in radians per second).
var ang_vel_target = sqrt(mu / pow(r2, 3))
# travel_angle: The angle the target planet will travel through during the ship's flight time.
var travel_angle = ang_vel_target * time_of_flight
# required_phase_angle: The starting angle needed between the ship and target for a successful intercept.
var required_phase_angle = PI - travel_angle
# vec_to_ship/target: Direction vectors from the star to the ship and target.
var vec_to_ship = (ship.global_position - star.global_position).normalized()
var vec_to_target = (target_planet.global_position - star.global_position).normalized()
# current_phase_angle: The angle between the ship and target right now.
var current_phase_angle = vec_to_ship.angle_to(vec_to_target)
# ang_vel_ship: The ship's current angular speed.
var ang_vel_ship = sqrt(mu / pow(r1, 3))
# relative_ang_vel: How quickly the ship is catching up to (or falling behind) the target.
var relative_ang_vel = ang_vel_ship - ang_vel_target
# angle_to_wait: The angular distance the ship needs to "wait" for alignment.
var angle_to_wait = current_phase_angle - required_phase_angle
# wait_time: The final calculated time in seconds to wait for the optimal launch window.
var wait_time = abs(angle_to_wait / relative_ang_vel)
# --- Final Plan Assembly ---
# Calculate burn durations
var acceleration = ship.thruster_controller.main_engine_max_thrust() / ship.mass
var burn_duration1 = delta_v1 / acceleration
var burn_duration2 = delta_v2 / acceleration
var plan = []
var burn1 = ImpulsiveBurn.new()
burn1.delta_v_magnitude = delta_v1
burn1.wait_time = wait_time
burn1.burn_duration = burn_duration1
# The desired rotation is the angle of the ship's prograde (tangential) velocity vector.
burn1.desired_rotation_rad = ship.linear_velocity.angle()
plan.append(burn1)
var burn2 = ImpulsiveBurn.new()
burn2.delta_v_magnitude = delta_v2
burn2.wait_time = time_of_flight - burn_duration1
burn2.burn_duration = burn_duration2
# The desired rotation for burn 2 is the tangential direction at the target orbit.
burn2.desired_rotation_rad = (target_planet.global_position - star.global_position).orthogonal().angle()
plan.append(burn2)
info_label.text = "Hohmann Plan:\nWait: %d s\nBurn 1: %.1f m/s (%.1f s)" % [wait_time, delta_v1, burn_duration1]
return plan
func _calculate_brachistochrone_transfer() -> ContinuousBurnPlan:
var distance = ship.global_position.distance_to(target_body.global_position)
var acceleration = ship.thruster_controller.main_engine_max_thrust() / ship.mass
if acceleration == 0: return null
# d = 1/2 * a * t^2 => t = sqrt(2d/a). We do this twice (accel/decel).
var time_for_half_journey = sqrt(distance / acceleration)
var plan = ContinuousBurnPlan.new()
plan.total_travel_time = 2 * time_for_half_journey
plan.acceleration_time = time_for_half_journey
plan.required_acceleration = acceleration
info_label.text = "Torchship Trajectory Calculated.\nTravel Time: %d s" % plan.total_travel_time
return plan
func _execute_maneuver():
if current_state != State.WAITING or not current_plan: return
current_state = State.EXECUTING
var burn: ImpulsiveBurn = current_plan.pop_front()
# Tell the controller to start burning. Orientation is already handled.
ship.signal_bus.emit_signal("timed_burn_commanded", burn.burn_duration)
# Set up for the next leg of the journey or finish
if not current_plan.is_empty():
# The next "wait_time" is the coasting period between burns.
current_state = State.WAITING
# Reset the flag here, preparing the system for the *next* burn's rotation command.
rotation_plan_sent = false
else:
current_state = State.IDLE
current_plan = null
update_ui()
var _previous_angular_velocity: float = 0.0
var actual_applied_torque: float = 0.0
func _update_ship_status_label():
if not is_instance_valid(ship):
ship_status_label.text = "NO SHIP DATA"
return
# Build an array of strings for each line of the display
var status_lines = []
var assume_torque = ship.thruster_controller.current_applied_torque
# Get rotation data from the ship
var rotation_deg = rad_to_deg(ship.rotation)
var current_angular_velocity = ship.angular_velocity
var angular_vel_dps = rad_to_deg(ship.angular_velocity)
var delta_omega = ship.angular_velocity - _previous_angular_velocity
var delta = get_physics_process_delta_time()
if delta > 0:
var angular_acceleration = delta_omega / delta
#actual_applied_torque = ship.accumulated_torque # ship.inertia * angular_acceleration
# Update the indicator color based on the comparison.
_previous_angular_velocity = current_angular_velocity
# Get the sign of each torque value.
var calc_sign = sign(assume_torque)
var actual_sign = sign(actual_applied_torque)
if calc_sign != 0 and calc_sign == actual_sign:
# Success: We are commanding a turn, and the physics engine agrees.
torque_indicator.color = Color.GREEN
elif calc_sign != 0 and actual_sign == 0:
# Mismatch: We are commanding a turn, but the physics engine reports no torque.
# This is the flicker you are seeing.
torque_indicator.color = Color.RED
else:
# Idle: No torque is being commanded, and none is being applied.
torque_indicator.color = Color.DARK_GRAY
var sensor_torque_sign = "+" if signf(actual_applied_torque) > 0.0 else "-" if signf(actual_applied_torque) < 0.0 else "0"
status_lines.append("Rotation: %.1f deg" % rotation_deg)
status_lines.append("Ang. Vel.: %.5f deg/s" % angular_vel_dps)
status_lines.append("Assumed Torque: %.2f N·m" % assume_torque)
status_lines.append("Sensed Torque: %.2f N·m" % actual_applied_torque)
# Get burn data from the thruster controller
var burn_time = ship.thruster_controller.current_burn_time_remaining
var thrust_force = ship.thruster_controller.current_thrust_force
status_lines.append("Burn Time: %.1f s" % burn_time)
status_lines.append("Thrust: %.5f N" % thrust_force)
# Join the lines with a newline character and update the label
ship_status_label.text = "\n".join(status_lines)
# Helper function to format a float of seconds into a M:SS string
func _format_seconds_to_mmss(seconds_float: float) -> String:
if seconds_float < 0:
seconds_float = 0
var total_seconds = int(seconds_float)
var minutes = total_seconds / 60
var seconds = total_seconds % 60
# "%02d" formats the seconds with a leading zero if needed (e.g., 05)
return "%d:%02d" % [minutes, seconds]

View File

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

View File

@ -1,103 +0,0 @@
[gd_scene load_steps=4 format=3 uid="uid://cxpjm8tp3l1j7"]
[ext_resource type="Script" uid="uid://cq2sgw12uj4jl" path="res://scenes/ship/systems/navigation_computer.gd" id="1_ys00n"]
[ext_resource type="Script" uid="uid://b74hxlsox8ldo" path="res://scenes/ship/systems/map_controller.gd" id="2_ys00n"]
[ext_resource type="PackedScene" uid="uid://c2imrmgjthfdm" path="res://scenes/UI/MapIcon.tscn" id="3_378us"]
[node name="NavigationComputer" type="Node"]
script = ExtResource("1_ys00n")
[node name="Sprite2D" type="Sprite2D" parent="."]
[node name="NavigationUI" type="CanvasLayer" parent="."]
unique_name_in_owner = true
[node name="NavigationScreen" type="MarginContainer" parent="NavigationUI"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="HBoxContainer" type="HBoxContainer" parent="NavigationUI/NavigationScreen"]
layout_mode = 2
[node name="VBoxContainer" type="VBoxContainer" parent="NavigationUI/NavigationScreen/HBoxContainer"]
custom_minimum_size = Vector2(250, 0)
layout_mode = 2
size_flags_horizontal = 4
[node name="TargetLabel" type="Label" parent="NavigationUI/NavigationScreen/HBoxContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
clip_text = true
[node name="InfoLabel" type="Label" parent="NavigationUI/NavigationScreen/HBoxContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="TorqueIndicator" type="ColorRect" parent="NavigationUI/NavigationScreen/HBoxContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(20, 20)
layout_mode = 2
[node name="PlanHohmannButton" type="Button" parent="NavigationUI/NavigationScreen/HBoxContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Hohmann Transfer"
[node name="PlanFastButton" type="Button" parent="NavigationUI/NavigationScreen/HBoxContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Fast Impulse"
[node name="PlanTorchshipButton" type="Button" parent="NavigationUI/NavigationScreen/HBoxContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Torchship Burn"
[node name="PlanGravityAssistButton" type="Button" parent="NavigationUI/NavigationScreen/HBoxContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Grav Assist"
[node name="ExecutePlanButton" type="Button" parent="NavigationUI/NavigationScreen/HBoxContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Launch"
[node name="MapController" type="Control" parent="NavigationUI/NavigationScreen/HBoxContainer"]
unique_name_in_owner = true
clip_contents = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_stretch_ratio = 3.0
script = ExtResource("2_ys00n")
map_icon_scene = ExtResource("3_378us")
[node name="RightHandPanel" type="VBoxContainer" parent="NavigationUI/NavigationScreen/HBoxContainer"]
custom_minimum_size = Vector2(0, 300)
layout_mode = 2
size_flags_horizontal = 4
[node name="ShipStatusLabel" type="Label" parent="NavigationUI/NavigationScreen/HBoxContainer/RightHandPanel"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 0
[node name="MarginContainer" type="MarginContainer" parent="NavigationUI/NavigationScreen/HBoxContainer/RightHandPanel"]
layout_mode = 2
size_flags_vertical = 3
[node name="SubViewportContainer" type="SubViewportContainer" parent="NavigationUI/NavigationScreen/HBoxContainer/RightHandPanel"]
layout_mode = 2
[node name="SubViewport" type="SubViewport" parent="NavigationUI/NavigationScreen/HBoxContainer/RightHandPanel/SubViewportContainer"]
unique_name_in_owner = true
transparent_bg = true
handle_input_locally = false
size = Vector2i(320, 180)
render_target_update_mode = 4
[node name="ShipViewCamera" type="Camera2D" parent="NavigationUI/NavigationScreen/HBoxContainer/RightHandPanel/SubViewportContainer/SubViewport"]
unique_name_in_owner = true

View File

@ -1,371 +0,0 @@
# ThrusterController.gd
class_name ThrusterController
extends Node2D
# --- References ---
@onready var ship: Spaceship = get_parent()
var ship_signal_bus: ShipSignalBus
@onready var main_engines: Array[Thruster] = []
@onready var rcs_thrusters: Array[Thruster] = []
# --- Public Status Variables ---
var current_burn_time_remaining: float = 0.0
var current_thrust_force: float = 0.0
var current_applied_torque: float = 0.0
# --- Autopilot & Manual Control State ---
enum RotationMode { NONE, AUTOPILOT_TURN, MANUAL_HOLD }
var rotation_mode: RotationMode = RotationMode.NONE
const ATTITUDE_TOLERANCE_RAD = 0.005 # ~1 degree. If error is > this, we correct.
# --- NEW: Inner class to store calibrated data for each thruster ---
class ThrusterData:
var thruster_node: Thruster
var measured_force_vector: Vector2 # The linear push it provides
var measured_torque: float # The rotational force it provides
var primary_axis: String # e.g., "Linear", "Yaw", "Surge"
# --- NEW: A dictionary to map thruster nodes to their performance data ---
var thruster_data_map: Dictionary[Thruster, ThrusterData] = {}
var target_rotation_rad: float = 0.0
var is_burning: bool = false
# --- Ship Performance Data (calibrated) ---
var max_positive_torque: float = 10000.0 # Default value, will be calibrated
var max_negative_torque: float = 10000.0 # Default value, will be calibrated
# --- Manual Hold Constants (PD Controller) ---
@export var HOLD_KP: float = 8000.0 # Proportional gain for stability
@export var HOLD_KD: float = 1200.0 # Derivative gain for damping
# --- Crew Comfort Levels ---
const COMFORTABLE_ANGULAR_VELOCITY = 0.5 # radians/sec
func _ready() -> void:
await get_tree().process_frame
if is_instance_valid(ship):
ship_signal_bus = ship.signal_bus
ship_signal_bus.timed_rotation_commanded.connect(_on_autopilot_rotation_commanded)
ship_signal_bus.timed_burn_commanded.connect(_on_autopilot_main_engine_burn_commanded)
# Register thrusters
var thruster_nodes = get_tree().get_nodes_in_group("ship_thrusters")
for thruster: Thruster in thruster_nodes:
if thruster.get_parent() == ship:
if thruster.main_thruster: main_engines.append(thruster)
else: rcs_thrusters.append(thruster)
await get_tree().physics_frame
# Calibrate thrusters on startup
await calibrate_rcs_performance()
command_hold_attitude(0.0)
func _physics_process(_delta: float) -> void:
match rotation_mode:
RotationMode.MANUAL_HOLD:
#if _is_attitude_misaligned() or true:
_perform_manual_hold()
#if ship: print("AUTOPILOT: Holding attitude.")
# --- PUBLIC API ---
# The Navigation Computer calls this.
func _on_autopilot_rotation_commanded(new_target_rot: float, time_window: float):
rotation_mode = RotationMode.AUTOPILOT_TURN
# Start the asynchronous autopilot maneuver
_execute_autopilot_rotation(new_target_rot, time_window)
func _on_autopilot_main_engine_burn_commanded(duration: float):
while _is_attitude_misaligned():
await get_tree().physics_frame
_fire_main_engine(duration)
# The player/manual input would call this.
func command_hold_attitude(rotation_rad: float):
if rotation_mode != RotationMode.MANUAL_HOLD:
print("AUTOPILOT: Engaging manual attitude hold at %.2f deg." % rad_to_deg(rotation_rad))
rotation_mode = RotationMode.MANUAL_HOLD
target_rotation_rad = rotation_rad
func command_stop_rotation():
if rotation_mode == RotationMode.MANUAL_HOLD:
command_hold_attitude(ship.rotation)
func command_disable_rcs():
rotation_mode = RotationMode.NONE
shutdown_rcs()
print("AUTOPILOT: RCS disabled.")
# --- Helper for Misalignment Check ---
func _is_attitude_misaligned() -> bool:
var error = shortest_angle_between(ship.rotation, target_rotation_rad)
return abs(error) > ATTITUDE_TOLERANCE_RAD
func calibrate_rcs_performance() -> void:
print("AUTOPILOT: Beginning full RCS calibration protocol...")
thruster_data_map.clear()
# Create a temporary copy as the original arrays may change
var all_thrusters_to_test = main_engines + rcs_thrusters
for thruster in all_thrusters_to_test:
var data: ThrusterData = await _calibrate_single_thruster(thruster)
thruster_data_map[thruster] = data
print(" - Calibrated %s: Force(%.3f, %.3f), Torque(%.3f)" % [thruster.name, data.measured_force_vector.x, data.measured_force_vector.y, data.measured_torque])
# Now that we have the data, we can update the ship's max torque values
max_positive_torque = 0
max_negative_torque = 0
for data in thruster_data_map.values():
if data.measured_torque > 0:
max_positive_torque += data.measured_torque
else:
max_negative_torque += abs(data.measured_torque)
print("RCS Calibration Complete: Max Pos Torque: %.2f, Max Neg Torque: %.2f" % [max_positive_torque, max_negative_torque])
# --- 3. Cleanup: Perform a final burn to stop the ship ---
# We use the newly calibrated torque to calculate the stop burn time.
await _reset_ship_rotation()
shutdown_rcs()
# Auto-tune the PD controller with the new values
var average_max_torque = (max_positive_torque + max_negative_torque) / 2.0
HOLD_KP = average_max_torque * 0.1
HOLD_KD = HOLD_KP * 1
print("PD Controller Auto-Tuned: Kp set to %.4f, Kd set to %.4f" % [HOLD_KP, HOLD_KD])
func _calibrate_single_thruster(thruster: Thruster) -> ThrusterData:
var data = ThrusterData.new()
data.thruster_node = thruster
# Prepare for test: save state, disable damping
var initial_velocity = ship.linear_velocity
var initial_angular_velocity = ship.angular_velocity
var test_burn_duration = 0.5 if thruster.main_thruster else 0.2 # A very short burst
# --- Perform Test Fire ---
thruster.turn_on()
await get_tree().create_timer(test_burn_duration).timeout
thruster.turn_off()
# Let the physics engine settle for one frame
await get_tree().physics_frame
# --- Measure Results ---
var delta_velocity = ship.linear_velocity - initial_velocity
var delta_angular_velocity = ship.angular_velocity - initial_angular_velocity
# --- Calculate Performance ---
# Force = mass * acceleration (a = dv/dt)
data.measured_force_vector = ship.mass * (delta_velocity / test_burn_duration)
# Torque = inertia * angular_acceleration (alpha = dw/dt)
data.measured_torque = ship.inertia * (delta_angular_velocity / test_burn_duration)
return data
# Resets the ship's rotation and angular velocity to zero.
func _reset_ship_rotation() -> void:
# Use the calibrated torque to calculate the final stop burn time.
if abs(ship.angular_velocity) > 0.01:
var stop_torque_dir = -sign(ship.angular_velocity)
var stop_torque_mag = max_positive_torque if stop_torque_dir > 0 else max_negative_torque
var stop_burn_time = abs((ship.inertia * ship.angular_velocity) / stop_torque_mag)
var stop_timer = get_tree().create_timer(stop_burn_time)
while stop_timer.get_time_left() > 0.0:
apply_rotational_thrust(stop_torque_dir * stop_torque_mag)
await get_tree().physics_frame
shutdown_rcs()
print("AUTOPILOT: Ship rotation and velocity reset.")
# --- AUTOPILOT "BANG-COAST-BANG" LOGIC (REFACTORED) ---
func _execute_autopilot_rotation(new_target_rot: float, time_window: float):
var angle_to_turn = shortest_angle_between(ship.rotation, new_target_rot)
if abs(angle_to_turn) < 0.01:
command_hold_attitude(new_target_rot)
return
# --- NEW: Get the specific torque values for each phase ---
var accel_torque = max_positive_torque if angle_to_turn > 0 else max_negative_torque
var decel_torque = max_negative_torque if angle_to_turn > 0 else max_positive_torque
if accel_torque == 0 or decel_torque == 0:
print("AUTOPILOT ERROR: Missing thrusters for a full rotation.")
return
# --- NEW: Asymmetrical Burn Calculation ---
# This is a more complex kinematic problem. We solve for the peak velocity and individual times.
var accel_angular_accel = accel_torque / ship.inertia
var decel_angular_accel = decel_torque / ship.inertia
# Solve for peak angular velocity (ω_peak) and times (t1, t2)
var peak_angular_velocity = (2 * angle_to_turn * accel_angular_accel * decel_angular_accel) / (accel_angular_accel + decel_angular_accel)
peak_angular_velocity = sqrt(abs(peak_angular_velocity)) * sign(angle_to_turn)
var accel_burn_time = abs(peak_angular_velocity / accel_angular_accel)
var decel_burn_time = abs(peak_angular_velocity / decel_angular_accel)
var total_maneuver_time = accel_burn_time + decel_burn_time
if total_maneuver_time > time_window:
print("AUTOPILOT WARNING: Maneuver is impossible in the given time window. Performing max-power turn.")
# Fallback to a simple 50/50 burn if time is too short.
accel_burn_time = time_window / 2.0
decel_burn_time = time_window / 2.0
# No coast time in this simplified model, but it could be added back with more complex math.
print("AUTOPILOT: Asymmetrical Plan: Accel Burn %.2fs, Decel Burn %.2fs" % [accel_burn_time, decel_burn_time])
# --- Execute Maneuver ---
# ACCELERATION BURN
var accel_timer = get_tree().create_timer(accel_burn_time)
while accel_timer.get_time_left() > 0.0:
apply_rotational_thrust(accel_torque * sign(angle_to_turn))
await get_tree().physics_frame
# DECELERATION BURN
print("AUTOPILOT: Acceleration complete, executing deceleration burn.")
var decel_timer = get_tree().create_timer(decel_burn_time)
while decel_timer.get_time_left() > 0.0:
apply_rotational_thrust(-accel_torque * sign(angle_to_turn)) # Apply opposite torque
await get_tree().physics_frame
shutdown_rcs()
print("AUTOPILOT: Rotation maneuver complete.")
command_hold_attitude(new_target_rot)
# --- MANUAL HOLD & STABILIZATION LOGIC (REFACTORED) ---
func _perform_manual_hold():
if not is_instance_valid(ship):
rotation_mode = RotationMode.NONE # Safety check
return
# 1. Calculate the error (how far we have to go).
var error = shortest_angle_between(ship.rotation, target_rotation_rad)
# 2. Calculate the required torque using the PD formula.
# This value will be positive, negative, or zero depending on our state.
var desired_torque = (error * HOLD_KP) - (ship.angular_velocity * HOLD_KD)
# 3. Continuously command the thrusters based on the calculated torque.
# This function will turn thrusters ON if they match the desired_torque's sign
# and OFF if they do not. It must be called every frame to work correctly.
if not _is_attitude_misaligned() and abs(ship.angular_velocity) < 0.01 and abs(desired_torque) < 0.0001:
ship.angular_velocity = 0.0
ship.rotation = target_rotation_rad
shutdown_rcs()
else:
apply_rotational_thrust(desired_torque)
# --- WORKER FUNCTIONS ---
func apply_rotational_thrust(desired_torque: float):
if not is_instance_valid(ship):
return
var delta_torque = 0.0
# Iterate through all available RCS thrusters
for thruster in rcs_thrusters:
# ... (your existing calculation for produced_torque is correct)
var thruster_local_pos = thruster.position
var thruster_data: ThrusterData = thruster_data_map.get(thruster)
# If this thruster can help, turn it on. Otherwise, explicitly turn it off.
if sign(thruster_data.measured_torque) == sign(desired_torque) and desired_torque != 0:
thruster.turn_on()
delta_torque += thruster_data.measured_torque
else:
thruster.turn_off()
current_applied_torque = delta_torque
## Applies forces from the correct thrusters to achieve a desired torque.
#func apply_rotational_thrust(desired_torque: float):
#if not is_instance_valid(ship):
#return
#
##print("AUTOPILOT: Applying rotational thrust %f" % desired_torque)
## Iterate through all available RCS thrusters
#var delta_torque = 0.0
#for thruster in rcs_thrusters:
## 1. Get the thruster's position relative to the ship's center. This is its local position.
#var thruster_local_pos = thruster.position
#
## 2. Calculate the force this thruster produces, also in LOCAL space.
#var force_local_vec = thruster.thrust_direction * thruster.max_thrust
#
## 3. Calculate the torque in LOCAL space. This is now a valid calculation.
#var produced_torque = thruster_local_pos.cross(force_local_vec)
## 4. Decide whether to fire the thruster. This check will now work correctly.
#if sign(produced_torque) == sign(desired_torque):
#thruster.turn_on()
#delta_torque += produced_torque
#
#else:
#thruster.turn_off()
#
#current_applied_torque = delta_torque
func shutdown_rcs():
for thruster in rcs_thrusters: thruster.turn_off()
current_applied_torque = 0.0
# Calculates the shortest angle between two angles (in radians).
# The result will be between -PI and +PI. The sign indicates the direction.
func shortest_angle_between(from_angle: float, to_angle: float) -> float:
var difference = fposmod(to_angle - from_angle, TAU)
if difference > PI:
return difference - TAU
else:
return difference
# Calculates the total torque available from all thrusters for a given direction.
func _get_total_possible_torque(direction: int) -> float:
var total_torque: float = 0.0
for thruster in rcs_thrusters:
var r = thruster.position
var force_local_vec = thruster.thrust_direction * thruster.max_thrust
var produced_torque = r.cross(force_local_vec)
if sign(produced_torque) == direction:
total_torque += abs(produced_torque)
return total_torque
# Fires all main engines for a specified duration.
func _fire_main_engine(duration: float):
print("AUTOPILOT: Firing main engine for %.2f seconds." % duration)
for engine in main_engines:
engine.turn_on()
await get_tree().create_timer(duration).timeout
for engine in main_engines:
engine.turn_off()
is_burning = false
print("AUTOPILOT: Main engine burn complete.")
func main_engine_max_thrust():
return main_engines.reduce(func(thrust, engine : Thruster): return thrust + engine.max_thrust, 0.0)

View File

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

View File

@ -0,0 +1,163 @@
# CharacterPawn.gd
extends CharacterBody3D
class_name CharacterPawn3D
## Core Parameters
@export var collision_energy_loss: float = 0.3
@export var base_inertia: float = 1.0 # Pawn's inertia without suit
## Input Buffers
var _move_input: Vector2 = Vector2.ZERO
var _roll_input: float = 0.0
var _vertical_input: float = 0.0
var _interact_input: PlayerController3D.KeyInput = PlayerController3D.KeyInput.new(false, false, false)
var _l_click_input: PlayerController3D.KeyInput = PlayerController3D.KeyInput.new(false, false, false)
var _r_click_input: PlayerController3D.KeyInput = PlayerController3D.KeyInput.new(false, false, false)
var _pitch_yaw_input: Vector2 = Vector2.ZERO
## Rotation Variables
@onready var camera_anchor: Marker3D = $CameraAnchor
@onready var camera_pivot: Node3D = $CameraPivot
@onready var camera: Camera3D = $CameraPivot/SpringArm/Camera3D
@export_range(0.1, PI / 2.0) var max_yaw_rad: float = deg_to_rad(80.0)
@export_range(-PI / 2.0 + 0.01, 0) var min_pitch_rad: float = deg_to_rad(-75.0)
@export_range(0, PI / 2.0 - 0.01) var max_pitch_rad: float = deg_to_rad(60.0)
@export var head_turn_lerp_speed: float = 15.0
## Movement Components
@onready var eva_suit_component: EVAMovementComponent = $EVAMovementComponent
@onready var zero_g_movemement_component: ZeroGMovementComponent = $ZeroGMovementComponent
## Physics State (Managed by Pawn)
var angular_velocity: Vector3 = Vector3.ZERO
@export var angular_damping: float = 0.95 # Base damping
## Other State Variables
var current_gravity: Vector3 = Vector3.ZERO # TODO: Implement gravity detection
var overlapping_ladder_area: Area3D = null
@onready var grip_detector: Area3D = $GripDetector
# Constants for State Checks
const WALKABLE_GRAVITY_THRESHOLD: float = 1.0
func _ready():
# find movement components
if eva_suit_component: print("Found EVA Suit Controller")
if zero_g_movemement_component: print("Found Zero-G Movement Controller")
# Connect grip detector signals
if grip_detector and zero_g_movemement_component:
print("GripDetector Area3D node found")
grip_detector.area_entered.connect(zero_g_movemement_component.on_grip_area_entered)
grip_detector.area_exited.connect(zero_g_movemement_component.on_grip_area_exited)
else:
printerr("GripDetector Area3D node not found on CharacterPawn!")
if is_multiplayer_authority():
camera.make_current()
camera.process_mode = Node.PROCESS_MODE_ALWAYS
func _process(delta: float) -> void:
camera_pivot.global_transform = camera_anchor.get_global_transform_interpolated()
func _physics_process(delta: float):
# 1. Apply Mouse Rotation (Universal head look)
_apply_mouse_rotation()
if zero_g_movemement_component: # Fallback to ZeroG controller (for initiating reach)
zero_g_movemement_component.process_movement(delta, _move_input, _vertical_input, _roll_input, _l_click_input, _r_click_input)
# 3. Integrate Angular Velocity (Universal)
_integrate_angular_velocity(delta)
# 4. Apply Linear Velocity & Collision (Universal)
# Use move_and_slide for states affected by gravity/floor or zero-g collisions
move_and_slide()
# Check for collision response AFTER move_and_slide
var collision_count = get_slide_collision_count()
if collision_count > 0:
var collision = get_slide_collision(collision_count - 1) # Get last collision
# Delegate or handle basic bounce
if eva_suit_component:
eva_suit_component.handle_collision(collision, collision_energy_loss)
else:
_handle_basic_collision(collision)
# 5. Reset Inputs
_reset_inputs()
# --- Universal Rotation ---
func _apply_mouse_rotation():
if _pitch_yaw_input != Vector2.ZERO:
camera_anchor.rotate_y(-_pitch_yaw_input.x)
# Apply Pitch LOCALLY to pivot
camera_anchor.rotate_object_local(Vector3.RIGHT, _pitch_yaw_input.y)
camera_anchor.rotation.x = clamp(camera_anchor.rotation.x, min_pitch_rad, max_pitch_rad)
_pitch_yaw_input = Vector2.ZERO
camera_anchor.rotation.z = 0.0
# --- Universal Integration & Collision ---
func _integrate_angular_velocity(delta: float):
# (Same integration logic as before using Basis or Quaternions)
if angular_velocity.length_squared() > 0.0001:
rotate(angular_velocity.normalized(), angular_velocity.length() * delta)
# Prevent drift if velocity is very small
if angular_velocity.length_squared() < 0.0001:
angular_velocity = Vector3.ZERO
func _handle_basic_collision(collision: KinematicCollision3D):
var surface_normal = collision.get_normal()
velocity = velocity.bounce(surface_normal)
velocity *= (1.0 - collision_energy_loss * 0.5)
# --- Public Helper for Controllers ---
# Applies torque affecting angular velocity
func add_torque(torque_global: Vector3, delta: float):
# Calculate effective inertia (base + suit multiplier if applicable)
var effective_inertia = base_inertia * (eva_suit_component.inertia_multiplier if eva_suit_component else 1.0)
if effective_inertia <= 0: effective_inertia = 1.0 # Safety prevent division by zero
# Apply change directly to angular velocity using the global torque
angular_velocity += (torque_global / effective_inertia) * delta
# --- Movement Implementations (Keep non-EVA ones here) ---
func _apply_walking_movement(_delta: float): pass # TODO
func _apply_ladder_floating_drag(delta: float):
velocity = velocity.lerp(Vector3.ZERO, delta * 2.0);
angular_velocity = angular_velocity.lerp(Vector3.ZERO, delta * 2.0)
func _apply_ladder_movement(_delta: float): pass # TODO
# --- Input Setters/Resets (Add vertical to set_movement_input) ---
func set_movement_input(move: Vector2, roll: float, vertical: float): _move_input = move; _roll_input = roll; _vertical_input = vertical
func set_interaction_input(interact_input: PlayerController3D.KeyInput): _interact_input = interact_input
func set_rotation_input(pitch_yaw_input: Vector2): _pitch_yaw_input += pitch_yaw_input
func set_click_input(l_action: PlayerController3D.KeyInput, r_action: PlayerController3D.KeyInput):
_l_click_input = l_action
_r_click_input = r_action
func _reset_inputs():
_move_input = Vector2.ZERO
_roll_input = 0.0
_vertical_input = 0.0
_interact_input = PlayerController3D.KeyInput.new(false, false, false)
_l_click_input = PlayerController3D.KeyInput.new(false, false, false)
_r_click_input = PlayerController3D.KeyInput.new(false, false, false)
_pitch_yaw_input = Vector2.ZERO # Keep _r_held
# --- Helper Functions ---
func _on_ladder_area_entered(area: Area3D): if area.is_in_group("Ladders"): overlapping_ladder_area = area
func _on_ladder_area_exited(area: Area3D): if area == overlapping_ladder_area: overlapping_ladder_area = null
func _reset_head_yaw(delta: float):
# Smoothly apply the reset target to the actual pivot rotation
camera_anchor.rotation.y = lerpf(camera_anchor.rotation.y, 0.0, delta * head_turn_lerp_speed)
func _notification(what: int) -> void:
match what:
NOTIFICATION_ENTER_TREE:
set_multiplayer_authority(int(name))

View File

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

View File

@ -0,0 +1,66 @@
[gd_scene load_steps=9 format=3 uid="uid://7yc6a07xoccy"]
[ext_resource type="Script" uid="uid://cdmmiixa75f3x" path="res://scenes/tests/3d/character_pawn_3d.gd" id="1_4frsu"]
[ext_resource type="PackedScene" uid="uid://bm1rbv4tuppbc" path="res://eva_suit_controller.tscn" id="3_gnddn"]
[ext_resource type="Script" uid="uid://y3vo40i16ek3" path="res://scenes/tests/3d/zero_g_movement_component.gd" id="4_8jhjh"]
[ext_resource type="PackedScene" uid="uid://ba3ijdstp2bvt" path="res://scenes/tests/3d/player_controller_3d.tscn" id="4_bcy3l"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_6vm80"]
[sub_resource type="CapsuleMesh" id="CapsuleMesh_6vm80"]
[sub_resource type="SphereShape3D" id="SphereShape3D_gnddn"]
radius = 1.0
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_gnddn"]
properties/0/path = NodePath(".:position")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath(".:rotation")
properties/1/spawn = true
properties/1/replication_mode = 1
properties/2/path = NodePath("CameraPivot:rotation")
properties/2/spawn = true
properties/2/replication_mode = 2
[node name="CharacterPawn3D" type="CharacterBody3D"]
script = ExtResource("1_4frsu")
metadata/_custom_type_script = "uid://cdmmiixa75f3x"
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("CapsuleShape3D_6vm80")
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
mesh = SubResource("CapsuleMesh_6vm80")
[node name="CameraAnchor" type="Marker3D" parent="."]
[node name="CameraPivot" type="Node3D" parent="."]
physics_interpolation_mode = 1
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.75, 0)
top_level = true
[node name="SpringArm" type="SpringArm3D" parent="CameraPivot"]
spring_length = 3.0
[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm"]
[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"]
shape = SubResource("SphereShape3D_gnddn")
[node name="ZeroGMovementComponent" type="Node3D" parent="."]
script = ExtResource("4_8jhjh")
metadata/_custom_type_script = "uid://y3vo40i16ek3"
[node name="EVAMovementComponent" parent="." instance=ExtResource("3_gnddn")]
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("SceneReplicationConfig_gnddn")
[node name="PlayerController3d" parent="." instance=ExtResource("4_bcy3l")]

View File

@ -0,0 +1,204 @@
# eva_suit_controller.gd
extends Node # Or Node3D if thrusters need specific positions later
class_name EVAMovementComponent
## References (Set automatically in _ready)
var pawn: CharacterPawn3D
## EVA Parameters (Moved from ZeroGPawn)
@export var orientation_speed: float = 2.0 # Used for orienting body to camera
@export var move_speed: float = 2.0
@export var roll_torque: float = 2.5
@export var angular_damping: float = 0.95 # Base damping applied by pawn, suit might add more?
@export var inertia_multiplier: float = 1.0 # How much the suit adds to pawn's base inertia (placeholder)
@export var stabilization_kp: float = 5.0
@export var stabilization_kd: float = 1.0
## State
var stabilization_target: Node3D = null
var stabilization_enabled: bool = false
func _ready():
pawn = get_parent() as CharacterPawn3D
if not pawn:
printerr("EVAMovementComponent must be a child of a CharacterBody3D pawn.")
return
# Make sure the paths match your CharacterPawn scene structure
# if camera_anchor:
# camera = camera_anchor.get_node_or_null("SpringArm/Camera3D") # Adjusted path for SpringArm
# if not camera_anchor or not camera:
# printerr("EVAMovementComponent could not find CameraPivot/SpringArm/Camera3D on pawn.")
# --- Standardized Movement API ---
## Called by Pawn's _physics_process when in FLOATING state with suit equipped
func process_movement(delta: float, move_input: Vector2, vertical_input: float, roll_input: float, orienting_input: PlayerController3D.KeyInput):
var orienting = orienting_input.held
if not is_instance_valid(pawn): return
if orienting:
_orient_pawn(delta)
# Check if stabilization is active and handle it first
if stabilization_enabled and is_instance_valid(stabilization_target):
_apply_stabilization_torques(delta)
else:
# Apply regular movement/torque only if not stabilizing
_apply_floating_movement(delta, move_input, vertical_input, roll_input)
func apply_thrusters(pawn: CharacterPawn3D, delta: float, move_input: Vector2, vertical_input: float, roll_input: float):
if not is_instance_valid(pawn): return
# Apply Linear Velocity
var pawn_forward = -pawn.global_basis.z
var pawn_right = pawn.global_basis.x
var pawn_up = pawn.global_basis.y
var move_dir_horizontal = (pawn_forward * move_input.y + pawn_right * move_input.x)
var move_dir_vertical = pawn_up * vertical_input
var combined_move_dir = move_dir_horizontal + move_dir_vertical
if combined_move_dir != Vector3.ZERO:
pawn.velocity += combined_move_dir.normalized() * move_speed * delta
# Apply Roll Torque
var roll_torque_global = -pawn.global_basis.z * (roll_input) * roll_torque # Sign fixed
pawn.add_torque(roll_torque_global, delta)
## Called by Pawn to handle collision response in FLOATING state
func handle_collision(collision: KinematicCollision3D, collision_energy_loss: float):
if not is_instance_valid(pawn): return
var surface_normal = collision.get_normal()
var reflected_velocity = pawn.velocity.bounce(surface_normal)
reflected_velocity *= (1.0 - collision_energy_loss)
pawn.velocity = reflected_velocity # Update pawn's velocity directly
## Called by Pawn when entering FLOATING state with suit
func on_enter_state():
print("EVA Suit Engaged")
# Any specific setup needed when activating the suit
## Called by Pawn when exiting FLOATING state with suit
func on_exit_state():
print("EVA Suit Disengaged")
# Any cleanup needed when deactivating the suit (e.g., stop thruster effects)
# --- Internal Logic ---
func _apply_floating_movement(delta: float, move_input: Vector2, vertical_input: float, roll_input: float):
# Apply Linear Velocity
var pawn_forward = -pawn.global_basis.z
var pawn_right = pawn.global_basis.x # Use pawn's right for consistency
var pawn_up = pawn.global_basis.y
var move_dir_horizontal = (pawn_forward * move_input.y + pawn_right * move_input.x)
var move_dir_vertical = pawn_up * vertical_input
var combined_move_dir = move_dir_horizontal + move_dir_vertical
if combined_move_dir != Vector3.ZERO:
pawn.velocity += combined_move_dir.normalized() * move_speed * delta
# --- Apply Roll Torque ---
# Calculate torque magnitude based on input
var roll_torque_vector = pawn.transform.basis.z * (-roll_input) * roll_torque
# Apply the global torque vector using the pawn's helper function
pawn.add_torque(roll_torque_vector, delta)
# --- Auto-Orientation Logic ---
func _orient_pawn(delta: float):
# 1. Determine Target Orientation Basis
var initial_cam_basis = pawn.camera_anchor.global_basis
var target_forward = -pawn.camera_anchor.global_basis.z # Look where camera looks
var target_up = Vector3.UP # Default up initially
# --- THE FIX: Adjust how target_up is calculated ---
# Calculate velocity components relative to camera orientation
var _forward_velocity_component = pawn.velocity.dot(target_forward)
var _right_velocity_component = pawn.velocity.dot(pawn.camera_anchor.global_basis.x)
# Only apply strong "feet trailing" if significant forward/backward movement dominates
# and we are actually moving.
#if abs(forward_velocity_component) > abs(right_velocity_component) * 0.5 and velocity.length_squared() > 0.1:
#target_up = -velocity.normalized()
## Orthogonalize to prevent basis skew
#var target_right = target_up.cross(target_forward).normalized()
## If vectors are parallel, cross product is zero. Fallback needed.
#if target_right.is_zero_approx():
#target_up = transform.basis.y # Fallback to current up
#else:
#target_up = target_forward.cross(target_right).normalized()
#else:
## If primarily strafing or stationary relative to forward,
## maintain the current body's roll orientation (its local Y-axis).
target_up = pawn.transform.basis.y
# Create the target basis
var target_basis = Basis.looking_at(target_forward, target_up)
# Optional Pitch Offset (Experimental):
# Apply the desired 70-degree pitch relative to the forward direction
# var target_pitch_rad = deg_to_rad(target_body_pitch_degrees)
# target_basis = target_basis.rotated(target_basis.x, target_pitch_rad) # Rotate around the target right vector
# 2. Smoothly Interpolate Towards Target Basis
var current_basis = pawn.global_basis
var new_basis = current_basis.slerp(target_basis, delta * orientation_speed).get_rotation_quaternion()
# Store the body's yaw *before* applying the new basis
var _old_body_yaw = current_basis.get_euler().y
var _old_body_pitch = current_basis.get_euler().x
# 3. Apply the new orientation
pawn.global_basis = new_basis
# 4. Reset camera pivot to rotation to what it was before we rotated the parent
pawn.camera_anchor.global_basis = initial_cam_basis
# --- Add new function placeholder ---
# TODO: Implement Rotation Stabilization Logic
func _apply_stabilization_torques(_delta: float):
if not is_instance_valid(stabilization_target):
stabilization_enabled = false
return
# TODO: Get the angular velocity from suit readings
var angular_velocity = Vector3.ZERO
# 1. Calculate Relative Angular Velocity:
# - Get the target's angular velocity (if it rotates) or assume zero.
# - Find the difference between our angular_velocity and the target's.
var relative_angular_velocity = angular_velocity # - target_angular_velocity (if applicable)
# 2. Calculate Target Orientation (Optional - for full orientation lock):
# - Determine the desired orientation relative to the target (e.g., face towards it).
# - Calculate the rotational error (e.g., using Quaternions or angle differences).
var rotational_error = Vector3.ZERO # Placeholder for difference from desired orientation
# 3. Calculate Corrective Torque (PD Controller):
# - Proportional Term (based on orientation error): P = rotational_error * stabilization_kp
# - Derivative Term (based on relative spin): D = relative_angular_velocity * stabilization_kd
# - Required Torque = -(P + D) # Negative to counteract error/spin
var required_torque = -(rotational_error * stabilization_kp + relative_angular_velocity * stabilization_kd)
print("Applying stabilization torque: ", required_torque)
# 4. Convert Required Torque into Thruster Actions:
# - This is the complex part. Need to know thruster positions, directions, and forces.
# - Determine which thrusters (likely RCS on the jetpack) can produce the required_torque.
# - Calculate firing times/intensities for those thrusters.
# - Apply the forces/torques (similar to how _apply_floating_movement applies roll torque).
# Example (highly simplified, assumes direct torque application possible):
# angular_velocity += (required_torque / inertia) * delta
# --- Add methods for enabling/disabling stabilization, setting target etc. ---
func set_stabilization_enabled(enable: bool):
if enable and is_instance_valid(stabilization_target):
stabilization_enabled = true
print("EVA Suit Stabilization Enabled")
else:
stabilization_enabled = false
print("EVA Suit Stabilization Disabled")
func set_stabilization_target(target: Node3D):
stabilization_target = target
func toggle_stabilization():
set_stabilization_enabled(not stabilization_enabled)

View File

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

View File

@ -0,0 +1,70 @@
extends Area3D
class_name GripArea3D
## Signal emitted when a pawn successfully grabs this grip.
signal grabbed(pawn: CharacterPawn3D)
## Signal emitted when a pawn releases this grip.
signal released(pawn: CharacterPawn3D)
## Enum to differentiate grip types. Add more as needed.
enum GripType { POINT, BAR, LADDER_RUNG, SEAT_HANDLE }
## The type of this specific grip. Should be set by inheriting classes.
@export var grip_type: GripType
## Can more than one pawn grab this simultaneously? (Usually false)
@export var allow_multiple_occupants: bool = false
## Tracks the pawn currently holding this grip.
var occupant: CharacterPawn3D = null
# --- Virtual Methods (to be overridden by subclasses) ---
## Calculates the ideal global transform (position and orientation)
## the pawn should aim for when grabbing *this specific* grip.
## The pawn_global_pos helps determine the closest point on extended grips like bars.
func get_grip_transform(_pawn_global_pos: Vector3) -> Transform3D:
# Default implementation: return the grip's own global transform.
# Subclasses (like BarGrip) MUST override this for correct behavior.
return global_transform
## Determines the direction the pawn should push off from this grip.
## Usually the opposite of the surface normal or grip's forward direction.
func get_push_off_normal() -> Vector3:
# Default: Push directly away along the grip's local -Z axis (assuming Z points into the wall)
return -global_transform.basis.z.normalized()
# --- Public API ---
## Check if a specific pawn *can* grab this grip right now.
func can_grab(pawn: CharacterPawn3D) -> bool:
if not is_instance_valid(pawn):
return false
if occupant != null and not allow_multiple_occupants:
return false # Already occupied
# Add distance checks or other conditions if needed
return true
## Called by the pawn to initiate grabbing.
func grab(pawn: CharacterPawn3D):
if can_grab(pawn):
occupant = pawn
emit_signal("grabbed", pawn)
# Disable collision? Monitor input? Subclasses might override.
print(pawn.name, " grabbed ", name)
return true
return false
## Called by the pawn to release the grip.
func release(pawn: CharacterPawn3D):
if occupant == pawn:
var released_pawn = occupant
occupant = null
emit_signal("released", released_pawn)
print(pawn.name, " released ", name)
# Re-enable collision?
return true
return false
func is_occupied() -> bool:
return occupant != null

View File

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

View File

@ -0,0 +1,64 @@
[gd_scene load_steps=6 format=3 uid="uid://5noqmp8b267n"]
[ext_resource type="Script" uid="uid://cnt6griexrsaa" path="res://scenes/tests/3d/grips/grip_area_3d.gd" id="1_c81dj"]
[sub_resource type="CylinderMesh" id="CylinderMesh_c81dj"]
top_radius = 0.02
bottom_radius = 0.02
height = 0.2
[sub_resource type="CylinderShape3D" id="CylinderShape3D_c81dj"]
height = 0.2
radius = 0.02
[sub_resource type="CylinderMesh" id="CylinderMesh_hnq38"]
top_radius = 0.01
bottom_radius = 0.01
height = 0.1
[sub_resource type="CylinderShape3D" id="CylinderShape3D_hnq38"]
height = 0.1
radius = 0.01
[node name="SingleHandhold" type="Node3D"]
[node name="Grip" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.1)
collision_layer = 32769
[node name="MeshInstance3D" type="MeshInstance3D" parent="Grip"]
mesh = SubResource("CylinderMesh_c81dj")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Grip"]
shape = SubResource("CylinderShape3D_c81dj")
[node name="Spoke" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, -4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0.0801313, -0.05)
[node name="MeshInstance3D" type="MeshInstance3D" parent="Spoke"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.01, 0)
mesh = SubResource("CylinderMesh_hnq38")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Spoke"]
shape = SubResource("CylinderShape3D_hnq38")
[node name="Spoke2" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, -4.37114e-08, 1, 0, -1, -4.37114e-08, 0, -0.08, -0.05)
[node name="MeshInstance3D" type="MeshInstance3D" parent="Spoke2"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.01, 0)
mesh = SubResource("CylinderMesh_hnq38")
[node name="CollisionShape3D2" type="CollisionShape3D" parent="Spoke2"]
shape = SubResource("CylinderShape3D_hnq38")
[node name="GripArea3D" type="Area3D" parent="."]
transform = Transform3D(4.37114e-08, -1, -2.62268e-07, -1, -4.37114e-08, -7.64275e-15, 0, 2.62268e-07, -1, 0, 0, -0.1)
collision_layer = 32768
collision_mask = 0
monitoring = false
script = ExtResource("1_c81dj")
metadata/_custom_type_script = "uid://cnt6griexrsaa"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="GripArea3D"]
shape = SubResource("CylinderShape3D_c81dj")

View File

@ -0,0 +1,93 @@
# PlayerController3D.gd
extends Node
class_name PlayerController3D
@onready var possessed_pawn: CharacterPawn3D = get_parent()
# --- Mouse Sensitivity ---
@export var mouse_sensitivity: float = 0.002 # Radians per pixel motion
var _mouse_motion_input: Vector2 = Vector2.ZERO
class KeyInput:
var pressed: bool = false
var held: bool = false
var released: bool = false
func _init(_p: bool, _h: bool, _r: bool):
pressed = _p
held = _h
released = _r
func _unhandled_input(event: InputEvent):
if not is_multiplayer_authority() or not is_instance_valid(possessed_pawn):
# print("Peer ID: %s, Node Authority: %s" % [multiplayer.get_unique_id(), get_multiplayer_authority()])
return
# 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)
func _physics_process(_delta):
if not is_multiplayer_authority() or not is_instance_valid(possessed_pawn):
return
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
server_process_rotation_input.rpc_id(multiplayer.get_unique_id(), sensitivity_modified_mouse_input)
# Reset the buffer
_mouse_motion_input = Vector2.ZERO
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 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"))
server_process_movement_input.rpc_id(multiplayer.get_unique_id(), move_vec, roll_input, vertical_input)
server_process_interaction_input.rpc_id(multiplayer.get_unique_id(), interact_input)
server_process_clicks.rpc_id(multiplayer.get_unique_id(), l_input, r_input)
@rpc("authority", "call_local")
func server_process_movement_input(move: Vector2, roll: float, vertical: float):
if is_instance_valid(possessed_pawn):
possessed_pawn.set_movement_input(move, roll, vertical)
@rpc("authority", "call_local")
func server_process_interaction_input(interact_input: KeyInput):
if is_instance_valid(possessed_pawn):
possessed_pawn.set_interaction_input(interact_input)
@rpc("authority", "call_local")
func server_process_rotation_input(input: Vector2):
if is_instance_valid(possessed_pawn):
possessed_pawn.set_rotation_input(input)
@rpc("authority", "call_local")
func server_process_clicks(l_action: KeyInput, r_action: KeyInput):
if is_instance_valid(possessed_pawn):
possessed_pawn.set_click_input(l_action, 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])
# Optional: Release mouse when losing focus
func _notification(what):
match what:
NOTIFICATION_WM_WINDOW_FOCUS_OUT:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
NOTIFICATION_WM_WINDOW_FOCUS_IN:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
NOTIFICATION_EXIT_TREE:
print("PlayerController %s exited tree" % multiplayer.get_unique_id())
NOTIFICATION_ENTER_TREE:
print("PlayerController %s entered tree" % multiplayer.get_unique_id())

View File

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

View File

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://ba3ijdstp2bvt"]
[ext_resource type="Script" uid="uid://vjfk3xnapfti" path="res://scenes/tests/3d/player_controller_3d.gd" id="1_elh6f"]
[node name="PlayerController3d" type="Node"]
script = ExtResource("1_elh6f")

View File

@ -0,0 +1,163 @@
[gd_scene load_steps=7 format=3 uid="uid://ddfsn0rtdnfda"]
[ext_resource type="PackedScene" uid="uid://5noqmp8b267n" path="res://scenes/tests/3d/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"]
[sub_resource type="BoxMesh" id="BoxMesh_kateb"]
size = Vector3(50, 1, 50)
[sub_resource type="BoxShape3D" id="BoxShape3D_25xtv"]
size = Vector3(50, 1, 50)
[sub_resource type="CylinderMesh" id="CylinderMesh_nvgim"]
top_radius = 4.0
bottom_radius = 4.0
height = 50.0
[sub_resource type="CylinderShape3D" id="CylinderShape3D_nvgim"]
height = 50.0
radius = 4.0
[node name="ZeroG3DTest" type="Node3D"]
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.743413, -0.535317, 0.400964, 0, 0.599499, 0.800376, -0.668832, -0.59501, 0.445675, 0, 0, 0)
[node name="StaticBody3D" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -25, 0)
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D"]
mesh = SubResource("BoxMesh_kateb")
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D"]
shape = SubResource("BoxShape3D_25xtv")
[node name="StaticBody3D2" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 25, 0)
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D2"]
mesh = SubResource("BoxMesh_kateb")
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D2"]
shape = SubResource("BoxShape3D_25xtv")
[node name="StaticBody3D3" type="StaticBody3D" parent="."]
transform = Transform3D(-4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 0, 1, 25, 0, 0)
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D3"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.050821304, 0.029743195, -0.014732361)
mesh = SubResource("BoxMesh_kateb")
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D3"]
shape = SubResource("BoxShape3D_25xtv")
[node name="StaticBody3D4" type="StaticBody3D" parent="."]
transform = Transform3D(-4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 0, 1, -25, 0, 0)
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D4"]
mesh = SubResource("BoxMesh_kateb")
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D4"]
shape = SubResource("BoxShape3D_25xtv")
[node name="StaticBody3D5" type="StaticBody3D" parent="."]
transform = Transform3D(1.91069e-15, 4.37114e-08, 1, 1, -4.37114e-08, 0, 4.37114e-08, 1, -4.37114e-08, 0, 0, 25)
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D5"]
mesh = SubResource("BoxMesh_kateb")
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D5"]
shape = SubResource("BoxShape3D_25xtv")
[node name="StaticBody3D6" type="StaticBody3D" parent="."]
transform = Transform3D(1.91069e-15, 4.37114e-08, 1, 1, -4.37114e-08, 0, 4.37114e-08, 1, -4.37114e-08, 0, 0, -25)
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D6"]
mesh = SubResource("BoxMesh_kateb")
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D6"]
shape = SubResource("BoxShape3D_25xtv")
[node name="SpotLight3D" type="SpotLight3D" parent="."]
transform = Transform3D(-0.769143, -0.409482, -0.490656, 0, 0.767758, -0.64074, 0.639077, -0.492821, -0.590516, -0.470994, -0.615063, -0.56685)
spot_range = 50.0
spot_angle = 61.19
[node name="StaticBody3D7" type="StaticBody3D" parent="."]
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D7"]
mesh = SubResource("CylinderMesh_nvgim")
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D7"]
shape = SubResource("CylinderShape3D_nvgim")
[node name="StaticBody3D8" type="StaticBody3D" parent="."]
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)
mesh = SubResource("CylinderMesh_nvgim")
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D8"]
transform = Transform3D(-0.999991, 0.00434579, 0, -0.00434579, -0.999991, 0, 0, 0, 1, 0, 0, 0)
shape = SubResource("CylinderShape3D_nvgim")
[node name="SingleHandhold" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.0698468, -16.9997, -4)
[node name="SingleHandhold8" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.0698468, -16.9997, -4)
[node name="SingleHandhold3" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.065501, -15.9997, -4)
[node name="SingleHandhold4" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.0611551, -14.9997, -4)
[node name="SingleHandhold5" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.0611551, -14.9997, -4)
[node name="SingleHandhold6" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.0568093, -13.9997, -4)
[node name="SingleHandhold7" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.0524635, -12.9997, -4)
[node name="SingleHandhold9" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.10026761, -23.99963, -4)
[node name="SingleHandhold10" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.10026761, -23.99963, -4)
[node name="SingleHandhold11" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.09592181, -22.99963, -4)
[node name="SingleHandhold12" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.091575906, -21.99963, -4)
[node name="SingleHandhold13" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.091575906, -21.99963, -4)
[node name="SingleHandhold14" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.08723011, -20.99963, -4)
[node name="SingleHandhold15" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(0.00434583, 0.99999, 0, -0.99999, 0.00434583, 0, 0, 0, 1, 0.08288431, -19.99963, -4)
[node name="SingleHandhold2" parent="StaticBody3D8" instance=ExtResource("1_jlvj7")]
transform = Transform3D(-0.00434583, 0.99999, -3.79925e-10, 0.99999, 0.00434583, 8.7421995e-08, 8.742269e-08, -3.123381e-18, -1, 0.0698468, -16.9997, 4)
[node name="StaticBody3D9" type="StaticBody3D" parent="."]
transform = Transform3D(0.00434584, 4.37103e-08, 0.999991, 0.999991, 0, -0.00434584, 0, 1, -4.37114e-08, 0, 0, 0)
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D9"]
mesh = SubResource("CylinderMesh_nvgim")
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D9"]
transform = Transform3D(4.33065e-08, 0.00434584, -0.999991, 4.38977e-08, -0.999991, -0.00434584, -1, -4.35214e-08, -4.37722e-08, 0, 0, 0)
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)

View File

@ -0,0 +1,484 @@
# zero_g_move_controller.gd
extends Node
class_name ZeroGMovementComponent
## References
var pawn: CharacterPawn3D
var camera_pivot: Node3D
## State & Parameters
var current_grip: GripArea3D = null # Use GripArea3D type hint
var nearby_grips: Array[GripArea3D] = []
# --- Reach Parameters ---
@export var reach_speed: float = 10.0 # Speed pawn moves towards grip
@export var reach_orient_speed: float = 10.0 # Speed pawn orients to grip
# --- Grip damping parameters ---
@export var gripping_linear_damping: float = 5.0 # How quickly velocity stops
@export var gripping_angular_damping: float = 5.0 # How quickly spin stops
@export var gripping_orient_speed: float = 2.0 # How quickly pawn rotates to face grip
# --- Climbing parameters ---
@export var climb_speed: float = 2.0
@export var grip_handover_distance: float = 1 # How close to next grip to initiate handover
@export var climb_acceleration: float = 10.0 # How quickly pawn reaches climb_speed
@export var climb_angle_threshold_deg: float = 120.0 # How wide the forward cone is
@export var release_past_grip_threshold: float = 0.4 # How far past the grip origin before releasing
var next_grip_target: GripArea3D = null # The grip we are trying to transition to
# --- Launch Parameters ---
# --- Seeking Climb State ---
var _seeking_climb_input: Vector2 = Vector2.ZERO # The move_input held when seeking started
@export var launch_charge_rate: float = 20.0
@export var max_launch_speed: float = 15.0
var launch_direction: Vector3 = Vector3.ZERO
var launch_charge: float = 0.0
# Enum for internal state
enum MovementState {
IDLE,
REACHING,
GRIPPING,
CLIMBING,
SEEKING_CLIMB,
CHARGING_LAUNCH
}
var current_state: MovementState = MovementState.IDLE:
set(new_state):
if new_state == current_state: return
_on_exit_state(current_state) # Call exit logic for old state
current_state = new_state
_on_enter_state(current_state) # Call enter logic for new state
func _ready():
pawn = get_parent() as CharacterPawn3D
if not pawn: printerr("ZeroGMovementComponent must be child of CharacterPawn3D")
camera_pivot = pawn.get_node_or_null("CameraPivot")
if not camera_pivot: printerr("ZeroGMovementComponent couldn't find CameraPivot")
# --- Standardized Movement API ---
## Called by Pawn when relevant state is active (e.g., GRABBING_GRIP, REACHING_MOVE)
func process_movement(delta: float, move_input: Vector2, vertical_input: float, roll_input: float, reach_input: PlayerController3D.KeyInput, release_input: PlayerController3D.KeyInput):
if not is_instance_valid(pawn): return
_update_state(
delta,
move_input,
reach_input,
release_input
)
match current_state:
MovementState.IDLE:
_process_idle(delta, move_input, vertical_input, roll_input, release_input)
MovementState.REACHING:
_process_reaching(delta)
MovementState.GRIPPING:
_apply_grip_physics(delta, move_input, roll_input)
MovementState.CLIMBING:
_apply_climb_physics(delta, move_input)
MovementState.SEEKING_CLIMB:
_process_seeking_climb(delta, move_input)
MovementState.CHARGING_LAUNCH:
_handle_launch_charge(delta)
## Called by Pawn for collision (optional, might not be needed if grabbing stops movement)
func handle_collision(collision: KinematicCollision3D, collision_energy_loss: float):
# Basic bounce if somehow colliding while using this controller
var surface_normal = collision.get_normal()
pawn.velocity = pawn.velocity.bounce(surface_normal)
pawn.velocity *= (1.0 - collision_energy_loss * 0.5)
# === STATE MACHINE ===
func _on_enter_state(state : MovementState):
print("ZeroGMovementComponent activated for state: ", MovementState.keys()[state])
if state == MovementState.GRIPPING:
pawn.velocity = Vector3.ZERO
pawn.angular_velocity = Vector3.ZERO
# else: # e.g., REACHING_MOVE?
# state = MovementState.IDLE # Or SEARCHING?
func _on_exit_state(state: MovementState):
print("ZeroGMovementComponent deactivated for state: ", MovementState.keys()[state])
pass
# Ensure grip is released if state changes unexpectedly
#if state == MovementState.GRIPPING:
#_release_current_grip()
func _update_state(
_delta: float,
move_input: Vector2,
reach_input: PlayerController3D.KeyInput,
release_input: PlayerController3D.KeyInput,
):
match current_state:
MovementState.IDLE:
# Already handled initiating reach in process_movement
if reach_input.pressed or reach_input.held:
current_state = MovementState.REACHING
MovementState.REACHING:
# TODO: If reach animation completes/hand near target -> GRIPPING
# If interact released during reach -> CANCEL -> IDLE
if _seeking_climb_input != Vector2.ZERO:
# We are in a "seek-climb-reach" chain. Cancel if move input stops.
if move_input == Vector2.ZERO:
# This will transition state to IDLE
_cancel_reach()
elif not (reach_input.pressed or reach_input.held):
# This was a normal reach initiated by click. Cancel if click is released.
_cancel_reach()
MovementState.GRIPPING:
# print("ZeroGMovementComponent: Gripping State Active")
if release_input.pressed or release_input.held or not is_instance_valid(current_grip):
_release_current_grip(move_input)
return
# Check for launch charge *before* checking for climb, as it's a more specific action.
if (reach_input.pressed or reach_input.held) and move_input != Vector2.ZERO:
_start_charge(move_input)
return
elif move_input != Vector2.ZERO:
_start_climb(move_input) # This is overshadowed by the above check.
MovementState.CLIMBING:
if reach_input.pressed or reach_input.held:
_start_charge(move_input)
return
if release_input.pressed or release_input.held or not is_instance_valid(current_grip):
_stop_climb(true) # Release grip and stop
return
if move_input == Vector2.ZERO: # Player stopped giving input
_stop_climb(false) # Stop moving, return to GRIPPING
return
# Continue climbing logic (finding next grip) happens in _process_climbing
MovementState.CHARGING_LAUNCH:
if not (reach_input.pressed or reach_input.held):
_execute_launch(move_input)
elif move_input == Vector2.ZERO: # Cancel charge while holding interact
current_state = MovementState.GRIPPING
print("ZeroGMovementComponent: Cancelled Launch Charge")
# === MOVEMENT PROCESSING ===
func _process_idle(delta: float, move_input: Vector2, vertical_input: float, roll_input: float, release_input: PlayerController3D.KeyInput):
# State is IDLE (free-floating).
# Check for EVA suit usage.
var has_movement_input = (move_input != Vector2.ZERO or vertical_input != 0.0 or roll_input != 0.0)
if has_movement_input and is_instance_valid(pawn.eva_suit_component):
# Use EVA suit
pawn.eva_suit_component.apply_thrusters(pawn, delta, move_input, vertical_input, roll_input)
# Check for body orientation (if applicable)
if release_input.held and is_instance_valid(pawn.eva_suit_component):
pawn.eva_suit_component._orient_pawn(delta) # Use suit's orient
func _process_reaching(_delta: float):
# TODO: Drive IK target towards current_grip.get_grip_transform().origin
# TODO: Monitor distance / animation state
# For now, we just instantly grip.
if _seeking_climb_input != Vector2.ZERO:
_attempt_grip(next_grip_target) # Complete the seek-reach
else:
_attempt_grip(_find_best_grip())
func _apply_grip_physics(delta: float, _move_input: Vector2, roll_input: float):
if not is_instance_valid(pawn) or not is_instance_valid(current_grip):
_release_current_grip(); return
# TODO: Later, replace step 2 and 3 with IK driving the hand bone to the target_transform.origin,
# while the physics/orientation logic stops the main body's momentum.
# --- 1. Calculate Target Transform (Same as before) ---
var grip_base_transform = current_grip.global_transform
var target_direction = grip_base_transform.basis.z.normalized()
var hold_distance = _get_hold_distance()
var target_position = grip_base_transform.origin + target_direction * hold_distance
var target_basis = _choose_grip_orientation(grip_base_transform.basis)
# --- 2. Apply Linear Force (PD Controller) ---
var error_pos = target_position - pawn.global_position
# Simple P-controller for velocity (acts as a spring)
var target_velocity_pos = error_pos * gripping_linear_damping # 'damping' here acts as Kp
# Simple D-controller (damping)
target_velocity_pos -= pawn.velocity * gripping_angular_damping # 'angular_damping' here acts as Kd
# Apply force via acceleration
pawn.velocity = pawn.velocity.lerp(target_velocity_pos, delta * 10.0) # Smoothly apply correction
# --- 3. Apply Angular Force (PD Controller) ---
if not is_zero_approx(roll_input):
# Manual Roll Input (applies torque)
var roll_torque_global = pawn.global_transform.basis.z * (-roll_input) * gripping_orient_speed # Use global Z
pawn.add_torque(roll_torque_global, delta)
else:
# Auto-Orient (PD Controller)
_apply_orientation_torque(target_basis, delta)
func _apply_climb_physics(delta: float, move_input: Vector2):
if not is_instance_valid(pawn) or not is_instance_valid(current_grip):
_stop_climb(true); return
# 1. Calculate Climb Direction: For climbing we interpret W as up from the pawns perspective instead of forward
var climb_direction = move_input.y * pawn.global_basis.y + move_input.x * pawn.global_basis.x
climb_direction = climb_direction.normalized()
# 2. Find Next Grip
next_grip_target = _find_best_grip(climb_direction, INF, climb_angle_threshold_deg)
# 3. Check for Handover: This should be more eager to mark a new grip as current than below check is to release when climbing past
var performed_handover = _attempt_grip(next_grip_target)
# 4. Check for Release Past Grip (if no handover)
if not performed_handover:
var current_grip_pos = current_grip.global_position
var vector_from_grip_to_pawn = pawn.global_position - current_grip_pos
var distance_along_climb_dir = vector_from_grip_to_pawn.dot(climb_direction)
if distance_along_climb_dir > release_past_grip_threshold: # Release threshold
_release_current_grip(move_input)
return # State changed to IDLE
# 5. Apply Movement Force
var target_velocity = climb_direction * climb_speed
pawn.velocity = pawn.velocity.lerp(target_velocity, delta * climb_acceleration)
# 6. Apply Angular Force (Auto-Orient to current grip)
var grip_base_transform = current_grip.global_transform
var target_basis = _choose_grip_orientation(grip_base_transform.basis)
_apply_orientation_torque(target_basis, delta)
func _process_seeking_climb(_delta: float, move_input: Vector2):
# If the player's input has changed from what initiated the seek, cancel it.
if not move_input.is_equal_approx(_seeking_climb_input):
var target_grip = _find_best_grip()
_seeking_climb_input = Vector2.ZERO # Reset for next time
if _attempt_grip(target_grip):
# Successfully found and grabbed a grip. The state is now GRIPPING.
print("Seeking Climb ended, gripped new target.")
else:
current_state = MovementState.IDLE
# No grip found. Transition to IDLE.
print("Seeking Climb ended, no grip found. Reverting to IDLE.")
# --- Grip Helpers
## The single, authoritative function for grabbing a grip.
func _attempt_grip(target_grip: GripArea3D) -> bool:
if not is_instance_valid(target_grip):
return false
if target_grip.grab(pawn):
# Successfully grabbed the new grip.
var old_grip = current_grip
if is_instance_valid(old_grip) and old_grip != target_grip:
old_grip.release(pawn)
current_grip = target_grip
next_grip_target = null
_seeking_climb_input = Vector2.ZERO
# If we weren't already climbing, transition to GRIPPING state.
if current_state != MovementState.CLIMBING:
current_state = MovementState.GRIPPING
print("Successfully gripped: ", current_grip.get_parent().name)
return true
else:
# Failed to grab the new grip.
print("Failed to grip: ", target_grip.get_parent().name, " (likely occupied).")
if current_state == MovementState.CLIMBING:
_stop_climb(false) # Stop climbing, return to gripping previous one
return false
# --- Grip Orientation Helper ---
func _choose_grip_orientation(grip_basis: Basis) -> Basis:
# 1. Define the two possible target orientations based on the grip.
# Both will look away from the grip's surface (-Z).
var look_at_dir = -grip_basis.z.normalized()
var target_basis_up = Basis.looking_at(look_at_dir, grip_basis.y.normalized()).orthonormalized()
var target_basis_down = Basis.looking_at(look_at_dir, -grip_basis.y.normalized()).orthonormalized()
# 2. Get the pawn's current orientation.
var current_basis = pawn.global_basis
# 3. Compare which target orientation is "closer" to the current one.
# We can do this by finding the angle of rotation needed to get from current to each target.
# The quaternion dot product is related to the angle between orientations. A larger absolute dot product means a smaller angle.
var dot_up = current_basis.get_rotation_quaternion().dot(target_basis_up.get_rotation_quaternion())
var dot_down = current_basis.get_rotation_quaternion().dot(target_basis_down.get_rotation_quaternion())
# We choose the basis that results in a larger absolute dot product (smaller rotational distance).
return target_basis_up if abs(dot_up) >= abs(dot_down) else target_basis_down
# --- Grip Selection Logic ---
# Finds the best grip based on direction, distance, and angle constraints
func _find_best_grip(direction := Vector3.ZERO, max_distance_sq := INF, angle_threshold_deg := 120.0) -> GripArea3D:
var best_grip: GripArea3D = null
var min_dist_sq = max_distance_sq # Start checking against max allowed distance
var use_direction_filter = direction != Vector3.ZERO
var max_allowed_angle_rad = 0.0 # Initialize
if use_direction_filter:
# Calculate the maximum allowed angle deviation from the center direction
max_allowed_angle_rad = deg_to_rad(angle_threshold_deg) / 2.0
# Iterate through all grips detected by the pawn
for grip in nearby_grips:
# Basic validity checks
if not is_instance_valid(grip) or grip == current_grip or not grip.can_grab(pawn):
continue
var grip_pos = grip.global_position
# Use direction_to which automatically normalizes
var dir_to_grip = pawn.global_position.direction_to(grip_pos)
var dist_sq = pawn.global_position.distance_squared_to(grip_pos)
# Check distance first
if dist_sq >= min_dist_sq: # Use >= because we update min_dist_sq later
continue
# If using direction filter, check angle constraint
if use_direction_filter:
# Ensure the direction vector we compare against is normalized
var normalized_direction = direction.normalized()
# Calculate the dot product
var dot = dir_to_grip.dot(normalized_direction)
# Clamp dot product to handle potential floating-point errors outside [-1, 1]
dot = clamp(dot, -1.0, 1.0)
# Calculate the actual angle between the vectors in radians
var angle_rad = acos(dot)
# Check if the calculated angle exceeds the maximum allowed deviation
if angle_rad > max_allowed_angle_rad:
# print("Grip ", grip.get_parent().name, " outside cone. Angle: ", rad_to_deg(angle_rad), " > ", rad_to_deg(max_allowed_angle_rad))
continue # Skip this grip if it's outside the cone
# If it passes all filters and is closer than the previous best:
min_dist_sq = dist_sq
best_grip = grip
# if is_instance_valid(best_grip):
# print("Best grip found: ", best_grip.get_parent().name, " at distance squared: ", min_dist_sq)
return best_grip
# --- Reaching Helpers ---
func _get_hold_distance() -> float:
# Use the pawn.grip_detector.position.length() method if you prefer that:
if is_instance_valid(pawn) and is_instance_valid(pawn.grip_detector):
return pawn.grip_detector.position.length()
else:
return 0.5 # Fallback distance if detector isn't set up right
func _release_current_grip(move_input: Vector2 = Vector2.ZERO):
if is_instance_valid(current_grip):
current_grip.release(pawn)
current_grip = null
# If we were climbing and are still holding a climb input, start seeking.
if move_input != Vector2.ZERO:
current_state = MovementState.SEEKING_CLIMB
_seeking_climb_input = move_input # Store the input that started the seek
# print("ZeroGMovementComponent: Released grip, now SEEKING_CLIMB.")
else:
current_state = MovementState.IDLE
# print("ZeroGMovementComponent: Released grip, now IDLE.")
func _cancel_reach():
# TODO: Logic to stop IK/animation if reach is cancelled mid-way
_release_current_grip(Vector2.ZERO) # Ensure grip reference is cleared
print("ZeroGMovementComponent: Reach cancelled.")
# --- Climbing Helpers ---
func _start_climb(move_input: Vector2):
if not is_instance_valid(current_grip): return
current_state = MovementState.CLIMBING
# Calculate initial climb direction based on input relative to camera/grip
var pawn_up = pawn.global_basis.y
var pawn_right = pawn.global_basis.x
print("ZeroGMoveController: Started Climbing in direction: ", (pawn_up * move_input.y + pawn_right * move_input.x).normalized())
func _stop_climb(release_grip: bool):
# print("ZeroGMoveController: Stopping Climb. Release Grip: ", release_grip)
pawn.velocity = pawn.velocity.lerp(Vector3.ZERO, 0.5) # Apply some braking
next_grip_target = null
if release_grip:
_release_current_grip() # Transitions to IDLE
else:
current_state = MovementState.GRIPPING # Go back to stationary gripping
func _apply_orientation_torque(target_basis: Basis, delta: float):
var current_quat = pawn.global_transform.basis.get_rotation_quaternion()
var target_quat = target_basis.get_rotation_quaternion()
var error_quat = target_quat * current_quat.inverse()
# Ensure we take the shortest path for rotation. If W is negative, the
# quaternion represents the "long way around". Negating it gives the same
# orientation but via the shorter rotational path.
if error_quat.w < 0: error_quat = -error_quat
var error_angle = error_quat.get_angle()
var error_axis = error_quat.get_axis()
var torque_proportional = error_axis.normalized() * error_angle * gripping_orient_speed
var torque_derivative = -pawn.angular_velocity * gripping_angular_damping
var total_torque_global = (torque_proportional + torque_derivative)
pawn.add_torque(total_torque_global, delta)
# --- Launch helpers ---
func _start_charge(move_input: Vector2):
if not is_instance_valid(current_grip): return # Safety check
current_state = MovementState.CHARGING_LAUNCH
launch_charge = 0.0
# Calculate launch direction based on input and push-off normal
# The direction is based on the pawn's current orientation, not the camera or grip.
# This makes it feel like you're pushing off in a direction relative to your body.
var pawn_up = pawn.global_basis.y
var pawn_right = pawn.global_basis.x
launch_direction = (pawn_up * move_input.y + pawn_right * move_input.x).normalized()
print("ZeroGMovementComponent: Charging Launch")
func _handle_launch_charge(delta: float):
launch_charge = min(launch_charge + launch_charge_rate * delta, max_launch_speed)
pawn.velocity = Vector3.ZERO
pawn.angular_velocity = Vector3.ZERO
func _execute_launch(move_input: Vector2):
if not is_instance_valid(current_grip): return # Safety check
pawn.velocity = launch_direction * launch_charge # Apply launch velocity to pawn
launch_charge = 0.0
_release_current_grip(move_input) # Release AFTER calculating direction
# Instead of going to IDLE, go to SEEKING_CLIMB to find the next grip.
# The move_input that started the launch is what we'll use for the seek direction.
# _seeking_climb_input = (pawn.global_basis.y.dot(launch_direction) * Vector2.UP) + (pawn.global_basis.x.dot(launch_direction) * Vector2.RIGHT)
# current_state = MovementState.SEEKING_CLIMB
print("ZeroGMovementComponent: Launched with speed ", pawn.velocity.length(), " and now SEEKING_CLIMB")
# --- Signal Handlers ---
func on_grip_area_entered(area: Area3D):
if area is GripArea3D: # Check if the entered area is actually a GripArea3D node
var grip = area as GripArea3D
if not grip in nearby_grips:
nearby_grips.append(grip)
# print("Detected nearby grip: ", grip.get_parent().name if grip.get_parent() else "UNKNOWN") # Print parent name for context
func on_grip_area_exited(area: Area3D):
if area is GripArea3D:
var grip = area as GripArea3D
if grip in nearby_grips:
nearby_grips.erase(grip)
# print("Grip out of range: ", grip.get_parent().name if grip.get_parent() else "UNKNOWN")

View File

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

View File

@ -0,0 +1,70 @@
[gd_scene load_steps=11 format=3 uid="uid://dq0r0kk8wa414"]
[sub_resource type="TorusMesh" id="TorusMesh_nvgim"]
flip_faces = true
inner_radius = 21.0
outer_radius = 24.0
[sub_resource type="ConvexPolygonShape3D" id="ConvexPolygonShape3D_5aph0"]
points = PackedVector3Array(11.4275, 1.16637, 22.0937, 12.9507, -1.11875, 15.2381, 0, -1.11875, 20.5706, 13.7133, 1.92857, 15.2381, 15.2366, -1.88095, 19.0484, 0, 1.16637, 24.3792, 5.3343, -1.88095, 23.6176, 18.2832, 1.92857, 16.0014, 0, 1.92857, 21.3321, 18.2832, -1.88095, 15.2381, 5.3343, 1.92857, 23.6176, 0, -1.88095, 21.3321, 7.62017, -1.11875, 23.6176, 0, -1.11875, 24.3792, 15.2366, -1.11875, 19.8091, 0, 1.16637, 20.5706, 13.7133, -1.88095, 15.2381, 19.0476, -1.11875, 16.0014, 15.9992, 1.16637, 19.0484, 4.57173, 0.404538, 24.3792, 12.9507, 1.92857, 20.5706, 19.0476, 1.16637, 15.2381, 9.90417, -1.11875, 22.8561, 7.62017, 1.16637, 23.6176, 0, -1.88095, 23.6176, 0, 1.92857, 23.6176, 12.9507, 1.16637, 15.2381, 4.57173, -1.11875, 24.3792, 19.0476, 1.16637, 16.0014, 8.38274, -1.88095, 22.8561, 12.9507, -1.11875, 21.3321, 8.38274, 1.92857, 22.8561)
[sub_resource type="ConvexPolygonShape3D" id="ConvexPolygonShape3D_iayf5"]
points = PackedVector3Array(13.7153, -0.356919, 15.2381, 24.3799, 0.404538, 4.57173, 24.3799, -1.11875, 4.57173, 20.5703, -1.11875, 0, 21.3324, 1.92857, 0, 19.0481, 1.92857, 15.2381, 19.0481, -1.88095, 15.2381, 15.2396, 1.92857, 13.7137, 23.6178, -1.88095, 0, 14.4775, -1.88095, 14.4759, 23.6178, 1.92857, 0, 22.8557, 1.16637, 9.90267, 13.7153, 0.404538, 14.4759, 21.3324, -1.11875, 12.9515, 20.5703, 1.16637, 0, 22.8557, -1.88095, 8.37827, 23.6178, 1.92857, 5.33393, 21.3324, -1.88095, 0, 24.3799, 1.16637, 0, 15.2396, 1.92857, 15.2381, 19.8082, 0.404538, 15.2381, 22.8557, -1.11875, 9.90267, 13.7153, -0.356919, 14.4759, 24.3799, -1.11875, 0, 14.4775, -1.88095, 15.2381, 22.8557, 1.92857, 8.37827, 23.6178, 1.16637, 7.61905, 22.0935, 1.16637, 11.4271, 23.6178, -1.88095, 5.33393, 24.3799, 1.16637, 3.80952, 19.8082, -1.11875, 15.2381, 23.6178, -1.11875, 7.61905)
[sub_resource type="ConvexPolygonShape3D" id="ConvexPolygonShape3D_ei2hu"]
points = PackedVector3Array(-3.80952, 1.16637, 24.3799, -15.2381, -0.356919, 13.7153, -15.2381, 0.404538, 13.7153, 0, -1.11875, 20.5703, -12.9515, -1.88095, 20.5703, -15.2381, 1.92857, 19.0481, 0, 1.92857, 21.3324, 0, -1.88095, 23.6178, -15.2381, -1.88095, 14.4775, -13.7137, 1.92857, 15.2396, -9.90267, -1.11875, 22.8557, 0, 1.92857, 23.6178, -15.2381, -1.11875, 19.8082, -9.90267, 1.16637, 22.8557, 0, 1.16637, 20.5703, -4.57173, -1.11875, 24.3799, 0, -1.88095, 21.3324, -8.37827, 1.92857, 22.8557, -14.4759, -0.356919, 13.7153, 0, -1.11875, 24.3799, -5.33393, -1.88095, 23.6178, -14.4759, -1.88095, 14.4775, -14.4759, 0.404538, 13.7153, -15.2381, 1.92857, 15.2396, -12.9515, 0.404538, 21.3324, -15.2381, -1.88095, 19.0481, 0, 1.16637, 24.3799, -7.61905, 1.16637, 23.6178, -5.33393, 1.92857, 23.6178, -8.37827, -1.88095, 22.8557, -12.9515, -1.11875, 21.3324, -15.2381, 0.404538, 19.8082)
[sub_resource type="ConvexPolygonShape3D" id="ConvexPolygonShape3D_7k4ip"]
points = PackedVector3Array(-24.3792, 0.404538, 4.57173, -15.2381, 1.16637, 19.0476, -15.2381, -1.11875, 19.0476, -20.5706, 1.16637, 0, -21.3321, -1.88095, 0, -20.5706, -1.88095, 12.9507, -15.2381, -1.88095, 13.7133, -20.5706, 1.92857, 12.9507, -15.2381, 1.92857, 13.7133, -23.6176, 1.92857, 0, -23.6176, -1.88095, 0, -22.8561, -1.11875, 9.90417, -19.8091, 0.404538, 15.2366, -22.8561, 1.16637, 9.90417, -16.0014, -1.88095, 18.2832, -23.6176, -1.88095, 5.3343, -16.0014, 1.92857, 18.2832, -21.3321, 1.92857, 0, -20.5706, -1.11875, 0, -15.2381, -1.11875, 12.9507, -23.6176, 1.92857, 5.3343, -24.3792, -1.11875, 0, -19.8091, -1.11875, 15.2366, -16.0014, 1.16637, 19.0476, -24.3792, 1.16637, 0, -24.3792, -1.11875, 4.57173, -15.2381, 1.16637, 12.9507, -16.0014, -1.11875, 19.0476, -19.0484, 1.16637, 15.9992, -21.3321, -1.11875, 12.9507, -23.6176, 1.16637, 7.62017, -22.8561, -1.88095, 8.38274)
[sub_resource type="ConvexPolygonShape3D" id="ConvexPolygonShape3D_ehkdd"]
points = PackedVector3Array(18.2869, -1.88095, -15.9992, 24.3792, 1.16637, 0, 24.3792, -1.11875, 0, 15.2381, -1.11875, -12.9507, 16.0014, 1.92857, -18.2832, 20.5706, 1.16637, 0, 22.8561, 1.16637, -9.90417, 21.3321, -1.88095, 0, 22.8561, -1.88095, -8.38274, 15.2381, 1.92857, -13.7133, 23.6176, 1.92857, 0, 15.2381, -1.88095, -18.2832, 19.8091, -1.11875, -15.2366, 20.5706, 1.92857, -12.9507, 24.3792, -1.11875, -4.57173, 23.6176, 1.92857, -5.3343, 21.3321, 1.92857, 0, 20.5706, -1.11875, 0, 15.2381, -1.88095, -13.7133, 16.0014, -1.11875, -19.0476, 19.0484, 1.16637, -15.9992, 23.6176, -1.88095, 0, 15.2381, 1.16637, -19.0476, 22.8561, -1.11875, -9.90417, 24.3792, 1.16637, -3.81102, 15.2381, 1.16637, -12.9507, 16.0014, 1.16637, -19.0476, 21.3321, 0.404538, -12.9507, 23.6176, 1.16637, -7.62017, 20.5706, -1.88095, -12.9507, 22.8561, 1.92857, -8.38274, 23.6176, -1.88095, -5.3343)
[sub_resource type="ConvexPolygonShape3D" id="ConvexPolygonShape3D_lr4gi"]
points = PackedVector3Array(15.2381, 0.404538, -19.8082, 0, -1.11875, -24.3799, 0, 1.16637, -24.3799, 14.4759, -0.356919, -13.7153, 15.2381, -1.88095, -19.0481, 0, -1.88095, -21.3324, 0, 1.92857, -21.3324, 15.2381, 1.92857, -15.2396, 8.37827, 1.92857, -22.8557, 8.37827, -1.88095, -22.8557, 15.2381, -1.88095, -14.4775, 0, 1.16637, -20.5703, 7.61905, -1.11875, -23.6178, 15.2381, 1.92857, -19.0481, 0, -1.11875, -20.5703, 13.7137, 1.92857, -15.2396, 4.57173, 0.404538, -24.3799, 9.90267, 1.16637, -22.8557, 12.9515, -1.11875, -21.3324, 14.4759, 0.404538, -13.7153, 0, 1.92857, -23.6178, 0, -1.88095, -23.6178, 14.4759, -1.88095, -14.4775, 4.57173, -1.11875, -24.3799, 5.33393, 1.92857, -23.6178, 15.2381, 0.404538, -13.7153, 7.61905, 1.16637, -23.6178, 11.4271, 1.16637, -22.0935, 9.90267, -1.11875, -22.8557, 5.33393, -1.88095, -23.6178, 3.80952, 1.16637, -24.3799, 15.2381, -1.11875, -19.8082)
[sub_resource type="ConvexPolygonShape3D" id="ConvexPolygonShape3D_4ekbl"]
points = PackedVector3Array(-21.3324, -1.88095, -11.4271, -13.7153, 0.404538, -14.4759, -15.2396, 1.92857, -13.7137, -20.5703, -1.11875, 0, -23.6178, 1.92857, -5.33393, -21.3324, 1.92857, 0, -19.0481, 1.92857, -15.2381, -14.4775, -1.88095, -15.2381, -23.6178, -1.88095, 0, -23.6178, -1.11875, -7.61905, -19.8082, -1.11875, -15.2381, -24.3799, 1.16637, 0, -22.8557, 1.16637, -9.90267, -20.5703, 1.16637, 0, -21.3324, -1.88095, 0, -24.3799, -1.11875, -4.57173, -15.2396, 1.92857, -15.2381, -14.4775, -1.88095, -14.4759, -19.0481, -1.88095, -15.2381, -23.6178, -1.88095, -5.33393, -24.3799, 1.16637, -3.80952, -23.6178, 1.92857, 0, -21.3324, -1.11875, -12.9515, -13.7153, -0.356919, -14.4759, -24.3799, -1.11875, 0, -19.8082, 0.404538, -15.2381, -23.6178, 1.16637, -7.61905, -22.8557, 1.92857, -8.37827, -13.7153, 0.404538, -15.2381, -22.8557, -1.11875, -9.90267, -22.0935, 1.16637, -11.4271, -22.8557, -1.88095, -8.37827)
[sub_resource type="ConvexPolygonShape3D" id="ConvexPolygonShape3D_p1in1"]
points = PackedVector3Array(-19.0476, 1.16637, -15.2381, -4.57173, -1.11875, -24.3792, -4.57173, 0.404538, -24.3792, 0, -1.11875, -20.5706, -18.2832, -1.88095, -15.2381, 0, 1.92857, -21.3321, -12.9507, 1.92857, -20.5706, -13.7133, 1.92857, -15.2381, -12.9507, -1.11875, -21.3321, 0, -1.88095, -23.6176, -13.7133, -1.88095, -15.2381, 0, 1.92857, -23.6176, -8.38274, -1.88095, -22.8561, -9.90417, 1.16637, -22.8561, -15.2366, 0.404538, -19.8091, -19.0476, -1.11875, -16.0014, -18.2832, 1.92857, -16.0014, 0, -1.88095, -21.3321, 0, 1.16637, -20.5706, -12.9507, -1.11875, -15.2381, -5.3343, 1.92857, -23.6176, 0, 1.16637, -24.3792, -15.2366, -1.88095, -19.0484, -9.90417, -1.11875, -22.8561, -12.9507, 1.16637, -15.2381, -19.0476, 1.16637, -16.0014, 0, -1.11875, -24.3792, -15.2366, -1.11875, -19.8091, -15.9992, 1.16637, -19.0484, -7.62017, 1.16637, -23.6176, -8.38274, 1.92857, -22.8561, -5.3343, -1.88095, -23.6176)
[sub_resource type="TorusMesh" id="TorusMesh_jlvj7"]
inner_radius = 25.0
outer_radius = 20.0
[node name="Node3D" type="Node3D"]
[node name="InnerTube" type="StaticBody3D" parent="."]
[node name="MeshInstance3D" type="MeshInstance3D" parent="InnerTube"]
mesh = SubResource("TorusMesh_nvgim")
[node name="CollisionShape3D8" type="CollisionShape3D" parent="InnerTube"]
shape = SubResource("ConvexPolygonShape3D_5aph0")
[node name="CollisionShape3D7" type="CollisionShape3D" parent="InnerTube"]
shape = SubResource("ConvexPolygonShape3D_iayf5")
[node name="CollisionShape3D6" type="CollisionShape3D" parent="InnerTube"]
shape = SubResource("ConvexPolygonShape3D_ei2hu")
[node name="CollisionShape3D5" type="CollisionShape3D" parent="InnerTube"]
shape = SubResource("ConvexPolygonShape3D_7k4ip")
[node name="CollisionShape3D4" type="CollisionShape3D" parent="InnerTube"]
shape = SubResource("ConvexPolygonShape3D_ehkdd")
[node name="CollisionShape3D3" type="CollisionShape3D" parent="InnerTube"]
shape = SubResource("ConvexPolygonShape3D_lr4gi")
[node name="CollisionShape3D2" type="CollisionShape3D" parent="InnerTube"]
shape = SubResource("ConvexPolygonShape3D_4ekbl")
[node name="CollisionShape3D" type="CollisionShape3D" parent="InnerTube"]
shape = SubResource("ConvexPolygonShape3D_p1in1")
[node name="OuterTube" type="StaticBody3D" parent="."]
[node name="MeshInstance3D" type="MeshInstance3D" parent="OuterTube"]
mesh = SubResource("TorusMesh_jlvj7")

View File

@ -1,67 +0,0 @@
class_name CelestialBody
extends RigidBody2D
# The celestial body that this body orbits.
@export var primary: CelestialBody
# This is a placeholder for your pixel art texture.
@export var texture: Texture2D
# The radius of the body, used for drawing and future collision detection.
@export var radius: float = 10.0
# Default color based on body type for visualization.
var body_color: Color = Color.ORANGE_RED
var orbit_radius_real : float = 0.0
var direction_to_primary : Vector2 = Vector2.ZERO
func get_class_name() -> String:
return "CelestialBody"
func _ready() -> void:
# We will handle gravity manually, so we set the built-in gravity scale to 0.
gravity_scale = 0.0
# To make the simulation work without drag, we must set linear damping to 0.
linear_damp = 0.0
angular_damp = 0.0
can_sleep = false
# Set the color based on the class name for easy differentiation.
match get_class_name():
"Star":
body_color = Color.GOLD
"Planet":
body_color = Color.BLUE
"Moon":
body_color = Color.PURPLE
"Station":
body_color = Color.WHITE
"Asteroid":
body_color = Color.BROWN
_:
body_color = Color.ORANGE_RED
# This callback is the correct place to apply custom forces to a RigidBody2D.
func _integrate_forces(state: PhysicsDirectBodyState2D) -> void:
if primary and is_instance_valid(primary):
var force = OrbitalMechanics.simple_n_body_grav(self, primary)
state.apply_central_force(force)
# We force a redraw here to update the body's visual representation.
queue_redraw()
# Override the default drawing function to draw the body.
# This is useful for debugging and visualization.
func _draw() -> void:
if texture:
# If a texture is assigned, draw it.
var size = Vector2(radius * 2, radius * 2)
var offset = -size / 2.0
draw_texture_rect(texture, Rect2(offset, size), false)
else:
# Otherwise, draw a simple placeholder circle.
draw_circle(Vector2.ZERO, radius, body_color)

View File

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

View File

@ -0,0 +1,50 @@
extends Node
var port = 42069
func create_server() -> void:
print(multiplayer.multiplayer_peer)
var peer = ENetMultiplayerPeer.new()
setup_connections()
var error = peer.create_server(port)
if error:
push_error(error)
return
multiplayer.multiplayer_peer = peer
print("Server Unique ID: ", multiplayer.get_unique_id())
func create_client() -> void:
setup_connections()
var peer = ENetMultiplayerPeer.new()
var error = peer.create_client("127.0.0.1", port)
if error:
push_error(error)
return
multiplayer.multiplayer_peer = peer
print("Client Unique ID: ", multiplayer.get_unique_id())
func setup_connections():
multiplayer.peer_connected.connect(on_peer_connected)
multiplayer.peer_disconnected.connect(on_peer_disconnected)
multiplayer.connected_to_server.connect(on_connected_to_server)
func on_peer_connected(peer_id: int) -> void:
print("Peer %s recieved connection: %s" % [multiplayer.get_unique_id(), peer_id])
# For each peer that connects, we put them in the queue to spawn
if multiplayer.is_server():
GameManager.queue_spawn_player(peer_id)
func on_peer_disconnected(peer_id: int) -> void:
print("Peer %s lost connection to: %s" % [multiplayer.get_unique_id(), peer_id])
print(multiplayer.get_peers())
func on_connected_to_server() -> void:
print("%s connected to server!" % multiplayer.get_unique_id())

View File

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

View File

@ -34,13 +34,13 @@ func _ready():
# --- PUBLIC FORCE APPLICATION METHODS ---
# This method is called by a component (like Thruster) at its global position.
func apply_force(force: Vector2, position: Vector2 = self.global_position):
func apply_force(force: Vector2, pos: Vector2 = self.global_position):
# This is the force routing logic.
match physics_mode:
PhysicsMode.INDEPENDENT:
_add_forces(force, position)
_add_forces(force, pos)
PhysicsMode.COMPOSITE:
_add_forces(force, position)
_add_forces(force, pos)
## If we are the root, accumulate the force and calculate torque on the total body.
#accumulated_force += force
#
@ -56,7 +56,7 @@ func apply_force(force: Vector2, position: Vector2 = self.global_position):
if p is OrbitalBody2D:
# Recursively call the parent's apply_force method.
# This sends the force (and its original global position) up the chain.
p.apply_force(force, position)
p.apply_force(force, pos)
return # Stop at the first OrbitalBody2D parent
p = p.get_parent()
@ -64,13 +64,13 @@ func apply_force(force: Vector2, position: Vector2 = self.global_position):
physics_mode = PhysicsMode.COMPOSITE
apply_force(force, position)
func _add_forces(force: Vector2, position: Vector2 = Vector2.ZERO):
func _add_forces(force: Vector2, pos: Vector2 = Vector2.ZERO):
# If we are the root, accumulate the force and calculate torque on the total body.
accumulated_force += force
# Calculate torque (2D cross product: T = r x F = r.x * F.y - r.y * F.x)
# 'r' is the vector from the center of mass (global_position) to the point of force application (position).
var r = position - global_position
var r = pos - global_position
var torque = r.x * force.y - r.y * force.x
accumulated_torque += torque

View File

@ -0,0 +1,3 @@
extends Node
const UI_GRID_SIZE = 96

View File

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

View File

@ -5,7 +5,7 @@ var config: GameConfig
# --- Dictionaries to track players and their objects ---
var player_controllers: Dictionary = {} # Key: player_id, Value: PlayerController node
var player_pawns: Dictionary = {} # Key: player_id, Value: PilotBall node
var player_pawns: Dictionary = {} # Key: player_id, Value: CharacterPawn3D node
# This variable will hold the reference to the currently active star system.
var current_star_system : StarSystem = null
@ -13,95 +13,85 @@ var current_star_system : StarSystem = null
var registered_spawners: Array[Spawner] = []
var waiting_players: Array[int] = [] # A queue for players waiting to spawn
# --- Scene References for 3D ---
var player_controller_3d_scene: PackedScene = preload("res://scenes/tests/3d/player_controller_3d.tscn")
var character_pawn_3d_scene: PackedScene = preload("res://scenes/tests/3d/character_pawn_3d.tscn")
func _ready():
# Load the configuration resource from its fixed path.
config = load("res://scripts/singletons/default_game_config.tres")
if not config:
push_error("GameManager could not load game_config.tres!")
# This is now expected since we are using 3D scenes directly.
print("GameManager: game_config.tres not found or used. Using direct 3D scene references.")
return
# We need to initialize a network peer for the authority system to work,
# even in a single-player game. This creates a "server" for just you.
var peer = ENetMultiplayerPeer.new()
peer.create_server(5999) # Port number can be anything for a local game
multiplayer.multiplayer_peer = peer
# When the local server is created, it automatically calls on_player_connected(1).
multiplayer.peer_connected.connect(on_player_connected)
start_game()
# Check command-line arguments to determine if this instance should be a server or client.
# Godot's "Run Multiple Instances" feature adds "--server" to the main instance.
if "--server" in OS.get_cmdline_args():
NetworkHandler.create_server()
elif "--host" in OS.get_cmdline_args():
NetworkHandler.create_server()
# Host also acts as a player, so we need to handle its own connection.
NetworkHandler.on_peer_connected.call_deferred(1)
else:
print("GameManager: Starting as CLIENT.")
NetworkHandler.create_client()
func _process(_delta):
if find_available_spawner():
_try_spawn_waiting_player()
# Called when the game starts (e.g., from _ready() in StarSystemGenerator)
func start_game():
# For a single-player game, we simulate a player connecting with ID 1.
on_player_connected(1)
# This would be connected to a network signal in a multiplayer game.
func on_player_connected(player_id: int):
print("GameManager: Player %d connected." % player_id)
# 1. Spawn a controller for the new player.
var controller = config.player_controller_scene.instantiate()
controller.name = "PlayerController_%d" % player_id
add_child(controller)
player_controllers[player_id] = controller
# 2. Attempt to spawn a pawn for them immediately.
_attempt_to_spawn_player(player_id)
func _attempt_to_spawn_player(player_id: int):
if registered_spawners.is_empty():
# No spawners available, add the player to the waiting queue.
if not player_id in waiting_players:
waiting_players.append(player_id)
print("GameManager: No spawners available. Player %d is now waiting." % player_id)
# You could show a "Waiting for available spawner..." UI here.
else:
# Spawners are available, proceed with spawning.
spawn_player_pawn(player_id)
pass
# NEW: A function to process the waiting queue.
func queue_spawn_player(player_id: int):
waiting_players.append(player_id)
print("GameManager: Player %d queued for spawn." % player_id)
# function to process the waiting queue.
func _try_spawn_waiting_player():
if not waiting_players.is_empty() and not registered_spawners.is_empty():
var player_to_spawn = waiting_players.pop_front()
print("GameManager: Spawner is now available. Spawning waiting player %d." % player_to_spawn)
spawn_player_pawn(player_to_spawn)
var player_id = waiting_players.pop_back()
print("GameManager: Spawner is now available. Spawning waiting player %d." % player_id)
var spawn_point = find_available_spawner()
if spawn_point:
_spawn_player_pawn(player_id)
var pawn = player_pawns[player_id]
pawn.set_multiplayer_authority(player_id)
spawn_point.add_child(pawn)
print("GameManager peer %s: Player %d spawned successfully." % [multiplayer.get_unique_id(), player_id])
else:
waiting_players.append(player_id)
print("GameManager peer %s: Failed to spawn player %d." % [multiplayer.get_unique_id(), player_id])
func find_available_spawner() -> Spawner:
var idx = registered_spawners.find_custom(func(spawner: Spawner) -> bool:
return spawner.can_spawn()
)
return registered_spawners[idx] if idx != -1 else null
# @rpc("call_local")
func _spawn_player_pawn(player_id: int):
var pawn = character_pawn_3d_scene.instantiate()
pawn.name = str(player_id)
func spawn_player_pawn(player_id: int):
if not player_controllers.has(player_id):
push_error("Cannot spawn pawn for non-existent player %d" % player_id)
return
# --- NEW SPAWNING LOGIC ---
if registered_spawners.is_empty():
push_error("GameManager: No spawners available to create pawn!")
return
# For now, we'll just pick the first available spawner.
# Later, you could present a UI for the player to choose.
var spawn_point: Spawner = registered_spawners[0]
if not is_instance_valid(spawn_point):
push_error("GameManager: Spawn point not found!")
return
var owning_module = spawn_point.get_root_module()
if not is_instance_valid(owning_module):
push_error("GameManager: Registered spawner has no owning module!")
return
var pawn = config.default_pawn_scene.instantiate()
owning_module.add_child(pawn)
pawn.owner = owning_module
# 2. Set its position and initial velocity from the spawner.
pawn.global_position = spawn_point.global_position
player_pawns[player_id] = pawn
# 3. Possess the pawn with the player's controller.
player_controllers[player_id].possess(pawn)
pawn.set_multiplayer_authority(player_id)
print("GameManager: Spawned 3D Pawn for player %d" % player_id)
# Any scene that generates a star system will call this function to register itself.
func register_star_system(system_node):
@ -121,7 +111,7 @@ func register_spawner(spawner_node: Spawner):
print("GameManager: Spawner '%s' registered." % spawner_node.name)
# NEW: If a player is waiting, try to spawn them now.
_try_spawn_waiting_player()
# _try_spawn_waiting_player()
# A helper function for easily accessing the system's data.
func get_system_data() -> SystemData:
@ -139,3 +129,11 @@ func get_all_trackable_bodies() -> Array[OrbitalBody2D]:
# Next, add all registered ships to the list.
return all_bodies
# --- CLIENT-SIDE RPC PROXY ---
# A client-side script can call this function to send a request to the server.
# Example: GameManager.request_server_action("some_action", [arg1, arg2])
@rpc("call_remote")
func request_server_action(action_name: String, args: Array = []):
# This function's body only runs on the SERVER.
print("Server received request: ", action_name, " with args: ", args)

View File

@ -166,72 +166,51 @@ func _calculate_n_body_orbital_path(body_to_trace: OrbitalBody2D) -> PackedVecto
return path_points
# Calculates an array of points for the orbit RELATIVE to the primary body.
func _calculate_relative_orbital_path(body_to_trace: CelestialBody) -> PackedVector2Array:
if not is_instance_valid(body_to_trace) or not is_instance_valid(body_to_trace.primary):
func _calculate_relative_orbital_path(body_to_trace: OrbitalBody2D) -> PackedVector2Array:
if not is_instance_valid(body_to_trace) or not body_to_trace.has_method("get_primary") or not is_instance_valid(body_to_trace.get_primary()):
return PackedVector2Array()
# --- Initial State ---
var primary = body_to_trace.primary
var primary = body_to_trace.get_primary()
var primary_mass = primary.mass
var body_mass = body_to_trace.mass
# The position of the body relative to its parent.
var ghost_relative_pos = body_to_trace.global_position - primary.global_position
# The velocity of the body relative to its parent's velocity.
var ghost_relative_vel = body_to_trace.linear_velocity - primary.linear_velocity
# --- NEW: Dynamically Calculate Simulation Time from Orbital Period ---
var r_magnitude = ghost_relative_pos.length()
if r_magnitude == 0:
return PackedVector2Array()
var v_sq = ghost_relative_vel.length_squared()
var mu = OrbitalMechanics.G * primary_mass # Standard Gravitational Parameter
var mu = G * primary_mass
# 1. Calculate the specific orbital energy. Negative energy means it's a stable orbit.
var specific_energy = v_sq / 2.0 - mu / r_magnitude
# --- Simulation Parameters ---
var num_steps = 200 # The desired number of points for the orbit line's smoothness.
var time_step: float # The duration of each step, which we will now calculate.
var num_steps = 200
var time_step: float
if specific_energy >= 0:
# Escape trajectory (parabolic or hyperbolic). The period is infinite.
# We'll just draw a segment of its path using a fixed time_step.
time_step = 0.1
else:
# Stable elliptical orbit.
# 2. Calculate the semi-major axis from the energy.
var semi_major_axis = -mu / (2.0 * specific_energy)
# 3. Calculate the orbital period using Kepler's Third Law: T = 2π * sqrt(a³/μ)
var orbital_period = 2.0 * PI * sqrt(pow(semi_major_axis, 3) / mu)
# 4. Calculate the time_step needed to complete one period in num_steps.
time_step = orbital_period / float(num_steps)
var path_points = PackedVector2Array()
for i in range(num_steps):
# --- Physics Calculation (Primary is now at the origin (0,0)) ---
var distance_sq = ghost_relative_pos.length_squared()
if distance_sq < 1.0:
break
# Direction is simply towards the origin.
var direction = -ghost_relative_pos.normalized()
var force_magnitude = (G * primary_mass * body_mass) / distance_sq
var force_vector = direction * force_magnitude
var acceleration = force_vector / body_mass
# --- Integration (Update ghost's relative state) ---
ghost_relative_vel += acceleration * time_step
ghost_relative_pos += ghost_relative_vel * time_step
path_points.append(ghost_relative_pos)
return path_points
# Calculates the Hill Sphere radius for a satellite.
@ -260,70 +239,3 @@ func get_orbital_time_in_seconds(orbiter: OrbitalBody2D, primary: OrbitalBody2D)
var mu = OrbitalMechanics.G * primary.mass
var r = orbiter.global_position.distance_to(primary.global_position)
return TAU * sqrt(pow(r, 3) / mu)
## Projects the future paths of an array of bodies interacting with each other.
## Returns a dictionary mapping each body to its calculated PackedVector2Array path.
func project_n_body_paths(
bodies_to_trace: Array[OrbitalBody2D],
num_steps: int,
time_step: float
) -> Dictionary:
# --- Step 1: Create a "ghost state" for each body ---
# A ghost state is just a simple dictionary holding the physics properties.
var ghost_states = []
for body in bodies_to_trace:
ghost_states.append({
"body_ref": body,
"mass": body.mass,
"position": body.global_position,
"velocity": body.linear_velocity # Velocity is always in the same space
})
# --- Step 2: Prepare the results dictionary ---
var paths: Dictionary = {}
for state in ghost_states:
paths[state.body_ref] = PackedVector2Array()
# --- Step 3: Run the ghost simulation ---
for i in range(num_steps):
# Create a list to hold the forces for this time step
var forces_for_step = {}
for state in ghost_states:
forces_for_step[state.body_ref] = Vector2.ZERO
# a) Calculate all gravitational forces between the ghosts
for j in range(ghost_states.size()):
var state_a = ghost_states[j]
for k in range(j + 1, ghost_states.size()):
var state_b = ghost_states[k]
# Calculate force between the two ghost states
var force_vector = _calculate_force_between_ghosts(state_a, state_b)
# Store the forces to be applied
forces_for_step[state_a.body_ref] += force_vector
forces_for_step[state_b.body_ref] -= force_vector
# b) Integrate forces for each ghost to find its next position
for state in ghost_states:
if state.mass > 0:
var acceleration = forces_for_step[state.body_ref] / state.mass
state.velocity += acceleration * time_step
state.position += state.velocity * time_step
# c) Record the new position in the path
paths[state.body_ref].append(state.position)
return paths
# TODO this could be used in the above apply_n_body_forces method to instead calculate the force and store it for applying later
# --- Private helper for the projection function ---
func _calculate_force_between_ghosts(state_a: Dictionary, state_b: Dictionary) -> Vector2:
var distance_sq = state_a.position.distance_squared_to(state_b.position)
if distance_sq < 1.0: return Vector2.ZERO
var force_magnitude = (G * state_a.mass * state_b.mass) / distance_sq
var direction = state_a.position.direction_to(state_b.position)
return direction * force_magnitude

View File

@ -1,42 +0,0 @@
# scripts/singletons/popup_manager.gd
extends Node
# A dictionary to track active UI panels, ensuring we only have one per object.
# Key: context_object (e.g., the SystemStation), Value: the instantiated panel node
var active_panels: Dictionary = {}
## The main entry point for the system.
#func request_popup(panel_world: World2D, player: PilotBall):
## Get the player's personal UI container (e.g., from PilotBall.gd)
#var ui_container = player.get_ui_container()
#if not ui_container: return
#
## Create a draggable Window for the UI
#var window = Window.new()
#window.title = panel_instance.name
#window.size = panel_instance.size + Vector2(20, 40) # Add space for title bar
#window.close_requested.connect(window.queue_free) # Close button works automatically
#ui_container.add_child(window)
#
## Create the SubViewport setup
#var vp_container = SubViewportContainer.new()
#vp_container.stretch = true
#vp_container.anchors_preset = Control.PRESET_FULL_RECT
#window.add_child(vp_container)
#
#var sub_viewport = SubViewport.new()
#sub_viewport.world_2d
#sub_viewport.size = panel_instance.size
#sub_viewport.transparent_bg = true
#vp_container.add_child(sub_viewport)
#
## Create and configure the camera
#var camera = Camera2D.new()
#camera.set_cull_mask_value(1, false) # Don't see the default world
#camera.set_cull_mask_value(2, true) # ONLY see the "UI_Panels" layer
#camera.global_position = panel_instance.global_position
## You can add zoom logic here if needed, similar to the station implementation
#sub_viewport.add_child(camera)
#
## Forward input from the screen overlay to the world-space panel
#vp_container.gui_input.connect(func(event): sub_viewport.push_input(event.duplicate()))

View File

@ -1 +0,0 @@
uid://6s8bq0if4mev

View File

@ -1,11 +0,0 @@
extends Node
# Signal emitted when the player requests to toggle the map view.
signal map_mode_toggled
# Signal emitted from the map when a body is selected to be followed.
# It passes the selected CelestialBody as an argument.
signal follow_target_selected(body: CelestialBody)
# Emitted by the NavComputer to command a timed rotation.
signal rotation_maneuver_planned(target_rotation_rad: float, time_window_seconds: float)

View File

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

View File

@ -44,7 +44,7 @@ func generate(star_system: StarSystem) -> SystemData:
planet.position = Vector2.ZERO
planet_barycenter.recalculate_total_mass()
# C. Create moons for this planet.
_generate_moons(planet, planet_barycenter, star_system, system_data)
_generate_moons(planet, planet_barycenter, system_data)
# D. Place the entire planetary system in a stable orbit.
planet_barycenter.global_position = Vector2(current_orbit_radius, 0).rotated(randf_range(0, TAU))
@ -63,7 +63,7 @@ func generate(star_system: StarSystem) -> SystemData:
return system_data
func _generate_moons(planet: OrbitalBody2D, planet_barycenter: Barycenter, star_system: StarSystem, system_data: SystemData):
func _generate_moons(planet: OrbitalBody2D, planet_barycenter: Barycenter, system_data: SystemData):
var num_moons = randi_range(0, int(planet.mass / MOON_MASS / 2.0)) # Heavier planets get more moons
num_moons = min(num_moons, MAX_MOONS_PER_PLANET)