neon-sprawl/client/scripts/main.gd

233 lines
10 KiB
GDScript

extends Node3D
## NS-16: server authority; see `position_authority_client.gd`. NS-18: interaction POST + radius
## glow preview (see child nodes).
## NEO-22: WASD locomotion + `move-stream` samples (see `player.gd` / `position_authority_client.gd`).
## NS-23: bakes `NavigationRegion3D` on startup; boot snap from authority `GET`.
## NEO-15: follow camera is `World/IsometricFollowCamera/Camera3D`
## (see `isometric_follow_camera.gd`).
## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see
## `random_floor_bumps.gd`) before nav bake.
## Prototype: `PhysicsRampTest*` (+X extension: west slab ends before the dip;
## `PhysicsRampTestFloorExtensionNorthFill` / `SouthFill` restore walkable floor past the dip in ±Z;
## block; gentle ramp on **+Z**; steep on **+X**), plus `PhysicsSmoothHillTest` /
## `PhysicsSmoothDipTest` (HeightMap collision).
## Steep ramp: **2 m / 1.5 m** rise/run (~53°) so slope exceeds `Player` `floor_max_angle` (~50°)
## and is not walkable; gentle ramp stays shallower than `NavigationMesh.agent_max_climb`. Steep is
## under `World` only (not nav source geometry).
## Prototype HUD: world `CharacterBody3D` position in `UICanvas/PlayerPositionLabel`
## (updated in `_physics_process` so it matches physics ticks).
const MOVE_REJECT_MSG_SECONDS: float = 4.0
## Throttle `move-stream` POSTs (~20 Hz at 120 physics ticks/sec).
const STREAM_PHYSICS_INTERVAL: int = 6
## Skip `move-stream` while standing still so idle + anchor are not fighting HTTP snaps.
const STREAM_IDLE_VEL_HZ_SQ: float = 0.0004
## Ignore authority snaps smaller than this (m) to avoid micro-flicker from float / echo noise.
const AUTH_SNAP_MIN_HORIZONTAL: float = 0.08
const AUTH_SNAP_MIN_VERTICAL: float = 0.04
## While moving, ignore stream snaps smaller than this (XZ m): responses are often one RTT behind
## integrated `move_and_slide`, so snapping causes rubber-band / bounciness.
const AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH: float = 0.55
## Horizontal speed² above this (m²/s²) counts as “moving” for snap suppression (~0.35 m/s).
const AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ: float = 0.12
## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0
var _stream_physics_counter: int = 0
## After the first `apply_as_snap` authority update, micro-snaps may be skipped (boot always applies).
var _allow_authority_soft_snap_skip: bool = false
## Next `apply_as_snap` must not be suppressed (e.g. `move-stream` 400 → `sync_from_server`).
var _authority_force_snap_next: bool = false
## [InputMap] [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**):
## Restored when the obstacle is shown again.
var _dev_saved_obstacle_layer: int = -1
var _dev_saved_obstacle_mask: int = -1
var _dev_saved_obstacle_process_mode: Node.ProcessMode = Node.PROCESS_MODE_INHERIT
## Cached `Obstacle` (path breaks after reparent for nav bake; no fixed [NodePath]).
var _dev_obstacle_smoke: Node3D
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
@onready var _world: Node3D = $World
@onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D
@onready var _player: CharacterBody3D = $World/Player
@onready var _authority: Node = $PositionAuthorityClient
@onready var _radius_preview: Node3D = $World/InteractionMarkers
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
@onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
func _ready() -> void:
set_physics_process(true)
set_process_unhandled_key_input(true)
await get_tree().process_frame
_dev_obstacle_smoke = get_node_or_null("World/NavigationRegion3D/Obstacle") as Node3D
# Runtime load + .call avoids class_name / preload static resolution issues across Godot parses.
load("res://scripts/random_floor_bumps.gd").call("spawn_short_random_bumps", _floor)
# Default `bake_navigation_mesh()` bakes on a background thread; sync bake ensures the map
# exists before any `NavigationAgent3D` queries (see Godot navigation_using_navigationagents).
_nav_region.bake_navigation_mesh(false)
await get_tree().physics_frame
await get_tree().physics_frame
_authority.connect(
"authoritative_position_received", Callable(self, "_on_authoritative_position")
)
_authority.connect("move_rejected", Callable(self, "_on_move_rejected"))
_authority.call("sync_from_server")
if _radius_preview.has_method("setup_player"):
_radius_preview.call("setup_player", _player)
func _physics_process(_delta: float) -> void:
if not is_instance_valid(_player) or not is_instance_valid(_player_pos_label):
return
var wish: Vector3 = _locomotion_wish_world_xz_from_camera()
_player.call("set_locomotion_wish_world_xz", wish)
var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared()
var moving: bool = wish.length_squared() > 1e-10 or vel_hz_sq > STREAM_IDLE_VEL_HZ_SQ
if moving:
_stream_physics_counter += 1
if _stream_physics_counter >= STREAM_PHYSICS_INTERVAL:
_stream_physics_counter = 0
var batch: Array = [_player.global_position]
_authority.call("submit_stream_targets", batch)
else:
_stream_physics_counter = 0
var p: Vector3 = _player.global_position
_player_pos_label.text = "x: %.3f\ny: %.3f\nz: %.3f" % [p.x, p.y, p.z]
func _locomotion_wish_world_xz_from_camera() -> Vector3:
var v: Vector2 = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
if v.length_squared() < 1e-8:
return Vector3.ZERO
if not is_instance_valid(_camera):
return Vector3.ZERO
var forward: Vector3 = -_camera.global_transform.basis.z
forward.y = 0.0
if forward.length_squared() < 1e-10:
return Vector3.ZERO
forward = forward.normalized()
var right: Vector3 = forward.cross(Vector3.UP)
if right.length_squared() < 1e-10:
return Vector3.ZERO
right = right.normalized()
var w: Vector3 = right * v.x + forward * (-v.y)
if w.length_squared() < 1e-10:
return Vector3.ZERO
return w.normalized()
func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
if not apply_as_snap:
return
var force: bool = _authority_force_snap_next
_authority_force_snap_next = false
var p: Vector3 = _player.global_position
var dh: float = Vector2(world.x - p.x, world.z - p.z).length()
var dy: float = absf(world.y - p.y)
if _allow_authority_soft_snap_skip and not force:
var wish_len2: float = _player._locomotion_wish_world_xz.length_squared()
var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared()
var locomoting: bool = wish_len2 > 1e-10 or vel_hz_sq > AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ
if locomoting and dh < AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH:
return
if dh < AUTH_SNAP_MIN_HORIZONTAL and dy < AUTH_SNAP_MIN_VERTICAL:
return
_allow_authority_soft_snap_skip = true
_player.snap_to_server(world)
func _on_move_rejected(reason_code: String) -> void:
_authority_force_snap_next = true
# Rejected stream: server state may differ; next snap is forced.
push_warning("Move rejected: %s" % reason_code)
_move_reject_msg_token += 1
var token: int = _move_reject_msg_token
_move_reject_label.text = "Move rejected: %s" % reason_code
get_tree().create_timer(MOVE_REJECT_MSG_SECONDS).timeout.connect(
func():
if token == _move_reject_msg_token:
_move_reject_label.text = "",
CONNECT_ONE_SHOT
)
## Dev smoke (NEO-18 occluder): action [code]dev_toggle_occluder_obstacle[/code]
## (default **Ctrl+Shift+K**). Toggles `Obstacle` **visibility + collision** —
## does not call [method Node.queue_free].
## Uses [member Node.process_mode] [code]PROCESS_MODE_DISABLED[/code] so the [StaticBody3D] is
## **removed from the physics simulation**
## (default [member CollisionObject3D.disable_mode] is [code]REMOVE[/code]).
## Layer/shape tweaks alone left a **Jolt ghost collider** (invisible wall) in practice.
## When hidden, reparents `Obstacle` under [member _world]: [NavigationMesh] source geometry scans
## [NavigationRegion3D] children only ([code]SOURCE_GEOMETRY_ROOT_NODE_CHILDREN[/code]), and the
## default [code]geometry_parsed_geometry_type[/code] is [code]PARSED_GEOMETRY_BOTH[/code], so a
## child [MeshInstance3D] still carved a hole even with collision disabled — rebake alone was not
## enough.
## After each toggle, rebakes (main thread) and syncs the player [NavigationAgent3D].
## **F9** / **F10** are debugger shortcuts in the embedded game; use the Input Map action.
## For a true **freed-node** occluder test, reload the scene or remove the body in the editor.
func _unhandled_key_input(event: InputEvent) -> void:
if not event.is_action_pressed("dev_toggle_occluder_obstacle"):
return
call_deferred("_dev_toggle_obstacle_smoke_deferred")
func _dev_toggle_obstacle_smoke_deferred() -> void:
var obstacle := _dev_obstacle_smoke
if obstacle == null or not is_instance_valid(obstacle):
return
var rig: Node = get_node_or_null("World/IsometricFollowCamera")
if obstacle.visible:
if rig != null and rig.has_method("restore_all_occluder_materials_now"):
rig.call("restore_all_occluder_materials_now")
_dev_saved_obstacle_process_mode = obstacle.process_mode
var co := obstacle as CollisionObject3D
if co != null:
_dev_saved_obstacle_layer = co.collision_layer
_dev_saved_obstacle_mask = co.collision_mask
co.collision_layer = 0
co.collision_mask = 0
_dev_collision_shapes_set_disabled(obstacle, true)
obstacle.process_mode = Node.PROCESS_MODE_DISABLED
obstacle.visible = false
if obstacle.get_parent() == _nav_region:
obstacle.reparent(_world)
else:
if obstacle.get_parent() == _world:
obstacle.reparent(_nav_region)
obstacle.visible = true
obstacle.process_mode = _dev_saved_obstacle_process_mode
var co2 := obstacle as CollisionObject3D
if co2 != null and _dev_saved_obstacle_layer >= 0:
co2.collision_layer = _dev_saved_obstacle_layer
co2.collision_mask = _dev_saved_obstacle_mask
_dev_collision_shapes_set_disabled(obstacle, false)
call_deferred("_dev_rebake_nav_after_obstacle_toggle_deferred")
func _dev_rebake_nav_after_obstacle_toggle_deferred() -> void:
if not is_instance_valid(_nav_region):
return
if _nav_region.is_baking():
await _nav_region.bake_finished
if not is_instance_valid(_nav_region):
return
_nav_region.bake_navigation_mesh(false)
if is_instance_valid(_player):
_player.sync_navigation_agent_after_map_rebuild(_nav_region)
func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void:
if root is CollisionShape3D:
(root as CollisionShape3D).disabled = disabled
for c in root.get_children():
_dev_collision_shapes_set_disabled(c, disabled)