From 46c8275523cb261b1b029a94d7472bd14b5c0250 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:33:28 +0100 Subject: [PATCH] feat(*): add wall/ceiling surface pull and retune ball-ship materials --- CLAUDE.md | 1 + Game/objects/ball.tscn | 8 +++++--- Game/objects/ship.tscn | 2 +- Game/scripts/arena_boundary.gd | 32 +++++++++++++++++++++++++++++++ Game/scripts/ball.gd | 35 ++++++++++++++++++++++++++++++++++ Game/scripts/ball.gd.uid | 1 + Game/scripts/ship.gd | 20 +++++++++++++++++++ 7 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 Game/scripts/ball.gd create mode 100644 Game/scripts/ball.gd.uid diff --git a/CLAUDE.md b/CLAUDE.md index e54b9153..3890e4cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ The structure was deliberately chosen so an RL-trained AI opponent and, later, m - **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (inner x ±12, z ±18, height 12, goal lines z ±17) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`. - **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal. - **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it. +- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants. - **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless RL runs work (`godot --headless`). - **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the ship, camera rig, and game mode via groups (`"ship"`, `"ship_camera"`, `"game"`), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling. - **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turn_left`, `turbo`, `reset_ball`, etc.) and read only by `PlayerShipController` (plus mode-level `_unhandled_input` for `reset_ball`/`ui_cancel`) — add new controls there rather than hardcoding key checks. diff --git a/Game/objects/ball.tscn b/Game/objects/ball.tscn index f10bfd1c..61c7e9f9 100644 --- a/Game/objects/ball.tscn +++ b/Game/objects/ball.tscn @@ -1,12 +1,13 @@ -[gd_scene load_steps=4 format=3 uid="uid://27u3tdc5yqnl"] +[gd_scene load_steps=5 format=3 uid="uid://27u3tdc5yqnl"] [ext_resource type="ArrayMesh" uid="uid://ucw1lyo43yi4" path="res://assets/models/gold_ball.res" id="1_ct1s3"] +[ext_resource type="Script" path="res://scripts/ball.gd" id="2_ball"] [sub_resource type="SphereShape3D" id="SphereShape3D_c5p07"] [sub_resource type="PhysicsMaterial" id="PhysicsMaterial_ball"] -bounce = 0.8 -friction = 0.3 +bounce = 0.5 +friction = 0.4 [node name="Ball" type="RigidBody3D" groups=["ball"]] mass = 3 @@ -16,6 +17,7 @@ inertia = Vector3(3, 3, 3) gravity_scale = 0.8 linear_damp = 0.1 angular_damp = 0.1 +script = ExtResource("2_ball") metadata/_edit_group_ = true [node name="CollisionShape3D" type="CollisionShape3D" parent="."] diff --git a/Game/objects/ship.tscn b/Game/objects/ship.tscn index 1678210a..7dd15378 100644 --- a/Game/objects/ship.tscn +++ b/Game/objects/ship.tscn @@ -4,7 +4,7 @@ [sub_resource type="PhysicsMaterial" id="PhysicsMaterial_ship"] friction = 0.1 -bounce = 0.2 +bounce = 0.15 [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_hull"] albedo_color = Color(0.35, 0.37, 0.42, 1) diff --git a/Game/scripts/arena_boundary.gd b/Game/scripts/arena_boundary.gd index 33729beb..a5d1a0b9 100644 --- a/Game/scripts/arena_boundary.gd +++ b/Game/scripts/arena_boundary.gd @@ -60,10 +60,42 @@ var _corner_visuals: Array[Dictionary] = [] func _ready() -> void: + add_to_group("arena_boundary") _build_corner_curves() _build_base_fillets() +# Wall+ceiling-only proximity force field ("artificial gravity" grav-plating, +# weaker than the floor's plain default gravity, which this deliberately +# leaves untouched). Ship and Ball each call this with their own +# strength/range so wall adherence and ceiling adherence can be tuned +# independently per body — see ship.gd/ball.gd. Quadratic falloff keeps a +# casual flyby near a wall almost force-free, concentrating the pull in +# roughly the last third of the range so it only bites once something is +# genuinely close to the surface. +func get_surface_pull( + global_pos: Vector3, wall_strength: float, wall_range: float, + ceiling_strength: float, ceiling_range: float +) -> Vector3: + var p := to_local(global_pos) + var pull := Vector3.ZERO + pull += Vector3(1, 0, 0) * _falloff(INNER_HALF_X - p.x, wall_range) * wall_strength + pull += Vector3(-1, 0, 0) * _falloff(INNER_HALF_X + p.x, wall_range) * wall_strength + # End walls are gated off inside the goal mouth — there is no physical + # wall there (see GOAL_MOUTH_HALF_WIDTH / _build_base_fillets), so a shot + # heading straight for the net doesn't feel a phantom sideways tug. + if abs(p.x) >= GOAL_MOUTH_HALF_WIDTH: + pull += Vector3(0, 0, 1) * _falloff(INNER_HALF_Z - p.z, wall_range) * wall_strength + pull += Vector3(0, 0, -1) * _falloff(INNER_HALF_Z + p.z, wall_range) * wall_strength + pull += Vector3.UP * _falloff(INNER_HEIGHT - p.y, ceiling_range) * ceiling_strength + return pull + + +func _falloff(dist: float, field_range: float) -> float: + var t: float = clamp(1.0 - dist / field_range, 0.0, 1.0) + return t * t + + func _process(_delta: float) -> void: # The translucent field material tints everything behind it, so any face # the camera has crossed to the outside of is hidden entirely — looking diff --git a/Game/scripts/ball.gd b/Game/scripts/ball.gd new file mode 100644 index 00000000..c3e78800 --- /dev/null +++ b/Game/scripts/ball.gd @@ -0,0 +1,35 @@ +class_name Ball +extends RigidBody3D + +# Physics ball. Floor gravity is untouched (gravity_scale in ball.tscn); this +# only adds the wall/ceiling "surface pull" (see ArenaBoundary.get_surface_pull) +# so the ball can cling near a wall for dribbling or hang against the ceiling +# for ceiling shots, plus a safety speed clamp (the ball previously had none). + +@export_group("Surface Pull") +@export var wall_pull_strength = 4.0 # Weaker than the ship's — assist, not adherence +@export var wall_pull_range = 2.0 +@export var ceiling_pull_strength = 5.5 # Stays below the ball's effective gravity (0.8 * 9.8) +@export var ceiling_pull_range = 2.0 + +# Kept close to ship_observations.gd's BALL_SPEED_SCALE (30.0) so this feature +# doesn't push ball velocity further out of the range trained policies expect. +const MAX_SPEED := 32.0 + +var _boundary: ArenaBoundary + + +func _ready() -> void: + _boundary = get_tree().get_first_node_in_group("arena_boundary") + + +func _integrate_forces(state: PhysicsDirectBodyState3D) -> void: + if _boundary: + var pull := _boundary.get_surface_pull( + global_position, wall_pull_strength, wall_pull_range, + ceiling_pull_strength, ceiling_pull_range + ) + state.apply_central_force(pull * mass) + + if state.linear_velocity.length() > MAX_SPEED: + state.linear_velocity = state.linear_velocity.normalized() * MAX_SPEED diff --git a/Game/scripts/ball.gd.uid b/Game/scripts/ball.gd.uid new file mode 100644 index 00000000..f19fe13a --- /dev/null +++ b/Game/scripts/ball.gd.uid @@ -0,0 +1 @@ +uid://da7cgcp5pt1ld diff --git a/Game/scripts/ship.gd b/Game/scripts/ship.gd index 606452e6..7f63f19f 100644 --- a/Game/scripts/ship.gd +++ b/Game/scripts/ship.gd @@ -19,6 +19,12 @@ extends RigidBody3D @export var drag_coefficient = 0.98 # Linear drag (air resistance) @export var angular_drag = 0.95 # Rotational drag +@export_group("Surface Pull") +@export var wall_pull_strength = 6.0 # Wall grav-plating strength (m/s^2-equivalent) +@export var wall_pull_range = 3.0 # Metres from a wall where pull begins +@export var ceiling_pull_strength = 11.5 # Ceiling grav-plating strength; nets above gravity so a ship can hold a ceiling +@export var ceiling_pull_range = 3.0 # Metres from the ceiling where pull begins + # Accent colours per team, applied to the nose and tail fin meshes so the # two sides are tellable apart at a glance. const TEAM_COLORS := { @@ -34,6 +40,7 @@ var team: int = 0: var controller: ShipController var _current_action: ShipAction = ShipAction.new() +var _boundary: ArenaBoundary # Instrument signals for efficient data distribution signal speed_changed(speed: float) @@ -74,6 +81,8 @@ func _ready(): _apply_team_color() + _boundary = get_tree().get_first_node_in_group("arena_boundary") + func _apply_team_color() -> void: if not is_inside_tree(): @@ -112,6 +121,7 @@ func _integrate_forces(state): # === TRANSLATION (Movement) === apply_thruster_forces(state, _current_action) + apply_surface_pull(state) # === ROTATION (Turning) === apply_rotation_forces(state, _current_action.rotation) @@ -153,6 +163,16 @@ func apply_thruster_forces(state: PhysicsDirectBodyState3D, action: ShipAction): state.apply_central_force(world_thrust) +func apply_surface_pull(state: PhysicsDirectBodyState3D) -> void: + if _boundary == null: + return + var pull := _boundary.get_surface_pull( + global_position, wall_pull_strength, wall_pull_range, + ceiling_pull_strength, ceiling_pull_range + ) + state.apply_central_force(pull * mass) + + func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vector3): if rotation_input.length() < 0.01: return