Files
Brachistochrone/Scenes/Scripts/player.gd
T
scorpion21211-cmd 369a0e2b52 Arcology Update
updated textures and collision on Young Arcology
2026-10-10 00:39:35 -04:00

1288 lines
40 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends CharacterBody3D
## PASTE 2026-10-10 — Habitat trimesh. View stairs + port ring are floors.
@export var speed: float = 5.5
@export var sprint_speed: float = 9.0
@export var jump_velocity: float = 4.6
@export var mouse_sensitivity: float = 0.0028
@export var move_accel: float = 22.0
@export var move_decel: float = 28.0
@export var ads_walk_scale: float = 0.72
@export var ads_sprint_scale: float = 0.84
var mag_boots_on: bool = true
var first_person: bool = false
var in_vehicle: bool = false
var pressurized: bool = true
@onready var camera_pivot: Node3D = $CameraPivot
@onready var camera: Camera3D = $CameraPivot/Camera3D
@onready var look_loader: Node = get_node_or_null("Body")
var anim_player: AnimationPlayer = null
var free_looking: bool = false
var free_look_yaw: float = 0.0
var free_look_pitch: float = 0.0
var free_look_sensitivity: float = 0.003
var eva_peak_height: float = 0.0
var _cam_pos_target: Vector3 = Vector3(0.58, 0.38, 3.20)
var _cam_rot_target: Vector3 = Vector3(deg_to_rad(-8.0), 0.0, 0.0)
var _fov_target: float = 70.0
var _fov_kick: float = 0.0
var player_name: String = "Colonist"
var background_id: String = ""
var background_name: String = ""
var culture_id: String = ""
var culture_name: String = ""
var appearance: Dictionary = {}
var food: float = 100.0
var health: float = 100.0
var money: int = 75
var oxygen: float = 100.0
var food_decay_rate: float = 0.7
var oxygen_decay_rate: float = 0.15
var starve_damage_rate: float = 2.5
var low_oxygen_damage_rate: float = 4.0
var inventory: Array[String] = []
var inventory_open: bool = false
var interact_distance: float = 6.0
var can_interact: bool = false
var current_interactable: Node = null
@onready var needs_label: Label = get_node_or_null("/root/Node3D/HUD/NeedsLabel")
@onready var vendor_menu: Control = get_node_or_null("/root/Node3D/VendorUI/VendorMenu")
@onready var pause_menu: Control = get_node_or_null("/root/Node3D/HUD/PauseMenu")
const MOON_RADIUS: float = 173700.0
const MOON_G: float = 1.62
const MOON_SOI: float = 400000.0
const EARTH_RADIUS: float = 637100.0
const EARTH_G: float = 9.81
const EARTH_SOI: float = 20000000.0
const EVA_SNAP_PEAK: float = 6.0
const STEP_HEIGHT: float = 0.22
const WALK_FLOORS: Array[String] = [
"Deck_",
"Stair_A_Treads", "Stair_B_Treads",
"Stair_A_Landings", "Stair_B_Landings", "Stair_LevelLandings",
"Stair_TopLandings", "Stair_HeadLanding",
"Lecture_Steps", "Lecture_Tiers", "Lecture_Seats", "School_Stage",
"Yard_Floor", "Yard_Apron", "Yard_Spoke", "Yard_Ramps",
"Yard_Bridges", "Yard_Stairs", "Yard_CollarWalk", "Yard_Piers",
"Dock_Gallery", "Dock_Threshold",
"Elev_Landings", "Fusion_Basemat", "Fusion_AmbulatoryWide",
"Fusion_HeaderStands",
"Port_Ring",
"Live_Terraces",
]
const WALK_WALLS: Array[String] = [
"Hull_Below", "Hull_Band", "Hull_Lintel",
"Yard_Wall", "Yard_StairRail", "Yard_Rails", "Yard_RampCurbs",
"Stair_A_Rails", "Stair_B_Rails",
"NAF_Blocks",
"Fusion_Wall", "Fusion_Pane", "Fusion_Frame", "Fusion_DoorFrames",
"Fusion_Control", "Fusion_Cryoplant", "Fusion_Turbine", "Fusion_Exchanger",
"Fusion_Columns", "Fusion_ShieldHigh", "Fusion_ShieldLow",
"Fusion_CryostatHigh", "Fusion_CryostatLow",
"School_Rooms", "School_Library", "School_Bulkhead",
"School_Colonnade", "Lecture_Pillars", "Lecture_Backs", "Lecture_Curb",
"Live_Wall",
"Market_Colonnade", "Market_Stalls",
"Core_",
"Dock_Hall", "Dock_Vestibule", "Dock_Frame", "Dock_Roof", "Dock_Rail",
"Well_Curb", "Well_Beam",
"Cap_Shell",
]
const WALK_SKIP: Array[String] = [
"Light", "LightRig_", "Elev_Cab_", "Exchange_Neon_",
"Stair_Lamps", "School_RoomDoors", "School_LibraryDoor",
"Well_Seal", "Well_Planet_", "Well_Beacon_",
"Marker_",
]
var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
var moon_gravity: float = 0.0
var coyote_time: float = 0.0
var pistol_drawn: bool = false
var ads: bool = false
var pistol_ammo: int = 12
var pistol_reserve: int = 36
const PISTOL_MAG: int = 12
var fire_cd: float = 0.0
const FIRE_INTERVAL: float = 0.28
var interact_cd: float = 0.0
var jump_anim_cd: float = 0.0
var _crust_on: bool = false
var _moon_colliders: Array[PhysicsBody3D] = []
func _ensure_action(action: String, keycode: Key) -> void:
if not InputMap.has_action(action):
InputMap.add_action(action)
for ev in InputMap.action_get_events(action):
if ev is InputEventKey and (ev as InputEventKey).physical_keycode == keycode:
return
var evk := InputEventKey.new()
evk.physical_keycode = keycode
InputMap.action_add_event(action, evk)
func _ensure_default_actions() -> void:
_ensure_action("interact", KEY_F)
_ensure_action("mag_boots", KEY_C)
_ensure_action("toggle_mag_boots", KEY_C)
_ensure_action("toggle_pistol", KEY_G)
_ensure_action("reload", KEY_R)
_ensure_action("toggle_inventory", KEY_TAB)
_ensure_action("toggle_first_person", KEY_V)
_ensure_action("sprint", KEY_SHIFT)
_ensure_action("eva_roll_left", KEY_Q)
_ensure_action("eva_roll_right", KEY_E)
func _ready() -> void:
_ensure_default_actions()
scale = Vector3.ONE
safe_margin = 0.05
floor_snap_length = 0.45
floor_max_angle = deg_to_rad(70.0)
motion_mode = CharacterBody3D.MOTION_MODE_GROUNDED
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
moon_gravity = gravity / 6.0
mag_boots_on = true
print("Mag Boots: ON (spawn)")
_apply_camera_mode(true)
_set_moon_surface_visible(true)
if vendor_menu:
vendor_menu.visible = false
add_to_group("player")
FloatingOrigin.set_focus(self)
var solar := get_node_or_null("/root/Node3D/SolarSystem")
if solar:
FloatingOrigin.register_root(solar)
if not GameState.scramble_begun.is_connected(_on_scramble_begun):
GameState.scramble_begun.connect(_on_scramble_begun)
if Engine.has_meta("load_save"):
Engine.remove_meta("load_save")
var data := GameState.load_game_data()
if not data.is_empty():
player_name = str(data.get("player_name", player_name))
background_id = str(data.get("background_id", ""))
background_name = str(data.get("background_name", ""))
culture_id = str(data.get("culture_id", ""))
culture_name = str(data.get("culture_name", ""))
appearance = data.get("appearance", {})
money = int(data.get("money", money))
food = float(data.get("food", food))
health = float(data.get("health", health))
oxygen = float(data.get("oxygen", oxygen))
inventory.clear()
for item in data.get("inventory", []):
inventory.append(str(item))
GameState.apply_loaded_clock(data)
print("Loaded: ", player_name, " | credits=", money)
elif Engine.has_meta("player_creation_data"):
_apply_creation_data()
else:
print("Player ready – Young Arcology")
_rebuild_look()
_hide_legacy_soldier()
call_deferred("_snap_to_spawn")
call_deferred("_snap_to_spawn") # second pass after FO / habitat owner
call_deferred("_ensure_moon_shell")
call_deferred("_ensure_habitat_collision")
func _hide_legacy_soldier() -> void:
var legacy := get_node_or_null("Body/BlueSoldier_Male")
if legacy:
legacy.visible = false
legacy.process_mode = Node.PROCESS_MODE_DISABLED
func _apply_creation_data() -> void:
var data: Dictionary = Engine.get_meta("player_creation_data")
Engine.remove_meta("player_creation_data")
player_name = str(data.get("player_name", "Colonist"))
background_id = str(data.get("background_id", ""))
background_name = str(data.get("background_name", ""))
culture_id = str(data.get("culture_id", ""))
culture_name = str(data.get("culture_name", ""))
appearance = data.get("appearance", {})
var bonuses: Dictionary = data.get("bonuses", {})
if bonuses.has("money"):
money = int(bonuses["money"])
if bonuses.has("food"):
food = clamp(food + float(bonuses["food"]), 0.0, 100.0)
if bonuses.has("health"):
health = clamp(health + float(bonuses["health"]), 0.0, 100.0)
print("Colonist: ", player_name, " | ", culture_name, " | ", background_name, " | credits=", money, " | look=", appearance)
func _rebuild_look() -> void:
if look_loader and look_loader.has_method("apply_look") and not appearance.is_empty():
anim_player = look_loader.apply_look(appearance)
if anim_player == null:
anim_player = get_node_or_null("Body/BlueSoldier_Male/AnimationPlayer") as AnimationPlayer
_reset_doll_basis()
_apply_camera_mode(true)
func _play_move_anim(clip: String, speed_scale: float = 1.0) -> void:
if look_loader and look_loader.has_method("play_clip") and look_loader.get("anim") != null:
look_loader.play_clip(clip, speed_scale)
return
if anim_player == null:
return
anim_player.speed_scale = speed_scale
var names: Array[String] = ["CharacterArmature|%s" % clip, clip]
for n in names:
if anim_player.has_animation(n):
if anim_player.current_animation != n:
anim_player.play(n)
return
func _set_body_visible(v: bool) -> void:
if look_loader and look_loader.has_method("set_meshes_visible") and look_loader.get("current") != null:
look_loader.set_meshes_visible(v)
return
if has_node("Body"):
$Body.visible = v
func _update_fp_meshes() -> void:
# look_loader._apply_keep owns outfit slots. Only tuck the kept head in 1P.
var root: Node = look_loader if look_loader else get_node_or_null("Body")
if root == null:
return
_hide_head_only(root, first_person)
func _hide_head_only(n: Node, conceal_head: bool) -> void:
if n is MeshInstance3D:
var nm: String = n.name.to_lower()
if "head" in nm or "hair" in nm or "hat" in nm or "neck" in nm:
if conceal_head:
n.visible = false
# Do not force visible=true — that stacked every creator outfit.
for c in n.get_children():
_hide_head_only(c, conceal_head)
func _collect_moon_colliders() -> void:
_moon_colliders.clear()
var moon: Node = get_node_or_null("/root/Node3D/SolarSystem/Moon")
if moon == null:
moon = get_tree().root.find_child("Moon", true, false)
if moon == null:
return
var stack: Array[Node] = [moon]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n.name == "LovellAnchor" or n.name == "Station" or n.name == "Habitat" or n.name == "ShellBody":
continue
if n is PhysicsBody3D and (n.name == "MoonBody" or String(n.name).begins_with("Moon")):
_moon_colliders.append(n as PhysicsBody3D)
for c in n.get_children():
stack.append(c)
print("Player crust: moon colliders=", _moon_colliders.size())
func _inside_young() -> bool:
# Hull r=200. Jetway ~216. Pad ~232. Keep MoonBody ignored on the whole station.
var habitat: Node3D = _habitat()
if habitat == null:
return false
var lp: Vector3 = habitat.to_local(global_position)
var radial: float = Vector2(lp.x, lp.z).length()
return radial < 2000.0 and lp.y < 80.0 and lp.y > -120.0
func _update_crust_exception() -> void:
if _moon_colliders.is_empty():
_collect_moon_colliders()
# Always show the sphere. Hiding it was the "luna deleted" bug.
_set_moon_surface_visible(true)
# Ignore MoonBody only in rooms / while parked in the ship. Pads and mare collide.
var want: bool = in_vehicle or _inside_young()
if want == _crust_on:
return
_crust_on = want
for b in _moon_colliders:
if not is_instance_valid(b):
continue
if want:
add_collision_exception_with(b)
else:
remove_collision_exception_with(b)
print("Player crust exception ", "ON" if want else "OFF", " interior=", _inside_young())
func _set_moon_surface_visible(vis: bool) -> void:
var moon: Node3D = _moon()
if moon == null:
return
moon.set_meta("hide_crust", false)
for c in moon.get_children():
var nm: String = String(c.name)
if nm == "SurfaceHigh" or nm == "SurfaceLow":
(c as Node3D).visible = true
if not vis:
pass # kept so old callers compile; we do not hide luna anymore
func _snap_to_spawn() -> void:
var spawn: Node3D = _find_l5_spawn()
if spawn == null:
push_warning("SpawnPoint not found")
return
var hab: Node3D = _habitat()
var up: Vector3 = Vector3.UP
if hab:
up = hab.global_transform.basis.y.normalized()
else:
up = _surface_up()
# Marker is often at the well (local 0). Always stand on the L5 slab.
var deck := Vector3(48.0, 1.20, 18.0)
if hab:
global_position = hab.to_global(deck)
else:
global_position = spawn.global_position + up * 1.2
velocity = Vector3.ZERO
up_direction = up
_align_walk(up)
var local_p := Vector3.ZERO
if hab != null:
local_p = hab.to_local(global_position)
print("Spawned L5 hab=", hab, " marker=", str(spawn.get_path()), " pos=", global_position, " local=", local_p)
func _find_l5_spawn() -> Node3D:
# Name match only: L5Spawn. Ignore every SpawnPoint under L5_Civic / Terminal.
var station := get_parent()
if station:
for hab_name in ["Habitat", "Habitat_Old"]:
var hab := station.get_node_or_null(hab_name)
if hab == null:
continue
var official: Node = hab.get_node_or_null("L5Spawn")
if official is Node3D:
return official as Node3D
official = hab.find_child("L5Spawn", true, false)
if official is Node3D:
return official as Node3D
var nested: Node = get_tree().root.find_child("L5Spawn", true, false)
if nested is Node3D:
return nested as Node3D
return null
func set_in_vehicle(value: bool) -> void:
in_vehicle = value
if value:
pressurized = true
_set_moon_surface_visible(true)
_update_crust_exception()
func _is_aiming() -> bool:
return pistol_drawn and ads and not in_vehicle
func _update_waist_aim() -> void:
if look_loader == null:
return
# 3P gun-out AND 1P on foot: fold spine to camera pitch.
# EVA orients the whole body, so waist stays off there.
var fold: bool = not in_vehicle and not _is_eva() and (pistol_drawn or first_person)
if look_loader.has_method("set_aim_enabled"):
look_loader.set_aim_enabled(fold)
if look_loader.has_method("set_aim_pitch"):
var pitch: float = 0.0
if fold and camera_pivot:
pitch = camera_pivot.rotation.x
look_loader.set_aim_pitch(pitch)
func _process(delta: float) -> void:
if first_person:
_cam_pos_target = _fp_cam_pos()
_update_fp_meshes()
_update_waist_aim()
if look_loader and look_loader.has_method("set_fp_ride"):
look_loader.set_fp_ride(camera, camera_pivot, first_person and not in_vehicle)
_smooth_camera(delta)
if in_vehicle:
_process_needs(delta)
_update_hud()
func _smooth_camera(delta: float) -> void:
if camera == null or camera_pivot == null:
return
if first_person and not free_looking:
camera_pivot.rotation.y = lerp_angle(camera_pivot.rotation.y, 0.0, clampf(14.0 * delta, 0.0, 1.0))
elif not first_person and not free_looking:
free_look_yaw = lerp_angle(free_look_yaw, 0.0, clampf(14.0 * delta, 0.0, 1.0))
free_look_pitch = lerp_angle(free_look_pitch, 0.0, clampf(14.0 * delta, 0.0, 1.0))
if abs(free_look_yaw) < 0.002:
free_look_yaw = 0.0
if abs(free_look_pitch) < 0.002:
free_look_pitch = 0.0
camera_pivot.rotation.y = free_look_yaw
var t: float = clampf(10.0 * delta, 0.0, 1.0)
camera.position = camera.position.lerp(_cam_pos_target, t)
camera.rotation.x = lerp_angle(camera.rotation.x, _cam_rot_target.x, t)
camera.rotation.y = lerp_angle(camera.rotation.y, _cam_rot_target.y, t)
camera.rotation.z = lerp_angle(camera.rotation.z, _cam_rot_target.z, t)
if _fov_kick > 0.0:
_fov_kick = maxf(_fov_kick - 18.0 * delta, 0.0)
camera.fov = lerpf(camera.fov, _fov_target - _fov_kick, t)
func _on_scramble_begun() -> void:
print("ALARM: People's Privateer Fleet on approach to Young")
func restore_from_vehicle() -> void:
set_physics_process(true)
set_process(true)
set_process_input(true)
set_process_unhandled_input(true)
process_mode = Node.PROCESS_MODE_INHERIT
visible = true
in_vehicle = false
var height: float = _height_above_crust()
if height < 12.0:
mag_boots_on = true
pressurized = true
_align_walk(_walk_up())
else:
mag_boots_on = false
pressurized = false
eva_peak_height = max(height, EVA_SNAP_PEAK)
_reset_camera_to_walk()
_align_walk(_walk_up())
var col = get_node_or_null("CollisionShape3D")
if col:
col.disabled = false
if camera:
camera.near = 0.05
camera.far = 1000000.0
camera.current = true
camera.make_current()
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
FloatingOrigin.set_focus(self)
func _ensure_moon_shell() -> void:
var moon := _moon()
if moon == null:
print("PlanetShell FAIL: Moon node missing")
return
if moon.has_method("build"):
moon.call("build")
print("PlanetShell: asked Moon.build()")
return
var high := moon.get_node_or_null("SurfaceHigh") as MeshInstance3D
var radius: float = 173700.0
if high and high.mesh is SphereMesh:
var sm := high.mesh as SphereMesh
radius = sm.radius
if sm.height > radius * 1.5:
radius = sm.height * 0.5
var old := moon.get_node_or_null("MoonBody") as CollisionObject3D
if old:
old.collision_layer = 1
old.collision_mask = 1
for c in old.get_children():
if c is CollisionShape3D:
(c as CollisionShape3D).disabled = false
var anchor := moon.get_node_or_null("LovellAnchor") as Node3D
var hole_dir := Vector3.ZERO
var hole_cos := 1.0
if anchor:
var to_a: Vector3 = anchor.global_position - moon.global_position
if to_a.length() > 1.0:
hole_dir = (moon.global_transform.basis.inverse() * to_a.normalized()).normalized()
hole_cos = cos(atan(205.0 / radius))
var faces := PackedVector3Array()
var segs := 40
var rgs := 24
for lat in range(rgs):
var t0: float = float(lat) / float(rgs) * PI - PI * 0.5
var t1: float = float(lat + 1) / float(rgs) * PI - PI * 0.5
var y0 := sin(t0)
var y1 := sin(t1)
var r0 := cos(t0)
var r1 := cos(t1)
for lon in range(segs):
var a0: float = float(lon) / float(segs) * TAU
var a1: float = float(lon + 1) / float(segs) * TAU
var p00 := Vector3(r0 * cos(a0), y0, r0 * sin(a0)) * radius
var p10 := Vector3(r0 * cos(a1), y0, r0 * sin(a1)) * radius
var p01 := Vector3(r1 * cos(a0), y1, r1 * sin(a0)) * radius
var p11 := Vector3(r1 * cos(a1), y1, r1 * sin(a1)) * radius
for pair in [[p00, p01, p11], [p00, p11, p10]]:
var a: Vector3 = pair[0]
var b: Vector3 = pair[1]
var c: Vector3 = pair[2]
var mid: Vector3 = (a + b + c) / 3.0
if hole_dir != Vector3.ZERO and mid.length() > 0.001 and mid.normalized().dot(hole_dir) >= hole_cos:
continue
faces.append(a)
faces.append(b)
faces.append(c)
faces.append(a)
faces.append(c)
faces.append(b)
var body := moon.get_node_or_null("ShellBody") as StaticBody3D
if body == null:
body = StaticBody3D.new()
body.name = "ShellBody"
moon.add_child(body)
body.transform = Transform3D()
body.collision_layer = 1
body.collision_mask = 1
var col := body.get_node_or_null("CollisionShape3D") as CollisionShape3D
if col == null:
col = CollisionShape3D.new()
col.name = "CollisionShape3D"
body.add_child(col)
var shape := ConcavePolygonShape3D.new()
shape.set_faces(faces)
col.shape = shape
col.disabled = false
print("PlanetShell FALLBACK radius=", radius, " faces=", faces.size() / 3, " hole=", hole_dir != Vector3.ZERO)
func _moon() -> Node3D:
return get_node_or_null("/root/Node3D/SolarSystem/Moon") as Node3D
func _earth() -> Node3D:
return get_node_or_null("/root/Node3D/SolarSystem/Earth") as Node3D
func _habitat() -> Node3D:
var paths := [
NodePath("../Habitat"),
NodePath("../../Habitat"),
NodePath("/root/Node3D/SolarSystem/Moon/LovellAnchor/Station/Habitat"),
NodePath("/root/Node3D/SolarSystem/Moon/LovellAnchor/Habitat"),
]
for p in paths:
var h := get_node_or_null(p) as Node3D
if h:
return h
var any: Node = get_tree().root.find_child("Habitat", true, false)
if any is Node3D:
return any as Node3D
return null
func _soi_gravity() -> Vector3:
var moon := _moon()
if moon:
var to_m: Vector3 = moon.global_position - global_position
var dm: float = to_m.length()
if dm > 1.0 and dm < MOON_SOI:
return to_m.normalized() * MOON_G
var earth := _earth()
if earth:
var to_e: Vector3 = earth.global_position - global_position
var de: float = to_e.length()
if de > 1.0 and de < EARTH_SOI:
return to_e.normalized() * EARTH_G
return Vector3.ZERO
func _surface_up() -> Vector3:
var gvec: Vector3 = _soi_gravity()
if gvec.length() > 0.01:
return -gvec.normalized()
return Vector3.UP
func _walk_up() -> Vector3:
# Inside Young: decks are flat in Habitat space. Outside: radial from the body.
if _inside_young():
var hab := _habitat()
if hab:
return hab.global_transform.basis.y.normalized()
return _surface_up()
func _height_above_crust() -> float:
var moon := _moon()
if moon == null:
return 9999.0
return global_position.distance_to(moon.global_position) - MOON_RADIUS
func _reset_camera_to_walk() -> void:
free_looking = false
free_look_yaw = 0.0
free_look_pitch = 0.0
if camera_pivot:
camera_pivot.rotation = Vector3.ZERO
_reset_doll_basis()
_apply_camera_mode(false)
func _try_snap_boots() -> void:
if mag_boots_on or in_vehicle:
return
if not is_on_floor():
eva_peak_height = max(eva_peak_height, _height_above_crust())
return
if eva_peak_height < EVA_SNAP_PEAK:
eva_peak_height = 0.0
return
eva_peak_height = 0.0
mag_boots_on = true
pressurized = true
_snap_upright(_walk_up())
velocity.x = 0.0
velocity.z = 0.0
_reset_camera_to_walk()
print("Mag Boots: ON (landed)")
func _snap_upright(up: Vector3) -> void:
_align_walk(up)
func _is_eva() -> bool:
return (not mag_boots_on) and (not is_on_floor())
func _reset_doll_basis() -> void:
if look_loader == null:
return
look_loader.rotation = Vector3(0.0, PI, 0.0)
look_loader.position = Vector3.ZERO
look_loader.scale = Vector3.ONE
var doll = look_loader.get("current")
if doll is Node3D and is_instance_valid(doll):
doll.position = Vector3.ZERO
doll.rotation = Vector3.ZERO
func _align_walk(up: Vector3) -> void:
if up.length_squared() < 0.0001:
return
up = up.normalized()
var fwd: Vector3 = -global_transform.basis.z.slide(up)
if fwd.length_squared() < 0.0001:
fwd = global_transform.basis.x.slide(up)
if fwd.length_squared() < 0.0001:
fwd = Vector3.FORWARD.slide(up)
if fwd.length_squared() < 0.0001:
return
fwd = fwd.normalized()
var right: Vector3 = fwd.cross(up)
if right.length_squared() < 0.0001:
return
right = right.normalized()
fwd = up.cross(right).normalized()
# X = right, Y = up, -Z = forward
global_transform.basis = Basis(right, up, -fwd).orthonormalized()
func _keep_walk_upright() -> void:
if _is_eva() or in_vehicle:
return
# Align whenever we have a floor, boots or not — otherwise the highlands dump you.
if not is_on_floor() and not mag_boots_on:
return
_align_walk(_walk_up())
if camera_pivot:
camera_pivot.rotation.z = 0.0
if look_loader:
look_loader.rotation = Vector3(0.0, PI, 0.0)
look_loader.position = Vector3.ZERO
look_loader.scale = Vector3.ONE
func _aim_visual_to_look() -> void:
_reset_doll_basis()
func _set_alt_look(on: bool) -> void:
if on and camera_pivot:
free_look_yaw = camera_pivot.rotation.y
free_look_pitch = camera_pivot.rotation.x
free_looking = on
if camera and camera_pivot:
camera.rotation.z = 0.0
camera_pivot.rotation.z = 0.0
func _unhandled_input(event: InputEvent) -> void:
if in_vehicle:
return
if vendor_menu and vendor_menu.visible:
if event.is_action_pressed("ui_cancel"):
close_vendor_menu()
return
if event is InputEventKey and event.keycode == KEY_QUOTELEFT:
if event.pressed:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
else:
if not get_tree().paused and not (vendor_menu and vendor_menu.visible):
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
return
if event is InputEventKey and event.keycode == KEY_ALT:
_set_alt_look(event.pressed)
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
if pistol_drawn:
ads = event.pressed
_apply_camera_mode(false)
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
if pistol_drawn:
_try_fire()
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
if _is_eva():
# Whole-body look in 1P and 3P EVA. World-Y yaw felt broken after a roll.
rotate_object_local(Vector3.UP, -event.relative.x * mouse_sensitivity)
rotate_object_local(Vector3.RIGHT, -event.relative.y * mouse_sensitivity)
if camera_pivot:
camera_pivot.rotation = Vector3.ZERO
elif first_person:
if free_looking:
# Euler SET — rotate_x+rotate_y on a pitched pivot was the warp.
free_look_yaw -= event.relative.x * mouse_sensitivity
free_look_pitch -= event.relative.y * mouse_sensitivity
free_look_pitch = clamp(free_look_pitch, deg_to_rad(-78), deg_to_rad(62))
free_look_yaw = clamp(free_look_yaw, deg_to_rad(-75), deg_to_rad(75))
camera_pivot.rotation = Vector3(free_look_pitch, free_look_yaw, 0.0)
else:
rotate_y(-event.relative.x * mouse_sensitivity)
camera_pivot.rotate_x(-event.relative.y * mouse_sensitivity)
camera_pivot.rotation.x = clamp(camera_pivot.rotation.x, deg_to_rad(-78), deg_to_rad(85))
elif free_looking:
free_look_yaw -= event.relative.x * free_look_sensitivity
free_look_pitch -= event.relative.y * free_look_sensitivity
free_look_pitch = clamp(free_look_pitch, deg_to_rad(-80), deg_to_rad(80))
if camera_pivot:
camera_pivot.rotation.y = free_look_yaw
camera_pivot.rotation.x = free_look_pitch
else:
rotate_y(-event.relative.x * mouse_sensitivity)
camera_pivot.rotate_x(-event.relative.y * mouse_sensitivity)
camera_pivot.rotation.x = clamp(camera_pivot.rotation.x, deg_to_rad(-78), deg_to_rad(55))
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_1:
_toggle_pistol()
KEY_F3:
_debug_fp_eye()
KEY_F4:
if look_loader and look_loader.has_method("cycle_arm_axis"):
look_loader.cycle_arm_axis()
if event.is_action_pressed("interact"):
if can_interact and current_interactable:
_try_interact()
if event.is_action_pressed("mag_boots"):
mag_boots_on = not mag_boots_on
if mag_boots_on:
eva_peak_height = 0.0
velocity = velocity.slide(_walk_up()) * 0.35
_align_walk(_walk_up())
_reset_camera_to_walk()
print("Mag Boots: ", "ON" if mag_boots_on else "OFF")
if event.is_action_pressed("toggle_pistol"):
_toggle_pistol()
if event.is_action_pressed("reload"):
_reload_pistol()
if event.is_action_pressed("toggle_inventory"):
inventory_open = not inventory_open
if event.is_action_pressed("toggle_first_person"):
if in_vehicle:
return
first_person = not first_person
_set_alt_look(false)
_apply_camera_mode(false)
if event.is_action_pressed("ui_page_up"):
food = min(food + 25.0, 100.0)
if event.is_action_pressed("ui_page_down"):
money += 50
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_F5:
GameState.save_game(self)
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_F4:
if not GameState.scramble_started:
GameState.scramble_started = true
GameState.scramble_begun.emit()
else:
var banner := get_node_or_null("/root/Node3D/HUD/ScrambleBanner")
if banner and banner.has_method("hide_banner"):
banner.hide_banner()
func _physics_process(delta: float) -> void:
_update_crust_exception()
if fire_cd > 0.0:
fire_cd = max(fire_cd - delta, 0.0)
if interact_cd > 0.0:
interact_cd = max(interact_cd - delta, 0.0)
if jump_anim_cd > 0.0:
jump_anim_cd = max(jump_anim_cd - delta, 0.0)
if in_vehicle:
return
if vendor_menu and vendor_menu.visible:
velocity = Vector3.ZERO
return
if is_on_floor():
coyote_time = 0.14
else:
coyote_time = max(coyote_time - delta, 0.0)
_try_snap_boots()
var gvec: Vector3 = _soi_gravity()
if _is_eva():
_set_body_visible(true)
if Input.is_action_pressed("eva_roll_left") or Input.is_key_pressed(KEY_Q):
rotate_object_local(Vector3.FORWARD, -2.2 * delta)
if Input.is_action_pressed("eva_roll_right") or Input.is_key_pressed(KEY_E):
rotate_object_local(Vector3.FORWARD, 2.2 * delta)
if gvec.length() > 0.01:
velocity += gvec * delta
var input_dir_eva: Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
if input_dir_eva.length() > 0.1:
var thrust: Vector3 = (
-transform.basis.z * -input_dir_eva.y + transform.basis.x * input_dir_eva.x
).normalized() * speed * 0.35
velocity += thrust * delta
move_and_slide()
_process_needs(delta)
_check_interactable()
_update_animations()
_update_hud()
return
var up: Vector3 = _walk_up()
up_direction = up
floor_max_angle = deg_to_rad(70.0)
var g_amt: float = gvec.length()
if g_amt < 0.01:
g_amt = moon_gravity if moon_gravity > 0.0 else MOON_G
# Boots on = snap-down. Boots off = 1/6 g.
if mag_boots_on:
g_amt = maxf(g_amt, MOON_G) * 6.0
else:
g_amt = maxf(g_amt, MOON_G)
velocity += -up * g_amt * delta
var input_dir: Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
var direction: Vector3 = (transform.basis * Vector3(input_dir.x, 0.0, input_dir.y)).slide(up)
if direction.length() > 0.001:
direction = direction.normalized()
else:
direction = Vector3.ZERO
var want_sprint: bool = Input.is_action_pressed("sprint") and input_dir.y < -0.12
var current_speed: float = sprint_speed if want_sprint else speed
if ads and pistol_drawn:
current_speed *= ads_sprint_scale if want_sprint else ads_walk_scale
var v_up: float = velocity.dot(up)
var v_h: Vector3 = velocity.slide(up)
if is_on_floor():
if direction:
v_h = v_h.move_toward(direction * current_speed, move_accel * delta)
else:
v_h = v_h.move_toward(Vector3.ZERO, move_decel * delta)
if mag_boots_on and v_up < 0.0:
v_up = -0.12
else:
var air_control: float = 0.08 if not mag_boots_on else 0.02
if direction:
v_h = v_h.move_toward(direction * current_speed, current_speed * air_control)
if mag_boots_on:
v_h = v_h.move_toward(Vector3.ZERO, 1.2 * delta)
velocity = v_h + up * v_up
# Jump LAST so floor rebuild cannot eat or replace the impulse.
var can_jump: bool = is_on_floor() or coyote_time > 0.0
if Input.is_action_just_pressed("ui_accept") and can_jump:
coyote_time = 0.0
jump_anim_cd = 0.45
var jv: float = 1.7 if mag_boots_on else 6.4
velocity = velocity.slide(up) + up * jv
print("JUMP boots=", mag_boots_on, " jv=", jv, " g=", g_amt, " floor=", is_on_floor())
if is_on_floor() and not Input.is_action_just_pressed("ui_accept"):
_try_step(up)
move_and_slide()
_keep_walk_upright()
_process_needs(delta)
_check_interactable()
_update_animations()
_update_hud()
func _look_down_amt() -> float:
if camera_pivot == null:
return 0.0
return clampf(-camera_pivot.rotation.x / deg_to_rad(78.0), 0.0, 1.0)
func _fp_cam_pos() -> Vector3:
# Same seat as gun-out hip. Unarmed sits a few cm back so the chest
# fills the hole the pistol otherwise occupies. G should barely jump.
if ads and pistol_drawn:
return Vector3(0.05, 0.05, -0.06)
return Vector3(0.03, 0.04, -0.04)
func _apply_camera_mode(snap: bool = false) -> void:
if first_person:
_cam_pos_target = _fp_cam_pos()
_cam_rot_target = Vector3.ZERO
_fov_target = 62.0 if (ads and pistol_drawn) else 72.0
if camera_pivot:
# 3P look-down must not come with us or 1P stares at the hoodie.
camera_pivot.rotation.x = clampf(camera_pivot.rotation.x, deg_to_rad(-18.0), deg_to_rad(20.0))
camera_pivot.rotation.z = 0.0
if look_loader and look_loader.has_method("set_first_person"):
look_loader.set_first_person(true)
_update_fp_meshes()
else:
camera_pivot.position = Vector3(0, 2.40, 0)
if ads and pistol_drawn:
_cam_pos_target = Vector3(0.62, 0.22, 2.15)
_cam_rot_target = Vector3(deg_to_rad(-6.0), 0.0, 0.0)
_fov_target = 58.0
else:
_cam_pos_target = Vector3(0.58, 0.38, 3.20)
_cam_rot_target = Vector3(deg_to_rad(-8.0), 0.0, 0.0)
_fov_target = 70.0
if look_loader and look_loader.has_method("set_first_person"):
look_loader.set_first_person(false)
_update_fp_meshes()
_set_body_visible(true)
if camera:
camera.near = 0.08 if first_person else 0.06
camera.far = 1000000.0
if snap:
camera.position = _cam_pos_target
camera.rotation = _cam_rot_target
camera.fov = _fov_target
if not in_vehicle:
camera.current = true
func _process_needs(delta: float) -> void:
if food > 0.0:
food -= food_decay_rate * delta
food = max(food, 0.0)
else:
health -= starve_damage_rate * delta
if not pressurized and not in_vehicle:
if oxygen > 0.0:
oxygen -= oxygen_decay_rate * delta
oxygen = max(oxygen, 0.0)
else:
health -= low_oxygen_damage_rate * delta
health = clamp(health, 0.0, 100.0)
func _query_interactable(origin: Vector3, end: Vector3) -> Dictionary:
var space = get_world_3d().direct_space_state
var query = PhysicsRayQueryParameters3D.create(origin, end)
query.collide_with_areas = true
query.collide_with_bodies = true
query.exclude = [get_rid()]
return space.intersect_ray(query)
func _check_interactable() -> void:
can_interact = false
current_interactable = null
if camera == null:
return
var hits: Array[Dictionary] = []
var cam_origin := camera.global_position
var cam_end := cam_origin + (-camera.global_transform.basis.z * interact_distance)
hits.append(_query_interactable(cam_origin, cam_end))
var chest := global_position + Vector3(0, 1.1, 0)
var look := -global_transform.basis.z
hits.append(_query_interactable(chest, chest + look * 3.5 + Vector3(0, -1.4, 0)))
hits.append(_query_interactable(chest, chest + Vector3(0, -2.2, 0)))
var feet := global_position + Vector3(0, 0.35, 0)
hits.append(_query_interactable(feet, feet + look * 2.4))
for result in hits:
if result and result.collider and result.collider.is_in_group("interactable"):
can_interact = true
current_interactable = result.collider
return
func _try_interact() -> void:
if current_interactable and current_interactable.has_method("on_interact"):
current_interactable.on_interact(self)
interact_cd = 0.55
_play_move_anim("Interact")
func open_vendor_menu() -> void:
if vendor_menu and vendor_menu.has_method("open_menu"):
vendor_menu.open_menu(self)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func close_vendor_menu() -> void:
if vendor_menu:
vendor_menu.visible = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _update_animations() -> void:
var dt: float = get_physics_process_delta_time()
if interact_cd > 0.0:
return
if jump_anim_cd > 0.0:
_play_move_anim("Jump")
return
if _is_eva() or (not mag_boots_on and not is_on_floor()):
if pistol_drawn and ads:
_play_move_anim("AimIdle")
elif pistol_drawn:
_play_move_anim("Idle_Gun")
else:
_play_move_anim("Idle")
return
if not is_on_floor():
_play_move_anim("Idle")
return
# Stick, not velocity — accel must not pick the clip.
var stick: Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
var strafe: float = clampf(stick.x, -1.0, 1.0)
var back: float = clampf(stick.y, -1.0, 1.0)
var want_sprint: bool = Input.is_action_pressed("sprint") and stick.y < -0.12
var run_amt: float = 1.0 if want_sprint else 0.0
if look_loader and look_loader.has_method("set_loco"):
look_loader.set_loco(strafe, back, run_amt, dt, _is_aiming())
return
if _is_aiming():
_play_move_anim("AimIdle")
elif pistol_drawn:
_play_move_anim("Idle_Gun")
else:
_play_move_anim("Idle")
func _update_hud() -> void:
if needs_label:
var interact_text: String = " [F] Use" if can_interact else ""
var gun_text: String = ""
if pistol_drawn:
gun_text = " | PISTOL %d/%d%s" % [pistol_ammo, pistol_reserve, " ADS" if ads else ""]
var inv_text: String = ""
if inventory_open:
if inventory.size() == 0:
inv_text = "\nInventory: (empty)"
else:
inv_text = "\nInventory: " + ", ".join(inventory)
var boot_text: String = " | BOOTS ON" if mag_boots_on else " | BOOTS OFF"
needs_label.text = "%s | %s | Food: %.0f | Health: %.0f | O₂: %.0f | Credits: %d%s%s%s%s" % [
player_name, GameState.get_time_string(),
food, health, oxygen, money, boot_text, interact_text, gun_text, inv_text
]
func _toggle_pistol() -> void:
pistol_drawn = not pistol_drawn
if not pistol_drawn:
ads = false
if look_loader and look_loader.has_method("set_pistol_drawn"):
look_loader.set_pistol_drawn(pistol_drawn)
_apply_camera_mode(false)
if pistol_drawn:
_play_move_anim("Idle_Gun")
else:
_play_move_anim("Idle")
print("Pistol: ", "DRAWN" if pistol_drawn else "HOLSTERED")
func _reload_pistol() -> void:
if not pistol_drawn:
return
var need: int = PISTOL_MAG - pistol_ammo
if need <= 0 or pistol_reserve <= 0:
return
var take: int = mini(need, pistol_reserve)
pistol_reserve -= take
pistol_ammo += take
interact_cd = 0.45
_play_move_anim("Interact")
print("Reload ", pistol_ammo, "/", pistol_reserve)
func _try_fire() -> void:
if fire_cd > 0.0 or interact_cd > 0.0 or in_vehicle:
return
if pistol_ammo <= 0:
_reload_pistol()
return
pistol_ammo -= 1
fire_cd = FIRE_INTERVAL
if look_loader and look_loader.has_method("play_fire"):
look_loader.play_fire(ads)
else:
_play_move_anim("Idle_Gun_Shoot" if ads else "Gun_Shoot")
_fov_kick = 3.2 if ads else 2.2
if camera == null:
return
var space := get_world_3d().direct_space_state
var origin := camera.global_position
var end := origin + (-camera.global_transform.basis.z * 80.0)
var query := PhysicsRayQueryParameters3D.create(origin, end)
query.exclude = [get_rid()]
var hit := space.intersect_ray(query)
if hit:
print("Shot ", hit.collider.name, " @ ", hit.position)
else:
print("Shot (miss)")
func add_food(amount: float) -> void:
food = clamp(food + amount, 0.0, 100.0)
func add_health(amount: float) -> void:
health = clamp(health + amount, 0.0, 100.0)
func add_oxygen(amount: float) -> void:
oxygen = clamp(oxygen + amount, 0.0, 100.0)
func _debug_fp_eye() -> void:
var head := Vector3.INF
if look_loader and look_loader.has_method("get_head_global_pos"):
head = look_loader.get_head_global_pos()
print("FP-EYE fp=", first_person,
" pitch=", snappedf(rad_to_deg(camera_pivot.rotation.x), 0.1) if camera_pivot else 0.0,
" pivot=", camera_pivot.global_position if camera_pivot else Vector3.ZERO,
" cam=", camera.global_position if camera else Vector3.ZERO,
" head=", head,
" local=", camera.position if camera else Vector3.ZERO)
func add_money(amount: int) -> void:
money += amount
func add_item(item_name: String) -> void:
inventory.append(item_name)
print("Picked up: ", item_name)
func remove_item(item_name: String) -> bool:
var index: int = inventory.find(item_name)
if index != -1:
inventory.remove_at(index)
return true
return false
func _ensure_habitat_collision() -> void:
var hab := _habitat()
if hab == null:
push_warning("Habitat collision: Habitat node not found")
return
var made := _add_walk_collision(hab)
print("Habitat collision shapes=", made)
func _add_walk_collision(n: Node) -> int:
var made := 0
for c in n.get_children():
made += _add_walk_collision(c)
if n is MeshInstance3D and _walkable_mesh(n.name) and (n as MeshInstance3D).mesh:
var mesh := n as MeshInstance3D
var body: StaticBody3D = null
for c in mesh.get_children():
if c is StaticBody3D:
body = c as StaticBody3D
break
if body == null:
mesh.create_trimesh_collision()
for c in mesh.get_children():
if c is StaticBody3D:
body = c as StaticBody3D
break
if body == null:
return made
body.collision_layer = 1
body.collision_mask = 0
for s in body.get_children():
if s is CollisionShape3D and (s as CollisionShape3D).shape is ConcavePolygonShape3D:
((s as CollisionShape3D).shape as ConcavePolygonShape3D).backface_collision = true
made += 1
return made
func _walkable_mesh(mesh_name: String) -> bool:
for s in WALK_SKIP:
if mesh_name.begins_with(s):
return false
for prefix in WALK_FLOORS:
if mesh_name.begins_with(prefix):
return true
for prefix in WALK_WALLS:
if mesh_name.begins_with(prefix):
return true
return false
func _feet_pos(up: Vector3) -> Vector3:
var col := get_node_or_null("CollisionShape3D") as CollisionShape3D
if col == null or col.shape == null:
return global_position
var half := 0.9
var shape := col.shape
if shape is CapsuleShape3D:
half = (shape as CapsuleShape3D).height * 0.5
elif shape is CylinderShape3D:
half = (shape as CylinderShape3D).height * 0.5
elif shape is BoxShape3D:
half = (shape as BoxShape3D).size.y * 0.5
return col.global_position - up * half
func _try_step(up: Vector3) -> void:
var dir := velocity.slide(up)
if dir.length() < 0.15:
return
dir = dir.normalized()
var feet := _feet_pos(up)
var space := get_world_3d().direct_space_state
var low_from := feet + up * 0.08
var low := PhysicsRayQueryParameters3D.create(low_from, low_from + dir * 0.55)
low.exclude = [get_rid()]
low.collision_mask = collision_mask
if space.intersect_ray(low).is_empty():
return
var high_from := feet + up * (STEP_HEIGHT + 0.05)
var high := PhysicsRayQueryParameters3D.create(high_from, high_from + dir * 0.6)
high.exclude = [get_rid()]
high.collision_mask = collision_mask
if not space.intersect_ray(high).is_empty():
return
var down_from := high_from + dir * 0.35
var down := PhysicsRayQueryParameters3D.create(down_from, down_from - up * (STEP_HEIGHT + 0.08))
down.exclude = [get_rid()]
down.collision_mask = collision_mask
var hit := space.intersect_ray(down)
if hit.is_empty():
return
var hit_pos: Vector3 = hit.position
var lift: float = (hit_pos - feet).dot(up)
if lift < 0.04 or lift > STEP_HEIGHT:
return
global_position += up * (lift + 0.02)