From 4480f90de31ae47cb295edc1378e77b72cdd1696 Mon Sep 17 00:00:00 2001 From: Kunal Siyag Date: Tue, 20 Oct 2026 20:13:57 +0530 Subject: [PATCH 1/2] feat(fp): get in and drive cars F (gamepad Y) by a stopped or parked car takes it over: traffic swaps it for a player-role car of the same model that the AI no longer steps, and its resident's trip ends. An arcade bicycle model drives it (throttle, brake and reverse, speed-scaled steering, grip by surface, handbrake slide), with a sweep-checked chase camera, a driver view on V, a speedometer and gear chip, and crashes that bounce off collision boxes. The hero sits in the driver's seat. AI cars and walkers yield to player cars as oriented boxes. F again gets out beside the door when slow and there is room; a car left behind is removed once it is out of range and out of sight. Behind the "drive" feature flag, with fp_drive_test. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 2 +- data/feature_flags.json | 1 + docs/api/index.md | 6 + docs/api/scripts.fp.drive.md | 117 ++++++ docs/api/scripts.fp.md | 1 + docs/api/scripts.fp.player.md | 1 + docs/api/scripts.fp.traffic.md | 11 +- docs/fp/ARCHITECTURE.md | 3 +- project.godot | 16 + scripts/fp/drive/drive_feature.gd | 414 +++++++++++++++++++++ scripts/fp/drive/drive_feature.gd.uid | 1 + scripts/fp/drive/drive_hud.gd | 112 ++++++ scripts/fp/drive/drive_hud.gd.uid | 1 + scripts/fp/drive/drive_model.gd | 123 ++++++ scripts/fp/drive/drive_model.gd.uid | 1 + scripts/fp/fp_features.gd | 1 + scripts/fp/game_bus.gd | 2 + scripts/fp/interact/interact_feature.gd | 12 +- scripts/fp/player/player.gd | 11 + scripts/fp/traffic/occupants.gd | 8 +- scripts/fp/traffic/traffic_feature.gd | 144 ++++++- scripts/fp/traffic/vehicles.gd | 70 +++- scripts/fp/ui/pause_menu.gd | 2 +- tools/release/PLAYING.md | 3 +- tools/tests/fp_core_test.gd | 2 +- tools/tests/fp_drive_test.gd | 474 ++++++++++++++++++++++++ tools/tests/fp_drive_test.gd.uid | 1 + 27 files changed, 1512 insertions(+), 28 deletions(-) create mode 100644 docs/api/scripts.fp.drive.md create mode 100644 scripts/fp/drive/drive_feature.gd create mode 100644 scripts/fp/drive/drive_feature.gd.uid create mode 100644 scripts/fp/drive/drive_hud.gd create mode 100644 scripts/fp/drive/drive_hud.gd.uid create mode 100644 scripts/fp/drive/drive_model.gd create mode 100644 scripts/fp/drive/drive_model.gd.uid create mode 100644 tools/tests/fp_drive_test.gd create mode 100644 tools/tests/fp_drive_test.gd.uid diff --git a/README.md b/README.md index 29eec0e0..c2889652 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Founding builds made before anyone lives in the city, and runs with residents of `godot --path .` starts in **first person** at street level. Set `GRIDFALL_VIEW=orbit` to play the orbit city builder with the full HUD described below. -- **First person:** WASD or the arrow keys walk, Shift runs, mouse to look (click to capture the pointer), `]` and `[` change time speed (1x, 2x, 4x, 8x), `T` skips to the next morning, `Esc` pauses, `F1` hides the HUD, `F5` saves, `F9` loads. +- **First person:** WASD or the arrow keys walk, Shift runs, mouse to look (click to capture the pointer), `]` and `[` change time speed (1x, 2x, 4x, 8x), `T` skips to the next morning, `Esc` pauses, `F1` hides the HUD, `F5` saves, `F9` loads. `F` by a car gets in (W/S throttle and brake or reverse, A/D steer, Space handbrake, V switches chase and driver view) and `F` again gets out once it slows down. The orbit view (`GRIDFALL_VIEW=orbit`): diff --git a/data/feature_flags.json b/data/feature_flags.json index 670edbb9..86f66a6b 100644 --- a/data/feature_flags.json +++ b/data/feature_flags.json @@ -11,6 +11,7 @@ "posters": {"default": true, "description": "First-person feature: campaign, newspaper, shop and notice posters (slice D)."}, "audio": {"default": true, "description": "First-person feature: street sound: buses, positional emitters and ambience (slice I)."}, "overview": {"default": true, "description": "First-person feature: Tab city overview to watch construction sites."}, + "drive": {"default": true, "description": "First-person feature: get into parked or stopped cars with F and drive them."}, "gmi_live": {"default": true, "description": "Kill switch for live GMI Cloud calls. Off: every call uses the replay cache or heuristics, as with GRIDFALL_OFFLINE=1."}, "analytics": {"default": false, "description": "Local product analytics to user://ops/analytics.jsonl (also GRIDFALL_ANALYTICS=1). Nothing is sent anywhere."}, "error_tracking": {"default": false, "description": "Write contextual error records to user://ops/errors.jsonl (also GRIDFALL_ERROR_LOG=1). In-memory error counters are always on."}, diff --git a/docs/api/index.md b/docs/api/index.md index 4a265e58..b5e314e0 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -47,6 +47,12 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [chat_text.gd](scripts.fp.chat.md#scriptsfpchatchat_textgd): Text helpers for the global chat and conversations (slice C): the display filter every line passes before it is shown or put into a prompt, dedupe normalisation, the checks the reply validators use (URLs, model names, sentence count), trimming, relative times and initials - [talk_dialog.gd](scripts.fp.chat.md#scriptsfpchattalk_dialoggd): Face-to-face conversation with a resident (slice C §4), and the "Ask someone here" answer +## [scripts/fp/drive/](scripts.fp.drive.md) + +- [drive_feature.gd](scripts.fp.drive.md#scriptsfpdrivedrive_featuregd): Getting into cars and driving them +- [drive_hud.gd](scripts.fp.drive.md#scriptsfpdrivedrive_hudgd): The drive feature's HUD: the get-in / get-out key chip above the minimap line and a speedometer (km/h and the gear) at the bottom centre while driving, clear of the corner captions +- [drive_model.gd](scripts.fp.drive.md#scriptsfpdrivedrive_modelgd): Arcade handling for the player's car + ## [scripts/fp/interact/](scripts.fp.interact.md) - [interact_feature.gd](scripts.fp.interact.md#scriptsfpinteractinteract_featuregd): Interaction with residents, buildings, posters and cars (slice A deliverables 3 and 6.7) diff --git a/docs/api/scripts.fp.drive.md b/docs/api/scripts.fp.drive.md new file mode 100644 index 00000000..bc755c49 --- /dev/null +++ b/docs/api/scripts.fp.drive.md @@ -0,0 +1,117 @@ + + +# `scripts/fp/drive/` + +[Index](index.md) + +## `scripts/fp/drive/drive_feature.gd` + +extends `FpFeature` + +Getting into cars and driving them. Near a parked or stopped car, F (Y on a pad) gets in: the hero sits in the driver's seat and the camera chases the car. W / S or the triggers drive and brake (S reverses once stopped), A / D or the left stick steer, Space (A) is the handbrake, the mouse or right stick look around, V switches to the driver's view, and F again gets out beside the driver's door. The car belongs to the traffic feature (lane traffic stops for it and it stays where it is left); it collides with the walker's world as three circles along its body. + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `MODEL = preload("res://scripts/fp/drive/drive_model.gd")` +- `HUD = preload("res://scripts/fp/drive/drive_hud.gd")` +- `ENTER_REACH = 2.0 * WS.M`: Reach from the hero to a car's body for the get-in prompt. +- `EXIT_SPEED = 8.5 * WS.M`: Faster than this the hero stays in (about 30 km/h). +- `EXIT_GAP = 0.7 * WS.M`: How far beside the body the hero steps out. +- `ENTER_BLEND = 0.45` +- `EXIT_BLEND = 0.35` +- `CHASE_DIST = 7.0 * WS.M` +- `CHASE_PITCH = deg_to_rad(12.0)` +- `LOOK_UP = 1.3 * WS.M` +- `LOOK_AHEAD = 3.0 * WS.M` +- `CAM_RADIUS = 0.25 * WS.M` +- `CAM_MIN = 1.5 * WS.M` +- `CHASE_FOLLOW = 2.5`: The chase camera swings in behind the car at this rate (1/s), faster with speed. +- `ORBIT_HOLD = 1.5` +- `ORBIT_RETURN = 2.5` +- `STICK_ORBIT = deg_to_rad(200.0)` +- `PITCH_MIN = deg_to_rad(-25.0)` +- `PITCH_MAX = deg_to_rad(40.0)` +- `FOV_FAST = 10.0` +- `EYE_ABOVE_HIP = 0.7 * WS.M` +- `EYE_INBOARD = 0.6`: The driver view sits a little toward the middle so the door pillar does not fill the left side. +- `SETTLE = 0.3`: Ground following: the body rises onto a curb at once and settles down at this rate (u/s). +- `PROMPT_TICK = 0.15` + +### Properties + +- `view: String`: "chase" or "driver". + +### Functions + +- `func feature_id() -> String` +- `func setup(p_ctx: FpContext) -> void` +- `func teardown() -> void` +- `func on_world_rebuilt() -> void` +- `func from_dict(_d: Dictionary) -> void` +- `func self_test() -> Dictionary` +- `func enter_target() -> Dictionary`: The car the hero could get into now ({} when none or while driving). +- `func enter() -> bool`: Gets into the nearest car in reach. Returns true when the hero is now driving. +- `func exit(force: bool = false) -> bool`: Gets out beside the driver's door (force: at any speed, wherever there is room or on the spot). Returns true when the hero is on foot again. +- `func drive_step(throttle: float, brake: float, steer: float, hand: float, dt: float) -> void`: One driving step with the given controls (throttle, brake, handbrake 0..1, steer -1..1). +- `func camera_xform() -> Transform3D`: The camera now: behind and above the car (pulled in front of walls), or at the driver's eyes. + +## `scripts/fp/drive/drive_hud.gd` + +extends `Control` + +The drive feature's HUD: the get-in / get-out key chip above the minimap line and a speedometer (km/h and the gear) at the bottom centre while driving, clear of the corner captions. + +### Constants + +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `CHIP_FROM_BOTTOM = 190.0` +- `SPEEDO_FROM_BOTTOM = 20.0` +- `FADE = 0.12` + +### Functions + +- `func setup() -> void` +- `func set_prompt(text: String) -> void`: Shows the key chip with text ("" hides it). +- `func prompt_text() -> String`: The chip text while it shows, else "". +- `func set_speed(show: bool, v: float) -> void`: Speedometer for a signed speed in u/s (hidden when show is false). +- `func speed_text() -> String` + +## `scripts/fp/drive/drive_model.gd` + +extends `RefCounted` + +Arcade handling for the player's car. Throttle and brake set the speed along the heading (brake reverses once stopped), the steering angle turns the car through a bicycle model, and the tyres bleed off sideways speed at a rate set by the surface, so a handbrake turn slides. World units (WS.M per metre) and seconds; the heading at yaw 0 is +Z, as for every vehicle. + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `TOP = 30.0 * WS.M`: 108 km/h on asphalt +- `REVERSE_TOP = 7.0 * WS.M` +- `ACCEL = 7.0 * WS.M`: m/s^2 from rest, falling off toward TOP +- `BRAKE = 14.0 * WS.M` +- `ROLL = 0.6 * WS.M`: coasting: rolling loss plus DRAG per m/s +- `DRAG = 0.12` +- `HANDBRAKE = 6.0 * WS.M` +- `STEER_MAX = deg_to_rad(34.0)` +- `STEER_HALF_AT = 13.0 * WS.M`: The steering lock halves by this speed (a light touch on the motorway, full lock in a car park). +- `STEER_RATE = 3.2`: rad/s toward the wanted angle +- `STEER_RETURN = 5.0` +- `WHEELBASE = 0.6`: Wheelbase as a share of body length. +- `GRIP = 9.0`: Sideways speed lost per second (1/s): tyres on asphalt, and on the handbrake. +- `GRIP_HANDBRAKE = 1.4` +- `SURFACES = { "asphalt": [1.0, 1.0], "bridge": [1.0, 1.0], "sidewalk": [0.9, 0.8], "lot": [0.85, 0....`: Grip and top-speed factors off the carriageway. +- `BOUNCE = 0.2`: Share of the speed kept (and reversed) after a crash. + +### Properties + +- `accel: float`: Signed acceleration along the heading over the last step (brake lights). + +### Functions + +- `func reset(p_yaw: float, p_length: float) -> void` +- `func heading() -> Vector2` +- `func speed() -> float`: Signed speed along the heading (u/s, negative in reverse). +- `func step(throttle: float, brake: float, steer_in: float, handbrake: float, surface: String, dt: float) -> Vector2`: One step: throttle, brake and handbrake 0..1, steer -1 (left) .. 1 (right). Returns the xz displacement; yaw and vel move on. +- `func crash() -> void`: A crash: the car stops dead and rebounds a little. diff --git a/docs/api/scripts.fp.md b/docs/api/scripts.fp.md index 982b1c79..f1f73473 100644 --- a/docs/api/scripts.fp.md +++ b/docs/api/scripts.fp.md @@ -216,6 +216,7 @@ Signals that cross first-person slices. Emitters and listeners are listed in doc - `modal_changed(active: bool)`: Some modal UI opened (true) or the last one closed (false). - `world_ready()`: The world was rebuilt and every feature ran on_world_rebuilt(). - `card_action(action: String, tile: int)`: A button on a place card: action "ask_here" (C answers through brain.inspect_tile). Emitted by B. +- `drive_changed(active: bool)`: The hero got into a car (true) or out of it (false). Emitted by the drive feature. ## `scripts/fp/road_topology.gd` diff --git a/docs/api/scripts.fp.player.md b/docs/api/scripts.fp.player.md index f083cebd..7464edcf 100644 --- a/docs/api/scripts.fp.player.md +++ b/docs/api/scripts.fp.player.md @@ -279,6 +279,7 @@ The on-foot player (slice A): feet at `position`, a kinematic controller against - `func setup(p_ctx: FpContext) -> void`: Builds the camera (sharing ctx.env, so SkyDirector's sky applies), the avatar and the rig, applies the settings (camera mode included) and makes the camera current. - `func teleport(pos: Vector3, yaw_deg: float, pitch_deg: float = 0.0, snap: bool = true) -> void`: Places the feet at pos (y snapped to the ground when snap is true), sets the body and the view to yaw / pitch (degrees) and snaps the camera (springs, pivot, arm, blends): no swoop. +- `func ride(pos: Vector3, body_yaw: float, view_yaw: float) -> void`: Carries the body with a vehicle (input off): feet at pos, body yaw and view yaw in radians, no ground snap and no camera snap (the vehicle owns the view through set_view_override). - `func look_toward(target: Vector3) -> void`: Faces a world point: the view (yaw and pitch) and the body. - `func eye_position() -> Vector3`: World position of the head (feet + current eye height; crouching lowers it). Talk and interact ranges are measured from here in both cameras. - `func view_position() -> Vector3`: Where the camera is (the arm end in third person). diff --git a/docs/api/scripts.fp.traffic.md b/docs/api/scripts.fp.traffic.md index 2bca09f2..fd363b2f 100644 --- a/docs/api/scripts.fp.traffic.md +++ b/docs/api/scripts.fp.traffic.md @@ -346,6 +346,7 @@ Drivers seated in the moving vehicles nearest the camera. Only while a vehicle d - `func sync(cars: Array, cam: Vector3, residents: Variant) -> void`: Seats a driver in each of the CAP nearest moving vehicles within RANGE that show their full model, and releases the rest. Call between people.begin and people.finish. - `func clear() -> void`: Releases every seated body. - `func body_of(id: int) -> PPL.Body`: The seated body in car id (null when it has no driver drawn). +- `func seat_point(node: Node3D) -> Variant`: The seat marker's position in the full model's space, cached on the node (null without one). ## `scripts/fp/traffic/people.gd` @@ -585,6 +586,7 @@ Traffic and pedestrians on lanes (slice E, docs/fp/slices/E-traffic.md): one roa - `PICK_PX = 26.0` - `PHOTO_BACK = 1.3`: The traffic photo stands this far from the junction centre along the arm. - `LINE_RETRY = 10.0`: Seconds between attempts to rebuild bus lines that no longer match the roads. +- `CLAIM_SPEED = 0.5 * WS.M`: The player can take a parked car, their own, or an ambient car stopped below this speed. ### Properties @@ -630,7 +632,13 @@ Traffic and pedestrians on lanes (slice E, docs/fp/slices/E-traffic.md): one roa - `func visible_near(p: Vector3, r: float) -> Array`: Visible named residents near p (on foot, in a vehicle, or standing outdoors; never indoors). - `func is_resident_visible(id: String) -> bool` - `func pick(origin: Vector3, dir: Vector3, max_dist: float) -> Dictionary`: Interaction pick (S0 §10): residents on foot, ambient pedestrians, vehicles (parked included). -- `func blockers_near(p: Vector3, r: float) -> Array`: Optional, for A: world AABBs of stopped vehicles near p (parked cars included). +- `func blockers_near(p: Vector3, r: float) -> Array`: Optional, for A: world AABBs of stopped vehicles near p (parked cars included). While the player drives, moving vehicles block too, and the player's own car does not. +- `func enter_target(p: Vector3, r: float) -> Dictionary`: The car the player could get into from p: the nearest one they may take whose body is within r of p, as {"id", "label", "pos", "dist"}; {} when none (or while they drive). +- `func claim_car(id: int) -> Dictionary`: Hands car id to the player: an ambient, decor or resident car is swapped for a player car of the same model where it stands (its owner's trip carries on without it). Returns the car's {"id", "pos", "yaw", "length", "half_w", "height"}, or {} when it cannot be taken. +- `func drive_car(pos: Vector3, yaw: float, v: float, a: float) -> void`: Poses the driven car (speed v signed, acceleration a) and its model at once, so the car and the seated hero move in the same frame. +- `func leave_car() -> void`: The player gets out: the car stays where it stands until it is far away and out of sight. +- `func driven_id() -> int`: Id of the car the player drives, -1 on foot. +- `func seat_offset() -> Variant`: The driver's hip point in the driven car's own frame (+Z forward), or null until its full model is drawn (the merged far LOD has no seat). - `func light_pole(i: int, corner: int) -> Node3D`: The traffic-light pole on cross tile i at corner (0..3 = NE, SE, SW, NW), or null. - `func anchor_of(id: String) -> Vector3`: Live head anchor of resident id while drawn (else above where they are). With HC bodies it is the Head bone's world position (an estimate from the stature while no body draws them). - `func anim_stats() -> Dictionary`: Animated-people stats for G2's bench: {"t0", "t1", "t2", "t3"} people on screen per LOD tier, "anim_us" microseconds advancing mixers this frame (T0 trees included), "char_draws" character draws (shadows extra), plus "off" (off-screen bodies holding their pose), "bodies" (instanced) and "people_us" (the whole people pass). Legacy static people report their shader draws only. @@ -728,6 +736,7 @@ Every road vehicle (docs/fp/slices/E-traffic.md §3): ambient filler, resident c - `LOD_NEAR = 6.0` - `LOD_DT = 1.0 / 10.0` - `FAR_SPEED = 0.9`: Data-only vehicles beyond the traffic radius cruise at this share of their own speed. +- `PLAYER = "player"`: Role of the player's own cars: never on a lane, posed by the drive feature or left standing. ### Properties diff --git a/docs/fp/ARCHITECTURE.md b/docs/fp/ARCHITECTURE.md index 80fb82de..1e3f8fc6 100644 --- a/docs/fp/ARCHITECTURE.md +++ b/docs/fp/ARCHITECTURE.md @@ -96,7 +96,7 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th - `city_sim.gd`: `var day_seconds: float = SimDefs.BASE_DAY_SECONDS`, read wherever `BASE_DAY_SECONDS` is used today. - `city_brain.gd` and `sim_defs.gd`: `MAYORS[i]["display"]` (human name). The display name replaces the model name in prompts, `city_log`, `owner_name()` and inspector text. `model` stays for the API. The offline Gazette editorial and the inspector prompt stop saying "AI mayors". - S0's own FP files also carry shared contracts that slices build on: `GameBus.card_action`, `CityDirectory.planned_name`, `CollisionWorld.boxes()`, `AP.load_scene()` and the GLB node-name prefixes, `FpPlayer.photo_mode`, and the `fp_kit` test context. S0 §23 (D-S0-17 to D-S0-22) lists them. -- `project.godot`: FP input actions (`move_forward`, `move_back`, `move_left`, `move_right`, `sprint`, `crouch`, `jump`, `interact`, `chat_open`, `map_toggle`, `pause_menu`, `time_faster`, `time_slower`, `time_skip`, `photo_mode`, `hud_toggle`, `quick_save`, `quick_load`) and, for D13, the camera actions `view_toggle` (V), `shoulder_swap` (Q), `zoom_in` / `zoom_out` (mouse wheel up / down) and `look_left` / `look_right` / `look_up` / `look_down` (gamepad right stick, dead zone 0.15). The existing `camera_*` actions stay. The integration overview adds `overview_toggle` (Tab, gamepad Back), `site_next` (N), `site_prev` (B), `site_go` (F) and `site_waypoint` (G). +- `project.godot`: FP input actions (`move_forward`, `move_back`, `move_left`, `move_right`, `sprint`, `crouch`, `jump`, `interact`, `chat_open`, `map_toggle`, `pause_menu`, `time_faster`, `time_slower`, `time_skip`, `photo_mode`, `hud_toggle`, `quick_save`, `quick_load`) and, for D13, the camera actions `view_toggle` (V), `shoulder_swap` (Q), `zoom_in` / `zoom_out` (mouse wheel up / down) and `look_left` / `look_right` / `look_up` / `look_down` (gamepad right stick, dead zone 0.15). The existing `camera_*` actions stay. The integration overview adds `overview_toggle` (Tab, gamepad Back), `site_next` (N), `site_prev` (B), `site_go` (F) and `site_waypoint` (G). Driving adds `vehicle_enter` (F, gamepad Y; on foot it only takes the press with a car in reach, so Y still opens the map elsewhere), `drive_throttle` (right trigger) and `drive_brake` (left trigger); on the keyboard the move keys drive. - `FpMode.DEFAULT_SETTINGS` (D13): `camera_mode: "third"`, `tps_fov: 60.0`, `tps_distance_m: 3.2`, `shoulder: 1` (right; -1 left), `auto_shoulder: false`, `hero: "m"` (or `"f"`). `"fov"` stays the first-person FOV. `apply_settings(patch, persist := true)`: `persist = false` changes the settings for this run without writing `user://gridfall_fp_settings.json` (the photo tour and `fp_bench` use it). ## 5. Slices (built in parallel after S0) @@ -116,6 +116,7 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th | **HC Characters** (D13) | `tools/characters/**`, `models/characters/**` **including its `.import` files** (the BoneMap retarget settings are content; exception to rule 11), the character entries of `scripts/fp/asset_paths.gd` (`CHARACTERS_DIR`, `CHAR_BODIES_DIR`, `CHAR_ROSTER`, `CHAR_LIB_M`, `CHAR_LIB_F`, `HERO_M`, `HERO_F`, `PERSON_TPS_ANIMS`), `tools/tests/fp_characters_test.gd` | Realistic clothed people from Microsoft Rocketbox (MIT): 2 hero bodies and 38 resident bodies plus far LODs, on Godot's humanoid skeleton. Mocap clips (Rocketbox, plus Quaternius UAL CC0 for jump, crouch, interact, drive, carry) in one shared animation library per gender. `roster.json` with job pools and name genders. Licences and provenance. | | **I Audio** | `scripts/fp/audio/**`, `sounds/fp/**`, `scripts/audio_director.gd` | 3D positional engines, horns and sirens, footsteps by surface (in step with the hero's stride when the avatar animates), area ambience (traffic hum, park birds, river, construction), and a UI chat blip. Assets synthesized procedurally or with the existing ACE-Step tool. | | **Integration: city overview** | `scripts/fp/overview/**`, `tools/tests/fp_overview_test.gd` | Tab on foot: an aerial camera over the city (through `FpPlayer.set_view_override`, so `player.camera` stays the one camera) with a marker per open construction site and a site list. N / B pick sites, F walks the hero to a vantage point by the site, G sets a waypoint, Tab or Esc return. "Planner view" leaves for orbit; Tab in orbit comes back to where the hero stood (`Main._fp_saved`). | +| **Integration: driving** | `scripts/fp/drive/**`, `tools/tests/fp_drive_test.gd` | F by a car takes it over (its resident's trip ends; traffic hands over a `player`-role car of the same model that the AI no longer steps). An arcade bicycle model (`drive_model.gd`: throttle, brake, reverse, speed-scaled steering, grip by surface, handbrake slide) moves it through `TrafficFeature.drive_car`; the hero sits in the driver's seat. Chase camera (sweep-checked) or driver view on V, a speedometer and gear chip, crashes bounce off `CollisionWorld` boxes. AI cars and walkers yield to player cars as oriented boxes. F again gets out on the driver's side when slow (under 8.5 m/s) and there is room; a left car is removed once out of range and out of sight. | ## 6. Rules for every slice diff --git a/project.godot b/project.godot index f8300fc1..e9266edd 100644 --- a/project.godot +++ b/project.godot @@ -244,6 +244,22 @@ site_waypoint={ , Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":true,"script":null) ] } +vehicle_enter={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":true,"script":null) +] +} +drive_throttle={ +"deadzone": 0.1, +"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null) +] +} +drive_brake={ +"deadzone": 0.1, +"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":4,"axis_value":1.0,"script":null) +] +} [rendering] diff --git a/scripts/fp/drive/drive_feature.gd b/scripts/fp/drive/drive_feature.gd new file mode 100644 index 00000000..ce195a79 --- /dev/null +++ b/scripts/fp/drive/drive_feature.gd @@ -0,0 +1,414 @@ +extends FpFeature +## Getting into cars and driving them. Near a parked or stopped car, F (Y on a pad) gets in: the +## hero sits in the driver's seat and the camera chases the car. W / S or the triggers drive and +## brake (S reverses once stopped), A / D or the left stick steer, Space (A) is the handbrake, the +## mouse or right stick look around, V switches to the driver's view, and F again gets out beside +## the driver's door. The car belongs to the traffic feature (lane traffic stops for it and it +## stays where it is left); it collides with the walker's world as three circles along its body. + +const WS := preload("res://scripts/fp/world_scale.gd") +const MODEL := preload("res://scripts/fp/drive/drive_model.gd") +const HUD := preload("res://scripts/fp/drive/drive_hud.gd") + +## Reach from the hero to a car's body for the get-in prompt. +const ENTER_REACH := 2.0 * WS.M +## Faster than this the hero stays in (about 30 km/h). +const EXIT_SPEED := 8.5 * WS.M +## How far beside the body the hero steps out. +const EXIT_GAP := 0.7 * WS.M +const ENTER_BLEND := 0.45 +const EXIT_BLEND := 0.35 +const CHASE_DIST := 7.0 * WS.M +const CHASE_PITCH := deg_to_rad(12.0) +const LOOK_UP := 1.3 * WS.M +const LOOK_AHEAD := 3.0 * WS.M +const CAM_RADIUS := 0.25 * WS.M +const CAM_MIN := 1.5 * WS.M +## The chase camera swings in behind the car at this rate (1/s), faster with speed. +const CHASE_FOLLOW := 2.5 +const ORBIT_HOLD := 1.5 +const ORBIT_RETURN := 2.5 +const STICK_ORBIT := deg_to_rad(200.0) +const PITCH_MIN := deg_to_rad(-25.0) +const PITCH_MAX := deg_to_rad(40.0) +const FOV_FAST := 10.0 +const EYE_ABOVE_HIP := 0.7 * WS.M +## The driver view sits a little toward the middle so the door pillar does not fill the left side. +const EYE_INBOARD := 0.6 +## Ground following: the body rises onto a curb at once and settles down at this rate (u/s). +const SETTLE := 0.3 +const PROMPT_TICK := 0.15 + +var active: bool = false +var car_id: int = -1 +var model := MODEL.new() +## "chase" or "driver". +var view: String = "chase" +var hud: HUD +## Car half width, height and length (u). +var _dims := Vector3(0.045, 0.075, 0.24) +var _pos := Vector3.ZERO +var _seat: Variant = null +var _hero_off := Vector3.ZERO +var _cam_yaw: float = 0.0 +var _orbit: float = 0.0 +var _orbit_pitch: float = 0.0 +var _orbit_t: float = 0.0 +var _prev_mode: String = "first" +var _target: Dictionary = {} +var _t: float = 0.0 + + +func feature_id() -> String: + return "drive" + + +func setup(p_ctx: FpContext) -> void: + super.setup(p_ctx) + if ctx.hud != null: + hud = HUD.new() + hud.setup() + ctx.hud.slot("overlay").add_child(hud) + + +func teardown() -> void: + exit(true) + if hud != null and is_instance_valid(hud): + hud.queue_free() + hud = null + + +func on_world_rebuilt() -> void: + exit(true) + + +func from_dict(_d: Dictionary) -> void: + exit(true) + + +func self_test() -> Dictionary: + var m := MODEL.new() + m.reset(0.0, 0.24) + for _k in range(120): + m.step(1.0, 0.0, 0.0, 0.0, "asphalt", 1.0 / 60.0) + var kmh := m.speed() / WS.M * 3.6 + return {"ok": kmh > 25.0 and kmh < 80.0, "detail": "2 s of full throttle: %.0f km/h" % kmh} + + +# ------------------------------------------------------------------ getting in and out + + +## The car the hero could get into now ({} when none or while driving). +func enter_target() -> Dictionary: + var p := ctx.player if ctx != null else null + var tr := _traffic() + if active or p == null or tr == null or ctx.is_modal() or p.photo_mode or p.in_conversation(): + return {} + return tr.call("enter_target", p.position, ENTER_REACH) + + +## Gets into the nearest car in reach. Returns true when the hero is now driving. +func enter() -> bool: + var t := enter_target() + if t.is_empty(): + return false + var info: Dictionary = _traffic().call("claim_car", int(t["id"])) + if info.is_empty(): + return false + var p := ctx.player + active = true + car_id = int(info["id"]) + _pos = info["pos"] + _dims = Vector3(float(info["half_w"]), float(info["height"]), float(info["length"])) + model.reset(float(info["yaw"]), _dims.z) + _seat = null + _hero_off = Vector3.ZERO + _cam_yaw = model.yaw + _orbit = 0.0 + _orbit_pitch = 0.0 + _prev_mode = p.camera_mode + p.input_enabled = false + ctx.collision.dynamic_boxes = [] + p.set_view_override(camera_xform(), _fov(), ENTER_BLEND) + p.set_camera_mode("third", false) + _ride() + _sit(true) + if ctx.bus != null: + ctx.bus.drive_changed.emit(true) + return true + + +## Gets out beside the driver's door (force: at any speed, wherever there is room or on the spot). +## Returns true when the hero is on foot again. +func exit(force: bool = false) -> bool: + if not active: + return false + if not force and absf(model.speed()) > EXIT_SPEED: + _toast("Slow down to get out") + return false + var spot := _exit_spot() + if is_nan(spot.x): + if not force: + _toast("No room to get out here") + return false + spot = _pos + var tr := _traffic() + if tr != null: + tr.call("leave_car") + active = false + car_id = -1 + if hud != null: + hud.set_prompt("") + hud.set_speed(false, 0.0) + var p := ctx.player + if p != null and is_instance_valid(p): + _sit(false) + if p.avatar != null: + p.avatar.visible = true + p.input_enabled = true + p.teleport(spot, rad_to_deg(model.yaw + PI), 0.0, true) + p.set_camera_mode(_prev_mode, false) + p.clear_view_override(0.0 if force else EXIT_BLEND) + if ctx.bus != null: + ctx.bus.drive_changed.emit(false) + return true + + +## Free ground beside the driver's door, then the other door, behind and in front; NAN when none. +func _exit_spot() -> Vector3: + var h := model.heading() + var left := Vector3(h.y, 0.0, -h.x) + var fwd := Vector3(h.x, 0.0, h.y) + var side := _dims.x + EXIT_GAP + var ends := _dims.z * 0.5 + EXIT_GAP + for off: Vector3 in [left * side, -left * side, -fwd * ends, fwd * ends]: + var c := _pos + off + var g := ctx.collision.walk_height(c) + if is_nan(g): + continue + c.y = g + if not ctx.collision.is_blocked(c, WS.PLAYER_RADIUS): + return c + return Vector3(NAN, NAN, NAN) + + +# ------------------------------------------------------------------ driving + + +func _process(delta: float) -> void: + if ctx == null or ctx.player == null: + return + if not active: + _t -= delta + if _t <= 0.0: + _t = PROMPT_TICK + _target = enter_target() + if hud != null: + hud.set_prompt( + "Get in %s" % str(_target["label"]) if not _target.is_empty() else "" + ) + return + var tr := _traffic() + if tr == null or int(tr.call("driven_id")) != car_id: + exit(true) + return + var dt := minf(delta, 0.1) + var free := not ctx.is_modal() + var throttle := maxf( + Input.get_action_strength("move_forward"), Input.get_action_strength("drive_throttle") + ) + var brake := maxf( + Input.get_action_strength("move_back"), Input.get_action_strength("drive_brake") + ) + var steer := Input.get_axis("move_left", "move_right") + var hand := Input.get_action_strength("jump") + if free: + drive_step(throttle, brake, steer, hand, dt) + _look(dt) + ctx.player.set_view_override(camera_xform(), _fov()) + else: + drive_step(0.0, 1.0, 0.0, 1.0, dt) + if hud != null: + hud.set_prompt("Get out" if absf(model.speed()) <= EXIT_SPEED and free else "") + hud.set_speed(free, model.speed()) + + +## One driving step with the given controls (throttle, brake, handbrake 0..1, steer -1..1). +func drive_step(throttle: float, brake: float, steer: float, hand: float, dt: float) -> void: + if not active or dt <= 0.0: + return + var col := ctx.collision + var yaw0 := model.yaw + var disp := model.step(throttle, brake, steer, hand, col.surface_at(_pos), dt) + var n := maxi(1, ceili(disp.length() / (_dims.x * 0.8))) + var to := _pos + for _k in range(n): + to = col.move_body( + to, Vector3(disp.x, 0.0, disp.y) / float(n), _dims.x, _dims.y, WS.STEP_HEIGHT + ) + var got := Vector2(to.x - _pos.x, to.z - _pos.z) + if not _ends_clear(to, _pos, yaw0): + model.yaw = yaw0 + to = _pos + got = Vector2.ZERO + if got.length() < disp.length() * 0.7: + if absf(model.speed()) > 2.0 * WS.M and got.length() < disp.length() * 0.2: + model.crash() + else: + model.vel = got / dt + var g := col.walk_height(to) + if not is_nan(g): + to.y = g if g > _pos.y else move_toward(_pos.y, g, SETTLE * dt) + _pos = to + _traffic().call("drive_car", _pos, model.yaw, model.speed(), model.accel) + _ride() + + +## True unless the front or rear of the body (yaw now) runs into something it was clear of at +## from (yaw0): a car that starts against a post can still drive away. +func _ends_clear(to: Vector3, from: Vector3, yaw0: float) -> bool: + var reach := maxf(_dims.z * 0.5 - _dims.x, 0.0) + var h := Vector3(sin(model.yaw), 0.0, cos(model.yaw)) + var h0 := Vector3(sin(yaw0), 0.0, cos(yaw0)) + for k: float in [-1.0, 1.0]: + var e := to + h * (k * reach) + e.y = to.y + if ctx.collision.body_blocked(e, _dims.x * 0.95, _dims.y): + var e0 := from + h0 * (k * reach) + e0.y = from.y + if not ctx.collision.body_blocked(e0, _dims.x * 0.95, _dims.y): + return false + return true + + +## The hero rides along, body turned to the car's heading, placed so that the seated hips are on +## the driver's seat (the avatar's own transform belongs to its lean and step springs, so the +## player's feet move instead: _hero_off from the car's centre, in the player's frame). +func _ride() -> void: + var p := ctx.player + _place_hero() + var yaw := model.yaw + PI + p.ride(_pos + Basis(Vector3.UP, yaw) * _hero_off, yaw, _cam_yaw + _orbit + PI) + + +## Measures the sit pose against the seat (the hero is hidden until the car's full model is drawn, +## and for the stand-in figure, which cannot sit). +func _place_hero() -> void: + var av := ctx.player.avatar + if av == null: + return + if _seat == null: + _seat = _traffic().call("seat_offset") + var rig: Variant = av.get("rig") + var seated: bool = _seat != null and rig != null and bool(av.call("is_animated")) + # The driver's eye is inside the hero's head, so the body is hidden in driver view. + av.visible = seated and view != "driver" + if not seated: + return + var s: Vector3 = _seat + # The player's body faces -Z, the car +Z: the seat in the player's frame is turned half a turn. + var want := Vector3(-s.x, s.y, -s.z) + var hw: Vector3 = (rig as RefCounted).call("bone_world", "Hips") + if not is_nan(hw.x): + _hero_off = want - ctx.player.to_local(hw) + + +func _sit(on: bool) -> void: + var av := ctx.player.avatar if ctx.player != null else null + var rig: Variant = av.get("rig") if av != null else null + if rig != null and (rig as RefCounted).has_method("sit"): + (rig as RefCounted).call("sit", on) + + +# ------------------------------------------------------------------ camera + + +func _input(event: InputEvent) -> void: + if ctx == null or ctx.player == null or ctx.is_modal() or event.is_echo(): + return + var handled := false + if event.is_action_pressed("vehicle_enter"): + # Driving, the key is always taken (a refusal toasts why); on foot only with a car near. + if active: + exit() + handled = true + else: + handled = enter() + elif active and event.is_action_pressed("view_toggle"): + view = "driver" if view == "chase" else "chase" + handled = true + elif ( + active and event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED + ): + var rel := (event as InputEventMouseMotion).relative + var sens := float(ctx.settings.get("mouse_sensitivity", WS.MOUSE_SENSITIVITY)) + _orbit_by(-rel.x * sens, rel.y * sens) + handled = true + if handled and is_inside_tree(): + get_viewport().set_input_as_handled() + + +func _orbit_by(d_yaw: float, d_pitch: float) -> void: + _orbit = wrapf(_orbit + d_yaw, -PI, PI) + _orbit_pitch = clampf(_orbit_pitch + d_pitch, PITCH_MIN, PITCH_MAX) + _orbit_t = ORBIT_HOLD + + +## Swings the chase camera in behind the car and lets a held look return to centre. +func _look(dt: float) -> void: + var stick := Input.get_vector("look_left", "look_right", "look_up", "look_down") + if stick.length() > 0.05: + _orbit_by(-stick.x * STICK_ORBIT * dt, stick.y * STICK_ORBIT * 0.5 * dt) + var v := model.speed() + if v > -1.0 * WS.M: + var k := CHASE_FOLLOW * (1.0 + absf(v) / MODEL.TOP) + _cam_yaw = lerp_angle(_cam_yaw, model.yaw, 1.0 - exp(-k * dt)) + _orbit_t -= dt + if _orbit_t <= 0.0: + _orbit = move_toward(_orbit, 0.0, ORBIT_RETURN * dt) + _orbit_pitch = move_toward(_orbit_pitch, 0.0, ORBIT_RETURN * 0.5 * dt) + + +## The camera now: behind and above the car (pulled in front of walls), or at the driver's eyes. +func camera_xform() -> Transform3D: + if view == "driver" and _seat != null: + var car := Transform3D(Basis(Vector3.UP, model.yaw), _pos) + var s: Vector3 = _seat + var eye := car * Vector3(s.x * EYE_INBOARD, s.y + EYE_ABOVE_HIP, s.z) + var a := model.yaw + _orbit + var look := Vector3(sin(a), -0.06 - _orbit_pitch, cos(a)) + return Transform3D(Basis.looking_at(look, Vector3.UP), eye) + var y := _cam_yaw + _orbit + var pitch := clampf(CHASE_PITCH + _orbit_pitch, PITCH_MIN, PITCH_MAX) + var dir := Vector3(-sin(y) * cos(pitch), sin(pitch), -cos(y) * cos(pitch)) + var target := _pos + Vector3(0.0, LOOK_UP, 0.0) + var dist := CHASE_DIST + if ctx.collision != null: + dist = clampf( + ctx.collision.sweep_sphere(target, dir, CHASE_DIST, CAM_RADIUS) - CAM_RADIUS, + CAM_MIN, + CHASE_DIST + ) + var eye := target + dir * dist + var aim := target + Vector3(sin(y), 0.0, cos(y)) * LOOK_AHEAD + return Transform3D(Basis.looking_at(aim - eye, Vector3.UP), eye) + + +func _fov() -> float: + var base := float(ctx.settings.get("fov", WS.FOV)) + return base + FOV_FAST * clampf(absf(model.speed()) / MODEL.TOP, 0.0, 1.0) + + +# ------------------------------------------------------------------ helpers + + +func _traffic() -> FpFeature: + var tr := ctx.feature("traffic") if ctx != null else null + if tr == null or not tr.has_method("claim_car"): + return null + return tr + + +func _toast(text: String) -> void: + if ctx.bus != null: + ctx.bus.toast.emit(text, "info") diff --git a/scripts/fp/drive/drive_feature.gd.uid b/scripts/fp/drive/drive_feature.gd.uid new file mode 100644 index 00000000..8ac2a522 --- /dev/null +++ b/scripts/fp/drive/drive_feature.gd.uid @@ -0,0 +1 @@ +uid://igd2chrajmyu diff --git a/scripts/fp/drive/drive_hud.gd b/scripts/fp/drive/drive_hud.gd new file mode 100644 index 00000000..de20c336 --- /dev/null +++ b/scripts/fp/drive/drive_hud.gd @@ -0,0 +1,112 @@ +extends Control +## The drive feature's HUD: the get-in / get-out key chip above the minimap line and a speedometer +## (km/h and the gear) at the bottom centre while driving, clear of the corner captions. + +const S := preload("res://scripts/fp/ui/hud_style.gd") +const WS := preload("res://scripts/fp/world_scale.gd") +const CHIP_FROM_BOTTOM := 190.0 +const SPEEDO_FROM_BOTTOM := 20.0 +const FADE := 0.12 + +var _chip: PanelContainer +var _chip_label: Label +var _speedo: PanelContainer +var _speed: Label +var _gear: Label +var _alpha: float = 0.0 +var _want: bool = false + + +func setup() -> void: + name = "DriveHud" + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _chip = PanelContainer.new() + _chip.name = "DriveChip" + _chip.mouse_filter = Control.MOUSE_FILTER_IGNORE + _chip.add_theme_stylebox_override("panel", S.box(Color(0.04, 0.05, 0.08, 0.6), 8, 10, 6)) + var h := HBoxContainer.new() + h.mouse_filter = Control.MOUSE_FILTER_IGNORE + h.add_theme_constant_override("separation", 9) + _chip.add_child(h) + var key := S.key_cap("F", 13) + key.size_flags_vertical = Control.SIZE_SHRINK_CENTER + h.add_child(key) + _chip_label = S.label("", 16, S.TEXT, S.bold(), 2) + _chip_label.name = "Text" + h.add_child(_chip_label) + _chip.visible = false + add_child(_chip) + _build_speedo() + + +func _build_speedo() -> void: + _speedo = S.panel(S.PANEL_BG, 10, 16, 8) + _speedo.name = "Speedometer" + var v := VBoxContainer.new() + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + v.add_theme_constant_override("separation", -4) + _speedo.add_child(v) + var row := HBoxContainer.new() + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_theme_constant_override("separation", 8) + v.add_child(row) + _speed = S.plain("0", 40, S.WHITE, S.title()) + _speed.name = "Speed" + _speed.custom_minimum_size = Vector2(84, 0) + _speed.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + row.add_child(_speed) + _gear = S.plain("N", 22, S.ACCENT, S.bold()) + _gear.name = "Gear" + _gear.size_flags_vertical = Control.SIZE_SHRINK_END + row.add_child(_gear) + var unit := S.plain("KM/H", 12, S.TEXT_DIM, S.caps()) + unit.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + v.add_child(unit) + _speedo.visible = false + add_child(_speedo) + + +## Shows the key chip with text ("" hides it). +func set_prompt(text: String) -> void: + _want = not text.is_empty() + if _want and _chip_label.text != text: + _chip_label.text = text + _chip.reset_size() + + +## The chip text while it shows, else "". +func prompt_text() -> String: + return _chip_label.text if _want else "" + + +## Speedometer for a signed speed in u/s (hidden when show is false). +func set_speed(show: bool, v: float) -> void: + _speedo.visible = show + if not show: + return + var kmh := roundi(absf(v) / WS.M * 3.6) + _speed.text = str(kmh) + _gear.text = "R" if v < -0.3 * WS.M else ("N" if kmh == 0 else "D") + + +func speed_text() -> String: + return _speed.text if _speedo.visible else "" + + +func _process(delta: float) -> void: + var rate := delta / FADE + _alpha = clampf(_alpha + (rate if _want else -rate), 0.0, 1.0) + _chip.visible = _alpha > 0.0 + _chip.modulate.a = _alpha + if _chip.visible: + var view := size if size.x > 0.0 else get_viewport_rect().size + var sz := _chip.get_combined_minimum_size() + _chip.size = sz + _chip.position = (Vector2(view.x * 0.5, view.y - CHIP_FROM_BOTTOM) - sz * 0.5).round() + if _speedo.visible: + var view := size if size.x > 0.0 else get_viewport_rect().size + var sz := _speedo.get_combined_minimum_size() + _speedo.position = ( + Vector2((view.x - sz.x) * 0.5, view.y - sz.y - SPEEDO_FROM_BOTTOM).round() + ) diff --git a/scripts/fp/drive/drive_hud.gd.uid b/scripts/fp/drive/drive_hud.gd.uid new file mode 100644 index 00000000..3d6daedf --- /dev/null +++ b/scripts/fp/drive/drive_hud.gd.uid @@ -0,0 +1 @@ +uid://cavwi3wys45o8 diff --git a/scripts/fp/drive/drive_model.gd b/scripts/fp/drive/drive_model.gd new file mode 100644 index 00000000..adcd4c77 --- /dev/null +++ b/scripts/fp/drive/drive_model.gd @@ -0,0 +1,123 @@ +extends RefCounted +## Arcade handling for the player's car. Throttle and brake set the speed along the heading +## (brake reverses once stopped), the steering angle turns the car through a bicycle model, and +## the tyres bleed off sideways speed at a rate set by the surface, so a handbrake turn slides. +## World units (WS.M per metre) and seconds; the heading at yaw 0 is +Z, as for every vehicle. + +const WS := preload("res://scripts/fp/world_scale.gd") + +const TOP := 30.0 * WS.M # 108 km/h on asphalt +const REVERSE_TOP := 7.0 * WS.M +const ACCEL := 7.0 * WS.M # m/s^2 from rest, falling off toward TOP +const BRAKE := 14.0 * WS.M +const ROLL := 0.6 * WS.M # coasting: rolling loss plus DRAG per m/s +const DRAG := 0.12 +const HANDBRAKE := 6.0 * WS.M +const STEER_MAX := deg_to_rad(34.0) +## The steering lock halves by this speed (a light touch on the motorway, full lock in a car park). +const STEER_HALF_AT := 13.0 * WS.M +const STEER_RATE := 3.2 # rad/s toward the wanted angle +const STEER_RETURN := 5.0 +## Wheelbase as a share of body length. +const WHEELBASE := 0.6 +## Sideways speed lost per second (1/s): tyres on asphalt, and on the handbrake. +const GRIP := 9.0 +const GRIP_HANDBRAKE := 1.4 +## Grip and top-speed factors off the carriageway. +const SURFACES := { + "asphalt": [1.0, 1.0], + "bridge": [1.0, 1.0], + "sidewalk": [0.9, 0.8], + "lot": [0.85, 0.7], + "dirt": [0.6, 0.5], + "grass": [0.55, 0.45], +} +## Share of the speed kept (and reversed) after a crash. +const BOUNCE := 0.2 + +var yaw: float = 0.0 +var vel := Vector2.ZERO +var steer: float = 0.0 +var length: float = 0.24 +## Signed acceleration along the heading over the last step (brake lights). +var accel: float = 0.0 + + +func reset(p_yaw: float, p_length: float) -> void: + yaw = p_yaw + length = p_length + vel = Vector2.ZERO + steer = 0.0 + accel = 0.0 + + +func heading() -> Vector2: + return Vector2(sin(yaw), cos(yaw)) + + +## Signed speed along the heading (u/s, negative in reverse). +func speed() -> float: + return vel.dot(heading()) + + +## One step: throttle, brake and handbrake 0..1, steer -1 (left) .. 1 (right). Returns the xz +## displacement; yaw and vel move on. +func step( + throttle: float, brake: float, steer_in: float, handbrake: float, surface: String, dt: float +) -> Vector2: + if dt <= 0.0: + return Vector2.ZERO + var surf: Array = SURFACES.get(surface, [0.8, 0.7]) + var vf := speed() + var lock := STEER_MAX / (1.0 + absf(vf) / STEER_HALF_AT) + var want := clampf(steer_in, -1.0, 1.0) * lock + var rate := ( + STEER_RATE if absf(want) > absf(steer) or signf(want) != signf(steer) else STEER_RETURN + ) + steer = move_toward(steer, want, rate * dt) + # Steering right turns the heading toward -X (the car's right when it faces +Z). + var turn := vf * tan(steer) / maxf(length * WHEELBASE, 1e-4) + if handbrake > 0.0: + turn *= 1.0 + 0.4 * handbrake + yaw = wrapf(yaw - turn * dt, -PI, PI) + var h := heading() + var side := Vector2(-h.y, h.x) + vf = vel.dot(h) + var vs := vel.dot(side) + var before := vf + vf = _drive(vf, throttle, brake, handbrake, TOP * float(surf[1]), dt) + accel = (vf - before) / dt + var grip := lerpf(GRIP * float(surf[0]), GRIP_HANDBRAKE, clampf(handbrake, 0.0, 1.0)) + vs *= exp(-grip * dt) + vel = h * vf + side * vs + return vel * dt + + +## A crash: the car stops dead and rebounds a little. +func crash() -> void: + vel *= -BOUNCE + steer *= 0.5 + + +## Speed along the heading after throttle, brake (reverse once stopped), handbrake and coasting. +static func _drive( + vf: float, throttle: float, brake: float, handbrake: float, top: float, dt: float +) -> float: + var stopped := 0.5 * WS.M + if throttle > 0.0: + if vf < -stopped: + vf = minf(vf + BRAKE * throttle * dt, 0.0) + else: + vf += ACCEL * throttle * maxf(0.0, 1.0 - vf / top) * dt + if brake > 0.0: + if vf > stopped: + vf = maxf(vf - BRAKE * brake * dt, 0.0) + else: + vf -= ACCEL * 0.7 * brake * maxf(0.0, 1.0 + vf / REVERSE_TOP) * dt + if throttle <= 0.0 and brake <= 0.0: + vf = move_toward(vf, 0.0, (ROLL + DRAG * absf(vf)) * dt) + if handbrake > 0.0: + vf = move_toward(vf, 0.0, HANDBRAKE * handbrake * dt) + if vf > top: + vf = move_toward(vf, top, BRAKE * 0.4 * dt) + return vf diff --git a/scripts/fp/drive/drive_model.gd.uid b/scripts/fp/drive/drive_model.gd.uid new file mode 100644 index 00000000..9a9ea8dd --- /dev/null +++ b/scripts/fp/drive/drive_model.gd.uid @@ -0,0 +1 @@ +uid://bci2imarp27eh diff --git a/scripts/fp/fp_features.gd b/scripts/fp/fp_features.gd index 1302336c..fefeba78 100644 --- a/scripts/fp/fp_features.gd +++ b/scripts/fp/fp_features.gd @@ -12,4 +12,5 @@ const FEATURES := [ {"id": "posters", "slice": "D", "path": "res://scripts/fp/posters/poster_feature.gd"}, {"id": "audio", "slice": "I", "path": "res://scripts/fp/audio/audio_feature.gd"}, {"id": "overview", "slice": "integration", "path": "res://scripts/fp/overview/overview_feature.gd"}, + {"id": "drive", "slice": "integration", "path": "res://scripts/fp/drive/drive_feature.gd"}, ] diff --git a/scripts/fp/game_bus.gd b/scripts/fp/game_bus.gd index 17aae2e1..258403c4 100644 --- a/scripts/fp/game_bus.gd +++ b/scripts/fp/game_bus.gd @@ -35,3 +35,5 @@ signal modal_changed(active: bool) signal world_ready() ## A button on a place card: action "ask_here" (C answers through brain.inspect_tile). Emitted by B. signal card_action(action: String, tile: int) +## The hero got into a car (true) or out of it (false). Emitted by the drive feature. +signal drive_changed(active: bool) diff --git a/scripts/fp/interact/interact_feature.gd b/scripts/fp/interact/interact_feature.gd index 47ff778e..34756056 100644 --- a/scripts/fp/interact/interact_feature.gd +++ b/scripts/fp/interact/interact_feature.gd @@ -31,6 +31,7 @@ var _key: String = "" var _modal_frame: int = -1 var _fallback_key: String = "" var _fallback_t: float = 0.0 +var _driving: bool = false func feature_id() -> String: @@ -42,6 +43,7 @@ func setup(p_ctx: FpContext) -> void: if ctx.bus != null: ctx.bus.modal_changed.connect(_on_modal_changed) ctx.bus.world_ready.connect(_on_world_ready) + ctx.bus.drive_changed.connect(_on_drive_changed) if ctx.player != null: ctx.player.photo_mode_changed.connect(_on_photo_mode_changed) @@ -54,6 +56,8 @@ func teardown() -> void: ctx.bus.modal_changed.disconnect(_on_modal_changed) if ctx.bus.world_ready.is_connected(_on_world_ready): ctx.bus.world_ready.disconnect(_on_world_ready) + if ctx.bus.drive_changed.is_connected(_on_drive_changed): + ctx.bus.drive_changed.disconnect(_on_drive_changed) if ctx.player != null and is_instance_valid(ctx.player) and ctx.player.photo_mode_changed.is_connected(_on_photo_mode_changed): ctx.player.photo_mode_changed.disconnect(_on_photo_mode_changed) @@ -70,7 +74,7 @@ func from_dict(_d: Dictionary) -> void: func evaluate() -> Dictionary: evaluations += 1 var p := ctx.player if ctx != null else null - if p == null or ctx.is_modal() or p.photo_mode: + if p == null or ctx.is_modal() or p.photo_mode or _driving: _set_target({}) return target var ray := view_ray() @@ -387,6 +391,12 @@ func _on_world_ready() -> void: _set_target({}) +## Nothing to interact with from behind the wheel. +func _on_drive_changed(active: bool) -> void: + _driving = active + evaluate() + + ## A straight sidewalk with a long run ahead and the most built-up sides (fp_shots' street rule), ## the hero facing along it. func _street_pose() -> Dictionary: diff --git a/scripts/fp/player/player.gd b/scripts/fp/player/player.gd index c354e4a0..e5df5ae0 100644 --- a/scripts/fp/player/player.gd +++ b/scripts/fp/player/player.gd @@ -226,6 +226,17 @@ func teleport(pos: Vector3, yaw_deg: float, pitch_deg: float = 0.0, snap: bool = _update_tile() +## Carries the body with a vehicle (input off): feet at pos, body yaw and view yaw in radians, +## no ground snap and no camera snap (the vehicle owns the view through set_view_override). +func ride(pos: Vector3, body_yaw: float, view_yaw: float) -> void: + position = pos + yaw = wrapf(body_yaw, -PI, PI) + cam_yaw = wrapf(view_yaw, -PI, PI) + velocity = Vector3.ZERO + _apply_rotation() + _update_tile() + + ## Faces a world point: the view (yaw and pitch) and the body. func look_toward(target: Vector3) -> void: var d := target - eye_position() diff --git a/scripts/fp/traffic/occupants.gd b/scripts/fp/traffic/occupants.gd index 2d5358db..bbec8eb2 100644 --- a/scripts/fp/traffic/occupants.gd +++ b/scripts/fp/traffic/occupants.gd @@ -37,12 +37,14 @@ func setup(p_people: PPL) -> void: func sync(cars: Array, cam: Vector3, residents: Variant) -> void: var cands: Array = [] for c: VH.Car in cars: - if c.dead or c.parked or c.far or c.node == null or not c.node.visible: + if c.dead or c.parked or c.far or c.role == VH.PLAYER: + continue + if c.node == null or not c.node.visible: continue var d := cam.distance_to(c.pos) if d > RANGE: continue - var seat: Variant = _seat(c.node) + var seat: Variant = seat_point(c.node) if seat != null: cands.append([d, c, seat]) cands.sort_custom(func(a: Array, b: Array) -> bool: return float(a[0]) < float(b[0])) @@ -113,7 +115,7 @@ static func _pose_seated(b: PPL.Body) -> Vector3: ## The seat marker's position in the full model's space, cached on the node (null without one). -func _seat(node: Node3D) -> Variant: +func seat_point(node: Node3D) -> Variant: if node.has_meta("seat_pos"): return node.get_meta("seat_pos") var pos: Variant = null diff --git a/scripts/fp/traffic/traffic_feature.gd b/scripts/fp/traffic/traffic_feature.gd index a201ee64..5c8a49a9 100644 --- a/scripts/fp/traffic/traffic_feature.gd +++ b/scripts/fp/traffic/traffic_feature.gd @@ -32,6 +32,8 @@ const PICK_PX := 26.0 const PHOTO_BACK := 1.3 ## Seconds between attempts to rebuild bus lines that no longer match the roads. const LINE_RETRY := 10.0 +## The player can take a parked car, their own, or an ambient car stopped below this speed. +const CLAIM_SPEED := 0.5 * WS.M var lanes: LG var peds: SW @@ -82,6 +84,8 @@ var _snap_head := PackedFloat32Array() var _snap_kind := PackedStringArray() var _snap_frame: int = -1 var _snap_serial: int = 0 +## The player's car while they drive it (role VH.PLAYER), else null. +var _driven: VH.Car = null func feature_id() -> String: @@ -164,6 +168,7 @@ func on_world_rebuilt() -> void: return var sim := ctx.sim _built = false + _driven = null _rng.seed = sim.city_seed * 31337 + 1 render.clear() occupants.clear() @@ -362,6 +367,10 @@ func _maintain(sync: bool) -> void: var p := ctx.player_position() var r := WS.TRAFFIC_RADIUS for c: VH.Car in vehicles.cars.duplicate(): + if c.role == VH.PLAYER: + if not c.driven and not c.dead: + _drop_left(c, p, r) + continue if c.parked or c.dead or c.role == "ambient" or c.role == "decor": continue var d := Vector2(c.pos.x - p.x, c.pos.z - p.z).length() @@ -398,6 +407,12 @@ func _maintain(sync: bool) -> void: fleets.retry_t = fleets.backoff +## A car the player left goes once it is out of the traffic radius and out of sight. +func _drop_left(c: VH.Car, p: Vector3, r: float) -> void: + if Vector2(c.pos.x - p.x, c.pos.z - p.z).length() > r and not on_screen(c.pos): + vehicles.remove(c) + + func _drop_stuck(c: VH.Car) -> void: if c.owner is TRIPS.Trip: trips.complete_now(c.owner) @@ -1053,7 +1068,7 @@ func pick(origin: Vector3, dir: Vector3, max_dist: float) -> Dictionary: bt = t best = _walker_target(w, t) for c: VH.Car in vehicles.cars: - if c.dead or c.far or (c.parked and c.slot_tile < 0): + if c.dead or c.far or c.driven or (c.parked and c.slot_tile < 0): continue if c.pos.distance_to(origin) > max_dist + 0.4: continue @@ -1123,20 +1138,141 @@ static func _ray_box(o: Vector3, d: Vector3, xf: Transform3D, he: Vector3, h: fl return maxf(t0, 0.0) -## Optional, for A: world AABBs of stopped vehicles near p (parked cars included). +## Optional, for A: world AABBs of stopped vehicles near p (parked cars included). While the +## player drives, moving vehicles block too, and the player's own car does not. func blockers_near(p: Vector3, r: float) -> Array: var out: Array = [] if not _built: return out + var driving := _driven != null for c: VH.Car in vehicles.cars: - if c.dead or c.far or (c.parked and c.slot_tile < 0): + if c.dead or c.far or c.driven or (c.parked and c.slot_tile < 0): continue - if c.v > 0.1 * WS.M or c.pos.distance_to(p) > r + 0.3: + if (c.v > 0.1 * WS.M and not driving) or c.pos.distance_to(p) > r + 0.3: continue out.append(vehicles.aabb(c)) return out +# ------------------------------------------------------------------ the player's car + +## The car the player could get into from p: the nearest one they may take whose body is within r +## of p, as {"id", "label", "pos", "dist"}; {} when none (or while they drive). +func enter_target(p: Vector3, r: float) -> Dictionary: + if not _built or _driven != null: + return {} + var best: VH.Car = null + var bd := r + for c: VH.Car in vehicles.cars: + if not _claimable(c) or c.pos.distance_to(p) > r + c.length: + continue + var d := _body_dist(c, p) + if d <= bd: + bd = d + best = c + if best == null: + return {} + var label := "your car" if best.role == VH.PLAYER else "the car" + return {"id": best.id, "label": label, "pos": best.pos, "dist": bd} + + +## Hands car id to the player: an ambient, decor or resident car is swapped for a player car of +## the same model where it stands (its owner's trip carries on without it). Returns the car's +## {"id", "pos", "yaw", "length", "half_w", "height"}, or {} when it cannot be taken. +func claim_car(id: int) -> Dictionary: + var c: VH.Car = vehicles.by_id.get(id) + if c == null or _driven != null or not _claimable(c): + return {} + if c.role != VH.PLAYER: + c = _take_over(c) + c.driven = true + _driven = c + return { + "id": c.id, + "pos": c.pos, + "yaw": c.yaw, + "length": c.length, + "half_w": c.half_w, + "height": c.height, + } + + +## Poses the driven car (speed v signed, acceleration a) and its model at once, so the car and the +## seated hero move in the same frame. +func drive_car(pos: Vector3, yaw: float, v: float, a: float) -> void: + var c := _driven + if c == null or c.dead: + return + c.wheel_spin += Vector2(pos.x - c.pos.x, pos.z - c.pos.z).length() * signf(v) + c.pos = pos + c.yaw = yaw + c.v = absf(v) + c.a = a + c.vel = Vector3(sin(yaw), 0.0, cos(yaw)) * v + if c.node != null and c.lod == 0: + c.node.transform = vehicles.draw_xform(c) * render.model(c.model).offset + + +## The player gets out: the car stays where it stands until it is far away and out of sight. +func leave_car() -> void: + if _driven == null: + return + _driven.driven = false + _driven.v = 0.0 + _driven.a = 0.0 + _driven.vel = Vector3.ZERO + _driven = null + + +## Id of the car the player drives, -1 on foot. +func driven_id() -> int: + return _driven.id if _driven != null and not _driven.dead else -1 + + +## The driver's hip point in the driven car's own frame (+Z forward), or null until its full model +## is drawn (the merged far LOD has no seat). +func seat_offset() -> Variant: + if _driven == null or _driven.node == null: + return null + var s: Variant = occupants.seat_point(_driven.node) + if s == null: + return null + return render.model(_driven.model).offset * (s as Vector3) + + +func _claimable(c: VH.Car) -> bool: + if c.dead or c.far or c.driven or c.kind != "car" or c.park_t >= 0.0 or c.model < 0: + return false + if c.role == VH.PLAYER: + return true + if c.parked: + return c.slot_tile >= 0 + return c.role == "ambient" and c.v < CLAIM_SPEED + + +func _take_over(o: VH.Car) -> VH.Car: + var c := vehicles.make("car", VH.PLAYER, Vector3(o.half_w * 2.0, o.height, o.length)) + c.model = o.model + c.pos = o.pos + o.vis_off + c.yaw = o.yaw + o.vis_yaw + c.label = "Your car" + c.state = "yours" + if o.owner is TRIPS.Trip: + trips.complete_now(o.owner) + if not o.dead: + vehicles.remove(o) + return c + + +## Distance in xz from p to c's body (0 inside it). +static func _body_dist(c: VH.Car, p: Vector3) -> float: + var rel := Vector2(p.x - c.pos.x, p.z - c.pos.z) + var h := Vector2(sin(c.yaw), cos(c.yaw)) + var along := maxf(absf(rel.dot(h)) - c.length * 0.5, 0.0) + var side := maxf(absf(rel.cross(h)) - c.half_w, 0.0) + return Vector2(along, side).length() + + ## The traffic-light pole on cross tile i at corner (0..3 = NE, SE, SW, NW), or null. func light_pole(i: int, corner: int) -> Node3D: return junctions.light_pole(i, corner) if _built else null diff --git a/scripts/fp/traffic/vehicles.gd b/scripts/fp/traffic/vehicles.gd index 70401921..8092786e 100644 --- a/scripts/fp/traffic/vehicles.gd +++ b/scripts/fp/traffic/vehicles.gd @@ -41,6 +41,8 @@ const LOD_NEAR := 6.0 const LOD_DT := 1.0 / 10.0 ## Data-only vehicles beyond the traffic radius cruise at this share of their own speed. const FAR_SPEED := 0.9 +## Role of the player's own cars: never on a lane, posed by the drive feature or left standing. +const PLAYER := "player" ## One vehicle. @@ -88,6 +90,7 @@ class Car extends RefCounted: var node: Node3D = null # full model (render) var lod: int = -1 var wheel_spin: float = 0.0 + var driven: bool = false # the player is driving it (role "player": posed from outside) var dead: bool = false var arrived: bool = false var stuck: bool = false # its lane vanished in view: frozen until out of sight @@ -104,6 +107,10 @@ var cars: Array = [] var by_id: Dictionary = {} var player_pos := Vector3(INF, 0, INF) var player_on: bool = false +## What lane traffic stops for, rebuilt each step: the player on foot and the player's cars, each +## [xz centre, unit heading, half length, half width, xz velocity]; half length < 0 is the player +## on foot (a circle). +var _blocks: Array = [] ## Bumped whenever the set of parked cars changes (the renderer's static layer rebuilds). var parked_version: int = 0 var _next_id: int = 1 @@ -407,8 +414,9 @@ func step(dt: float) -> void: var decay := exp(-dt * 1.6) var near2 := LOD_NEAR * LOD_NEAR var full2 := LOD_FULL * LOD_FULL + _gather_blocks() for c: Car in cars.duplicate(): - if c.dead or c.parked: + if c.dead or c.parked or c.role == PLAYER: continue var h := dt if not c.far and c.park_t < 0.0: @@ -457,11 +465,12 @@ func _drive(c: Car, dt: float) -> void: gap = sgap + IDM.S0 vl = 0.0 hard = minf(hard, sgap) - var pg := _player_gap(c) + var pl := _player_gap(c) + var pg: float = pl[0] if pg < INF: if pg < gap: gap = pg - vl = 0.0 + vl = pl[1] hard = minf(hard, pg - MIN_GAP - WS.PLAYER_RADIUS) var acc := IDM.acc(c.v, vmax, gap, c.v - vl) if c.hold: @@ -619,24 +628,48 @@ func _stops(c: Car) -> Array: ## Gap from c's front to the player when they stand in its forward corridor, else INF. -func _player_gap(c: Car) -> float: - if not player_on: - return INF - var pp := Vector2(player_pos.x, player_pos.z) +func _gather_blocks() -> void: + _blocks.clear() + var driving := false + for c: Car in cars: + if c.role != PLAYER or c.dead: + continue + driving = driving or c.driven + var h := Vector2(sin(c.yaw), cos(c.yaw)) + var v := Vector2(c.vel.x, c.vel.z) + _blocks.append([Vector2(c.pos.x, c.pos.z), h, c.length * 0.5, c.half_w, v]) + if player_on and not driving: + _blocks.append([Vector2(player_pos.x, player_pos.z), Vector2.ZERO, -1.0, 0.0, Vector2.ZERO]) + + +## [gap, speed] to the nearest block on c's path: the distance from its front to the block's +## edge along the path, and the block's speed along the path there; [INF, 0] when none. +func _player_gap(c: Car) -> Array: + var best := [INF, 0.0] + if _blocks.is_empty(): + return best var front := lg.pos_at(c.lane, c.s) var look := c.v * c.v / (2.0 * IDM.B) + 2.0 * WS.M + c.v * 0.5 + 0.1 - if front.distance_to(pp) > look + 0.2: - return INF - var lat_max := WS.ROAD_HALF * 0.5 + for b: Array in _blocks: + if front.distance_to(b[0]) > look + 0.2 + maxf(float(b[2]), 0.0): + continue + var g := _gap_to(c, b, look) + if float(g[0]) < float(best[0]): + best = g + return best + + +func _gap_to(c: Car, b: Array, look: float) -> Array: var d := 0.0 var x := c.lane var s := c.s var k := -1 while d <= look: var q := lg.pos_at(x, s) - if q.distance_to(pp) <= lat_max: - # Distance from the front to the player's edge, along the path. - return maxf(d - WS.PLAYER_RADIUS, 0.0) + if _in_block(q, b, c.half_w): + var along := maxf(0.0, (b[4] as Vector2).dot(lg.tan_at(x, s))) + # Distance from the front to the player's edge (a car's box is grown by c's half width). + return [maxf(d - WS.PLAYER_RADIUS, 0.0) if float(b[2]) < 0.0 else d, along] s += 0.02 d += 0.02 if s > x.length: @@ -645,7 +678,16 @@ func _player_gap(c: Car) -> float: break s -= x.length x = c.path[k] - return INF + return [INF, 0.0] + + +static func _in_block(q: Vector2, b: Array, half_w: float) -> bool: + var rel: Vector2 = q - (b[0] as Vector2) + if float(b[2]) < 0.0: + return rel.length() <= WS.ROAD_HALF * 0.5 + var h: Vector2 = b[1] + var grow := half_w + 0.2 * WS.M + return absf(rel.dot(h)) <= float(b[2]) + grow and absf(rel.cross(h)) <= float(b[3]) + grow ## True when walkers are on (or stepping onto) the zebra across `arm` of tile i. diff --git a/scripts/fp/ui/pause_menu.gd b/scripts/fp/ui/pause_menu.gd index 163488c6..87daf028 100644 --- a/scripts/fp/ui/pause_menu.gd +++ b/scripts/fp/ui/pause_menu.gd @@ -54,7 +54,7 @@ const LEGEND := [ ["alert", "Incidents", "How many are active, and the newest"], ] const KEYS := [ - ["M", "Map"], ["Tab", "City overview"], ["V", "Camera view"], ["Q", "Shoulder"], ["E", "Interact"], ["Enter", "Chat"], + ["M", "Map"], ["Tab", "City overview"], ["V", "Camera view"], ["Q", "Shoulder"], ["E", "Interact"], ["F", "Get in / out of a car"], ["Enter", "Chat"], ["[ ]", "Game speed"], ["T", "Skip to morning"], ["P", "Photo mode"], ["F1", "Hide HUD"], ["F5", "Quick save"], ["F9", "Quick load"], ] diff --git a/tools/release/PLAYING.md b/tools/release/PLAYING.md index 5a9dc6e9..5047a078 100644 --- a/tools/release/PLAYING.md +++ b/tools/release/PLAYING.md @@ -19,7 +19,8 @@ The game starts at street level. To play the orbit city builder with the full HU ## Controls -- **On foot:** WASD or arrow keys to walk, Shift to run, Space to jump, Ctrl to crouch, mouse to look (click to capture the pointer), E to interact, M for the map, Enter to chat, V for third person, Tab for the city overview, `[` and `]` for time speed, T to skip to the next morning, Esc to pause, F1 to hide the HUD, F5 to save, F9 to load. A gamepad also works. +- **On foot:** WASD or arrow keys to walk, Shift to run, Space to jump, Ctrl to crouch, mouse to look (click to capture the pointer), E to interact, F to get in or out of a car, M for the map, Enter to chat, V for third person, Tab for the city overview, `[` and `]` for time speed, T to skip to the next morning, Esc to pause, F1 to hide the HUD, F5 to save, F9 to load. A gamepad also works. +- **Driving:** W or the right trigger to accelerate, S or the left trigger to brake and reverse, A/D to steer, Space for the handbrake, V for the driver's view. Slow down to get out. - **Orbit builder:** WASD to pan, mouse wheel to zoom, right-drag to rotate, C to recenter, Space to pause, 1-4 for speed, F5 to save, F9 to load. ## AI mayors diff --git a/tools/tests/fp_core_test.gd b/tools/tests/fp_core_test.gd index 39613e63..3d173a66 100644 --- a/tools/tests/fp_core_test.gd +++ b/tools/tests/fp_core_test.gd @@ -79,7 +79,7 @@ func _check_registry(ctx: FpContext) -> void: ids[str(e["id"])] = true if f is Node: (f as Node).free() - K.check(fails, ok and ids.size() == 10, "all 10 registry entries load as FpFeatures with matching unique ids") + K.check(fails, ok and ids.size() == 11, "all 11 registry entries load as FpFeatures with matching unique ids") var feat := K.make_feature(ctx, "traffic") K.check(fails, feat != null and feat.is_inside_tree() and ctx.feature("traffic") == feat, "make_feature puts the feature in the tree and ctx.features") var st: Dictionary = feat.self_test() if feat != null else {} diff --git a/tools/tests/fp_drive_test.gd b/tools/tests/fp_drive_test.gd new file mode 100644 index 00000000..08d8cb95 --- /dev/null +++ b/tools/tests/fp_drive_test.gd @@ -0,0 +1,474 @@ +extends SceneTree +## Driving (integration feature "drive"): the handling model, getting into a stopped car, driving +## it along a straight road, lane traffic stopping behind it, crashing into a wall, getting out +## beside the door, the car left standing as an obstacle, the seated hero, keys and pad bindings, +## and teardown. Time runs through drive_step(1/60) and the traffic feature's step(1/30). + +const K := preload("res://tools/tests/fp_kit.gd") +const WS := preload("res://scripts/fp/world_scale.gd") +const LG := preload("res://scripts/fp/traffic/lane_graph.gd") +const VH := preload("res://scripts/fp/traffic/vehicles.gd") +const MODEL := preload("res://scripts/fp/drive/drive_model.gd") +const DT := 1.0 / 60.0 +const KMH := WS.M / 3.6 + +var fails: Array[String] = [] +var ctx: FpContext +var tr: FpFeature +var drive: FpFeature +var lanes: Array = [] +var heading := Vector2.ZERO + + +func _init() -> void: + await process_frame + _check_model() + ctx = K.make_ctx(get_root(), {"view": true, "grow": true, "camera_mode": "first"}) + ctx.player.set_process(false) + tr = K.make_feature(ctx, "traffic") + var it := K.make_feature(ctx, "interact") + drive = K.make_feature(ctx, "drive") + K.check( + fails, drive != null and drive.feature_id() == "drive", "the drive feature is registered" + ) + if drive == null or tr == null: + K.finish(self, "FP_DRIVE_TEST", fails) + return + tr.set("auto_step", false) + tr.set("ambient_enabled", false) + drive.set_process(false) + it.set_process(false) + K.check( + fails, + drive.self_test().get("ok", false), + "self_test: %s" % drive.self_test().get("detail", "") + ) + if not _road(): + K.finish(self, "FP_DRIVE_TEST", fails) + return + _check_enter(it) + _check_seated() + _check_drive() + _check_yield() + _check_exit() + _check_crash() + _check_keys() + _check_teardown() + K.finish(self, "FP_DRIVE_TEST", fails) + + +# ------------------------------------------------------------------ model + + +func _check_model() -> void: + var m := MODEL.new() + m.reset(0.0, 0.24) + _run(m, 1.0, 0.0, 0.0, 0.0, "asphalt", 3.0) + var v3 := m.speed() + K.check( + fails, + v3 > 40.0 * KMH and v3 < 90.0 * KMH, + "model: 3 s of full throttle reaches %.0f km/h" % (v3 / KMH) + ) + K.check( + fails, + absf(m.yaw) < 1e-6 and m.heading().y > 0.999, + "model: no steering keeps the heading (+Z at yaw 0)" + ) + _run(m, 1.0, 0.0, 0.0, 0.0, "asphalt", 20.0) + K.check( + fails, + m.speed() <= MODEL.TOP + 1e-4, + "model: top speed holds (%.0f km/h)" % (m.speed() / KMH) + ) + _run(m, 0.0, 1.0, 0.0, 0.0, "asphalt", 3.0) + K.check(fails, absf(m.speed()) < 2.0 * KMH or m.speed() < 0.0, "model: braking stops the car") + m.reset(0.0, 0.24) + _run(m, 0.0, 1.0, 0.0, 0.0, "asphalt", 6.0) + var rev := m.speed() + K.check( + fails, + rev < -2.0 * WS.M and rev >= -MODEL.REVERSE_TOP - 1e-4, + "model: S from rest reverses (%.1f m/s)" % (rev / WS.M) + ) + m.reset(0.0, 0.24) + _run(m, 1.0, 0.0, 0.0, 0.0, "asphalt", 2.0) + _run(m, 0.5, 0.0, 1.0, 0.0, "asphalt", 0.5) + K.check( + fails, + m.heading().x < -0.05, + "model: steering right turns toward the car's right (-X at yaw 0): %.2f" % m.heading().x + ) + var g := MODEL.new() + g.reset(0.0, 0.24) + _run(g, 1.0, 0.0, 0.0, 0.0, "grass", 20.0) + K.check( + fails, + g.speed() < 0.6 * MODEL.TOP, + ( + "model: grass holds the car well under its asphalt top speed (%.0f km/h)" + % (g.speed() / KMH) + ) + ) + K.check( + fails, + _slip(1.0) > 2.0 * _slip(0.0), + "model: the handbrake lets the car slide (slip %.3f vs %.3f u/s)" % [_slip(1.0), _slip(0.0)] + ) + + +func _run( + m: MODEL, th: float, br: float, st: float, hb: float, surface: String, secs: float +) -> void: + for _k in range(roundi(secs / DT)): + m.step(th, br, st, hb, surface, DT) + + +## Sideways speed after a hard turn at 50 km/h with the handbrake at hand. +func _slip(hand: float) -> float: + var m := MODEL.new() + m.reset(0.0, 0.24) + m.vel = Vector2(0.0, 50.0 * KMH) + _run(m, 0.0, 0.0, 1.0, hand, "asphalt", 0.4) + var h := m.heading() + return absf(m.vel.dot(Vector2(-h.y, h.x))) + + +# ------------------------------------------------------------------ world + + +func _vh() -> VH: + return tr.get("vehicles") + + +## The longest straight road run; its first lane heads along it. +func _road() -> bool: + var lg: LG = tr.get("lanes") + var best: Array[int] = [] + var best_e := 0 + for i: int in lg.tiles.keys(): + for e: int in _straight_edges(lg.tiles[i]): + var run := _run_from(lg, i, e) + if run.size() > best.size(): + best = run + best_e = e + K.check(fails, best.size() >= 3, "fixture: a straight road of 3+ tiles (%d)" % best.size()) + if best.size() < 3: + return false + lanes = lg.lanes_along(best, LG.OPP[best_e], best_e) + heading = LG.DIRS[best_e] + return not lanes.is_empty() + + +## The N-S / E-W edges a plain straight (not a bridge) runs along. +func _straight_edges(t: LG.TileInfo) -> Array[int]: + var out: Array[int] = [] + if t.piece == "straight" and not t.bridge: + out.assign([0, 1].filter(func(e: int) -> bool: return t.mask & (1 << e))) + return out + + +## Straight tiles from i onward along edge e. +func _run_from(lg: LG, i: int, e: int) -> Array[int]: + var run: Array[int] = [i] + var nx := lg.across(i, e) + while nx >= 0 and lg.info(nx) != null and (lg.info(nx) as LG.TileInfo).piece == "straight": + run.append(nx) + nx = lg.across(nx, e) + return run + + +## Every vehicle but the player's goes. +func _clear_others() -> void: + var keep := int(tr.call("driven_id")) + for c: VH.Car in _vh().cars.duplicate(): + if c.id != keep and c.role != VH.PLAYER: + _vh().remove(c) + ctx.collision.dynamic_boxes = [] + + +func _drive(th: float, br: float, st: float, secs: float) -> void: + for k in range(roundi(secs / DT)): + drive.call("drive_step", th, br, st, 0.0, DT) + if k % 2 == 0: + tr.call("step", DT * 2.0) + ctx.player.update_view(DT) + + +func _car() -> VH.Car: + return _vh().by_id.get(int(drive.get("car_id"))) + + +# ------------------------------------------------------------------ checks + + +func _check_enter(it: FpFeature) -> void: + _clear_others() + var m: int = tr.call("car_model_for", "drive-test") + var c := _vh().make("car", "ambient", tr.call("dims", m)) + c.model = m + _vh().place(c, lanes[0], 0.35) + c.v = 0.0 + var left := Basis(Vector3.UP, c.yaw) * Vector3(c.half_w + 0.6 * WS.M, 0.0, 0.0) + ctx.player.teleport(c.pos + left, 0.0) + var t: Dictionary = drive.call("enter_target") + K.check( + fails, + int(t.get("id", -1)) == c.id, + "a stopped car beside the hero is the get-in target (%s)" % str(t) + ) + var got := [] + ctx.bus.drive_changed.connect(func(on: bool) -> void: got.append(on)) + K.check(fails, drive.call("enter") == true and bool(drive.get("active")), "enter() gets in") + var car := _car() + K.check( + fails, + car != null and car.role == VH.PLAYER and car.driven and car.model == m, + "the car is now the player's, same model" + ) + K.check( + fails, + not _vh().by_id.has(c.id) and int(tr.call("driven_id")) == car.id, + "the ambient car it replaced is gone" + ) + var p := ctx.player + K.check( + fails, + not p.input_enabled and p.view_override and p.camera_mode == "third", + "walking input is off and the drive camera has the view" + ) + K.check(fails, got == [true], "drive_changed(true) on the bus") + K.check( + fails, + (it.call("evaluate") as Dictionary).is_empty(), + "nothing to interact with from behind the wheel" + ) + K.check( + fails, + Vector2(p.position.x - car.pos.x, p.position.z - car.pos.z).length() < car.length * 0.5, + "the hero rides inside the car" + ) + _clear_others() + + +func _check_seated() -> void: + tr.call("sync_visuals", DT) + _drive(0.0, 0.0, 0.0, 1.5) + tr.call("sync_visuals", DT) + var av := ctx.player.avatar + var seat: Variant = tr.call("seat_offset") + var rig: Variant = av.get("rig") + if seat == null or rig == null: + K.check( + fails, seat != null or _car().node == null, "the drawn car has a seat (%s)" % str(seat) + ) + return + _drive(0.0, 0.0, 0.0, DT) + var car := _car() + var want := Transform3D(Basis(Vector3.UP, car.yaw), car.pos) * (seat as Vector3) + var hips: Vector3 = (rig as RefCounted).call("bone_world", "Hips") + var st := str((rig as RefCounted).call("state")) + var off := (hips - want) / WS.M + K.check( + fails, + av.visible and off.length() < 0.05, + "the hero's hips are on the driver's seat (%.2f m off)" % off.length() + ) + K.check(fails, st == "sit" or st == "sit_down", "the hero sits (%s)" % st) + + +func _check_drive() -> void: + var car := _car() + var from := car.pos + _drive(1.0, 0.0, 0.0, 2.0) + car = _car() + var moved := Vector2(car.pos.x - from.x, car.pos.z - from.z) + var v := float((drive.get("model") as MODEL).speed()) + K.check( + fails, + moved.dot(heading) > 8.0 * WS.M, + "full throttle for 2 s drives the car down the road (%.1f m)" % (moved.dot(heading) / WS.M) + ) + K.check(fails, absf(moved.normalized().dot(heading)) > 0.98, "straight along it") + K.check( + fails, + v > 20.0 * KMH and absf(car.v - absf(v)) < 1e-4, + "traffic sees its speed (%.0f km/h)" % (v / KMH) + ) + var hud: Variant = drive.get("hud") + if hud != null: + (hud as Node).call("set_speed", true, v) + K.check( + fails, + str((hud as Node).call("speed_text")) == str(roundi(v / KMH)), + "the speedometer reads km/h" + ) + K.check( + fails, ctx.player.position.distance_to(car.pos) < car.length * 0.5, "the hero rides along" + ) + _drive(0.0, 1.0, 0.0, 2.0) + + +func _check_yield() -> void: + var car := _car() + var vh := _vh() + var m: int = tr.call("car_model_for", "yield-test") + var ai := vh.make("car", "ambient", tr.call("dims", m)) + ai.model = m + vh.place(ai, lanes[0], 0.05) + ai.path = lanes.slice(1) + ai.v = WS.CAR_CITY + ai.v0 = WS.CAR_CITY + var gap := INF + for _k in range(int(15.0 / (DT * 2.0))): + tr.call("step", DT * 2.0) + gap = minf(gap, _box_gap(vh.aabb(ai), vh.aabb(car))) + K.check( + fails, + not ai.dead and ai.v < 0.1 * WS.M and gap > 0.5 * WS.M, + "lane traffic stops behind the player's car (closest %.1f m)" % (gap / WS.M) + ) + vh.remove(ai) + + +func _check_exit() -> void: + var p := ctx.player + var toasts: Array[String] = [] + var cb := func(t: String, _k: String) -> void: toasts.append(t) + ctx.bus.toast.connect(cb) + _drive(1.0, 0.0, 0.0, 2.5) + K.check( + fails, + drive.call("exit") == false and toasts.size() == 1, + "too fast to get out: it says so (%s)" % str(toasts) + ) + ctx.bus.toast.disconnect(cb) + _drive(0.0, 1.0, 0.0, 3.0) + var car := _car() + var id := car.id + K.check( + fails, + drive.call("exit") == true and not bool(drive.get("active")), + "slowed down, exit() gets out" + ) + var hud: Variant = drive.get("hud") + if hud != null: + K.check( + fails, str((hud as Node).call("speed_text")) == "", "the speedometer goes away on foot" + ) + K.check( + fails, + p.input_enabled and not p.view_override and p.camera_mode == "first", + "walking input, the hero's camera and the first-person view are back" + ) + K.check( + fails, + not ctx.collision.is_blocked(p.position, WS.PLAYER_RADIUS), + "the hero stands on free ground" + ) + var d: float = tr.call("_body_dist", car, p.position) + K.check( + fails, + d > 0.2 * WS.M and d < 1.5 * WS.M, + "beside the car (%.1f m from its body)" % (d / WS.M) + ) + var left := Vector2(car.pos.x - p.position.x, car.pos.z - p.position.z).dot( + Vector2(-heading.y, heading.x) + ) + K.check(fails, left != 0.0, "on one side of it") + K.check( + fails, + _vh().by_id.has(id) and not car.driven and int(tr.call("driven_id")) == -1, + "the car stays where it was left" + ) + var blocks: Array = tr.call("blockers_near", p.position, 0.2) + K.check(fails, blocks.size() >= 1, "and blocks the hero on foot") + var t: Dictionary = drive.call("enter_target") + K.check( + fails, + int(t.get("id", -1)) == id and str(t.get("label", "")) == "your car", + "it is the get-in target again: %s" % str(t) + ) + K.check(fails, avatar_free(), "the hero stands up and is drawn at the feet again") + + +func avatar_free() -> bool: + var rig: Variant = ctx.player.avatar.get("rig") + var st := str((rig as RefCounted).call("state")) if rig != null else "" + return ctx.player.avatar.visible and not st.begins_with("sit") + + +## Drives into a wall across the road: the body never enters it and the car stops. +func _check_crash() -> void: + K.check(fails, drive.call("enter") == true, "back in") + _clear_others() + var car := _car() + var start := car.pos + var h3 := Vector3(heading.x, 0.0, heading.y) + var wall_c := car.pos + h3 * (25.0 * WS.M) + var ext := ( + Vector3(absf(heading.y), 0.0, absf(heading.x)) * WS.ROAD_HALF * 1.6 + + Vector3(0.1, 0.0, 0.1) * WS.M * 5.0 + ) + var wall := AABB( + Vector3(wall_c.x - ext.x, 0.0, wall_c.z - ext.z), Vector3(ext.x * 2.0, 0.2, ext.z * 2.0) + ) + ctx.collision.add_test_box(wall) + var deep := -INF + var near := (wall_c - start).dot(h3) - 0.5 * WS.M + for _k in range(roundi(5.0 / DT)): + drive.call("drive_step", 1.0, 0.0, 0.0, 0.0, DT) + var c := _car() + deep = maxf(deep, (c.pos - start).dot(h3) + c.length * 0.5 - near) + var v := float((drive.get("model") as MODEL).speed()) + K.check( + fails, + deep < 0.1 * WS.M, + "the car never drives into the wall (front %.2f m past its face)" % (deep / WS.M) + ) + K.check(fails, absf(v) < 5.0 * KMH, "and stops against it (%.0f km/h)" % (v / KMH)) + ctx.collision.clear_test_boxes() + _drive(0.0, 1.0, 0.0, 1.0) + + +func _check_keys() -> void: + var pad := false + for e: InputEvent in InputMap.action_get_events("vehicle_enter"): + pad = pad or e is InputEventJoypadButton + var trig := InputMap.has_action("drive_throttle") and InputMap.has_action("drive_brake") + K.check(fails, pad and trig, "get in / out has a pad button and the triggers drive") + drive.call("_input", _action("vehicle_enter")) + K.check(fails, not bool(drive.get("active")), "F gets out") + drive.call("_input", _action("vehicle_enter")) + K.check(fails, bool(drive.get("active")), "F gets back in") + drive.call("_input", _action("view_toggle")) + K.check(fails, str(drive.get("view")) == "driver", "V switches to the driver's view") + ctx.push_modal(&"test") + drive.call("_input", _action("vehicle_enter")) + K.check(fails, bool(drive.get("active")), "keys wait while a menu is open") + ctx.pop_modal(&"test") + drive.call("_input", _action("view_toggle")) + + +func _check_teardown() -> void: + drive.teardown() + var p := ctx.player + K.check( + fails, + not bool(drive.get("active")) and p.input_enabled and not p.view_override, + "teardown gets the hero out" + ) + K.check(fails, int(tr.call("driven_id")) == -1, "and hands the car back to traffic") + + +static func _box_gap(a: AABB, b: AABB) -> float: + var dx := maxf(0.0, maxf(a.position.x - b.end.x, b.position.x - a.end.x)) + var dz := maxf(0.0, maxf(a.position.z - b.end.z, b.position.z - a.end.z)) + return Vector2(dx, dz).length() + + +func _action(a: String) -> InputEventAction: + var e := InputEventAction.new() + e.action = a + e.pressed = true + return e diff --git a/tools/tests/fp_drive_test.gd.uid b/tools/tests/fp_drive_test.gd.uid new file mode 100644 index 00000000..e6baa16f --- /dev/null +++ b/tools/tests/fp_drive_test.gd.uid @@ -0,0 +1 @@ +uid://bk2b3h8laadbu From 40089b5c2e5ae652d711e35ca05d4cb713d9ff16 Mon Sep 17 00:00:00 2001 From: Kunal Siyag Date: Tue, 20 Oct 2026 20:17:46 +0530 Subject: [PATCH 2/2] refactor(fp): split car upkeep out of traffic _maintain Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- scripts/fp/traffic/traffic_feature.gd | 33 +++++++++++++++------------ 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/scripts/fp/traffic/traffic_feature.gd b/scripts/fp/traffic/traffic_feature.gd index 5c8a49a9..a067f53f 100644 --- a/scripts/fp/traffic/traffic_feature.gd +++ b/scripts/fp/traffic/traffic_feature.gd @@ -366,20 +366,7 @@ func step(dt: float) -> void: func _maintain(sync: bool) -> void: var p := ctx.player_position() var r := WS.TRAFFIC_RADIUS - for c: VH.Car in vehicles.cars.duplicate(): - if c.role == VH.PLAYER: - if not c.driven and not c.dead: - _drop_left(c, p, r) - continue - if c.parked or c.dead or c.role == "ambient" or c.role == "decor": - continue - var d := Vector2(c.pos.x - p.x, c.pos.z - p.z).length() - if not c.far and d > r + 0.5 and not on_screen(c.pos): - vehicles.make_far(c) - elif c.far and d < r - 0.5: - vehicles.make_near(c) - if c.stuck and not on_screen(c.pos): - _drop_stuck(c) + _maintain_cars(p, r) for w: WK.Walker in walkers.walkers: if w.role == "resident": w.far = Vector2(w.pos.x - p.x, w.pos.z - p.z).length() > r @@ -407,6 +394,24 @@ func _maintain(sync: bool) -> void: fleets.retry_t = fleets.backoff +## Near/far switching for lane cars around p, stuck cars and the cars the player left. +func _maintain_cars(p: Vector3, r: float) -> void: + for c: VH.Car in vehicles.cars.duplicate(): + if c.role == VH.PLAYER: + if not c.driven and not c.dead: + _drop_left(c, p, r) + continue + if c.parked or c.dead or c.role == "ambient" or c.role == "decor": + continue + var d := Vector2(c.pos.x - p.x, c.pos.z - p.z).length() + if not c.far and d > r + 0.5 and not on_screen(c.pos): + vehicles.make_far(c) + elif c.far and d < r - 0.5: + vehicles.make_near(c) + if c.stuck and not on_screen(c.pos): + _drop_stuck(c) + + ## A car the player left goes once it is out of the traffic radius and out of sight. func _drop_left(c: VH.Car, p: Vector3, r: float) -> void: if Vector2(c.pos.x - p.x, c.pos.z - p.z).length() > r and not on_screen(c.pos):