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)