From 25b70d7dd8297da0ac33e7ff97a69167bce0901d Mon Sep 17 00:00:00 2001 From: Kunal Siyag Date: Wed, 21 Oct 2026 14:41:32 +0530 Subject: [PATCH] feat(fp): walk round to the driver's door, open it and climb in F by a car no longer drops the hero straight into the seat. With the car_doors flag (on by default) the hero walks round the car to the driver's door, taking the shorter clear way round its front or rear and jogging while it is far. It reaches for the handle (door_try), pulls the door open and swings it wide, steps into the opening, sits down onto the sill (sit_down, its root travel walked back so the feet stay planted), swings round into the seat and the door slams. F while driving brakes the car to a stop and plays the climb the other way: the door opens, the hero swings out onto the sill, stands up (stand_up), steps clear and the door shuts. From the reach on, a camera beside the door frames the climb and blends into the chase camera. F on the way to the door turns back. With no room for the door or no clear way round, F gets in and out at once as before. traffic/car_doors.gd cuts each car model's driver's door out of its body between the shut lines the generator draws, splitting the triangles that cross them, and hangs it on its hinge; render.gd swings it on the full model and door_info / set_door hand it to the drive feature. drive/car_entry.gd runs the walk, the reach, the door, the sit and the swing. hero_rig.gd gains travel / root_travel (how far a clip carries its root), stop_gesture, and sit_down / stand_up played 1.5x faster. GameBus gains car_door. The audio feature plays the latch letting go and the slam at the door, synthesized by tools/audio/synth_fp_sfx.py (2 small WAVs). fp_car_doors_test (new) covers the door cut for every car model, the climb in from the far side, the climb out, braking to a stop first, turning back on the way, a blocked door and the flag off; fp_audio_test covers the door sounds; fp_drive_test keeps the instant way in and out. A photo shot holds the climb with the door wide open. 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 | 4 +- docs/api/scripts.fp.audio.md | 5 +- docs/api/scripts.fp.drive.md | 91 +++- docs/api/scripts.fp.md | 1 + docs/api/scripts.fp.player.md | 5 + docs/api/scripts.fp.traffic.md | 53 +- docs/fp/ARCHITECTURE.md | 6 +- docs/fp/S0-foundation.md | 4 + docs/fp/slices/I-audio.md | 1 + scripts/fp/audio/audio_bank.gd | 5 +- scripts/fp/audio/audio_feature.gd | 11 +- scripts/fp/audio/emitters.gd | 5 +- scripts/fp/drive/car_entry.gd | 719 ++++++++++++++++++++++++++ scripts/fp/drive/car_entry.gd.uid | 1 + scripts/fp/drive/drive_feature.gd | 322 ++++++++++-- scripts/fp/game_bus.gd | 3 + scripts/fp/player/hero_rig.gd | 59 ++- scripts/fp/player/player.gd | 3 + scripts/fp/traffic/car_doors.gd | 563 ++++++++++++++++++++ scripts/fp/traffic/car_doors.gd.uid | 1 + scripts/fp/traffic/render.gd | 8 +- scripts/fp/traffic/traffic_feature.gd | 31 +- scripts/fp/traffic/vehicles.gd | 1 + sounds/fp/car_door_close.wav | Bin 0 -> 79424 bytes sounds/fp/car_door_close.wav.import | 24 + sounds/fp/car_door_open.wav | Bin 0 -> 66194 bytes sounds/fp/car_door_open.wav.import | 24 + tools/audio/synth_fp_sfx.py | 52 +- tools/tests/fp_audio_test.gd | 28 +- tools/tests/fp_car_doors_test.gd | 423 +++++++++++++++ tools/tests/fp_car_doors_test.gd.uid | 1 + tools/tests/fp_drive_test.gd | 6 +- 34 files changed, 2386 insertions(+), 77 deletions(-) create mode 100644 scripts/fp/drive/car_entry.gd create mode 100644 scripts/fp/drive/car_entry.gd.uid create mode 100644 scripts/fp/traffic/car_doors.gd create mode 100644 scripts/fp/traffic/car_doors.gd.uid create mode 100644 sounds/fp/car_door_close.wav create mode 100644 sounds/fp/car_door_close.wav.import create mode 100644 sounds/fp/car_door_open.wav create mode 100644 sounds/fp/car_door_open.wav.import create mode 100644 tools/tests/fp_car_doors_test.gd create mode 100644 tools/tests/fp_car_doors_test.gd.uid diff --git a/README.md b/README.md index 32febe67..869ee260 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,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. `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 car is solid: it bounces off walls, scrapes along them at a glance, shoves the cars it hits (they slide off, then pull back into their lane) and sends bins, benches, planters and other light street props flying. `B` opens the build phone: `1`-`6` pick road, homes, shops, industry, a building (mouse wheel picks which) or bulldoze, left click places on the tile ahead (roads and zones take a start and an end click), right click undoes the start. Each order is paid by the district that owns the land, as if its mayor had ordered it, and one of your two building crews builds it in real time (see Staged construction). The phone says how many crews are free and how long the order takes, a card under the top bar counts down your orders, and `E` on a worker gets a word about the job. The AI activity feed (top right) shows each mayor turn, council ruling, Chaos Director pick, grid order and Gazette front page as it lands. A card shows who decided, the model they run on, whether the answer came live, from a replay, from a fallback or from the offline planner, what they did and why. On foot the mayors take their turns spread over the day. A decision with a place raises a light beacon there. `J` cycles the feed between full, brief and hidden, and `G` routes the GPS to the newest decision with a place. +- **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: your character walks round to the driver's door, opens it, climbs in and shuts it (`F` on the way turns back). W/S throttle and brake or reverse, A/D steer, Space handbrake, V switches chase and driver view, and `F` again, once the car slows down, brakes to a stop and climbs out the same way. The car is solid: it bounces off walls, scrapes along them at a glance, shoves the cars it hits (they slide off, then pull back into their lane) and sends bins, benches, planters and other light street props flying. `B` opens the build phone: `1`-`6` pick road, homes, shops, industry, a building (mouse wheel picks which) or bulldoze, left click places on the tile ahead (roads and zones take a start and an end click), right click undoes the start. Each order is paid by the district that owns the land, as if its mayor had ordered it, and one of your two building crews builds it in real time (see Staged construction). The phone says how many crews are free and how long the order takes, a card under the top bar counts down your orders, and `E` on a worker gets a word about the job. The AI activity feed (top right) shows each mayor turn, council ruling, Chaos Director pick, grid order and Gazette front page as it lands. A card shows who decided, the model they run on, whether the answer came live, from a replay, from a fallback or from the offline planner, what they did and why. On foot the mayors take their turns spread over the day. A decision with a place raises a light beacon there. `J` cycles the feed between full, brief and hidden, and `G` routes the GPS to the newest decision with a place. The orbit view (`GRIDFALL_VIEW=orbit`): diff --git a/data/feature_flags.json b/data/feature_flags.json index 2a052c32..aea62490 100644 --- a/data/feature_flags.json +++ b/data/feature_flags.json @@ -12,6 +12,7 @@ "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."}, + "car_doors": {"default": true, "description": "Getting into and out of cars the long way: the hero walks round to the driver's door, opens it, climbs in and shuts it (and the reverse, once the car has stopped). Off: F gets in and out at once."}, "phone": {"default": true, "description": "First-person feature: B opens the phone Build app, which places roads, zones and buildings for the mayor who owns the land."}, "activity": {"default": true, "description": "First-person feature: AI activity feed, decision beacons and map pings showing which mayor or agent decided what, on which model, from which source, and where; mayors take staggered turns through the day on foot. J cycles the feed, G routes to the newest decision."}, "crews": {"default": true, "description": "First-person feature: the player's two building crews. Phone orders are built in real time (2-5 minutes for a building) by crews you can watch on site, with a board and a HUD card counting down; later orders queue for a free crew."}, diff --git a/docs/api/index.md b/docs/api/index.md index eb1c861d..a1805073 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -49,7 +49,7 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [ambience.gd](scripts.fp.audio.md#scriptsfpaudioambiencegd): Area ambience (slice I): looping 2D beds on the Ambience bus (traffic hum, park birds, river, the quiet residential bed, night insects and rain), weighted by what lies around the player and by the weather, and cross-faded over CROSSFADE_S - [audio_bank.gd](scripts.fp.audio.md#scriptsfpaudioaudio_bankgd): Slice I's sound assets (sounds/fp/, made by tools/audio/synth_fp_sfx.py): names, a load-once stream cache and the mix - [audio_feature.gd](scripts.fp.audio.md#scriptsfpaudioaudio_featuregd): City sound on foot (slice I) -- [emitters.gd](scripts.fp.audio.md#scriptsfpaudioemittersgd): Positional city sound (slice I): logical sources (engines, horns, sirens, construction, fire, fountains, power plants, river banks, passers-by's footsteps, crashes and knocked props) share a pool of at most MAX_PLAYERS AudioStreamPlayer3D +- [emitters.gd](scripts.fp.audio.md#scriptsfpaudioemittersgd): Positional city sound (slice I): logical sources (engines, horns, sirens, construction, fire, fountains, power plants, river banks, passers-by's footsteps, crashes, knocked props and car doors) share a pool of at most MAX_PLAYERS AudioStreamPlayer3D - [footsteps.gd](scripts.fp.audio.md#scriptsfpaudiofootstepsgd): The hero's footsteps (slice I) ## [scripts/fp/chat/](scripts.fp.chat.md) @@ -72,6 +72,7 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too ## [scripts/fp/drive/](scripts.fp.drive.md) - [car_body.gd](scripts.fp.drive.md#scriptsfpdrivecar_bodygd): The player's car as a rigid box on the ground plane +- [car_entry.gd](scripts.fp.drive.md#scriptsfpdrivecar_entrygd): Getting into the player's car and out of it on foot, for drive_feature.gd (car_doors flag) - [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 @@ -144,6 +145,7 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too ## [scripts/fp/traffic/](scripts.fp.traffic.md) - [ambient.gd](scripts.fp.traffic.md#scriptsfptrafficambientgd): Ambient filler (docs/fp/slices/E-traffic.md §6): unnamed commuters and pedestrians around the player, denser where sim.traffic and residents' trip_load are high (caps 40 cars, 60 people) +- [car_doors.gd](scripts.fp.traffic.md#scriptsfptrafficcar_doorsgd): The driver's door of a full car model (vehicles_v2) as a part of its own that swings open on a hinge at its front edge - [contact_shadows.gd](scripts.fp.traffic.md#scriptsfptrafficcontact_shadowsgd): Contact shadows: a soft dark patch on the ground under every drawn vehicle and person, all in one MultiMesh draw - [fleets.gd](scripts.fp.traffic.md#scriptsfptrafficfleetsgd): City fleets on lanes in first person (docs/fp/slices/E-traffic.md §5): buses on 2-4 seeded closed lines through the bus stops (they pull in at each shelter, people board and alight), delivery and garbage trucks, and emergency crews that run to incidents with sirens (through red lights at walking pace once the box is clear), wait at the scene, and drive back to the station - [heap.gd](scripts.fp.traffic.md#scriptsfptrafficheapgd): Binary min-heap of (priority, int item) for the lane and sidewalk A* searches diff --git a/docs/api/scripts.fp.audio.md b/docs/api/scripts.fp.audio.md index c2534b12..5486381f 100644 --- a/docs/api/scripts.fp.audio.md +++ b/docs/api/scripts.fp.audio.md @@ -78,6 +78,7 @@ Slice I's sound assets (sounds/fp/, made by tools/audio/synth_fp_sfx.py): names, - `KNOCK_STREAMS = { "bin": "prop_clatter", "bench_park": "prop_clatter", "planter": "crash_light", }`: The one-shot a knocked prop of each kind plays (KNOCK_DEFAULT for the metal ones), at most one per KNOCK_GAP (s). - `KNOCK_DEFAULT = "prop_clang"` - `KNOCK_GAP = 0.1` +- `DOOR_OPEN_DB = -7.0`: The player's car door (car_door): the latch letting go plays this far under the slam. ### Functions @@ -94,7 +95,7 @@ Slice I's sound assets (sounds/fp/, made by tools/audio/synth_fp_sfx.py): names, extends `FpFeature` -City sound on foot (slice I). Owns the Music, SFX, Ambience and UI buses while on foot, puts the AudioDirector's music under the street (set_fp), and runs three parts: positional emitters (engines, horns, sirens, construction, fire, fountains, plants, the river, passers-by's steps, the player's car crashing and the street props it knocks over), the area beds (with rain from G1's weather) and the hero's footsteps (splashing on wet paving), plus the chat blip and alert stingers. Pausing muffles the music (the only thing still playing); talking to someone ducks the beds. +City sound on foot (slice I). Owns the Music, SFX, Ambience and UI buses while on foot, puts the AudioDirector's music under the street (set_fp), and runs three parts: positional emitters (engines, horns, sirens, construction, fire, fountains, plants, the river, passers-by's steps, the player's car crashing, the street props it knocks over and its doors), the area beds (with rain from G1's weather) and the hero's footsteps (splashing on wet paving), plus the chat blip and alert stingers. Pausing muffles the music (the only thing still playing); talking to someone ducks the beds. ### Constants @@ -138,7 +139,7 @@ City sound on foot (slice I). Owns the Music, SFX, Ambience and UI buses while o extends `Node3D` -Positional city sound (slice I): logical sources (engines, horns, sirens, construction, fire, fountains, power plants, river banks, passers-by's footsteps, crashes and knocked props) share a pool of at most MAX_PLAYERS AudioStreamPlayer3D. Every refresh scores the sources by their estimated level at the listener and gives the players to the loudest; the farthest or quietest lose theirs first. Doppler is computed by hand (the built-in effect works in world units against 343 u/s and is inaudible at 1 u = 20 m). A grown city registers hundreds of static sources (every park's fountain, every site), so the per-frame work touches only the sources holding a player, the refresh only the volatile ones (agents, one-shots, sirens, river) and the static ones near the listener. +Positional city sound (slice I): logical sources (engines, horns, sirens, construction, fire, fountains, power plants, river banks, passers-by's footsteps, crashes, knocked props and car doors) share a pool of at most MAX_PLAYERS AudioStreamPlayer3D. Every refresh scores the sources by their estimated level at the listener and gives the players to the loudest; the farthest or quietest lose theirs first. Doppler is computed by hand (the built-in effect works in world units against 343 u/s and is inaudible at 1 u = 20 m). A grown city registers hundreds of static sources (every park's fountain, every site), so the per-frame work touches only the sources holding a player, the refresh only the volatile ones (agents, one-shots, sirens, river) and the static ones near the listener. ### Constants diff --git a/docs/api/scripts.fp.drive.md b/docs/api/scripts.fp.drive.md index d7279981..dd242b67 100644 --- a/docs/api/scripts.fp.drive.md +++ b/docs/api/scripts.fp.drive.md @@ -37,11 +37,82 @@ The player's car as a rigid box on the ground plane. Each step it moves by the h - `func setup(half_w: float, length: float, p_height: float) -> void` - `func move(model: MODEL, pos: Vector3, yaw0: float, dt: float, col: CollisionWorld, traffic: Object, props: Object) -> Vector3`: Moves the body from the feet position pos by the model's velocity over dt (the model's yaw has already turned from yaw0 this step), resolving contacts on the way; the model's velocity and spin take the impulses. traffic and props are those features, or null. Returns the new feet position with y unchanged. +## `scripts/fp/drive/car_entry.gd` + +extends `RefCounted` + +Getting into the player's car and out of it on foot, for drive_feature.gd (car_doors flag). In: the hero walks round the car to the driver's door (jogging while it is far), reaches for the handle (door_try), pulls the door open and swings it wide, steps into the opening, turns its back to the seat and sits down onto the sill (sit_down; its root travel is walked back so the feet stay planted), swings round into the seat and the door shuts. Out: the door opens, the hero swings round onto the sill, stands up (stand_up, walked forward), steps clear of the door and it shuts. From the reach on, a camera beside the door watches and blends to and from the drive camera. The points come from the traffic feature's door_info, in the car's frame (+Z forward, +X the driver's side); the car stands still throughout. The hero's rig is reached by duck typing (the player folder is another slice): travel, root_travel, sit, play_gesture, stop_gesture, state and bone_world. + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `STAND_OUT = 0.28 * WS.M`: The reach: the hero stands STAND_OUT out from the body side with the handle REACH_FWD ahead and REACH_SIDE to its right (door_try's hand at the grip), and clears the swinging door's edge by BODY_R. +- `REACH_FWD = 0.44 * WS.M` +- `REACH_SIDE = 0.26 * WS.M` +- `BODY_R = 0.22 * WS.M` +- `ROUTE_GAP = 0.45 * WS.M`: The walk keeps ROUTE_GAP off the car's body and comes in along its side from LEAD_IN behind the stand spot when it comes from behind; a corner counts as passed WP_REACH from it and a goal as reached ARRIVE from it. It slows down within SLOW_R and jogs while more than JOG_FROM is left. A way is checked for room every ROUTE_STEP. +- `LEAD_IN = 0.45 * WS.M` +- `WP_REACH = 0.3 * WS.M` +- `ARRIVE = 0.08 * WS.M` +- `SLOW_R = 0.6 * WS.M` +- `JOG_FROM = 3.0 * WS.M` +- `ROUTE_STEP = 0.25 * WS.M` +- `ROUTE_REACH = 3.0 * WS.M`: How far round the car other cars can block the walk (drive_feature asks traffic for them). +- `WALK_MAX = 8.0`: The walk gives up after WALK_MAX s, or STUCK_T s without getting STUCK_GAIN closer; the step into the opening (at STEP_PACE of a walk) after STEP_MAX s. +- `STUCK_T = 1.0` +- `STUCK_GAIN = 0.05 * WS.M` +- `STEP_PACE = 0.55` +- `STEP_MAX = 1.5` +- `TURN_RATE = 5.0`: Lining up on a spot: turning at TURN_RATE (rad/s), sliding at ALIGN_SPEED, for at least ALIGN_MIN s. +- `ALIGN_SPEED = 0.6 * WS.M` +- `ALIGN_MIN = 0.2` +- `REACH_AT = 0.75`: door_try: the hand is on the handle REACH_AT s in and the pull ends at PULL_END with the door PULL_OPEN open; it then swings wide over DOOR_WIDE s. +- `PULL_END = 1.2` +- `PULL_OPEN = 0.3` +- `DOOR_WIDE = 0.45` +- `SILL_IN = 0.06 * WS.M`: Sat down, the hips are SILL_IN inside the body side. Sitting takes at most SIT_MAX s, the swing into or out of the seat SWING_T s and the door's shutting CLOSE_T s. +- `SIT_MAX = 1.3` +- `SWING_T = 0.7` +- `CLOSE_T = 0.35` +- `OPEN_T = 0.45`: Out: the door opens over OPEN_T s, standing up takes at most STAND_MAX s, then the hero steps AWAY out and AWAY_BACK toward the rear (for at most AWAY_MAX s). +- `STAND_MAX = 1.7` +- `AWAY = 0.55 * WS.M` +- `AWAY_BACK = 0.25 * WS.M` +- `AWAY_MAX = 1.2` +- `DOOR_R = 0.08 * WS.M`: Nothing fixed may stand within DOOR_R of the wide-open door's middle and edge. +- `CAM_DIR = Vector3(0.72, 0.3, -0.62)`: The door camera looks along CAM_DIR (car frame: from behind the door, mirrored ahead of it when that has more room) at a point just outside the door from CAM_DIST (CAM_MIN at least, after the pull-in in front of walls). The view eases onto it over FRAME_BLEND s and between it and the drive camera over CAM_BLEND s. +- `CAM_DIST = 3.2 * WS.M` +- `CAM_MIN = 1.6 * WS.M` +- `CAM_RADIUS = 0.25 * WS.M` +- `FRAME_BLEND = 0.6` +- `CAM_BLEND = 1.0` +- `STOP_V = 0.15 * WS.M`: Getting out waits for the car to stop: below STOP_V (u/s), or after BRAKE_MAX s of braking. +- `BRAKE_MAX = 2.5` + +### Properties + +- `kind: String`: "" (idle), "in" or "out". +- `phase: String`: In: "walk", "align", "open", "step", "turn", "sit", "swing", "close". Out: "open", "swing", "stand", "away", "close". +- `t: float`: Seconds into the phase. +- `door: float`: The driver's door: 0 shut .. 1 open. +- `hold: String`: A phase to wait at (the photo tour holds the climb in at "swing"); "" for none. + +### Functions + +- `func begin_in(ctx: FpContext, car: Dictionary, pts: Dictionary, boxes: Array = []) -> bool`: Starts getting into the claimed car `car` ({"pos", "yaw", "half_w", "length", "height"}) through its driver's door pts (door_info); boxes are the other cars round it. False when the hero cannot sit, the door or the ground beside it is blocked, or no way round the car is clear. +- `func begin_out(ctx: FpContext, car: Dictionary, pts: Dictionary, boxes: Array = []) -> bool`: Starts getting out of the stopped car (same arguments). False when there is no room for the door or for the hero beside it. +- `func step(dt: float) -> String`: Advances by dt s. Returns "" while under way, then "seated" (in), "out" (out) or "fail" (the walk could not reach the door; the hero is still on foot). +- `func cancel() -> void`: Stops where it is: the door is shut and a gesture fades out. +- `func framing() -> bool`: True once the door camera has the view: from the reach on the way in, at once on the way out (the walk to the door keeps the hero's own camera). +- `func aboard() -> bool`: True while the hero is in the seat or half way through the door, where stopping leaves it inside the car (in: from the turn into the opening on; out: until it stands). +- `func camera(drive: Transform3D) -> Transform3D`: The camera now: the door camera, blended toward drive (the drive camera). +- `func route() -> PackedVector3Array`: The world points the walk to the door passes through (tests). + ## `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 is a rigid box (car_body.gd): it bounces off walls, slides along them at a glance, spins when hit off-centre, shoves the cars it hits and knocks street props over; car_impact on the bus reports the hard hits. +Getting into cars and driving them. Near a parked or stopped car, F (Y on a pad) gets in: the hero walks round to the driver's door, opens it, climbs in and shuts it (car_entry.gd, the car_doors flag; at once without it), 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 stops the car and gets out through the driver's door (or at once beside it). The car belongs to the traffic feature (lane traffic stops for it and it stays where it is left). It is a rigid box (car_body.gd): it bounces off walls, slides along them at a glance, spins when hit off-centre, shoves the cars it hits and knocks street props over; car_impact on the bus reports the hard hits and car_door the door opening and shutting. ### Constants @@ -49,6 +120,7 @@ Getting into cars and driving them. Near a parked or stopped car, F (Y on a pad) - `MODEL = preload("res://scripts/fp/drive/drive_model.gd")` - `HUD = preload("res://scripts/fp/drive/drive_hud.gd")` - `BODY = preload("res://scripts/fp/drive/car_body.gd")` +- `ENTRY = preload("res://scripts/fp/drive/car_entry.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. @@ -72,10 +144,15 @@ Getting into cars and driving them. Near a parked or stopped car, F (Y on a pad) - `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` - `IMPACT_GAP = 0.25`: car_impact goes out at most this often (s), for the hardest hit. +- `BLOCKER_REACH = 2.0 * WS.M`: Stopped cars this close to the hero block it on foot (as FpPlayer polls them). +- `PHOTO_REACH = 30.0 * WS.M`: The photo shot's car is the nearest one to get into this close to the spawn point, picked PHOTO_FRAMES frames after the tour puts the hero there (traffic fills the street round it). +- `PHOTO_FRAMES = 3` +- `PHOTO_DT = 1.0 / 60.0` ### Properties - `view: String`: "chase" or "driver". +- `entry`: The walk to the door and the climb in, or the climb out (car_entry.gd). ### Functions @@ -85,11 +162,17 @@ Getting into cars and driving them. Near a parked or stopped car, F (Y on a pad) - `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 enter_target() -> Dictionary`: The car the hero could get into now ({} when none, while driving or climbing in or out). +- `func enter() -> bool`: Gets into the nearest car in reach at once. Returns true when the hero is now driving. +- `func board() -> bool`: Gets into the nearest car in reach the long way (car_doors): the hero walks round to its driver's door, opens it, climbs in and shuts it, then drives (climb_step runs it). At once (enter) without the flag, or when the door cannot open (a model without one, the stand-in figure, no room beside it or no clear way round). Returns true when it started. +- `func leave() -> bool`: Gets out the long way (car_doors): the car brakes to a stop, the door opens and the hero climbs out and shuts it (climb_step runs it). At once (exit) without the flag. False when the car is too fast (it says so). +- `func exit(force: bool = false) -> bool`: Gets out at once beside the driver's door (force: at any speed, wherever there is room or on the spot); also ends a climb in or out half way (a hero still outside the car stays where it stands). Returns true when the hero is on foot again. +- `func climb_step(dt: float) -> void`: One step of the climb in or out, or of braking to a stop for the climb out; _process runs it, tests call it with their own dt. - `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. +- `func photo_shots() -> Array`: The photo shot: the hero half way into the car nearest the spawn point, sat on the sill with the door wide open, seen from the door camera (car_doors). +- `func photo_prepare() -> void`: Photo tour: the tour puts the hero at the spawn point after this, so the climb is staged once traffic has filled the street round it. +- `func photo_restore() -> void` ## `scripts/fp/drive/drive_hud.gd` diff --git a/docs/api/scripts.fp.md b/docs/api/scripts.fp.md index 164307fa..78fe1a4b 100644 --- a/docs/api/scripts.fp.md +++ b/docs/api/scripts.fp.md @@ -243,6 +243,7 @@ Signals that cross first-person slices. Emitters and listeners are listed in doc - `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. - `car_impact(at: Vector3, speed: float, what: String)`: The player's car hit something hard: the contact point, the closing speed (u/s) and what was hit, "wall" (anything fixed) or "car". Emitted by the drive feature. +- `car_door(at: Vector3, open: bool)`: The driver's door of the player's car opened (true) or slammed shut (false), at its handle. Emitted by the drive feature. - `prop_knocked(at: Vector3, kind: String, speed: float)`: A street prop was knocked over at a point (its StreetDressing kind: "bin", "bench" ...) and flies off at speed (u/s). Emitted by the props feature. ## `scripts/fp/obb2d.gd` diff --git a/docs/api/scripts.fp.player.md b/docs/api/scripts.fp.player.md index 7464edcf..ab49b617 100644 --- a/docs/api/scripts.fp.player.md +++ b/docs/api/scripts.fp.player.md @@ -112,6 +112,8 @@ HC's animated hero for the player (slice A stage A2; A-player.md 6.9, TPS_PLAN 2 - `STEP_MIN_MOVE = 0.25`: Footsteps sound while the walk blend is at least this strong. - `SHOT_IN = 0.25`: Gesture one-shot fades (s). - `SHOT_OUT = 0.35` +- `SIT_SPEED = 1.5`: sit_down and stand_up play this much faster than authored (in and out of a car seat). +- `SIT_CLIPS = ["sit_down", "stand_up"]` - `LOOK_YAW = deg_to_rad(70.0)`: Head look (6.9): Head limits (yaw, pitch), the Neck's share and limit, the modifier's own interpolation, the influence fade when the target leaves the limits, and the halflife that smooths a jumping target. - `LOOK_PITCH = deg_to_rad(35.0)` - `NECK_INFLUENCE = 0.35` @@ -155,6 +157,9 @@ HC's animated hero for the player (slice A stage A2; A-player.md 6.9, TPS_PLAN 2 - `func sit(on: bool) -> void`: Sits down (on) or stands up; moving stands up by itself. - `func play_gesture(g: String) -> void`: Plays an upper-body gesture over the current state ("interact", "wave", "phone", "door_try"). - `func gesture_active() -> bool` +- `func stop_gesture() -> void`: Fades the playing gesture out before its end. +- `func travel(clip: String, t: float) -> float`: How far clip has carried its root t seconds after its state began (0..1 of the whole clip's horizontal root travel; sit_down and stand_up play at SIT_SPEED). The tree drops root motion, so whoever moves the body through a clip (into a car seat) follows this curve; a clip without travel runs linearly. +- `func root_travel(clip: String) -> float`: How far clip carries the body along the ground over its whole length (world units). - `func set_look_target(p: Vector3) -> void`: The world point the head turns toward; NAN components let the head return to the clip. - `func snap(crouched: bool) -> void`: Back to standing (or crouched) locomotion at once: no cross-fade, no landing, no gesture. - `func bone_world(bone: String) -> Vector3`: A bone's world position (the animated pose, before the look-at modifiers). diff --git a/docs/api/scripts.fp.traffic.md b/docs/api/scripts.fp.traffic.md index 85289220..9cb135c5 100644 --- a/docs/api/scripts.fp.traffic.md +++ b/docs/api/scripts.fp.traffic.md @@ -45,6 +45,51 @@ Ambient filler (docs/fp/slices/E-traffic.md §6): unnamed commuters and pedestri - `func maintain(p: Vector3, fwd: Vector3, sync: bool) -> void`: Removes ambient agents beyond the radius (out of sight) and tops up toward the targets. sync: fill completely now (first fill or after a teleport), anywhere out of the camera's view. - `func fill_parking() -> int`: Fills curb slots with parked cars, deterministically per slot (bulk: one lane rebuild after). Returns how many were parked. +## `scripts/fp/traffic/car_doors.gd` + +extends `RefCounted` + +The driver's door of a full car model (vehicles_v2) as a part of its own that swings open on a hinge at its front edge. The generator (tools/blender/realcity/vehicles2.py) builds each body as one mesh and draws the doors' shut lines down its sides as thin dark strips from just above the sill to under the belt; the driver's door lies between the strips around the driver's seat (Seat_Driver, on +X; the models face +Z). The cut takes, between those lines and above the strips' foot: the outer skin and what sits on it (handle, trim, the mirror), the door card behind it, and above the belt the side glass with its frame. Triangles that cross a shut line or the foot are split there, so the shut door shows no seam. Once per model (cached); a drawn car gets its door on demand (pose) and keeps it, shut, when its node goes back to the pool. Lengths below are model units (the generator's metre is WS.M). + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `SEAT_NODE = "Seat_Driver"` +- `OPEN_ANGLE = deg_to_rad(68.0)`: Fully open, the rear edge has swung out by this angle (radians). +- `STRIP_W = 0.03 * WS.M`: A shut-line strip: at most STRIP_W along the car, at least STRIP_SPAN of the body height tall, STRIP_TRIS triangles or more, facing out (+X) beyond STRIP_SIDE of the half width. +- `STRIP_JOIN = 0.015 * WS.M` +- `STRIP_SPAN = 0.35` +- `STRIP_TRIS = 6` +- `STRIP_SIDE = 0.6` +- `STRIP_OFF = 0.0012 * WS.M`: The strips stand this far off the skin (side_line's out) and stop this far under the belt. +- `BELT_ABOVE = 0.015 * WS.M` +- `DOOR_MIN = 0.7 * WS.M`: A door is DOOR_MIN to DOOR_MAX long; vans draw no line behind the front door, which then ends DOOR_FALLBACK behind its front line. +- `DOOR_MAX = 1.7 * WS.M` +- `DOOR_FALLBACK = 0.95 * WS.M` +- `SKIN_IN = 0.024 * WS.M`: Below the belt the door is the skin and what is on it (within SKIN_IN inside it) and the door card (facing in, within CARD_IN); above it the side glass and its frame (facing out, within GLASS_IN of the line from the belt to the glass top) and what stands out past the belt line (the mirror, within MIRROR_IN). +- `CARD_IN = 0.12 * WS.M` +- `GLASS_IN = 0.03 * WS.M` +- `MIRROR_IN = 0.012 * WS.M` +- `GLASS_NX = 0.85` +- `CARD_NX = -0.7` +- `GLASS_MIN = 0.2 * WS.M`: The side glass reaches at least this far over the belt (a truck's cab corner has none). +- `HANDLE_BACK = 0.24 * WS.M`: The handle sits this far ahead of the door's rear edge and under the belt (vehicles2 handle). +- `HANDLE_DOWN = 0.075 * WS.M` +- `ON_PLANE = 1e-7`: Points this close to a cutting plane count as on it. + +### Inner classes + +- `Door`: One model's driver's door. Points and planes are in the body mesh's space. +- `Cut`: Vertex arrays being cut: the source's attributes plus the points made on the cutting planes. + +### Functions + +- `static func of(key: String, scene: PackedScene) -> Door`: The door of the model `scene` (cached under key); check .ok. +- `static func pose(node: Node3D, d: Door, amount: float) -> bool`: Shows node's door opened by amount (0 shut .. 1 open), cutting it out of the body the first time it opens. Returns false when the model has no door. +- `static func points(d: Door) -> Dictionary`: The door's key points in the model's own space (its root node's): the hinge, the handle, the driver's seat (hip point), the rear edge at the belt and that edge with the door wide open. +- `static func skin_x(d: Door, y: float, z: float) -> float`: The outer skin's x at height y (below the belt) at z, from the two lines' profiles. +- `static func is_door(d: Door, c: Vector3, n: Vector3) -> bool`: True when a piece of surface centred at c, facing n, belongs to the door (inside its lines). + ## `scripts/fp/traffic/contact_shadows.gd` extends `RefCounted` @@ -449,7 +494,7 @@ Asset building for people.gd: the layered clips (a box held in both hands, a pho extends `RefCounted` -Draws E's agents (docs/fp/slices/E-traffic.md §3 Draw calls). Vehicles: the full GLB only within NEAR of the camera (pooled per model, capped), beyond that one MultiMeshInstance3D per model built from a single-surface, vertex-coloured merge of the GLB (H's _lod1.glb when it exists), with shadows to SHADOW_DIST and none beyond. Parked cars go through a static layer rebuilt only when the parked set changes or the camera moves. Brake lights, siren flashes and wheel spin on full models; after dark their head and tail lamps light up, the nearest moving vehicles throw headlight beams, and the head and tail lamps of the merged ones glow. Responding crews near the camera wash their surroundings in flashing siren light. People (the legacy static bodies, until HC's rigged ones): every distance draws the merged body through the people shader, which swings the legs and arms from per-instance stride data and dithers people in and out (spawned in view, or at the edge of VIS_PERSON). Same geometry and colours as the GLB, one draw per body model and layer. +Draws E's agents (docs/fp/slices/E-traffic.md §3 Draw calls). Vehicles: the full GLB only within NEAR of the camera (pooled per model, capped), beyond that one MultiMeshInstance3D per model built from a single-surface, vertex-coloured merge of the GLB (H's _lod1.glb when it exists), with shadows to SHADOW_DIST and none beyond. Parked cars go through a static layer rebuilt only when the parked set changes or the camera moves. Brake lights, siren flashes and wheel spin on full models; after dark their head and tail lamps light up, the nearest moving vehicles throw headlight beams, and the head and tail lamps of the merged ones glow. Responding crews near the camera wash their surroundings in flashing siren light. A car with its driver's door open (the player's) swings it out on a hinge cut from its body (car_doors.gd). People (the legacy static bodies, until HC's rigged ones): every distance draws the merged body through the people shader, which swings the legs and arms from per-instance stride data and dithers people in and out (spawned in view, or at the edge of VIS_PERSON). Same geometry and colours as the GLB, one draw per body model and layer. ### Constants @@ -458,6 +503,7 @@ Draws E's agents (docs/fp/slices/E-traffic.md §3 Draw calls). Vehicles: the ful - `KIT = preload("res://scripts/fp/traffic/mesh_kit.gd")` - `VH = preload("res://scripts/fp/traffic/vehicles.gd")` - `WK = preload("res://scripts/fp/traffic/walkers.gd")` +- `DOORS = preload("res://scripts/fp/traffic/car_doors.gd")` - `NEAR = 2.0` - `NEAR_OUT = 2.25` - `SHADOW_DIST = 3.0` @@ -628,6 +674,7 @@ Traffic and pedestrians on lanes (slice E, docs/fp/slices/E-traffic.md): one roa - `CS = preload("res://scripts/fp/traffic/contact_shadows.gd")` - `OCC = preload("res://scripts/fp/traffic/occupants.gd")` - `SHOVES = preload("res://scripts/fp/traffic/shoves.gd")` +- `DOORS = preload("res://scripts/fp/traffic/car_doors.gd")` - `MAX_STEP = 1.0 / 30.0` - `CAR_PREFIXES = ["sedan", "hatch", "suv", "taxi", "van", "pickup", "coupe"]` - `TELEPORT = 2.0`: A jump of the player beyond this in one frame is a teleport: the street is refilled around them. @@ -688,9 +735,11 @@ Traffic and pedestrians on lanes (slice E, docs/fp/slices/E-traffic.md): one roa - `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 leave_car() -> void`: The player gets out: the car stays where it stands (its door shut) 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 door_info(id: int = -1) -> Dictionary`: The driver's door of car id (the car the player drives when id < 0), in the car's frame (+Z forward, +X its left and the driver's side, y up from the ground; world units): {"hinge", "handle", "seat" (the hip point), "rear" (the rear edge at the belt line), "open_rear" (that edge with the door wide open)}, or {} when its model has no door that opens (car_doors.gd). +- `func set_door(amount: float) -> void`: Opens the driver's door of the car the player drives by amount (0 shut .. 1 open); drawn on its full model. - `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. diff --git a/docs/fp/ARCHITECTURE.md b/docs/fp/ARCHITECTURE.md index 2ee4d27d..784980e0 100644 --- a/docs/fp/ARCHITECTURE.md +++ b/docs/fp/ARCHITECTURE.md @@ -113,15 +113,15 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th | **B Lean HUD & map** | `scripts/fp/ui/**` except `chat_panel*` | Top indicator chips. Rotating circular minimap (roads, zones, water, player arrow, pings, incidents, nearby cars and people). Full-screen map (M). Compass. Street-name label. Toasts. Interact prompt anchored over its target in third person (crosshair only in first person). Pause menu (resume, save, load, settings for sensitivity, FOV, bob, quality preset and camera, quit to orbit, quit). Time-speed display. No model branding. | | **C Chat & conversations** | `scripts/fp/chat/**`, `scripts/ai/chatter_minds.gd`, `scripts/fp/ui/chat_panel*` | Clash of Clans–style global chat panel: collapsible, unread badge, role colours, district badges, relative time, system cards, a drip queue so lines don't burst. Chatter minds: residents and mayors talk about real events, gossip, reply to each other and to the player. Player input (Enter). Direct face-to-face conversations: E on a resident opens a dialogue that runs through that resident's mind. Answers in context: the reply and talk prompts carry the city's real state (`chat_facts.gd`: the mayor's last turn, the Gazette, the council, incidents, sites, district numbers), a mayor the player names answers in person, a typing row shows who is answering, and a status card says when live replies fail or come back. Offline templates that quote the same facts. Save and load. | | **D Posters** | `scripts/ai/gmi_image_client.gd`, `scripts/fp/posters/**`, `tools/gmi/generate_posters.py`, `assets/posters/**` | Async HY image client (submit, poll, download, `user://` cache keyed by prompt hash). Poster director: mayor campaigns, Gazette front pages, shop ads using `CityDirectory` names, incident notices, ordinance notices. Surfaces: billboards, bus shelters, walls, hoardings at construction sites. Night lighting. The pre-baked offline set. | -| **E Traffic & pedestrians** | `scripts/fp/traffic/**`, `scripts/world/city_life.gd`, `scripts/world/resident_avatars.gd` | One road-agent system. Residents and ambient walkers drawn with HC's roster bodies and shared animation libraries in four LOD tiers (legacy static people until HC lands). Lane graph (right-hand traffic, turn arcs). IDM car-following. Intersections with traffic lights (4-way) and yield (T). Resident drivers whose trips come from their minds (`trip_started`). Ambient filler traffic and pedestrians on sidewalks. Buses, trucks and emergency crews on lanes. Agents yield to the player. Spinning wheels where the model allows. Cars the player's car hits are knocked (`shoves.gd`; `bodies_near` lists the vehicle bodies for the drive feature and `bump_car` takes its impulses): they slide and spin off and stop against walls and other cars. A lane car brakes hard, waits longer the harder it was hit, then steers back onto its lane and drives on, and lane traffic stops for it meanwhile. A parked car knocked out of its slot rides up curbs, stays where it stops and is towed back to its slot once it has stood still 30 s and is out of sight. Keeps `vehicle_counts()`, `visible_count()`, `pick()`, `position_of()` and `select()`. | +| **E Traffic & pedestrians** | `scripts/fp/traffic/**`, `scripts/world/city_life.gd`, `scripts/world/resident_avatars.gd` | One road-agent system. Residents and ambient walkers drawn with HC's roster bodies and shared animation libraries in four LOD tiers (legacy static people until HC lands). Lane graph (right-hand traffic, turn arcs). IDM car-following. Intersections with traffic lights (4-way) and yield (T). Resident drivers whose trips come from their minds (`trip_started`). Ambient filler traffic and pedestrians on sidewalks. Buses, trucks and emergency crews on lanes. Agents yield to the player. Spinning wheels where the model allows. Cars the player's car hits are knocked (`shoves.gd`; `bodies_near` lists the vehicle bodies for the drive feature and `bump_car` takes its impulses): they slide and spin off and stop against walls and other cars. A lane car brakes hard, waits longer the harder it was hit, then steers back onto its lane and drives on, and lane traffic stops for it meanwhile. A parked car knocked out of its slot rides up curbs, stays where it stops and is towed back to its slot once it has stood still 30 s and is out of sight. Each car model's driver's door is cut out of its body between its shut lines and hung on its hinge (`car_doors.gd`), so the driven car's door swings open (`door_info`, `set_door`). Keeps `vehicle_counts()`, `visible_count()`, `pick()`, `position_of()` and `select()`. | | **F Sim performance** | `scripts/sim/city_sim.gd`, `scripts/sim/residents.gd`, `scripts/ai/resident_minds.gd` (perf only) | Time-sliced tick (frame budget ≤ 4 ms in first person, synchronous path kept for tests). A `tick_progress` signal. No stall over 50 ms. | | **G1 Streetscape & look** | `scripts/world/city_view.gd`, `scripts/world/construction_kit.gd`, `scripts/world/sky_director.gd`, `scenes/main-environment.tres`, `shaders/**`, `textures/real/**` and `env/**` (contents and `.import` params) | Scale fixes (REAL_SCALE, people, vehicles, machines via `WS`). Street-level ground: anti-tiling PBR, curbs, lane markings, crosswalks, sidewalk detail. Street furniture density. Street-name signs at intersections. First-person light profile (shadow distance, SSAO and SSIL radii in units, fog, volumetric fog for night and smog). Weather (clear, overcast, rain with wet roads). A budgeted view-rebuild queue. | | **G2 Render performance** | `scripts/fp/render/**`, `project.godot` `[rendering]`, `tools/optimize_imports.py`, the `.import` params under `models/**` **except `models/characters/**`** (HC) | Visibility ranges, HLOD and impostors, occluders (applied by a post-pass on `tile_built`). Quality presets (Low, Medium, High) applied at runtime: FSR2, shadow atlas, SSAO, SSIL. VRAM texture compression and mipmaps on imports (an allow-list keeps HC's retarget settings). `fp_bench` perf gate, including a third-person street shot and the animated-people budget. | | **H Assets** | `tools/blender/**`, `models/real/**`, `scripts/fp/asset_paths.gd` (path list only, except HC's entries) | Vehicles at 1.0× with separate wheel nodes. Traffic lights, street-name signs, bus shelter with a poster panel, billboard with a UV'd poster surface, wall poster frame, crosswalk and curb pieces, fixed door heights. All validated by `validate_real.py`. | | **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), a UI chat blip, crashes where the player's car hits a wall or a car (`car_impact`, heavier and louder with the closing speed) and a clatter or clang by kind for each street prop it knocks over (`prop_knocked`). Assets synthesized procedurally or with the existing ACE-Step tool. | +| **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), a UI chat blip, crashes where the player's car hits a wall or a car (`car_impact`, heavier and louder with the closing speed) a clatter or clang by kind for each street prop it knocks over (`prop_knocked`), and its driver's door opening and slamming shut (`car_door`). 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. The car is a rigid box (`car_body.gd`, on the shared `scripts/fp/obb2d.gd` and `scripts/fp/body_contacts.gd`): it moves in short substeps and each time resolves its deepest contact, with `CollisionWorld`'s walls, posts and water edges, with other vehicles (traffic's `bodies_near`, impulses handed back through `bump_car`) and with street props (the props feature knocks them over when hit hard enough). So it bounces a little off what it hits head-on, slides along what it hits at a glance and spins when hit off-centre; `car_impact` on the bus reports the hard hits. 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. | +| **Integration: driving** | `scripts/fp/drive/**`, `tools/tests/fp_drive_test.gd`, `tools/tests/fp_car_doors_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). With the `car_doors` flag the hero gets in the long way (`car_entry.gd`): it walks round the car to the driver's door (the shorter clear way round its front or rear, past other cars, jogging while far), reaches for the handle (`door_try`), pulls the door open and swings it wide, steps into the opening, sits onto the sill (`sit_down`, its root travel walked back so the feet stay planted), swings round into the seat, and the door slams (`car_door` on the bus). From the reach on, a camera beside the door watches and blends into the chase camera. F on the way to the door turns back. 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. The car is a rigid box (`car_body.gd`, on the shared `scripts/fp/obb2d.gd` and `scripts/fp/body_contacts.gd`): it moves in short substeps and each time resolves its deepest contact, with `CollisionWorld`'s walls, posts and water edges, with other vehicles (traffic's `bodies_near`, impulses handed back through `bump_car`) and with street props (the props feature knocks them over when hit hard enough). So it bounces a little off what it hits head-on, slides along what it hits at a glance and spins when hit off-centre; `car_impact` on the bus reports the hard hits. 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: the long way, the car brakes to a stop, the door opens, the hero swings out onto the sill, stands up (`stand_up`), steps clear and the door shuts. With no room for the door or no clear way round (or without the flag) F gets in and out at once. A left car is removed once out of range and out of sight. | | **Integration: phone** | `scripts/fp/phone/**`, `tools/tests/fp_phone_test.gd` | B opens a phone with a Build app: road, homes, shops, industry, a building from the service list, and bulldoze, on the tile ahead of the hero (the camera's pitch sets the distance, 0.6 to 6 tiles). Each order goes through `CitySim.apply_player_order` for the mayor who owns that tile, so it is paid from that district's treasury and follows the mayors' rules. Its construction sites go to the player's crews (below; with the `crews` flag off, `apply_action` hands them to the contractors). Unclaimed land is refused. A line on the phone says how many crews are free and how long the order would take, or when a crew frees up for it. `build_rules.gd` mirrors the sim's checks for the preview: a cyan or amber ghost over the tiles, the owner, the price and why not. Roads and zones take two clicks (start or corner, then end). Put away while driving or under a menu. | | **Integration: AI activity** | `scripts/fp/activity/**`, `tools/tests/fp_activity_test.gd` | Shows the city's AI at work from the street. Every mayor turn, council ruling, Chaos Director pick, Grid Operator order and Gazette front page lands as a card in a feed at the top right (J cycles full, brief and hidden). A card names who decided and the model they run on, and says whether the answer came live, from a replay, from a fallback after a failed live call, or from the offline planner. It also shows what they did and why, any votes, and where. A decision with a place raises a light beacon in the agent's colour (tagged within 30 tiles) and pings the compass and minimap (`ai:`, no toast). G routes the GPS to the newest decision with a place. On foot it turns on `CityBrain.stagger_mayors`, so the five mayors take their turns spread over the day and the header line says who is deciding or who goes next. The feed hides under modals (not under the chat input), and the beacons hide in photo mode. | | **Integration: crews** | `scripts/fp/crews/**`, `tools/tests/fp_crews_test.gd`, `tools/tests/fp_crew_jobs_test.gd` | Phone orders go to the player's two building crews, which build them in real time: 2 to 5 minutes for a building at speed 1, roads by the tile. A third order waits for a free crew. Each crew is on its site to see (`crew_rig.gd`): a foreman by the gate, two builders whose clips follow the stage, and a hauler carrying crates from a pile to the work. They use the trades bodies from HC's roster (`crew_bodies.gd`, loaded in the background), with dust while they work and a board over the gate naming the crew, the order, the stage and the time left. Where they stand and walk comes from the site's `CrewGrid` (`crew_grid.gd`). It is a grid of cells 0.8 m apart with nodes on the ground inside the hoarding and on the kit's decks (on a narrow scaffold board, along its middle). Each node is clear of every collision box and cylinder near the tile, except the boxes that enclose its deck (the slab under it, the frame or scaffold round it), and of the deck's posts and of the decks above. Nodes link by steps up to 1 m and climbs over berms and formwork. `crew_spots.gd` picks the door inside the gate, the foreman's spot and the pile beside it, and work spots nearest the middle that the door reaches. It also picks a drop point the hauler reaches without walking into the crew. Where a finished building fills its lot, the crew works from the scaffold. A crew that takes an order while you play walks in through the gate. The crew is laid out again when its site is redrawn for a new stage (a worker walks over when it can), and a road crew moves along the road. A card under the top bar lists the orders with their stage, progress and countdown, and the ones waiting. E on a worker gets a word about the job, the site's interact label says which crew builds it, and a toast says when an order is done. | diff --git a/docs/fp/S0-foundation.md b/docs/fp/S0-foundation.md index f675f519..509d9d3d 100644 --- a/docs/fp/S0-foundation.md +++ b/docs/fp/S0-foundation.md @@ -682,6 +682,9 @@ signal drive_changed(active: bool) ## The player's car hit something hard: the contact point, the closing speed (u/s) and what was ## hit, "wall" (anything fixed) or "car". Emitted by the drive feature. signal car_impact(at: Vector3, speed: float, what: String) +## The driver's door of the player's car opened (true) or slammed shut (false), at its handle. +## Emitted by the drive feature. +signal car_door(at: Vector3, open: bool) ## A street prop was knocked over at a point (its StreetDressing kind: "bin", "bench" ...) and ## flies off at speed (u/s). Emitted by the props feature. signal prop_knocked(at: Vector3, kind: String, speed: float) @@ -707,6 +710,7 @@ signal prop_knocked(at: Vector3, kind: String, speed: float) | `card_action` | `"ask_here"`, tile index | B (place-card button) | C (the "Ask someone here" answer) | | `drive_changed` | in a car | Integration: driving (getting in and out) | A's interact (no targets from behind the wheel), the phone (puts itself away) | | `car_impact` | `at`: the contact point, at half the car's height. `speed`: the closing speed (u/s), at least 1.5 m/s. `what`: `"wall"` or `"car"`. The hardest hit of a step, at most one every 0.25 s; knocked props report themselves | Integration: driving | I (crash sound at the contact) | +| `car_door` | `at`: the driver's door handle. `open`: `true` as the latch lets go (the pull on the handle getting in, the door starting to open getting out), `false` as the door slams shut | Integration: driving (the `car_doors` climb in and out) | I (the latch and the slam at the door) | | `prop_knocked` | `at`: where the car hit it. `kind`: its StreetDressing kind. `speed`: how fast it flies off (u/s); a fallen prop hit again reports from 1 m/s | Integration: props | I (clatter or clang by kind) | The **target** dict: `{"kind": "resident"|"building"|"poster"|"vehicle"|"pedestrian", "id": String, "tile": int, "pos": Vector3, "dist": float, "label": String, "data": Dictionary}`. diff --git a/docs/fp/slices/I-audio.md b/docs/fp/slices/I-audio.md index 3950e750..fb1f75a8 100644 --- a/docs/fp/slices/I-audio.md +++ b/docs/fp/slices/I-audio.md @@ -123,6 +123,7 @@ Settings map `music_volume_db` to `Music` and `sfx_volume_db` to `SFX` and `Ambi | `traffic_hum` | | | `chat_blip`, `chat_blip_soft`, `alert_stinger` | | | `crash_heavy`, `crash_light`, `prop_clatter`, `prop_clang` | the player's car hitting walls and cars (`car_impact`) and street props knocked over (`prop_knocked`); WAV | +| `car_door_open`, `car_door_close` | the player's car door (`car_door`): the handle, the latch letting go, the seal and the door check as it opens; the slam, the latch catching and the panel as it shuts; WAV | Total size must be at most 8 MB. The ACE-Step tool is optional and needs the GPU, so only under the heavy lock and only for the beds. Write the import settings so loops loop (OGG `loop=true` in each `sounds/fp/*.ogg.import`, which I owns). diff --git a/scripts/fp/audio/audio_bank.gd b/scripts/fp/audio/audio_bank.gd index 9343c60c..6802a23b 100644 --- a/scripts/fp/audio/audio_bank.gd +++ b/scripts/fp/audio/audio_bank.gd @@ -21,12 +21,13 @@ const LOOPS: Array[String] = [ ] const ONE_SHOTS: Array[String] = [ "horn_short", "horn_long", "chat_blip", "chat_blip_soft", "alert_stinger", "crash_heavy", - "crash_light", "prop_clatter", "prop_clang", + "crash_light", "prop_clatter", "prop_clang", "car_door_open", "car_door_close", ] ## Stored as WAV (with every footstep_*); everything else is .ogg. const WAV_SOUNDS: Array[String] = [ "engine_idle", "engine_run", "siren_two_tone", "horn_short", "horn_long", "chat_blip", "chat_blip_soft", "alert_stinger", "crash_heavy", "crash_light", "prop_clatter", "prop_clang", + "car_door_open", "car_door_close", ] ## Bus names (created by audio_feature.setup when missing). @@ -71,6 +72,8 @@ const KNOCK_STREAMS := { } const KNOCK_DEFAULT := "prop_clang" const KNOCK_GAP := 0.1 +## The player's car door (car_door): the latch letting go plays this far under the slam. +const DOOR_OPEN_DB := -7.0 static var _cache: Dictionary = {} static var _legacy_loop: AudioStream = null diff --git a/scripts/fp/audio/audio_feature.gd b/scripts/fp/audio/audio_feature.gd index 2bb639a3..c98cb73e 100644 --- a/scripts/fp/audio/audio_feature.gd +++ b/scripts/fp/audio/audio_feature.gd @@ -2,7 +2,7 @@ extends FpFeature ## City sound on foot (slice I). Owns the Music, SFX, Ambience and UI buses while on foot, puts ## the AudioDirector's music under the street (set_fp), and runs three parts: positional emitters ## (engines, horns, sirens, construction, fire, fountains, plants, the river, passers-by's steps, -## the player's car crashing and the street props it knocks over), +## the player's car crashing, the street props it knocks over and its doors), ## the area beds (with rain from G1's weather) and the hero's footsteps (splashing on wet paving), ## plus the chat blip and alert stingers. Pausing muffles the music (the only thing still ## playing); talking to someone ducks the beds. @@ -101,6 +101,7 @@ func setup(p_ctx: FpContext) -> void: _link(bus.time_scale_changed, _on_time_scale_changed) _link(bus.car_impact, _on_car_impact) _link(bus.prop_knocked, _on_prop_knocked) + _link(bus.car_door, _on_car_door) if ctx.sim != null: _link(ctx.sim.event_started, emitters.on_event) _link(ctx.sim.event_ended, emitters.on_event_ended) @@ -335,6 +336,14 @@ func _on_prop_knocked(at: Vector3, kind: String, speed: float) -> void: emitters.shot("knock", at, s, trim, _rng.randf_range(0.94, 1.06)) +## The player's car door opening (its handle and latch, BANK.DOOR_OPEN_DB under the slam) or +## slamming shut (car_door), at the door. +func _on_car_door(at: Vector3, open: bool) -> void: + var s := "car_door_open" if open else "car_door_close" + var trim := BANK.DOOR_OPEN_DB if open else 0.0 + emitters.shot("door", at, s, trim, _rng.randf_range(0.96, 1.04)) + + ## The level trim (dB) of a hit at share k of its full-level speed. static func _impact_db(k: float) -> float: return linear_to_db(clampf(k, BANK.IMPACT_MIN_GAIN, 1.0)) diff --git a/scripts/fp/audio/emitters.gd b/scripts/fp/audio/emitters.gd index fb639997..7a90ab42 100644 --- a/scripts/fp/audio/emitters.gd +++ b/scripts/fp/audio/emitters.gd @@ -1,7 +1,7 @@ extends Node3D ## Positional city sound (slice I): logical sources (engines, horns, sirens, construction, fire, -## fountains, power plants, river banks, passers-by's footsteps, crashes and knocked props) share -## a pool of at most MAX_PLAYERS AudioStreamPlayer3D. +## fountains, power plants, river banks, passers-by's footsteps, crashes, knocked props and car +## doors) share a pool of at most MAX_PLAYERS AudioStreamPlayer3D. ## Every refresh scores the sources by their estimated level at the listener and gives the players ## to the loudest; the farthest or quietest lose theirs first. Doppler is computed by hand (the ## built-in effect works in world units against 343 u/s and is inaudible at 1 u = 20 m). @@ -29,6 +29,7 @@ const TYPES := { "step": {"max": 0.5, "db": -24.0, "near": 6.0, "stream": "footstep_sidewalk_1"}, "crash": {"max": 10.0, "db": -6.0, "near": 6.0, "stream": "crash_heavy"}, "knock": {"max": 4.0, "db": -10.0, "near": 6.0, "stream": "prop_clang"}, + "door": {"max": 3.0, "db": -12.0, "near": 6.0, "stream": "car_door_close"}, } ## Residents on foot (E's "pedestrian" agents): positional footsteps for the WALKER_MAX nearest ## walkers within WALKER_RADIUS of the hero, one per stride of their travel (E has no foot-contact diff --git a/scripts/fp/drive/car_entry.gd b/scripts/fp/drive/car_entry.gd new file mode 100644 index 00000000..02f56a6e --- /dev/null +++ b/scripts/fp/drive/car_entry.gd @@ -0,0 +1,719 @@ +extends RefCounted +## Getting into the player's car and out of it on foot, for drive_feature.gd (car_doors flag). +## In: the hero walks round the car to the driver's door (jogging while it is far), reaches for +## the handle (door_try), pulls the door open and swings it wide, steps into the opening, turns +## its back to the seat and sits down onto the sill (sit_down; its root travel is walked back so +## the feet stay planted), swings round into the seat and the door shuts. Out: the door opens, +## the hero swings round onto the sill, stands up (stand_up, walked forward), steps clear of the +## door and it shuts. From the reach on, a camera beside the door watches and blends to and from +## the drive camera. The points come from the traffic feature's door_info, in the car's frame +## (+Z forward, +X the driver's side); the car stands still throughout. The hero's rig is reached +## by duck typing (the player folder is another slice): travel, root_travel, sit, play_gesture, +## stop_gesture, state and bone_world. + +const WS := preload("res://scripts/fp/world_scale.gd") + +## The reach: the hero stands STAND_OUT out from the body side with the handle REACH_FWD ahead +## and REACH_SIDE to its right (door_try's hand at the grip), and clears the swinging door's edge +## by BODY_R. +const STAND_OUT := 0.28 * WS.M +const REACH_FWD := 0.44 * WS.M +const REACH_SIDE := 0.26 * WS.M +const BODY_R := 0.22 * WS.M +## The walk keeps ROUTE_GAP off the car's body and comes in along its side from LEAD_IN behind +## the stand spot when it comes from behind; a corner counts as passed WP_REACH from it and a +## goal as reached ARRIVE from it. It slows down within SLOW_R and jogs while more than JOG_FROM +## is left. A way is checked for room every ROUTE_STEP. +const ROUTE_GAP := 0.45 * WS.M +const LEAD_IN := 0.45 * WS.M +const WP_REACH := 0.3 * WS.M +const ARRIVE := 0.08 * WS.M +const SLOW_R := 0.6 * WS.M +const JOG_FROM := 3.0 * WS.M +const ROUTE_STEP := 0.25 * WS.M +## How far round the car other cars can block the walk (drive_feature asks traffic for them). +const ROUTE_REACH := 3.0 * WS.M +## The walk gives up after WALK_MAX s, or STUCK_T s without getting STUCK_GAIN closer; the step +## into the opening (at STEP_PACE of a walk) after STEP_MAX s. +const WALK_MAX := 8.0 +const STUCK_T := 1.0 +const STUCK_GAIN := 0.05 * WS.M +const STEP_PACE := 0.55 +const STEP_MAX := 1.5 +## Lining up on a spot: turning at TURN_RATE (rad/s), sliding at ALIGN_SPEED, for at least +## ALIGN_MIN s. +const TURN_RATE := 5.0 +const ALIGN_SPEED := 0.6 * WS.M +const ALIGN_MIN := 0.2 +## door_try: the hand is on the handle REACH_AT s in and the pull ends at PULL_END with the door +## PULL_OPEN open; it then swings wide over DOOR_WIDE s. +const REACH_AT := 0.75 +const PULL_END := 1.2 +const PULL_OPEN := 0.3 +const DOOR_WIDE := 0.45 +## Sat down, the hips are SILL_IN inside the body side. Sitting takes at most SIT_MAX s, the swing +## into or out of the seat SWING_T s and the door's shutting CLOSE_T s. +const SILL_IN := 0.06 * WS.M +const SIT_MAX := 1.3 +const SWING_T := 0.7 +const CLOSE_T := 0.35 +## Out: the door opens over OPEN_T s, standing up takes at most STAND_MAX s, then the hero steps +## AWAY out and AWAY_BACK toward the rear (for at most AWAY_MAX s). +const OPEN_T := 0.45 +const STAND_MAX := 1.7 +const AWAY := 0.55 * WS.M +const AWAY_BACK := 0.25 * WS.M +const AWAY_MAX := 1.2 +## Nothing fixed may stand within DOOR_R of the wide-open door's middle and edge. +const DOOR_R := 0.08 * WS.M +## The door camera looks along CAM_DIR (car frame: from behind the door, mirrored ahead of it when +## that has more room) at a point just outside the door from CAM_DIST (CAM_MIN at least, after the +## pull-in in front of walls). The view eases onto it over FRAME_BLEND s and between it and the +## drive camera over CAM_BLEND s. +const CAM_DIR := Vector3(0.72, 0.3, -0.62) +const CAM_DIST := 3.2 * WS.M +const CAM_MIN := 1.6 * WS.M +const CAM_RADIUS := 0.25 * WS.M +const FRAME_BLEND := 0.6 +const CAM_BLEND := 1.0 +## Getting out waits for the car to stop: below STOP_V (u/s), or after BRAKE_MAX s of braking. +const STOP_V := 0.15 * WS.M +const BRAKE_MAX := 2.5 + +## "" (idle), "in" or "out". +var kind: String = "" +## In: "walk", "align", "open", "step", "turn", "sit", "swing", "close". Out: "open", "swing", +## "stand", "away", "close". +var phase: String = "" +## Seconds into the phase. +var t: float = 0.0 +## The driver's door: 0 shut .. 1 open. +var door: float = 0.0 +## A phase to wait at (the photo tour holds the climb in at "swing"); "" for none. +var hold: String = "" +var _ctx: FpContext +var _car := Transform3D.IDENTITY +var _pts: Dictionary = {} +## Other cars' boxes round the car (world AABBs) that the walk must not run into. +var _boxes: Array = [] +var _side: float = 0.0 +var _half_len: float = 0.0 +var _height: float = 0.0 +## The stand spot for the reach and the spot the feet sit down from (or stand up to), car frame. +var _stand := Vector3.ZERO +var _sill := Vector3.ZERO +var _route := PackedVector3Array() +var _wp: int = 0 +var _yaw_reach: float = 0.0 +var _yaw_out: float = 0.0 +var _yaw_seat: float = 0.0 +var _from := Vector3.ZERO +var _from_yaw: float = 0.0 +var _to := Vector3.ZERO +var _to_yaw: float = 0.0 +var _hips := Vector3.ZERO +var _len: float = 0.0 +var _dt: float = 0.0 +var _fresh: bool = false +var _best: float = INF +var _stuck: float = 0.0 +var _walked: float = 0.0 +## The door's ramp (from, to, elapsed, length) and its curve: "smooth", "out" or "in". +var _ramp_v := Vector4.ZERO +var _curve: String = "smooth" +var _cam := Transform3D.IDENTITY +## The drive camera's share of the view (0 the door camera .. 1) and where it is heading. +var _cam_w: float = 0.0 +var _cam_goal: float = 0.0 + + +## Starts getting into the claimed car `car` ({"pos", "yaw", "half_w", "length", "height"}) +## through its driver's door pts (door_info); boxes are the other cars round it. False when the +## hero cannot sit, the door or the ground beside it is blocked, or no way round the car is clear. +func begin_in(ctx: FpContext, car: Dictionary, pts: Dictionary, boxes: Array = []) -> bool: + _boxes = boxes + if not _setup(ctx, car, pts, "sit_down") or _blocked(_car * _stand): + return false + _route = _plan(_flat(_car.affine_inverse() * ctx.player.position)) + if _route.is_empty(): + return false + _start_walk() + _cam_w = 0.0 + _cam_goal = 0.0 + door = 0.0 + _ramp_v = Vector4.ZERO + kind = "in" + _go("walk") + ctx.player.avatar.visible = true + return true + + +## Starts getting out of the stopped car (same arguments). False when there is no room for the +## door or for the hero beside it. +func begin_out(ctx: FpContext, car: Dictionary, pts: Dictionary, boxes: Array = []) -> bool: + _boxes = boxes + if not _setup(ctx, car, pts, "stand_up") or _blocked(_away()): + return false + _cam_w = 1.0 + _cam_goal = 0.0 + door = 0.0 + kind = "out" + _ramp(1.0, OPEN_T, "out") + _sound(true) + _go("open") + ctx.player.avatar.visible = true + return true + + +## Advances by dt s. Returns "" while under way, then "seated" (in), "out" (out) or "fail" (the +## walk could not reach the door; the hero is still on foot). +func step(dt: float) -> String: + if kind.is_empty() or dt <= 0.0 or (not hold.is_empty() and phase == hold): + return "" + _dt = dt + t += dt + _step_door(dt) + _cam_w = move_toward(_cam_w, _cam_goal, dt / CAM_BLEND) + var was := phase + var done := _step_in() if kind == "in" else _step_out() + if phase == was: + _fresh = false + if not done.is_empty(): + kind = "" + phase = "" + return done + + +## Stops where it is: the door is shut and a gesture fades out. +func cancel() -> void: + if kind.is_empty(): + return + if kind == "in" and phase == "open": + _rig_call("stop_gesture", []) + door = 0.0 + _ramp_v = Vector4.ZERO + kind = "" + phase = "" + hold = "" + + +## True once the door camera has the view: from the reach on the way in, at once on the way out +## (the walk to the door keeps the hero's own camera). +func framing() -> bool: + return kind == "out" or (kind == "in" and phase != "walk") + + +## True while the hero is in the seat or half way through the door, where stopping leaves it +## inside the car (in: from the turn into the opening on; out: until it stands). +func aboard() -> bool: + if kind == "in": + return phase in ["turn", "sit", "swing", "close"] + return kind == "out" and phase in ["open", "swing", "stand"] + + +## The camera now: the door camera, blended toward drive (the drive camera). +func camera(drive: Transform3D) -> Transform3D: + return _cam.interpolate_with(drive, smoothstep(0.0, 1.0, _cam_w)) + + +## The world points the walk to the door passes through (tests). +func route() -> PackedVector3Array: + return _route + + +# ------------------------------------------------------------------ the plan + + +## Works out the spots and headings in the car's frame; clip sits down or stands up (its root +## travel sets how far out from the sill the feet are). False when the hero has no rig that sits, +## or the door or the spot beside it is blocked. +func _setup(ctx: FpContext, car: Dictionary, pts: Dictionary, clip: String) -> bool: + var rig := _rig_of(ctx) + if rig == null or pts.is_empty(): + return false + _ctx = ctx + _pts = pts + var yaw := float(car["yaw"]) + _car = Transform3D(Basis(Vector3.UP, yaw), car["pos"]) + _half_len = float(car["length"]) * 0.5 + _height = float(car["height"]) + var handle: Vector3 = pts["handle"] + _side = maxf(float(car["half_w"]), handle.x) + _stand = _stand_spot(handle, pts["hinge"]) + var out := float(rig.call("root_travel", clip)) + _sill = Vector3(_side - SILL_IN + out, 0.0, (pts["seat"] as Vector3).z) + _yaw_seat = wrapf(yaw + PI, -PI, PI) + _yaw_out = _yaw_of(_car.basis.x) + var grip := atan2(REACH_SIDE, REACH_FWD) + _yaw_reach = wrapf(_yaw_of(_car * handle - _car * _stand) + grip, -PI, PI) + _cam = _door_camera() + return not _blocked(_car * _sill) and not _door_blocked() + + +## Where the hero stands for the reach: STAND_OUT out from the side, back along it until the +## handle is at door_try's grip and the swinging door clears the body. +func _stand_spot(handle: Vector3, hinge: Vector3) -> Vector3: + var x := _side + STAND_OUT + var reach := Vector2(REACH_SIDE, REACH_FWD).length() + var z := handle.z - sqrt(maxf(reach * reach - pow(x - handle.x, 2.0), 0.0)) + var swing := _door_len() + BODY_R + var hx := x - hinge.x + if hx < swing: + z = minf(z, hinge.z - sqrt(swing * swing - hx * hx)) + return Vector3(x, 0.0, z) + + +## The walk from `from` (car frame xz) to the stand spot: out of the car's footprint grown by +## ROUTE_GAP, round its front or its rear (the shorter way that is clear) and along the driver's +## side, in from LEAD_IN behind the spot when it comes from behind. World points; empty when no +## way is clear. +func _plan(from: Vector2) -> PackedVector3Array: + var s := Vector2(_stand.x, _stand.z) + var ex := _side + ROUTE_GAP + var ez := _half_len + ROUTE_GAP + var ways: Array = [] + if from.x > _side and from.distance_to(s) <= LEAD_IN * 1.5: + ways.append([from, s]) + else: + var p := _out_of(from, ex, ez) + for corners: Array in _corners(p, ex, ez): + var w: Array = [from] + if p != from: + w.append(p) + w.append_array(corners) + if (w.back() as Vector2).y < s.y - LEAD_IN * 0.5: + w.append(Vector2(ex, s.y - LEAD_IN)) + w.append(s) + ways.append(w) + ways.sort_custom(func(a: Array, b: Array) -> bool: return _poly_len(a) < _poly_len(b)) + for w: Array in ways: + if _clear(w): + var out := PackedVector3Array() + for k in range(1, w.size()): + var q: Vector2 = w[k] + out.append(_ground(_car * Vector3(q.x, 0.0, q.y))) + return out + return PackedVector3Array() + + +## The ways round the grown footprint from p (outside it) to the driver's side, as lists of the +## corners to pass: none on the driver's side, else round the front or round the rear. +static func _corners(p: Vector2, ex: float, ez: float) -> Array: + var fl := Vector2(ex, ez) + var rl := Vector2(ex, -ez) + var fr := Vector2(-ex, ez) + var rr := Vector2(-ex, -ez) + if p.x >= ex - 1e-6: + return [[]] + if p.y >= ez - 1e-6: + return [[fl], [fr, rr, rl]] + if p.y <= -ez + 1e-6: + return [[rl], [rr, fr, fl]] + return [[fr, fl], [rr, rl]] + + +## True when the hero has room all along way w (car frame xz; its first point is where it stands). +func _clear(w: Array) -> bool: + for k in range(w.size() - 1): + var a: Vector2 = w[k] + var b: Vector2 = w[k + 1] + var n := maxi(1, ceili(a.distance_to(b) / ROUTE_STEP)) + for i in range(1 if k == 0 else 0, n + 1): + var q := a.lerp(b, float(i) / n) + if q.distance_to(w[0]) > WP_REACH and _blocked(_car * Vector3(q.x, 0.0, q.y)): + return false + return true + + +static func _poly_len(w: Array) -> float: + var d := 0.0 + for k in range(w.size() - 1): + d += (w[k] as Vector2).distance_to(w[k + 1]) + return d + + +## p pushed out of the box of half extents (ex, ez) across its nearest edge. +static func _out_of(p: Vector2, ex: float, ez: float) -> Vector2: + var dx := ex - absf(p.x) + var dz := ez - absf(p.y) + if dx <= 0.0 or dz <= 0.0: + return p + if dx < dz: + return Vector2(ex if p.x >= 0.0 else -ex, p.y) + return Vector2(p.x, ez if p.y >= 0.0 else -ez) + + +## A camera beside the door looking at a point just outside it: from behind it, or from ahead of +## it when that has more room; pulled in front of walls. +func _door_camera() -> Transform3D: + var hinge: Vector3 = _pts["hinge"] + var rear: Vector3 = _pts["rear"] + var mid := (hinge.z + rear.z) * 0.5 - 0.2 * WS.M + var focus := _car * Vector3(_side + 0.35 * WS.M, _height * 0.6, mid) + var best := Transform3D.IDENTITY + var bd := -INF + for s: float in [1.0, -1.0]: + var dir := (_car.basis * Vector3(CAM_DIR.x, CAM_DIR.y, CAM_DIR.z * s)).normalized() + var dist := CAM_DIST + if _ctx.collision != null: + var hit := _ctx.collision.sweep_sphere(focus, dir, CAM_DIST, CAM_RADIUS) + dist = minf(CAM_DIST, hit - CAM_RADIUS) + if dist > bd + 0.05 * WS.M: + bd = dist + var eye := focus + dir * maxf(dist, 0.3 * WS.M) + best = Transform3D(Basis.looking_at(focus - eye, Vector3.UP), eye) + if bd >= CAM_MIN: + break + return best + + +# ------------------------------------------------------------------ getting in + + +func _step_in() -> String: + match phase: + "walk", "step": + return _walk() + "align", "turn": + _align() + "open": + _open_in() + "sit": + _sit_down() + "swing": + _swing_in() + "close": + _place_hips(_seat_world(), _yaw_seat) + return _shut("seated") + return "" + + +func _start_walk() -> void: + _wp = 0 + _best = INF + _stuck = 0.0 + _walked = 0.0 + + +## Follows the route on foot (FpPlayer.simulate, so the walk animates and collides): to the stand +## spot ("walk"; "fail" when it stops getting closer), or into the door's opening ("step"; lined +## up from wherever it stops). +func _walk() -> String: + var p := _ctx.player + var stepping := phase == "step" + _walked += _dt + while _wp < _route.size() - 1 and _flat(_route[_wp] - p.position).length() < WP_REACH: + _wp += 1 + var d := _flat(_route[_wp] - p.position) + var last := _wp == _route.size() - 1 + if last and (d.length() < ARRIVE or (d.length() < WP_REACH and _passed(p.position))): + _line_up() + return "" + var left := d.length() + _route_left() + if left < _best - STUCK_GAIN: + _best = left + _stuck = 0.0 + else: + _stuck += _dt + if _stuck > STUCK_T or _walked > (STEP_MAX if stepping else WALK_MAX): + if not stepping: + return "fail" + _line_up() + return "" + var k := clampf(d.length() / SLOW_R, 0.3, 1.0) if last else 1.0 + if stepping: + k = minf(k, STEP_PACE) + p.simulate(_wish(d.normalized() * k, p.cam_yaw), left > JOG_FROM and not stepping, _dt) + return "" + + +## The last few centimetres onto the walk's goal and the turn: to the handle after the walk, the +## back to the seat after the step. +func _line_up() -> void: + var p := _ctx.player + _from = p.position + _from_yaw = p.yaw + _to = _route[_route.size() - 1] + _to_yaw = _yaw_reach if phase == "walk" else _yaw_out + var turn := absf(wrapf(_to_yaw - p.yaw, -PI, PI)) / TURN_RATE + _len = maxf(ALIGN_MIN, maxf(turn, _flat(_to - _from).length() / ALIGN_SPEED)) + p.simulate(Vector2.ZERO, false, _dt) + _go("align" if phase == "walk" else "turn") + + +func _align() -> void: + var k := smoothstep(0.0, 1.0, t / _len) + _ride(_ground(_from.lerp(_to, k)), lerp_angle(_from_yaw, _to_yaw, k)) + if t >= _len: + _go("open" if phase == "align" else "sit") + + +## door_try: the hand meets the handle, the pull opens the door a little, then it swings wide and +## the hero steps into the opening. +func _open_in() -> void: + if _fresh: + _rig_call("play_gesture", ["door_try"]) + if t - _dt < REACH_AT and t >= REACH_AT: + _ramp(PULL_OPEN, PULL_END - REACH_AT, "smooth") + _sound(true) + if t >= PULL_END: + _rig_call("stop_gesture", []) + _ramp(1.0, DOOR_WIDE, "out") + _route = PackedVector3Array([_ground(_car * _sill)]) + _start_walk() + _go("step") + + +## sit_down onto the sill: the clip's root travel walks the feet back toward the car. +func _sit_down() -> void: + var rig := _rig() + if _fresh: + rig.call("sit", true) + _from = _ctx.player.position + _len = t + var k := float(rig.call("travel", "sit_down", t - _len)) + var back := float(rig.call("root_travel", "sit_down")) + _ride(_ground(_from - _car.basis.x * back * k), _yaw_out) + if (k >= 0.995 and t - _len > 0.3) or t >= SIT_MAX: + _hips = rig.call("bone_world", "Hips") + _cam_goal = 1.0 + _go("swing") + + +## From the sill round into the seat, facing forward. +func _swing_in() -> void: + var k := smoothstep(0.0, 1.0, t / SWING_T) + _place_hips(_hips.lerp(_seat_world(), k), lerp_angle(_yaw_out, _yaw_seat, k)) + if t >= SWING_T: + _ramp(0.0, CLOSE_T, "in") + _go("close") + + +# ------------------------------------------------------------------ getting out + + +func _step_out() -> String: + match phase: + "open": + _open_out() + "swing": + _swing_out() + "stand": + _stand_up() + "away": + _walk_away() + "close": + _ctx.player.simulate(Vector2.ZERO, false, _dt) + return _shut("out") + return "" + + +## Seated while the door opens; then the swing out onto the sill, the feet on the ground below it. +func _open_out() -> void: + _place_hips(_seat_world(), _yaw_seat) + if t < OPEN_T: + return + var p := _ctx.player + var rig := _rig() + var hw: Vector3 = rig.call("bone_world", "Hips") if rig != null else p.position + _hips = _seat_world() + _to = _car * Vector3(_side - SILL_IN, 0.0, _sill.z) + _to.y = _ground(_car * _sill).y + (hw.y - p.position.y) + _go("swing") + + +func _swing_out() -> void: + var k := smoothstep(0.0, 1.0, t / SWING_T) + _place_hips(_hips.lerp(_to, k), lerp_angle(_yaw_seat, _yaw_out, k)) + if t >= SWING_T: + _from = _ctx.player.position + _go("stand") + + +## stand_up off the sill: the clip's root travel walks the feet out from the car. +func _stand_up() -> void: + var rig := _rig() + if _fresh: + rig.call("sit", false) + _len = t + var k := float(rig.call("travel", "stand_up", t - _len)) + var fwd := float(rig.call("root_travel", "stand_up")) + _ride(_ground(_from + _car.basis.x * fwd * k), _yaw_out) + var standing := t - _len > 0.5 and str(rig.call("state")) == "loco" + if standing or t >= STAND_MAX: + _go("away") + + +## A step clear of the door's swing, then it shuts. +func _walk_away() -> void: + var p := _ctx.player + var d := _flat(_away() - p.position) + var hinge := _car * (_pts["hinge"] as Vector3) + var clear := _flat(p.position - hinge).length() > _door_len() + BODY_R + if d.length() < ARRIVE or (clear and t > 0.4) or t >= AWAY_MAX: + _ramp(0.0, CLOSE_T, "in") + _go("close") + return + var k := clampf(d.length() / SLOW_R, 0.4, 1.0) + p.simulate(_wish(d.normalized() * k, p.cam_yaw), false, _dt) + + +## Where the hero ends up: out from the stand-up spot and a little toward the rear (world). +func _away() -> Vector3: + return _ground(_car * Vector3(_sill.x + AWAY, 0.0, _sill.z - AWAY_BACK)) + + +# ------------------------------------------------------------------ helpers + + +func _go(p: String) -> void: + phase = p + t = 0.0 + _fresh = true + + +## The door shutting (and on the way in the camera settling behind the car); done once it has, +## with the slam. +func _shut(done: String) -> String: + if _ramp_v.z < _ramp_v.w or _cam_w < _cam_goal: + return "" + _sound(false) + return done + + +func _ramp(to: float, secs: float, curve: String) -> void: + _ramp_v = Vector4(door, to, 0.0, maxf(secs, 1e-3)) + _curve = curve + + +func _step_door(dt: float) -> void: + if _ramp_v.w <= 0.0: + return + _ramp_v.z = minf(_ramp_v.z + dt, _ramp_v.w) + var x := _ramp_v.z / _ramp_v.w + if _curve == "in": + x = x * x + elif _curve == "out": + x = 1.0 - (1.0 - x) * (1.0 - x) + else: + x = smoothstep(0.0, 1.0, x) + door = lerpf(_ramp_v.x, _ramp_v.y, x) + + +func _sound(open: bool) -> void: + if _ctx.bus != null: + _ctx.bus.car_door.emit(_car * (_pts["handle"] as Vector3), open) + + +## Puts the hero so that its hips are at world point `at`, the body turned to yaw. +func _place_hips(at: Vector3, yaw: float) -> void: + var p := _ctx.player + var rig := _rig() + var hw: Vector3 = rig.call("bone_world", "Hips") if rig != null else Vector3(NAN, NAN, NAN) + if is_nan(hw.x): + return + p.ride(at - Basis(Vector3.UP, yaw) * p.to_local(hw), yaw, yaw) + + +func _ride(pos: Vector3, yaw: float) -> void: + _ctx.player.ride(pos, yaw, yaw) + + +func _seat_world() -> Vector3: + return _car * (_pts["seat"] as Vector3) + + +func _door_len() -> float: + return _flat((_pts["rear"] as Vector3) - (_pts["hinge"] as Vector3)).length() + + +## The route's length beyond the current waypoint. +func _route_left() -> float: + var d := 0.0 + for k in range(_wp, _route.size() - 1): + d += _flat(_route[k + 1] - _route[k]).length() + return d + + +## True when p is past the goal along the last leg (the spring overshot it). +func _passed(p: Vector3) -> bool: + var n := _route.size() + if n < 2: + return false + return _flat(p - _route[n - 1]).dot(_flat(_route[n - 1] - _route[n - 2])) > 0.0 + + +## p on the walkable ground under it (unchanged over unwalkable ground). +func _ground(p: Vector3) -> Vector3: + var g := _ctx.collision.walk_height(p) if _ctx.collision != null else NAN + if not is_nan(g): + p.y = g + return p + + +## True when the hero cannot stand at p: no ground, something fixed, or another car's box. +func _blocked(p: Vector3) -> bool: + var col := _ctx.collision + if col == null: + return false + var g := col.walk_height(p) + var r := WS.PLAYER_RADIUS * 0.8 + if is_nan(g) or col.is_blocked(Vector3(p.x, g, p.z), r): + return true + for b: AABB in _boxes: + if p.x > b.position.x - r and p.x < b.end.x + r and p.z > b.position.z - r: + if p.z < b.end.z + r: + return true + return false + + +## True when something stands where the door swings (its middle and its edge, wide open). +func _door_blocked() -> bool: + var col := _ctx.collision + if col == null: + return false + var h: Vector3 = _pts["hinge"] + var o: Vector3 = _pts["open_rear"] + for f: float in [0.5, 1.0]: + var q := _car * h.lerp(o, f) + var g := col.walk_height(q) + if not is_nan(g) and col.is_blocked(Vector3(q.x, g, q.z), DOOR_R): + return true + return false + + +func _rig() -> RefCounted: + return _rig_of(_ctx) + + +func _rig_call(method: String, args: Array) -> void: + var rig := _rig() + if rig != null: + rig.callv(method, args) + + +## The hero's rig when it can sit (HC's animated hero; the stand-in figure cannot). +static func _rig_of(ctx: FpContext) -> RefCounted: + var av := ctx.player.avatar if ctx != null and ctx.player != null else null + if av == null or not bool(av.call("is_animated")): + return null + var rig: Variant = av.get("rig") + if rig is RefCounted and (rig as RefCounted).has_method("travel"): + return rig + return null + + +## The view-relative wish (FpPlayer.simulate) that walks along world direction d (xz). +static func _wish(d: Vector2, cam_yaw: float) -> Vector2: + var right := Vector2(cos(cam_yaw), -sin(cam_yaw)) + var fwd := Vector2(-sin(cam_yaw), -cos(cam_yaw)) + return Vector2(d.dot(right), d.dot(fwd)) + + +## The body yaw (FpPlayer: forward is (-sin, 0, -cos)) that faces along d. +static func _yaw_of(d: Vector3) -> float: + return atan2(-d.x, -d.z) + + +static func _flat(v: Vector3) -> Vector2: + return Vector2(v.x, v.z) diff --git a/scripts/fp/drive/car_entry.gd.uid b/scripts/fp/drive/car_entry.gd.uid new file mode 100644 index 00000000..13451cec --- /dev/null +++ b/scripts/fp/drive/car_entry.gd.uid @@ -0,0 +1 @@ +uid://cepuhfosnsxbe diff --git a/scripts/fp/drive/drive_feature.gd b/scripts/fp/drive/drive_feature.gd index 15efa860..0dc2b129 100644 --- a/scripts/fp/drive/drive_feature.gd +++ b/scripts/fp/drive/drive_feature.gd @@ -1,17 +1,20 @@ 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 is a rigid box (car_body.gd): it bounces off walls, slides along -## them at a glance, spins when hit off-centre, shoves the cars it hits and knocks street props -## over; car_impact on the bus reports the hard hits. +## hero walks round to the driver's door, opens it, climbs in and shuts it (car_entry.gd, the +## car_doors flag; at once without it), 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 stops the +## car and gets out through the driver's door (or at once beside it). The car belongs to the +## traffic feature (lane traffic stops for it and it stays where it is left). It is a rigid box +## (car_body.gd): it bounces off walls, slides along them at a glance, spins when hit off-centre, +## shoves the cars it hits and knocks street props over; car_impact on the bus reports the hard +## hits and car_door the door opening and shutting. 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") const BODY := preload("res://scripts/fp/drive/car_body.gd") +const ENTRY := preload("res://scripts/fp/drive/car_entry.gd") ## Reach from the hero to a car's body for the get-in prompt. const ENTER_REACH := 2.0 * WS.M @@ -43,6 +46,13 @@ const SETTLE := 0.3 const PROMPT_TICK := 0.15 ## car_impact goes out at most this often (s), for the hardest hit. const IMPACT_GAP := 0.25 +## Stopped cars this close to the hero block it on foot (as FpPlayer polls them). +const BLOCKER_REACH := 2.0 * WS.M +## The photo shot's car is the nearest one to get into this close to the spawn point, picked +## PHOTO_FRAMES frames after the tour puts the hero there (traffic fills the street round it). +const PHOTO_REACH := 30.0 * WS.M +const PHOTO_FRAMES := 3 +const PHOTO_DT := 1.0 / 60.0 var active: bool = false var car_id: int = -1 @@ -64,6 +74,12 @@ var _prev_mode: String = "first" var _target: Dictionary = {} var _t: float = 0.0 var _impact_t: float = 0.0 +## The walk to the door and the climb in, or the climb out (car_entry.gd). +var entry := ENTRY.new() +## Seconds spent braking to a stop before the climb out, or -1. +var _stop_t: float = -1.0 +## The hero's input before the photo shot. +var _photo_input: bool = true func feature_id() -> String: @@ -105,25 +121,111 @@ func self_test() -> Dictionary: # ------------------------------------------------------------------ getting in and out -## The car the hero could get into now ({} when none or while driving). +## The car the hero could get into now ({} when none, while driving or climbing in or out). 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(): + if active or _climbing() or p == null or tr == null or ctx.is_modal(): + return {} + if 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. +## Gets into the nearest car in reach at once. Returns true when the hero is now driving. func enter() -> bool: + if _claim().is_empty(): + return false + _take_wheel() + return true + + +## Gets into the nearest car in reach the long way (car_doors): the hero walks round to its +## driver's door, opens it, climbs in and shuts it, then drives (climb_step runs it). At once +## (enter) without the flag, or when the door cannot open (a model without one, the stand-in +## figure, no room beside it or no clear way round). Returns true when it started. +func board() -> bool: + if not FeatureFlags.enabled("car_doors"): + return enter() + var info := _claim() + if info.is_empty(): + return false + if not _climb_in(info): + _take_wheel() + return true + + +## Gets out the long way (car_doors): the car brakes to a stop, the door opens and the hero +## climbs out and shuts it (climb_step runs it). At once (exit) without the flag. False when the +## car is too fast (it says so). +func leave() -> bool: + if not active or _climbing(): + return false + if not FeatureFlags.enabled("car_doors"): + return exit() + if absf(model.speed()) > EXIT_SPEED: + _toast("Slow down to get out") + return false + _stop_t = 0.0 + if hud != null: + hud.set_prompt("") + return true + + +## Gets out at once beside the driver's door (force: at any speed, wherever there is room or on +## the spot); also ends a climb in or out half way (a hero still outside the car stays where it +## stands). Returns true when the hero is on foot again. +func exit(force: bool = false) -> bool: + var climbing := not entry.kind.is_empty() + if not active and not climbing: + return false + if not force and not climbing and absf(model.speed()) > EXIT_SPEED: + _toast("Slow down to get out") + return false + var spot := Vector3(NAN, NAN, NAN) + if not climbing or entry.aboard(): + spot = _exit_spot() + if is_nan(spot.x): + if not force: + _toast("No room to get out here") + return false + spot = _pos + entry.cancel() + _on_foot(spot, 0.0 if force else EXIT_BLEND) + return true + + +## One step of the climb in or out, or of braking to a stop for the climb out; _process runs it, +## tests call it with their own dt. +func climb_step(dt: float) -> void: + var tr := _traffic() + if tr == null or int(tr.call("driven_id")) != car_id: + exit(true) + return + if _stop_t >= 0.0: + _stop_for_exit(dt) + return + var done := entry.step(dt) + tr.call("set_door", entry.door) + if done == "seated" or done == "fail": + _take_wheel() + return + if entry.framing() or done == "out": + ctx.player.set_view_override(entry.camera(camera_xform()), _fov(), ENTRY.FRAME_BLEND) + if done == "out": + _stood_out() + + +## Takes the car in reach from traffic and sets the drive state up on it (the hero is not at the +## wheel yet): its claim_car info, or {} when there is none. +func _claim() -> Dictionary: var t := enter_target() if t.is_empty(): - return false + return {} var info: Dictionary = _traffic().call("claim_car", int(t["id"])) if info.is_empty(): - return false + return {} 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"])) @@ -134,37 +236,81 @@ func enter() -> bool: _cam_yaw = model.yaw _orbit = 0.0 _orbit_pitch = 0.0 + _stop_t = -1.0 _prev_mode = p.camera_mode p.input_enabled = false + if hud != null: + hud.set_prompt("") + if ctx.bus != null: + ctx.bus.drive_changed.emit(true) + return info + + +## Starts the walk to the claimed car's door; false when it cannot (see board). +func _climb_in(info: Dictionary) -> bool: + var tr := _traffic() + var p := ctx.player + # The claimed car is no obstacle any more; the others near the hero still are. + var near: Array[AABB] = [] + near.assign(tr.call("blockers_near", p.position, BLOCKER_REACH)) + ctx.collision.dynamic_boxes = near + var boxes: Array = tr.call("blockers_near", _pos, _dims.z + ENTRY.ROUTE_REACH) + if not entry.begin_in(ctx, info, tr.call("door_info", -1), boxes): + return false + p.set_camera_mode("third", true) + return true + + +## Behind the wheel: the drive camera takes the view and the hero sits in the driver's seat. +func _take_wheel() -> void: + var p := ctx.player + active = true + 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 +## Brakes to a stop for the climb out; stopped, the door opens (or, with no room for the door or +## for the hero beside it, the hero gets out at once). +func _stop_for_exit(dt: float) -> void: + drive_step(0.0, 1.0, 0.0, 1.0, dt) + _look(dt) + _stop_t += dt + ctx.player.set_view_override(camera_xform(), _fov()) + if absf(model.speed()) > ENTRY.STOP_V and _stop_t < ENTRY.BRAKE_MAX: + return + _stop_t = -1.0 + model.reset(model.yaw, _dims.z) + var tr := _traffic() + tr.call("drive_car", _pos, model.yaw, 0.0, 0.0) + if hud != null: + hud.set_speed(false, 0.0) + var boxes: Array = tr.call("blockers_near", _pos, _dims.z + ENTRY.ROUTE_REACH) + if not entry.begin_out(ctx, _car_info(), tr.call("door_info", -1), boxes): + exit() + + +## Out and the door shut: the hero walks on from where it stands, the view along the car. +func _stood_out() -> void: + var p := ctx.player + p.cam_yaw = wrapf(model.yaw + PI, -PI, PI) + p.cam_pitch = 0.0 + p.pitch = 0.0 + _on_foot(Vector3(NAN, NAN, NAN), EXIT_BLEND) + + +## Back on foot (moved to spot unless it is NAN, facing along the car): the car goes back to +## traffic, and walking input and the hero's own camera return, easing over blend s. +func _on_foot(spot: Vector3, blend: float) -> void: var tr := _traffic() if tr != null: tr.call("leave_car") active = false car_id = -1 + _stop_t = -1.0 if hud != null: hud.set_prompt("") hud.set_speed(false, 0.0) @@ -174,12 +320,12 @@ func exit(force: bool = false) -> bool: 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 not is_nan(spot.x): + p.teleport(spot, rad_to_deg(model.yaw + PI), 0.0, true) + p.set_camera_mode(_prev_mode, blend > 0.0 and not p.view_override) + p.clear_view_override(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. @@ -200,27 +346,33 @@ func _exit_spot() -> Vector3: return Vector3(NAN, NAN, NAN) +func _car_info() -> Dictionary: + return {"pos": _pos, "yaw": model.yaw, "half_w": _dims.x, "length": _dims.z, "height": _dims.y} + + +## True while climbing in or out, or braking to a stop for it. +func _climbing() -> bool: + return not entry.kind.is_empty() or _stop_t >= 0.0 + + # ------------------------------------------------------------------ driving func _process(delta: float) -> void: if ctx == null or ctx.player == null: return + var dt := minf(delta, 0.1) + if _climbing(): + if not ctx.is_modal(): + climb_step(dt) + 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 "" - ) + _poll_prompt(delta) 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") @@ -241,6 +393,17 @@ func _process(delta: float) -> void: hud.set_speed(free, model.speed()) +## The get-in prompt, a few times a second while on foot. +func _poll_prompt(delta: float) -> void: + _t -= delta + if _t > 0.0: + return + _t = PROMPT_TICK + _target = enter_target() + if hud != null: + hud.set_prompt("Get in %s" % str(_target["label"]) if not _target.is_empty() else "") + + ## 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: @@ -321,18 +484,24 @@ func _input(event: InputEvent) -> void: if ctx == null or ctx.player == null or ctx.is_modal() or event.is_echo(): return var handled := false + var driving := active and not _climbing() 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() + # Driving or climbing, the key is always taken (a refusal toasts why); on foot only with a + # car near. On the way to the door it turns back. + if _climbing(): + if entry.kind == "in" and not entry.aboard(): + exit() + handled = true + elif active: + leave() handled = true else: - handled = enter() - elif active and event.is_action_pressed("view_toggle"): + handled = board() + elif driving 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 + driving 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)) @@ -393,6 +562,61 @@ func _fov() -> float: return base + FOV_FAST * clampf(absf(model.speed()) / MODEL.TOP, 0.0, 1.0) +# ------------------------------------------------------------------ photo tour + + +## The photo shot: the hero half way into the car nearest the spawn point, sat on the sill with +## the door wide open, seen from the door camera (car_doors). +func photo_shots() -> Array: + if _traffic() == null or ctx.mode == null or not FeatureFlags.enabled("car_doors"): + return [] + var sp: Dictionary = ctx.mode.spawn_point() + var shot := {"name": "car_door", "pos": sp["pos"], "yaw": float(sp["yaw"]), "pitch": 0.0} + shot.merge({"hour": 10.5, "prepare": "photo_prepare", "restore": "photo_restore"}) + return [shot] + + +## Photo tour: the tour puts the hero at the spawn point after this, so the climb is staged once +## traffic has filled the street round it. +func photo_prepare() -> void: + _photo_input = ctx.player.input_enabled + _photo_stage.call_deferred() + + +func photo_restore() -> void: + exit(true) + ctx.player.input_enabled = _photo_input + + +## Claims the car nearest the hero, walks it in from beside the driver's door and holds the climb +## at the swing into the seat. +func _photo_stage() -> void: + for _k in range(PHOTO_FRAMES): + await get_tree().process_frame + var p := ctx.player + var tr := _traffic() + if tr == null or active or _climbing(): + return + var near: Dictionary = tr.call("enter_target", p.position, PHOTO_REACH) + if near.is_empty(): + return + p.teleport(near["pos"], rad_to_deg(p.yaw)) + var info := _claim() + if info.is_empty(): + return + var car := Transform3D(Basis(Vector3.UP, model.yaw), _pos) + p.teleport(car * Vector3(_dims.x + 0.9 * WS.M, 0.0, -0.4 * _dims.z), rad_to_deg(model.yaw)) + if not _climb_in(info): + exit(true) + return + entry.hold = "swing" + for _k in range(roundi(20.0 / PHOTO_DT)): + climb_step(PHOTO_DT) + p.update_view(PHOTO_DT) + if entry.phase == "swing" or entry.kind.is_empty(): + break + + # ------------------------------------------------------------------ helpers diff --git a/scripts/fp/game_bus.gd b/scripts/fp/game_bus.gd index 6f2c4089..33a74322 100644 --- a/scripts/fp/game_bus.gd +++ b/scripts/fp/game_bus.gd @@ -41,6 +41,9 @@ signal drive_changed(active: bool) ## The player's car hit something hard: the contact point, the closing speed (u/s) and what was ## hit, "wall" (anything fixed) or "car". Emitted by the drive feature. signal car_impact(at: Vector3, speed: float, what: String) +## The driver's door of the player's car opened (true) or slammed shut (false), at its handle. +## Emitted by the drive feature. +signal car_door(at: Vector3, open: bool) ## A street prop was knocked over at a point (its StreetDressing kind: "bin", "bench" ...) and ## flies off at speed (u/s). Emitted by the props feature. signal prop_knocked(at: Vector3, kind: String, speed: float) diff --git a/scripts/fp/player/hero_rig.gd b/scripts/fp/player/hero_rig.gd index 18ea6a5d..a01d41dc 100644 --- a/scripts/fp/player/hero_rig.gd +++ b/scripts/fp/player/hero_rig.gd @@ -76,6 +76,9 @@ const STEP_MIN_MOVE := 0.25 ## Gesture one-shot fades (s). const SHOT_IN := 0.25 const SHOT_OUT := 0.35 +## sit_down and stand_up play this much faster than authored (in and out of a car seat). +const SIT_SPEED := 1.5 +const SIT_CLIPS := ["sit_down", "stand_up"] ## Head look (6.9): Head limits (yaw, pitch), the Neck's share and limit, the modifier's own ## interpolation, the influence fade when the target leaves the limits, and the halflife that ## smooths a jumping target. @@ -287,6 +290,52 @@ func gesture_active() -> bool: return bool(tree.get(P_SHOT_ACTIVE)) +## Fades the playing gesture out before its end. +func stop_gesture() -> void: + tree.set(P_SHOT, AnimationNodeOneShot.ONE_SHOT_REQUEST_FADE_OUT) + + +## How far clip has carried its root t seconds after its state began (0..1 of the whole clip's +## horizontal root travel; sit_down and stand_up play at SIT_SPEED). The tree drops root motion, +## so whoever moves the body through a clip (into a car seat) follows this curve; a clip without +## travel runs linearly. +func travel(clip: String, t: float) -> float: + var a := _clip(clip) + var ti := _root_track(a) + var length := a.length if a != null else 1.0 + var tc := t * (SIT_SPEED if clip in SIT_CLIPS else 1.0) + if ti < 0: + return clampf(tc / length, 0.0, 1.0) + var p0 := a.position_track_interpolate(ti, 0.0) + var d := a.position_track_interpolate(ti, length) - p0 + d.y = 0.0 + if d.length_squared() < 1e-8: + return clampf(tc / length, 0.0, 1.0) + var p := a.position_track_interpolate(ti, clampf(tc, 0.0, length)) - p0 + return clampf(Vector3(p.x, 0.0, p.z).dot(d) / d.length_squared(), 0.0, 1.0) + + +## How far clip carries the body along the ground over its whole length (world units). +func root_travel(clip: String) -> float: + var a := _clip(clip) + var ti := _root_track(a) + if ti < 0: + return 0.0 + var d := a.position_track_interpolate(ti, a.length) - a.position_track_interpolate(ti, 0.0) + var sc := skeleton.global_transform.basis.get_scale().x if skeleton.is_inside_tree() else 1.0 + return Vector2(d.x, d.z).length() * skeleton.motion_scale * sc + + +func _clip(clip: String) -> Animation: + return _lib.get_animation(clip) if _lib != null and _lib.has_animation(clip) else null + + +static func _root_track(a: Animation) -> int: + if a == null: + return -1 + return a.find_track(NodePath(AP.CHAR_ROOT_MOTION_TRACK), Animation.TYPE_POSITION_3D) + + ## The world point the head turns toward; NAN components let the head return to the clip. func set_look_target(p: Vector3) -> void: _look_goal = p @@ -395,8 +444,14 @@ func _state_machine() -> AnimationNodeStateMachine: land.connect_node("output", 0, "mix") sm.add_node("land", land) sm.add_node("talk", _anim("talk")) - for s: String in ["sit_down", "sit", "stand_up"]: - sm.add_node(s, _anim(s)) + sm.add_node("sit", _anim("sit")) + for s: String in SIT_CLIPS: + var a := _anim(s) + a.use_custom_timeline = true + a.timeline_length = float(lengths[s]) / SIT_SPEED + a.stretch_time_scale = true + a.loop_mode = Animation.LOOP_NONE + sm.add_node(s, a) _tr(sm, "Start", "loco", 0.0, true) for pair: Array in [["loco", "crouch"], ["crouch", "loco"], ["land", "crouch"]]: _tr(sm, pair[0], pair[1], XF_CROUCH) diff --git a/scripts/fp/player/player.gd b/scripts/fp/player/player.gd index e5df5ae0..840b4134 100644 --- a/scripts/fp/player/player.gd +++ b/scripts/fp/player/player.gd @@ -233,6 +233,9 @@ func ride(pos: Vector3, body_yaw: float, view_yaw: float) -> void: yaw = wrapf(body_yaw, -PI, PI) cam_yaw = wrapf(view_yaw, -PI, PI) velocity = Vector3.ZERO + # The avatar leans from the last walk's acceleration and turn rate: a carried body does not. + _fwd_acc = 0.0 + _yaw_rate = 0.0 _apply_rotation() _update_tile() diff --git a/scripts/fp/traffic/car_doors.gd b/scripts/fp/traffic/car_doors.gd new file mode 100644 index 00000000..09acb3c7 --- /dev/null +++ b/scripts/fp/traffic/car_doors.gd @@ -0,0 +1,563 @@ +extends RefCounted +## The driver's door of a full car model (vehicles_v2) as a part of its own that swings open on a +## hinge at its front edge. The generator (tools/blender/realcity/vehicles2.py) builds each body +## as one mesh and draws the doors' shut lines down its sides as thin dark strips from just above +## the sill to under the belt; the driver's door lies between the strips around the driver's seat +## (Seat_Driver, on +X; the models face +Z). The cut takes, between those lines and above the +## strips' foot: the outer skin and what sits on it (handle, trim, the mirror), the door card +## behind it, and above the belt the side glass with its frame. Triangles that cross a shut line +## or the foot are split there, so the shut door shows no seam. Once per model (cached); a drawn +## car gets its door on demand (pose) and keeps it, shut, when its node goes back to the pool. +## Lengths below are model units (the generator's metre is WS.M). + +const WS := preload("res://scripts/fp/world_scale.gd") +const SEAT_NODE := "Seat_Driver" +## Fully open, the rear edge has swung out by this angle (radians). +const OPEN_ANGLE := deg_to_rad(68.0) +## A shut-line strip: at most STRIP_W along the car, at least STRIP_SPAN of the body height tall, +## STRIP_TRIS triangles or more, facing out (+X) beyond STRIP_SIDE of the half width. +const STRIP_W := 0.03 * WS.M +const STRIP_JOIN := 0.015 * WS.M +const STRIP_SPAN := 0.35 +const STRIP_TRIS := 6 +const STRIP_SIDE := 0.6 +## The strips stand this far off the skin (side_line's out) and stop this far under the belt. +const STRIP_OFF := 0.0012 * WS.M +const BELT_ABOVE := 0.015 * WS.M +## A door is DOOR_MIN to DOOR_MAX long; vans draw no line behind the front door, which then ends +## DOOR_FALLBACK behind its front line. +const DOOR_MIN := 0.7 * WS.M +const DOOR_MAX := 1.7 * WS.M +const DOOR_FALLBACK := 0.95 * WS.M +## Below the belt the door is the skin and what is on it (within SKIN_IN inside it) and the door +## card (facing in, within CARD_IN); above it the side glass and its frame (facing out, within +## GLASS_IN of the line from the belt to the glass top) and what stands out past the belt line +## (the mirror, within MIRROR_IN). +const SKIN_IN := 0.024 * WS.M +const CARD_IN := 0.12 * WS.M +const GLASS_IN := 0.03 * WS.M +const MIRROR_IN := 0.012 * WS.M +const GLASS_NX := 0.85 +const CARD_NX := -0.7 +## The side glass reaches at least this far over the belt (a truck's cab corner has none). +const GLASS_MIN := 0.2 * WS.M +## The handle sits this far ahead of the door's rear edge and under the belt (vehicles2 handle). +const HANDLE_BACK := 0.24 * WS.M +const HANDLE_DOWN := 0.075 * WS.M +## Points this close to a cutting plane count as on it. +const ON_PLANE := 1e-7 + +## Model key -> Door. +static var _cache: Dictionary = {} + + +## One model's driver's door. Points and planes are in the body mesh's space. +class Door: + extends RefCounted + var ok: bool = false + var why: String = "" + ## Name of the body MeshInstance3D in the model, and its transform in the model's root. + var body_name: String = "" + var body_xf := Transform3D.IDENTITY + ## The front (hinge) and rear shut lines (z), the door's foot (y) and the belt at each line. + var front: float = 0.0 + var rear: float = 0.0 + var bottom: float = 0.0 + var belt_front: float = 0.0 + var belt_rear: float = 0.0 + ## The outer skin along each line from the foot up: Vector2(y, x). + var skin_front := PackedVector2Array() + var skin_rear := PackedVector2Array() + ## Where the side glass meets the roof: its height and x. + var top: float = 0.0 + var top_x: float = 0.0 + var hinge := Vector3.ZERO + var handle := Vector3.ZERO + var seat := Vector3.ZERO + ## The body without the door (same surfaces, same order) and the door, hinge at its origin. + var body_mesh: ArrayMesh = null + var door_mesh: ArrayMesh = null + var door_tris: int = 0 + var cut_tris: int = 0 + + +## Vertex arrays being cut: the source's attributes plus the points made on the cutting planes. +class Cut: + extends RefCounted + var pos: PackedVector3Array + var nrm: PackedVector3Array + var tan: PackedFloat32Array + var col: PackedColorArray + var uv: PackedVector2Array + var uv2: PackedVector2Array + var has_tan: bool = false + var has_col: bool = false + var has_uv: bool = false + var has_uv2: bool = false + var _made: Dictionary = {} + + func _init(arrays: Array) -> void: + pos = arrays[Mesh.ARRAY_VERTEX] + nrm = ( + arrays[Mesh.ARRAY_NORMAL] if arrays[Mesh.ARRAY_NORMAL] != null else PackedVector3Array() + ) + if nrm.size() != pos.size(): + nrm.resize(pos.size()) + has_tan = arrays[Mesh.ARRAY_TANGENT] != null + has_col = arrays[Mesh.ARRAY_COLOR] != null + has_uv = arrays[Mesh.ARRAY_TEX_UV] != null + has_uv2 = arrays[Mesh.ARRAY_TEX_UV2] != null + if has_tan: + tan = arrays[Mesh.ARRAY_TANGENT] + if has_col: + col = arrays[Mesh.ARRAY_COLOR] + if has_uv: + uv = arrays[Mesh.ARRAY_TEX_UV] + if has_uv2: + uv2 = arrays[Mesh.ARRAY_TEX_UV2] + + ## The point where edge a-b crosses plane `plane` (axis, value), made once per edge and plane. + func on_plane(a: int, b: int, plane: int, axis: int, value: float) -> int: + var lo := mini(a, b) + var hi := maxi(a, b) + var key := Vector3i(lo, hi, plane) + if _made.has(key): + return _made[key] + var d0 := pos[lo][axis] - value + var d1 := pos[hi][axis] - value + var f := clampf(d0 / (d0 - d1), 0.0, 1.0) + var i := pos.size() + pos.append(pos[lo].lerp(pos[hi], f)) + nrm.append(nrm[lo].lerp(nrm[hi], f).normalized()) + if has_tan: + for k in range(4): + tan.append(lerpf(tan[lo * 4 + k], tan[hi * 4 + k], f)) + if has_col: + col.append(col[lo].lerp(col[hi], f)) + if has_uv: + uv.append(uv[lo].lerp(uv[hi], f)) + if has_uv2: + uv2.append(uv2[lo].lerp(uv2[hi], f)) + _made[key] = i + return i + + ## Surface arrays holding only the vertices of triangles idx, moved by shift. + func arrays_for(idx: PackedInt32Array, shift: Vector3) -> Array: + var remap := PackedInt32Array() + remap.resize(pos.size()) + remap.fill(-1) + var out := [] + out.resize(Mesh.ARRAY_MAX) + var p := PackedVector3Array() + var n := PackedVector3Array() + var t := PackedFloat32Array() + var c := PackedColorArray() + var u := PackedVector2Array() + var u2 := PackedVector2Array() + var ni := PackedInt32Array() + ni.resize(idx.size()) + for k in range(idx.size()): + var v := idx[k] + if remap[v] < 0: + remap[v] = p.size() + p.append(pos[v] + shift) + n.append(nrm[v]) + if has_tan: + t.append_array(tan.slice(v * 4, v * 4 + 4)) + if has_col: + c.append(col[v]) + if has_uv: + u.append(uv[v]) + if has_uv2: + u2.append(uv2[v]) + ni[k] = remap[v] + out[Mesh.ARRAY_VERTEX] = p + out[Mesh.ARRAY_NORMAL] = n + if has_tan: + out[Mesh.ARRAY_TANGENT] = t + if has_col: + out[Mesh.ARRAY_COLOR] = c + if has_uv: + out[Mesh.ARRAY_TEX_UV] = u + if has_uv2: + out[Mesh.ARRAY_TEX_UV2] = u2 + out[Mesh.ARRAY_INDEX] = ni + return out + + +## The door of the model `scene` (cached under key); check .ok. +static func of(key: String, scene: PackedScene) -> Door: + if _cache.has(key): + return _cache[key] + var d := Door.new() + _cache[key] = d + var root := scene.instantiate() as Node3D if scene != null else null + if root == null: + d.why = "no model" + return d + var body: MeshInstance3D = null + for n: Node in root.find_children("*", "MeshInstance3D", true, false): + if str(n.name).ends_with("_body") and (n as MeshInstance3D).mesh is ArrayMesh: + body = n + var seat := root.find_child(SEAT_NODE, true, false) as Node3D + if body == null or seat == null: + d.why = "no body mesh" if body == null else "no seat" + root.free() + return d + d.body_name = str(body.name) + d.body_xf = _in_root(body, root) + _analyse(d, body.mesh, d.body_xf.affine_inverse() * _in_root(seat, root).origin) + if d.ok: + _cut(d, body.mesh as ArrayMesh) + root.free() + return d + + +## Shows node's door opened by amount (0 shut .. 1 open), cutting it out of the body the first +## time it opens. Returns false when the model has no door. +static func pose(node: Node3D, d: Door, amount: float) -> bool: + if node == null or d == null or not d.ok: + return false + var pivot := node.get_meta("door") as Node3D if node.has_meta("door") else null + if pivot == null: + if amount <= 0.0: + return true + pivot = _attach(node, d) + if pivot == null: + return false + pivot.rotation.y = -OPEN_ANGLE * clampf(amount, 0.0, 1.0) + return true + + +## The door's key points in the model's own space (its root node's): the hinge, the handle, the +## driver's seat (hip point), the rear edge at the belt and that edge with the door wide open. +static func points(d: Door) -> Dictionary: + var rear := Vector3(skin_x(d, d.belt_rear, d.rear), d.belt_rear, d.rear) + var open := d.hinge + Basis(Vector3.UP, -OPEN_ANGLE) * (rear - d.hinge) + var xf := d.body_xf + return { + "hinge": xf * d.hinge, + "handle": xf * d.handle, + "seat": xf * d.seat, + "rear": xf * rear, + "open_rear": xf * open, + } + + +static func _attach(node: Node3D, d: Door) -> Node3D: + var mi := node.find_child(d.body_name, true, false) as MeshInstance3D + if mi == null: + return null + mi.mesh = d.body_mesh + var pivot := Node3D.new() + pivot.name = "DoorFL" + pivot.position = d.hinge + var dm := MeshInstance3D.new() + dm.name = "DoorMesh" + dm.mesh = d.door_mesh + dm.cast_shadow = mi.cast_shadow + dm.layers = mi.layers + pivot.add_child(dm) + mi.add_child(pivot) + node.set_meta("door", pivot) + # The new mesh drops the lamp overrides: let render's lamp pass set them again. + if node.has_meta("lamp_key"): + node.remove_meta("lamp_key") + return pivot + + +static func _in_root(n: Node3D, root: Node3D) -> Transform3D: + var xf := Transform3D.IDENTITY + var cur: Node = n + while cur != null and cur != root: + if cur is Node3D: + xf = (cur as Node3D).transform * xf + cur = cur.get_parent() + return xf + + +# ------------------------------------------------------------------ finding the door + + +static func _analyse(d: Door, mesh: Mesh, seat: Vector3) -> void: + var box := mesh.get_aabb() + var lines := _strips(mesh, box.end.x * STRIP_SIDE, box.size.y * STRIP_SPAN) + var front: Array = [] + var rear: Array = [] + for l: Array in lines: + var z: float = l[0] + if z > seat.z and (front.is_empty() or z < float(front[0])): + front = l + elif z < seat.z and (rear.is_empty() or z > float(rear[0])): + rear = l + if front.is_empty(): + d.why = "no shut line ahead of the seat" + return + var fz: float = front[0] + if rear.is_empty() or fz - float(rear[0]) < DOOR_MIN or fz - float(rear[0]) > DOOR_MAX: + rear = [fz - DOOR_FALLBACK, front[1]] + d.front = fz + d.rear = float(rear[0]) + d.skin_front = front[1] + d.skin_rear = rear[1] + d.bottom = minf(d.skin_front[0].x, d.skin_rear[0].x) + d.belt_front = d.skin_front[d.skin_front.size() - 1].x + BELT_ABOVE + d.belt_rear = d.skin_rear[d.skin_rear.size() - 1].x + BELT_ABOVE + if not _glass_top(d, mesh): + d.why = "no side glass over the belt" + return + var hx := 0.0 + for p: Vector2 in d.skin_front: + hx = maxf(hx, p.y) + d.hinge = Vector3(hx, (d.bottom + d.belt_front) * 0.5, d.front) + var hz := d.rear + HANDLE_BACK + var ht := inverse_lerp(d.rear, d.front, hz) + var hy := lerpf(d.belt_rear, d.belt_front, ht) - HANDLE_DOWN + d.handle = Vector3(skin_x(d, hy, hz), hy, hz) + d.seat = seat + d.ok = true + + +## The shut lines on the +X side: [z, the skin along it as Vector2(y, x) from the foot up], sorted +## by z. A line is a run of thin, tall, outward-facing triangles of the atlas surfaces. +static func _strips(mesh: Mesh, x_min: float, span: float) -> Array: + var cands: Array = [] + for s in range(mesh.get_surface_count()): + var mat := mesh.surface_get_material(s) + var mname := mat.resource_name if mat != null else "" + if mname.begins_with("Paint") or mname.contains("Glass") or mname.begins_with("Light"): + continue + var sf := _surface(mesh, s) + var v: PackedVector3Array = sf[0] + var nn: PackedVector3Array = sf[1] + var idx: PackedInt32Array = sf[2] + for t in range(0, idx.size() - 2, 3): + var a := v[idx[t]] + var b := v[idx[t + 1]] + var c := v[idx[t + 2]] + if a.x + b.x + c.x < 3.0 * x_min: + continue + if nn[idx[t]].x + nn[idx[t + 1]].x + nn[idx[t + 2]].x < 2.1: + continue + var z0 := minf(a.z, minf(b.z, c.z)) + var z1 := maxf(a.z, maxf(b.z, c.z)) + if z1 - z0 > STRIP_W: + continue + cands.append([(z0 + z1) * 0.5, a, b, c]) + cands.sort_custom(func(p: Array, q: Array) -> bool: return float(p[0]) < float(q[0])) + var out: Array = [] + var k := 0 + while k < cands.size(): + var z0: float = cands[k][0] + var run: Array = [] + while k < cands.size() and float(cands[k][0]) - z0 <= STRIP_JOIN: + run.append(cands[k]) + k += 1 + var line := _line(run, span) + if not line.is_empty(): + out.append(line) + return out + + +## [z, skin profile] of a run of strip triangles, or [] when it is not a shut line. +static func _line(run: Array, span: float) -> Array: + if run.size() < STRIP_TRIS: + return [] + var zs := 0.0 + var pts: Array = [] + for r: Array in run: + zs += float(r[0]) + for k in range(1, 4): + var p: Vector3 = r[k] + pts.append(Vector2(p.y, p.x - STRIP_OFF)) + pts.sort_custom(func(p: Vector2, q: Vector2) -> bool: return p.x < q.x) + if (pts[pts.size() - 1] as Vector2).x - (pts[0] as Vector2).x < span: + return [] + var prof := PackedVector2Array() + var acc := Vector2.ZERO + var n := 0 + for p: Vector2 in pts: + if n > 0 and p.x - acc.x / n > 1e-5: + prof.append(acc / n) + acc = Vector2.ZERO + n = 0 + acc += p + n += 1 + prof.append(acc / n) + return [zs / run.size(), prof] + + +## The top of the side glass between the lines: where it meets the roof (height and x). False +## when there is none, or it stops short of GLASS_MIN over the belt. +static func _glass_top(d: Door, mesh: Mesh) -> bool: + d.top = -INF + for s in range(mesh.get_surface_count()): + var mat := mesh.surface_get_material(s) + if mat == null or not mat.resource_name.contains("Glass"): + continue + var sf := _surface(mesh, s) + var v: PackedVector3Array = sf[0] + var nn: PackedVector3Array = sf[1] + var idx: PackedInt32Array = sf[2] + for t in range(0, idx.size() - 2, 3): + var c := (v[idx[t]] + v[idx[t + 1]] + v[idx[t + 2]]) / 3.0 + var nx := (nn[idx[t]] + nn[idx[t + 1]] + nn[idx[t + 2]]).normalized().x + if c.x <= 0.0 or c.z <= d.rear or c.z >= d.front or nx < GLASS_NX: + continue + for k in range(3): + var p := v[idx[t + k]] + if p.y > d.top: + d.top = p.y + d.top_x = p.x + return d.top >= maxf(d.belt_front, d.belt_rear) + GLASS_MIN + + +## Surface s of mesh as [vertices, normals, indices] (no indices: an empty array). +static func _surface(mesh: Mesh, s: int) -> Array: + var arr := mesh.surface_get_arrays(s) + var idx: Variant = arr[Mesh.ARRAY_INDEX] + return [ + arr[Mesh.ARRAY_VERTEX], arr[Mesh.ARRAY_NORMAL], idx if idx != null else PackedInt32Array() + ] + + +## The outer skin's x at height y (below the belt) at z, from the two lines' profiles. +static func skin_x(d: Door, y: float, z: float) -> float: + var t := clampf(inverse_lerp(d.rear, d.front, z), 0.0, 1.0) if d.front > d.rear else 1.0 + return lerpf(_profile_x(d.skin_rear, y), _profile_x(d.skin_front, y), t) + + +static func _profile_x(prof: PackedVector2Array, y: float) -> float: + if prof.is_empty(): + return 0.0 + if y <= prof[0].x: + return prof[0].y + for k in range(1, prof.size()): + if y <= prof[k].x: + var a := prof[k - 1] + var b := prof[k] + return lerpf(a.y, b.y, (y - a.x) / maxf(b.x - a.x, 1e-9)) + return prof[prof.size() - 1].y + + +## True when a piece of surface centred at c, facing n, belongs to the door (inside its lines). +static func is_door(d: Door, c: Vector3, n: Vector3) -> bool: + var t := clampf(inverse_lerp(d.rear, d.front, c.z), 0.0, 1.0) + var belt := lerpf(d.belt_rear, d.belt_front, t) + if c.y <= belt: + var skin := skin_x(d, c.y, c.z) + if c.x >= skin - SKIN_IN: + return true + return n.x < CARD_NX and c.x >= skin - CARD_IN + var xb := skin_x(d, belt, c.z) + if c.x >= xb - MIRROR_IN: + return true + var k := clampf((c.y - belt) / maxf(d.top - belt, 1e-6), 0.0, 1.0) + return n.x > GLASS_NX and c.x >= lerpf(xb, d.top_x, k) - GLASS_IN + + +# ------------------------------------------------------------------ cutting + + +static func _cut(d: Door, mesh: ArrayMesh) -> void: + var body := ArrayMesh.new() + var door := ArrayMesh.new() + # (axis, value, side): a point is inside the door's slab when side * (p[axis] - value) >= 0. + var planes := [[2, d.front, -1.0], [2, d.rear, 1.0], [1, d.bottom, 1.0]] + var x_cut := minf(d.top_x - GLASS_IN, skin_x(d, d.bottom, d.rear) - CARD_IN) + x_cut = minf(x_cut, skin_x(d, d.bottom, d.front) - CARD_IN) + for s in range(mesh.get_surface_count()): + var arrays := mesh.surface_get_arrays(s) + var cut := Cut.new(arrays) + var idx: PackedInt32Array = ( + arrays[Mesh.ARRAY_INDEX] + if arrays[Mesh.ARRAY_INDEX] != null + else PackedInt32Array(range(cut.pos.size())) + ) + var keep := PackedInt32Array() + var moved := PackedInt32Array() + for t in range(0, idx.size() - 2, 3): + var tri := PackedInt32Array([idx[t], idx[t + 1], idx[t + 2]]) + if not _near(cut.pos, tri, d, x_cut): + keep.append_array(tri) + continue + d.cut_tris += 1 + var poly := tri + for pi in range(planes.size()): + var pl: Array = planes[pi] + var halves := _split(cut, poly, pi, int(pl[0]), float(pl[1]), float(pl[2])) + _fan(halves[1], keep) + poly = halves[0] + if poly.size() < 3: + break + if poly.size() < 3: + continue + var cen := Vector3.ZERO + var nrm := Vector3.ZERO + for v in poly: + cen += cut.pos[v] + nrm += cut.nrm[v] + cen /= poly.size() + if is_door(d, cen, nrm.normalized()): + _fan(poly, moved) + else: + _fan(poly, keep) + if keep.is_empty(): + keep = PackedInt32Array([0, 0, 0]) + var mat := mesh.surface_get_material(s) + body.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, cut.arrays_for(keep, Vector3.ZERO)) + body.surface_set_material(s, mat) + body.surface_set_name(s, mesh.surface_get_name(s)) + if not moved.is_empty(): + door.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, cut.arrays_for(moved, -d.hinge)) + door.surface_set_material(door.get_surface_count() - 1, mat) + d.door_tris += moved.size() / 3 + d.body_mesh = body + d.door_mesh = door + if d.door_tris == 0: + d.ok = false + d.why = "nothing between the lines" + + +## True when triangle tri may reach into the door: past x_cut, across the lines, above the foot. +static func _near(pos: PackedVector3Array, tri: PackedInt32Array, d: Door, x_cut: float) -> bool: + var a := pos[tri[0]] + var b := pos[tri[1]] + var c := pos[tri[2]] + if maxf(a.x, maxf(b.x, c.x)) < x_cut or maxf(a.y, maxf(b.y, c.y)) <= d.bottom: + return false + return maxf(a.z, maxf(b.z, c.z)) > d.rear and minf(a.z, minf(b.z, c.z)) < d.front + + +## Splits convex polygon poly (vertex indices) by plane `plane`: [inside, outside]. +static func _split( + cut: Cut, poly: PackedInt32Array, plane: int, axis: int, value: float, side: float +) -> Array: + var inside := PackedInt32Array() + var outside := PackedInt32Array() + var n := poly.size() + for k in range(n): + var a := poly[k] + var b := poly[(k + 1) % n] + var da := side * (cut.pos[a][axis] - value) + var db := side * (cut.pos[b][axis] - value) + if da > ON_PLANE: + inside.append(a) + elif da < -ON_PLANE: + outside.append(a) + else: + inside.append(a) + outside.append(a) + if (da > ON_PLANE and db < -ON_PLANE) or (da < -ON_PLANE and db > ON_PLANE): + var m := cut.on_plane(a, b, plane, axis, value) + inside.append(m) + outside.append(m) + return [inside, outside] + + +## Appends convex polygon poly to idx as a fan of triangles (winding kept). +static func _fan(poly: PackedInt32Array, idx: PackedInt32Array) -> void: + for k in range(1, poly.size() - 1): + idx.append(poly[0]) + idx.append(poly[k]) + idx.append(poly[k + 1]) diff --git a/scripts/fp/traffic/car_doors.gd.uid b/scripts/fp/traffic/car_doors.gd.uid new file mode 100644 index 00000000..d7594e06 --- /dev/null +++ b/scripts/fp/traffic/car_doors.gd.uid @@ -0,0 +1 @@ +uid://dvmgk3p8wwbqu diff --git a/scripts/fp/traffic/render.gd b/scripts/fp/traffic/render.gd index 90f891be..aba4ea97 100644 --- a/scripts/fp/traffic/render.gd +++ b/scripts/fp/traffic/render.gd @@ -6,7 +6,8 @@ extends RefCounted ## rebuilt only when the parked set changes or the camera moves. Brake lights, siren flashes and ## wheel spin on full models; after dark their head and tail lamps light up, the nearest moving ## vehicles throw headlight beams, and the head and tail lamps of the merged ones glow. Responding -## crews near the camera wash their surroundings in flashing siren light. +## crews near the camera wash their surroundings in flashing siren light. A car with its driver's +## door open (the player's) swings it out on a hinge cut from its body (car_doors.gd). ## People (the legacy static bodies, until HC's rigged ones): every distance draws the merged body ## through the people shader, which swings the legs and arms from per-instance stride data and ## dithers people in and out (spawned in view, or at the edge of VIS_PERSON). Same geometry and @@ -17,6 +18,7 @@ const AP := preload("res://scripts/fp/asset_paths.gd") const KIT := preload("res://scripts/fp/traffic/mesh_kit.gd") const VH := preload("res://scripts/fp/traffic/vehicles.gd") const WK := preload("res://scripts/fp/traffic/walkers.gd") +const DOORS := preload("res://scripts/fp/traffic/car_doors.gd") const NEAR := 2.0 const NEAR_OUT := 2.25 const SHADOW_DIST := 3.0 @@ -353,6 +355,10 @@ func draw_car(c: VH.Car, xf: Transform3D, d: float, vis_end: float, brake: bool, c.lod = _last_lod var lit := _glow > LAMPS_ON if c.lod == 0: + # A pooled node keeps its cut door: shut it again for the next car of its model. + if c.door > 0.0 or c.node.has_meta("door"): + var m := model(c.model) + DOORS.pose(c.node, DOORS.of(m.key, m.scene), c.door) _extras(c.node, model(c.model), brake, lit, siren, spin) if c.lod >= 0: if lit and d < BEAM_DIST and c.kind != "bike": diff --git a/scripts/fp/traffic/traffic_feature.gd b/scripts/fp/traffic/traffic_feature.gd index 3061197a..d86f7c0d 100644 --- a/scripts/fp/traffic/traffic_feature.gd +++ b/scripts/fp/traffic/traffic_feature.gd @@ -23,6 +23,7 @@ const PPL := preload("res://scripts/fp/traffic/people.gd") const CS := preload("res://scripts/fp/traffic/contact_shadows.gd") const OCC := preload("res://scripts/fp/traffic/occupants.gd") const SHOVES := preload("res://scripts/fp/traffic/shoves.gd") +const DOORS := preload("res://scripts/fp/traffic/car_doors.gd") const MAX_STEP := 1.0 / 30.0 const CAR_PREFIXES := ["sedan", "hatch", "suv", "taxi", "van", "pickup", "coupe"] ## A jump of the player beyond this in one frame is a teleport: the street is refilled around them. @@ -1253,11 +1254,13 @@ func drive_car(pos: Vector3, yaw: float, v: float, a: float) -> void: 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. +## The player gets out: the car stays where it stands (its door shut) until it is far away and +## out of sight. func leave_car() -> void: if _driven == null: return _driven.driven = false + _driven.door = 0.0 _driven.v = 0.0 _driven.a = 0.0 _driven.vel = Vector3.ZERO @@ -1280,6 +1283,32 @@ func seat_offset() -> Variant: return render.model(_driven.model).offset * (s as Vector3) +## The driver's door of car id (the car the player drives when id < 0), in the car's frame (+Z +## forward, +X its left and the driver's side, y up from the ground; world units): {"hinge", +## "handle", "seat" (the hip point), "rear" (the rear edge at the belt line), "open_rear" (that +## edge with the door wide open)}, or {} when its model has no door that opens (car_doors.gd). +func door_info(id: int = -1) -> Dictionary: + var c: VH.Car = _driven if id < 0 else vehicles.by_id.get(id) + var m: RD.Model = render.model(c.model) if c != null and c.model >= 0 else null + if m == null or m.scene == null: + return {} + var d: DOORS.Door = DOORS.of(m.key, m.scene) + if not d.ok: + return {} + var out := {} + var pts := DOORS.points(d) + for k: String in pts: + out[k] = m.offset * (pts[k] as Vector3) + return out + + +## Opens the driver's door of the car the player drives by amount (0 shut .. 1 open); drawn on its +## full model. +func set_door(amount: float) -> void: + if _driven != null: + _driven.door = clampf(amount, 0.0, 1.0) + + 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 diff --git a/scripts/fp/traffic/vehicles.gd b/scripts/fp/traffic/vehicles.gd index 5241fad2..beef267d 100644 --- a/scripts/fp/traffic/vehicles.gd +++ b/scripts/fp/traffic/vehicles.gd @@ -94,6 +94,7 @@ class Car extends RefCounted: 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 door: float = 0.0 # its driver's door: 0 shut .. 1 open (the player's cars) var dead: bool = false var arrived: bool = false var stuck: bool = false # its lane vanished in view: frozen until out of sight diff --git a/sounds/fp/car_door_close.wav b/sounds/fp/car_door_close.wav new file mode 100644 index 0000000000000000000000000000000000000000..864d20e90ba27082781b84801d7a415e536dba75 GIT binary patch literal 79424 zcmXV21$fim*KX3(ytG)lQ8&N3x^iko z6$gNt;9=L?4AyXFypa&w;kKKtu%_KBZ2f4u!(^Ud+wq^~JoT)u3}s>$q~ zdF!+E^O{eUANzm2@}b~8`7Z3;){MVz{ok&Alan5vek85+b<*qSul!%VdpY-|^kr)5 z>eP{`T~ei~*(obh>{DL981N$R`MT$!&wo5ieD?Ke-{hE^x?6AWZck=XCVg~!e==p; zvjeYweT@CO{maJ8@K1w2cz=xklJX{tV9RoE!iBSZ0SW3o>Kh*W^gy;P&dQ!HqX;Ggk7UVj<+Z9}$GPHMiUWKH?qigndZ^?RE#o9r9U*Q*+? zGz2uX)TPyqsoh+c*W|2QW_Chu@cIhe`GLF=Za8-a=P|EJYIPXr-ri?m`^N$N&<7D0 zq6Wsy?ewmbI%a?Ps=#&L8=Y^ck0|-dP*sEaw&QyjcehWjeOx=ZM!N2D>+31hcHX1dh2HT22E9$;LjJEh5K z8>o4!y{9iTJ~S<`wr9QPWQZ0jyzE^aX~%Ak$qw!8!_@KW<@TT2J#pUd_Q&I(*K6;` zK6$G-|t)40qYaXr`d%IkfuFV+9$z?j6mB<~@3;;2Cz z`+w{8HO|<1fApd7=HQ(H)q3W<6;jxhcG1;Aq zWBEOqJ_83l85la)Ja~HIsl-c(w-WCp9!wmZ*f4nO;2DD}1_vZL4hwWK(EM(D{HU|I;1Lbnp%c4tf#vH8?xWJ<6pc+Ep4q ztiLredc^(Fv&OcK?Kbw}=p7?h44azhpD@4gn4UvpC7nDYuZ3g;ENMT&XPRfe+a8xf z$8`Jtb}dS+f~UA8cU3qjOO&{BrjnH3k-Ces1oL<(CmSVM!!Rl=7W%Z|S)2kQ- z5{f=Um!NZzzl?@{XI*cZV48}LG1%#jwlz1$Htwp+t{z+|E7z2oiU$StW z!VhcS|9;n+@%SzOO}DfcuP(oA_tNR*jF+!o*1i1oa@fmhsZJ@IpYMO#s^X^OW4K1O`1``q+R{zLKgK-Q+Lv!BJE7H0 zaI|z&MfZw}Rf`(P#)RtT($6JXe;?$g{yy@1#GmBc=Xui#x0l>4dsceCWMuKrzvX|A z6ry>ne<%K&^mWW9m-kEGcl+r1xpx-#YtOHdSw}v#d}#cz=yT$?u{k%3(`x3m^6~Ce z3c}$Hl@79dqqZtHNiPUXxnb<>2#(A_i;#3y0)x@(EiC-Ewz2hRGto#k9Bo)tzoV|D zPSSY1b*SN!Nn;5iT`Y4={fRHcYqJ;C51Gcxle|`?I4GTtxtQE@y;6Mq{6_n=`80Z# zyOukyv1e2c$|~7x@eG~|VzkuhOIp4+DC@O#ruv%3;Fei!*R^*Ie@wfm{>TZ=Pkx=y zS^QpnOfp;+rwp;*?8I>^bARq`=l0b(%(2|Qw|bA=db@>o?d;N2^Hd%xnJQ9+E8i#< z%LvghUSISZeVLqUnMnH6?^r{yJ3NW#inNC^-d^M!?h)_n9q=WjOXS^-y}K#mtNVl` zbQ^)Q|a-pn)v{>?6QX;u8sSvLaRS3%Y&VpKD zC+QburNa}KWRK0>Lf;L(^L)qn&h|ax`@vW5+u5(s&!@x8fK9=cuFWdO5Fe_ejlE<;ufkm^4gsL%db&A*qr2D>)8+t`_f%fG=S; zqXRq7?s~eLb8Jj3*==UGIbF*;-|N^IMTFahb_$B}$9?B{9dS!`if}k+M=DP%H>(z_ zGackk0+;QsOWnBc7u@H2O!bWOs_^RPz1O?Mdz;T=-_h;;{9gpT3v3@$7w8n|6fo0& zq(9}a2y_g(5tJBY3#jT~@Ats#yW47~d+MF?1W|kLY~(OalP@jjEmO#&)@xJ}{g7tc zPTL+ZOOSMS5AI`rk?^#*LXs(+AX_ifNMB3xM3DkFt_fAJNV*4IPoH2akR)s(w~S}x zrwIlL{|KjxETZ+|f8t2^T{lWoWbuk3rPS`W-7~vryMC&k%B2dKV!UFxGQ`fyzQtjv zV`ryJPBWeMI6AaL?PF9wWG>=E{1KcB=ss4fZ3o?r(vll3MJ7Muow3Dm$53Y2XiUOA z36aUwoMbskzO!zo&QiGbB$;NuL!2=-=v#GHb*VawuBU#wzMH;8H%^zL#k8|Ef7|@q zCbnK|u{IxUPHJ{({@XOa>2~A1hLXCt+Hcj_Rr{-qRkhXKYE$d5hSr9ajqe+C8&5Y{ z8`|n8)^)5oS()+ATr#&Pu&`_XrrhT_$9{=_j{Ej4>+a|7pRRuV@bSc_w$Ep>7Jg0q zuKsy9JNwt<-;;m8`L!lH^~aNMcfT}eUi|#{6Zg}OkK&J;KBT-)ez!WK&)c4F7Np-x zvrqf?ntr`GZE5=1H+SEz%BamK%;=Wk_x5}G;Kkj9J|GnbR#oWz#?ej0@Q~ARSUKRuuZYn%fIH+(#fhiBkHU9pSz3B(> zbyU`mPn$pNc}KskedGLQT>84SrLTit?MprKV*RrowdN=Xo(9Dxxxj)DM9-Ui||GLof_j!>+vAXzJ(e1y-3-=YQ z&ri$?&h7u_W=_`}>+cW0ul@e>dty$~pVzsY^VbzVDT*%*Ebm!0s&;fkk7jP$No|h) zno&U{n6{dKSq4}QREljIt226?eI4u0ImP+OVR8F&A8>fEid+r!xHabvrO_CVrq#?$eFo+a@U<0tlLjth+q z?fSP2PUSLkJSGELa` zc?tg#UL_1kSTi8L|CheXKEA!&TEnx#lfyTJ z`-i86`G?I2ogLyDd@fKCaIAx@{Yl?0K8;>mJncN*x$SaY?Q+8TyOXEW6~{=&^mZ}r zPCFFaBlco7qxz>jsc@BFmikIgh^&H>{MEdRTpMQxCzg}Mxx=yJuI1))J$aLP2Y45F z$6>jTw-;;$FO-+cb?46IoX4KCPogo%E2fXlOfl9uRxfLu^@Vjd6+*Yr$8B911GArH zVJ$&Q5h?17hNC0UndmHZJQ{=+A`1}>E0#5mnPQtmAEFwp>#WgM6ZxFnOpYWS$#<6C zmfPkglcQ+}u?N3oJZ3ncf1z{MWoln)zi9txbF^=@TeU9Qmzq_YiJGaJ@tP=&t?fhG ziZ*H6MFEtU3s~bZbd5!AEm5mNfB~5ReKeptz*0 zZs=|7h1(I2h@K|W^xiz*!X{6XPS$f)H)=2ShDxEfQa;onYX(_jq0Bz!S*EW<2(bdc zXKXceG;GnA>n7;_Y3FL&X)820G|M!LHP+|cHYXfWd)KJwss%6z@t3s;kEB{v3 zS86NEE1y+vuN+@Fv~q7HQgyp(X0=nz{TgL$V(q-zwY4j1`_+nS-`C8qNvIi7)4k?z z_1NmxswGu_DpM;~mS_Kq{KqXTDw$MrswAW|x3q8BkFw|g{*+rPT&tq0qiQ6zPirIV zPS@qt@#+)mPt@D$XEp3@nAX5;*i@fa=UBJ6wy>s0&Fkv8>a419Rfftdm9s0OE438| zD?}9=%A5a9{zsHOD?3!SrEE`GdYSXT-T&C-56cf%oUQy+h1NvYcC1s?m)DxR@VulraxuYP31n8qbd&zgN( zkF>qiJl0;*t~LCePGKFt%G-!{)}nb5ki?Ulw&_ffyrNZ4qeW|BEZLUPmd9q?6mPmk1QNOURs1;q1V&3H@zC_l{Ld0@y+MWA?lP6E zZ^&LWfz89-W2ZUCxTkq%_@@LHgJ|;Jd*$ zVZAZfCuCqqQb=q_K!{gJNXWR5iyYsEDM9zz9=#YWSA$@!>tg6T)|gH--<5 zco@M%#78cO+#7i!@<`;d$n${bpCif0h^YBd529Gn!=txG?~C3RJtU-p- z$Wf7Ru6rkYRd{-O zF7U{3=ej4j-FCHiUFY)ES>oK?X`o|tJGn!R`ifm2l|}JEenz%hI$knRJW{kl_*UQ~ zILPPock!G6RXcLea!lAH>@Rx-+lT!e6{4YtFAHUA>EG0Q>sJ!DL|EpUH=8aKQos-~ z{=#^~*wdI}7;m_%Kdu|04c827d)VsGI<;k9b4*hK%-s!jnYCfHuWBaM_|>Rt>Z-3) zZ>~O3t*bs()2p_zc6%LGKeT>c{jB<=`oMZ&eRiYY$JPnz&(}*DrZ((rnALzboT~3xZ>qae7hJcy_D0Ro>KRq>m0c~AGngWEc{IkwfezHZ&yn$+suinNxtylOeqvbW`Y%eNLu>x9(9zf)uf$go%|x7Ov+1_!ylIFjkLX5h!tWb(hM5Kj1INHKQ2G!09{O`SJKbY# ze{Hd5yQZ(kL1Sq9+xEWgSzA^crkSMqrs<%aq+O<6uAQbGu3e%{(JFOYfSa$;TlAX@ zh;f1Ok@2_jpYgMCyRoa0Fx)mwHpCjj3@Ss7K1F|6KSw`6-%CGAzf)hLA8)W3o*7r; zqlj3O!klB?WeF#<$d1;{*2mUjE1Pns>`dKZ;07F^8S;0BV$>3->0?KU~ibz14X-Q|?)J-3_gYdr!z z(>xu$61|ps?ef~=HQy`F%iXJ;m$R43i}oyoZKph^dpdYN_Za3O_t3fjbN}sL>mK59 z%_G9|y=SafnpZFH7v6|Z7oUYb$9!)3-1oWTbIIqd&tsn|AGxpEx58(!PoZ~r@AqEg zz098LJ)1q2dZ<0#x=(YLyJxwrckAM2?ubr4+ON+|{*9=z&x2bL^ZhXMEKW^XMXtzZ7)9&f+FWfJ> zUw6-N&vF0Yz7@Xfv)d9kp4$!A39b&VnJ#->y1FiOx4IWP?{kQE$ZL1PsmNLHT;{BG zk-J@UJ?+xs{LJ~2^F-&(PPvY|9OpXrcBI=~Z`ZNi0|%AE7JEWHUtM4qVz)x|TFF)R zSL~Hn$cD*&N=HexlHC$}$t`g=aiM63Xq2dnNG{41ZWp=<9}0R2aQ-j;8~y|SdHza% z5I>u@m?!4l;`ZZ`oY$QB915F-rLgVTdr$;jhqSRKv;Hz8n76h7TN=HX?o7MUK6E%8 zO?QF_yB93|p~sE(rQK;BT}wTsc2c{k+tgcNhR3OJD$P2?T1_q{3Cm7EXOlV2yuut| zwwZEGH%-e-{-#$%FmVL0G=>|`8XOJD`U${J-s^hnQnhhfjphaLML$hdTXNglw%%=C zZ4Pa2ZQa}EwQT_=+q12VP2HRF8n-s~YxHY$YwXmxpz%iI=f;nX zsg3U%n;P3U&1kyR^ruPFRNeHnX<3tBQ%2*U#)^iOfSX(DTk59Q@$1TK|JF9tQng5( zruKX7>Ds}y#+s8g(KXfJ6^2)9tM*iRSEW_}9>Qhyp>d)1^YcgtrYLC`3wQKA6^&jf*G+b!h+BBtk zc+0?6zqZ=8i<+TYoA!>bm;SBZ%dpY#)u1viGgbimK8u&)0>X*#Ai{_wVk41C7zhtj zu*uoPHfe}_;u*1*7(iI@TX-D)-8ju?XKXT58MFqW(cjqLIMz7JxX<{~_|5ncaI(Ny zX3RByF{T-h8&F_4Bh z;|}8wqX_SakHdH3890X+LL4E|i4uY!)TSUfAGOI!;DpSSWV&H8m?F)qVJzv)o|XZY zDV7D66@a71EEg@;ERQU|Edq#=9+PtGLTi@QotjOZq0*^R%0zK#AuXeoG>6v0`w!GP zY8f?}N~A_mlc@#NX6iKc2=@F=vFMo)qph_e%s$4Bb%G^Eb|EOb0xdv0uotkC*;;lu zwg|h6RfCE=l5>KS!x3?NahGuqanEqCaG!C1a$C4c-ay_}o`k=RZ{QCXToW`1f`oI0 z7lpY(7nqX@ai;jFWSG<}-66BerpTYkxr%v;KMGf6yfQ)AUFoZoDtVAYu_-DQ&lD>a zZi-}ikUUw|Sym~1CVeDLmwuDBNP}f-W$$G|`Dpnyd7HeuVzc6`;-cb~;-i953{hTE z76Yn>srsoVt9GbftN3=a?Xv9L)RWX3)qB)u)GyR!YNdUD`?>au?Wfxhuy?oDs|(dR z(A%aa)U;Y+?`xlEf6-p(u+c%|FuPq@yRMGw9d9}QbmTY4DQzr>jnzpvTY2=2+(V!SSBsDaS32(;d4yN*!z4eQtNI-H3Li!zG8_ z4wd#B?A`3Ys@JMx)t+iQwMgxx9{`}E2ET$6qJ00Tqj#DGfVeK z-J~g!u@Z?SM|?>l5odYa&a*dcpK%if!9$-EF8X zgWdpmE~ZPV8`LPO(Ynm4uzn&}ksZhi%UR1fO9uZFUb9}) zOGCEZZyVWWZN1jor?s)=RLhVShnDK*XU)f&w>Hmdj%n66oonjS)C%bTsPSUsUQjpY zHcn_vXpC<3ZM27+LUzN2h9wQ78b&tEXxP<|(ZFj=Y~0;=7h><6#_$UFb3aYhZU_t);=OaUA#^O=D!! zil&SvyXNK170n4P7g`Ei8Tl}8Kp_m4AIQg?9klQeAnnT4%%+ORu5=DYNfjAx_3Gsz|r4& zN5g!>7ek2gy3qr_fQyJ>#2jKiv5eS6oF?88t%TI%V)8Wkn7pCIm}Z+^nw-pA%|&LZ zCDby}a>b&vj3B?0eXVKM0O~MRNjcGR^l-qyHS{)m8Qq)4=q&0KwU`=8MNu~E9cwph zCOLxCz$cemW?9ApPmKic?rULNO3bO|^XBd5>EE{RT559et1fMC(uiyB(WBzoQ3XKFQExBpGJp1Y`&@8QG3JM~uh- z^a1M3KFOA1^RXM)cg%o!aE5X=an5nBbMA2-a;`xBup5WXDaPJlH?hOmc5F9x9m~Vo zah3ubpTce9F6LG81_RfM7CZ;^-zWSjnidQu$k16Ant%_p`KSi2+rrbx4!|`s)7R#b#i0mKujuhz~>02o+ zO_Dv3$>me!$K+4tS@N&)ck&nVyYj2@JM!;xwjxY15WLbMMY7_%LaR_H{gpkGBb1Yr zla*7IbCk=K+aMZvuKcV_S6)!gQhF*Y;5@f0rYVL)4rROIvjS6wD-)H&!KVemR=Luk z$Wx?%huWi2$YZ$0+zf6q_cr+EUt9!wCh^wt4)ad&j_|hf7V@U>X7SeZPVk=avU$}!J&)mG zd^0bfcb~VPw}dx{=gFhFIxYs8CR5`&3w!hxXi2H<+-IDxwpqlt?| z8_^r0=SEWp^F;G*^JVj0^CR;MU{$ZpDPURVdNXZCER5M?E`{Dl(4T9znY%(fT5TCf zz9L<%3#|97RiFb8ra znGpFGwS8)i%kReVY9sFDGqgHRBLR;7!8j@a84W-3TncDU_L zo3_ngGhdUY;eZF5q}`#tul=QM)mpU_FmRg|*VckU|4Ew-8h9M2ux$N&eTE)2!~+vf zGc*_&1INfW%8X7%7o!5!)W(j+DZq{|7_S&l8h07j8P^zh8qXVF7)y;pJOZBv%)Ax% zC594z%TaQBS) zr8&>6H47}>mN-yzmst)0hyH63lL_Q8vYZUD?zXmB$3jGBM^A>ks-rvER@rXZe%Kmq zRvVA8V*;1~%o652^N}fMYM6W`gGq&!$$VixG0&O9%oHY?ku!v?6|B)#Zp*WMvOTrk zv%R#H+GI>`W)*XfDPpXQ3#$ifJZm0n6YDhVIcx0UT>Ji zqaZtTmG=SWcn;VX-Yb~j*LhcXmw0D*$9U&>_h9}%<~;;^!F$X5$os=%@%!>m@>}>L z1YZO_gfE2wqCKKQk-K=Z_<;D1I8~e?z6ROz;bKQ|mFSh|oM?+^f~bS2MfgOxRyau5 zQP@kkSollpvdf0 zRjOj`4%lVevDLonK;Xrgy3H=f?y=orV7guG1a|*aX{u|W?VM2^RjpTzh3W;FO0UdQ zJ^@W*J>FvS@~og!ShLU~vDLs_Y8RT4_Q zvPk(@xn9{(saL#K98`=`I4cU}C**_VwCt8FQASChOV>(=Ne4>jNN-4u(gARNEcqDu zBRLDMV*_}Le27#clyjBWm37Jp)fQEj%Br%nbFuTb3%852>t)x?F38T=PH1OVHL0pp zrK$qeXSjkzDqqzX2OPc_EyuR1v5MS9mH=MV|bqyf-lUv$CGR z<ZIm0*TI$KTJ7%z0+Rdeq{JQvB; zLauKDN6jh6USk)rt)Ptz!on~G*2>OgUtzCi_hrl2x#)4=+7xmXxPuE~W&MT`I-3>3 zYGy7mBbjzgIgIsfwi&iqo7h%Hr_iVA&Gc+~2xuuDG);X0y<{lmPa#yH^||$=b&Ykl zb&7S8btZ7|m9XB&Dz$zD1;vfbgRIL$ONfO7Eb9^Qw}EDSd`V-TL<-`VJ6)}}a00p%RV8u{i zjt3y_ZXx6lbN4p&GP#;ch#iDGk&e&9Lvbn2#~tua_)N(2JjUPS-{5%1@i};J+z032 zjS&5RhWNh{BKa_g<=t_;@d+pdUdAfJb;DdkXV6Y{`ak+K{T=-g{c`7q*8h1@^(1NaOay49SckKpk8X&4n=cRMe zS+rlZ=Rw6CsP)#8nlG9oz@mdSDvd-V*Vt*4@G69N;gAD82#T*%o2cCl*-=6p3OdAd zod8taZ+cJQ&vy)^1|Db{J&h9qZSTW%4=zegMc#=ewTNNgig2$JXySpC6dGxY=}{Lk!c=>tl~Qpmj> z1Le5I(uv$p7DFC3&e{chX&$+S^d!GPZqD8E+k6t3^bGT8@Rj~%rI|4O2F%|Mynmc& zvT3>LI52V{Y`tw}S*AfAPeJx2XOrv6UE~q+Cixq5ld0Bp@amhXe9E5gPLHQI(Rb+_ z+DyActT@Ux+qTYj4lrDAYtKw#_A|+V_4!OSBLIB935@&@qCiiga`qZ_4%-h~hCRnx zK_?03bl^B~WE?IBg=ILfpn06doJE`&oDrPFCTFUo?~RuL+;7q=I8689GO5QmGs#javcagaD(JQ29#GjX{Xk$6bDNCrwqOD0H$ zNP;9L@jLNF@nvy_SR)RRtdKmB)JSB~2x%|CvGLM1(o@pM(ky9()GBQ!i<2#qotM3m zWy-Q-X|hYQrLx|#U|EDLNw!t?Rpu#QC(i`jjs|30qFAF?uUM;CtQe~ZQZxew9+e-I zpOoJOoPQ&~FW)U6EBBJO$le0`y(oJu%a*muB=RtMl6<~=Jtz`Ma(j6p#0A4-BH34n z6ShdVO7}`nN^eS^NS{jY!|P?~G3gHJcIiRs5!kX-x=FfGx(?X#RB3`VR4SAfNX|;8 zOFBv9k~;B6@e}bw@oR`Y?BKf3Oa4eq5<6+Iv>z$78X@9}z6Y^8y&HW7djai&d95JUE%f=pJd$H+QM+^nM1nKftPU(LtCgt&-crbXXWlY*nLWT( zIy0V(JtJnUz*U~x*4UzLW{5d20h66e&!VRTatF{hDhE{V?bISr4hI5y)7Ec*-Se$U zFdw6=K~{GwLuSK#8bgMXYEnYlkE}9y9@ONJ@ni4srm-Jn_+}utKpU*(@<-$0GqWKnhYgSm5^dMZ&(l8f*?EBq%Y8C z>fh-L^%BDv@C+3OnX!v;Hn7@1M#kulcL$Gh0#60+l8fi#g^=%S#p~hSHGDlj2Jedd z;cA?Rb8&k-0Pl?V!V~Zz_y~L?J`5iK@BQ(1IEJ_Vr$v7UWje=L3?8W#)b3(qjS+=U zC*hm$+xTa^3bz4A_8}s`XL=Dr!ho0HukizLEJwV;_|$mHxEu7Wwcrn~7&D9|Mw>Ad zpMjqN|0yT>f>%97JS1KbFNr6B=9h@0fZK~fL-!_haFiWz6knW#@B9o$x@9f=78|d;lKfi?Q0s#iQ_r_h&;5lf^O)(cC7zlA@9 z`9hu0K@=?-AQ~^4FIpqo1wQ2p=rmcPdJ!USCk_MeF%3&eB8GsRQIV?p!j2EJ#E zc)9qf_yrupDwav?BvJ_n{7$VnNBl;7Q@mF^Up!RY5pdKf$`_@IPK#EH#)zUs0#SwV z9jFT@ghxSPxGl^OmJ0PkT8N26B0G_b$V(Iq`qC)TRM9kOGerwUOW;(z78=0DHy6z!3bZ>jA3FKA7J73j3eN>KQuP;*LKIY4EVU*)=K|`s^VkxM$kFuLwq}oPNK&E zJ6}a_1#M*~w2ky4=ue>iXfySVxbI!2%^O7QRIb6$4Mrht2cN zNuZmyH@7p}fl{hAiy>3mW-0*ha1Lr6CIExvo9c*P#8;x2pa@@60(gt{rd^=K?=o#Q zZ7{7hZ8YsR9R#ii74o3<(nKNgoY+T<0*$^H&~q7J(g1ugJ`rDopTl1O0{#I#s10Wm zjzka<4H-Z$(1{d;fH2`jcnW?JJWK-af@_SK#yiHdkkvZ^?X>Y6AoUgF4bYn}LGLN! zVdGZga^pg<#gJ88Zd?k>mC)uJ=R(gq$Pk`0-Uh7u3jCNb+5ta~1Dp=W193N8gqw{` zfX>ZElaVy)j8(=$$Rid5djF4oHybt3r!%&|D+A0r6d#CB!Dj)#9)XXAC}bJF4&RFJ z!H+;5^9p_)zXR2Tr8tGFi4K73;Y29JH4}+lL^Awptpo-Yli|R{$Adz4!1T=Y7gT6I z{L-GllOxS>fTx4aL(QYilgzUrPrcK8*8Bvt5zNxrG7qXW(k;ani^Y-b2={d@d4>E* zT1XG@_d~7YtP`wbtb?uHtzlMQs|Qqi23zB;W34N!hoSzXz-qO&qx`@;C>P3^a--T) zG1LHR9E_z|)J#~;1Rk*svXPIdchoPcjB24!+7op5+4LSzT;9@|bO!whP<#iyl3orY zbs>z^CG-jyr5osju*YY*ftJ}KZ6kr1U$=b&X6_3-<|OlusRUg`#`0ptz^s_X+Q2%* zy1=@_dImY5e=M5i33F!-vJE+fJc8Vl4iTe%Xe^otm8iqe;pk{|B$@;^7M%xd>LK7i zgZiJ{&oR z#j%3NP;z`ZojLu$7YyVia>hZO$|l(J1m_m#1E+#RawJ?AZhLsfERH*Xo5&r^9SELb z3U@u^RG$Kh7`ZOIzP!1-jl4ZjZ+ipOoA105UL()K6Y*X7!Tg>OBP`+X;a}!I0cG|p zzl3k&hk`nLTJTO#ClCumgbBjQ!ujAE773>T!gU2U>LFAMWkQjVD`X1=u63h*#a&`r=q5F-c^LAI~mDwgr-JFSMshr6!D>aa4%iG0Q0Vvn(N*hXvu7KQP!Ldc@; zWKUstVXN3}=r8mYdK*299)WST3aX%o0NOjDW~2ytgIqw?B4d%Rh!a8pE4j*A$r{S) z2qRj?VxWS$4wS=eP#WKZ=6H`e$82M!Grbr;M!+<~Jh)<8ZcDPo*gR}JTO<90ehFBA zm%dLwg|&40BmD(tP6o`c_cT08K&R2Kq5nB-O@VEH=n}Bsu>AqdyzMaOCex!q+a3mb z_IOyI1h2gy5)je#)NAShR2dA1Io*ruLG`8v!+H{|N5FjNP+Dub^#^D>=d3%dOCh!% z0aaizRv)VzD!TuYPsx4c6f%P3l9j;Vo`Md(3(%&&#nWO1?73^+U>;KTpMaS@B(8#{u$$Nn%J5ub z3NeA0Oe`SQ0Uqv$-u19Pkr+XY1pQ|bXvABf?*MV=zb(rke>fHV#vq~>5leIi-w;J~ z0xa!Dbc8xzCs3H%@B&bhlJWC^y?dc%V+Xzs-+-^gm*PtRl{esr@SC6w{Q>VHCj5ad zPX(Q6Gt`%?Af`h;;(tf#2j4xFSOUEViSxjE9|M#A07^(cQ4Q*cy(!Gp7b2o*p!M%C zoi#mzTw=EApGjk4neCy5Xc!>$S#!F%7^*-x7PZCC($zB5GTXAravJoJ0t*2i*o_Q; zTDyVdSaK>kgPcPy1C8S{`G%|^)qvLfte=4=tEmoD7sz?9hs^g+il7|mK)NH{lO70n zdNkNjx<6wD0(jj4d&cp`fLb5R3xT|w8fw!po|#+6%?2#G2RXbKz$ZU)e{(BVUVA9(MfWrC0R$@>Oc{%c+;?+s}Fg}i#6iHGtzd>Yoi@}5Et z;2|#s_WkkSoy)&Q*w? z4s*7F3N{*Ira+DzAZ96KlWzbQ-h!>f=3!F+FDGL&u{p3l4^)m}SU1cElVLWthF#BY zf=VPkG%cH8Bhc%Cg=2kS|8>}Ls7}hjaQCvE#|8-dL)w9U0mw@m;Jlwj)y^U~GEf&A!iI+eam@1hsLJdUOv zX)Ca+44CPAsrgiY$`{bN&YEp~Ykg|HVLc0MZ42Zy#{fP@TRlN{N39fTBAdtp@*|l_ zJ|UCId*lOneN5gbZvgi{1I+$Bc@CCm;hC5Nr@&}nq=96V)3&{YYMp;K$ms|H+FF{uLiS;!oVpYI=m6R7101BBG z_(C^$?LrNrCPKB{TF84GqK?Azkq@b_R1IYVtvQSy0G@RveHidHk0xmkTQ9h$n{8(x zUzlSv*z5pR`!nOf$IoKs0J_eHyyJRMpf7;$f5yCk_6XLlGnbhwpyj=UvGbcL29984 z*eoT>1IAYuR!_*P_Xq8-7p(PR4S?}Ck~IpHj~>9016eLC0ZY&PgMXIz#(ZYd;fVL) zSVx%6%)1CX7LwPl&>%X^KJiZ)wUX2Ei5Z+RknJ_aGF6~aRPm%E7ONbgmaw* z=eQi=&{?3k3<7Qv3;$RV&O|VsAoDpCjyaB*$V`Q;ix0)^ZAIRRPQtKefEuv6F>kW2f@&SJl1KV{!xUj_~L2>TFw zKkT~$+F|xt_D$IG0nn+DZDM1X7?VO1U|fuiG2o+g>=t$d`ycxk`#n3A{e=A(+EX}g zI=ue`$Nj-BWY@x8G+T_hU;$Vsz{sKS9LYj#11QI*pmrw(`vSOY#AJYyy&-Pd1{FT( zfYraCvNH#s-~9fceF6M_%()19*JjQt$X(2Y%tbsW2(lVdju4_84`9N5Kn0u1nGO|z z!y&8D8FVlQ$avsb4VD8L#Or|T+W?(cLG|bqECDi!?cfS(*x%VN+4tf1I009@ke$R1 zV)NKF=tuC``@we)LA#*-P|K%8d8idC1T{zt@PlF`8~Ffy;St=atH?#A%r38tS7a!>Ahv-!T+MT2B~j5x`}f5I&-V$t#1#I$vHUW@+D9p9NFw_1w`v$T`u*R~+!OR;B=pP8xh(;LmKY+)lgQdY- zz7MnbB+R^npviB5xt_#CF&K~vTYgQ0WR7O+jfDnxeH#;+O9#a^|9?4 z)D~pevS5!qTM=-wJlkjJyJ9+~Ud9Vq(~LH8L0%zgs2 z|FOQoU`hY6{R#9GdKx_ywv40`0ipT;@9zQG+YqRF_kh|K7upFtixa%UUwc4(F^6U- z5}2fpYNeW}#{aAt)~cyu>JP;C-yq6=O5LI^Ld1WOx=tliuc$1@C;Ww)FcS_x6fKWhcNR)PHkl_J~v1sL&1$o)OD z-nZVgUbSAc-nQPgCc{&!pRBpCZ?hG*Qt+)}$`z`4dQ&5*X>e9^;X9{+nlThIA^oAZ zCs=nX`ajI~0NnSZLa5Hr*B`e3&ywIwmq2!LFDTR}p|0>0bprgrL3qD|+DNUW7Q*o+ z!jBPPJ-VyUiFCn^%IGn5Jgi-e=a!xasOD_j7TM=Pj#a3)>Bo7=(FS*$v%=0DR} zjc~rW)n=8z`T9fcVmJ6)U+_O8zytkHb(sc7m@vQBn%Z0e-2@aLlD} zR=43!{O|6RfREBaWw;dX@&BH9aD^uxJixr+35XEzTw$;bg!Mo=g6;+}!zieqTm-fN zBF815+N_4EsLi0@?D~J(0Wsxn7<;E-1g6m0bPa8xmB3pD+2+``*iP7P!x;VyUhf}_ zbrK>90c51)jEaFrli^j$$QT7^n(j;x6VHr?8k!^EIX*BY5EBa_I_?7VY&6Wg*{r3k z^{kz&qpXWiUG{+W1i07}nDy7;-E&ZZa#@w&PuSoUf?=i)L8gH}*@Wyt4j=~s>rVmd zKR{j~pP&Lc2kaMMP7Xxn)krHuCnAVTA|dLS0rAI0sBX?it56+EqZnJjma>(=6rI^# zY=3qrRA$DoBcTE_h#d$mlHCJpG#5hre;)Av4PbvR=t){&uqw*Rj6H!E9l_4P-p{emSUy&Z;SjGVfem|dyg2R)E|v;CWuSN&A=I{D6!w4ZoeBJv)As+*@Xe4hG9^P2QW0gAOQKMwkn5T= zPgh8$QsHVM3YB@xm1LgjBJ&uMkSS9amsBL*&N=`0XRou`&pF@oZNT-qe*bUl^?io@ z?7g4;>}T)K`mD9~bE;HLdEKs+o94@>NR^ z`EJNvmaI3kY6>;q#EQ|As}88z5v|myYB}`TT=@JXmGY4_F=DVPwF|B8v3w3iyDt3_S%(|w(WT1>+$~cndw-Y4`i;&oX+}~^;pgR z225fQT;(9xNBi_PXqV>cbT{%6@LGJ;3Yl@ zzrzt&1!B*q{&F%*`%oBmCw#~o{IWW%e;FA35^(tRFZsE@U=RF&b?_zf?c7=Tnsam0 z*afS=|F?sK9|8A1pH&}MV}m@6P4W$_y8)5rjjK1U-lTd%ESARAt5-K7WdkD6ZK}6p z?aYTHy9*KQ1F8?H zK8hzh2{zxG)qj@~*}k4~w^06?>MN*e0G_P#sP)L|{i}D!)4hH5Hh8qQuHKQe{aL+q zVs)?T3#x~ae{A(bSXUF!E6<}{UaEctc^R2lJq4Zf8JcJ|Uq`MsuN64o4K(kEE*Op8 zdv)GNc){n)%koXaihQxt0Nh=ML+1w48+qoj@6AH7Je!yZn_nt^#H!95Z5^Z zKl>@EGg4<0iMj}X`zTf$j7vQV*1pQhuy?`RZ&SadmH~0sPq$9DOLt-}bb(=YhR=6` zm+wT(bxUT(rZB5*INKeLeo(qQOutw9!t@Ac&=cub`OS#0`5q*nUJ6#Y7SXY7G95Cz zWjbYcWxEen`H{rPPJlc1#76%|W+=A$9hpa1_5N9A7QB49Y-4bEd;B~{F?WYC`ybCv zgon<}t^l%#AMXy@c7Y}TwdyF=R`vx$Z-FJh0G@qZ^%Y*-pOKkW)4{@tRnLNoV?f)h zt44sg@(x{upSKq*w|CWGR?XdC^%`a7RV|MvVgo!8ZHWrBg*P{a8`rOwulfnJ{Ra7# zbwb}$_xJ3-qxN5j8LR+GtIQUZ*`i+SddGcne6P$ z*XY5inJJk`^!Ia_$3eim`I6vnH00CZ;WRvK%M|;6_iVT95xj##vioE^U?;W2URpid zAiGSqUN(m={S<%Q^O?so_h#T+NjJbNzj}VTd^$ge z`8b_fIW_lQZZfm>MSS!32T%R2c-}98HJwHz>=4*@XP9?8*wyyA?fCL^^IR+V*7~{i zU}0k3tL9eAt^lm2RRPU9S3{+JM8TQnA~CTxjl&j?}l_H_gH(F$33Jd2gBaG1-`rYyuFDYh(+%NhZghQ3SPR& zyq5D?5H)N9+g}w~i{sW@+l^A);mfDNfqTw7Yu-6XFV0TJ0yqVWp+5}!hIx1KP2l_Ac-3queLFzt4D!Kjd<`W%Fz2Tjx9E4}{5{k?+Gu7=Yz93{E=? zy=*cRYwdDUuHyH+Mq;-~baY+*4tVcN*lg4B_sj!}S7fv`Dm2FSTfeX&qpu}@8*F3g~z_=#`nM_>ZeUN`DE8kydmOq6ic$^5?1oXi> z=?~H$2AP`vnDsi};3b%qo|`VDtB4@1Ok7}7yfga{3phG+JeuL8OpnajnG4Vi*McYG zh$Y;Q+{e1Pd)Pk6-?+>ebjBFYA0gK8EMHDdBm(>yex2&fve~uacz?kk+%@~RZ1?Q( zd~tPh_7phfN!eqwM}ugGWe>=92GiPsYHhNuLAK`E4UrA9&9WPDzAbA!_QET4C^5jp z$$bcNC^&i?b)1Q>xi1{EFLD9=bRa&Xp+%q2VEF50_(?_~!?=1n_d9};hp{~l4tgG| zM}}rcW^V?kpMcN4oBa&WPa(Tp)taDqYpjZ`St-~CyJEwtwW?OCS_)jBo&68kJ}vuU z_C4}X!t?zCo})*p;pXfJ?)MMc(TgWM9JKDt6Sd$A%O$h3G9P7L0@H8hyUUC5diA7F zk05H^KGT}@Dh)DK#4o>1e?(k;0(g7}{+S`@?>=~Fdg8H@x9&*3$~=nq{`d50_|kf? zKPi1YDTm`_IVgP~zPxVuS@ufr#=laoU;9~>3f2lr-70^S&eZzmV|75eK&bo?nZRj z6SNRp3 zQgeH>)?uK(T04k#TtT1Q$kW{)(ETNR5^wPL8h;aE&Xds1@4=#{@Entah+9wMimWL` z&?oS$ikg3x`#Lux_Y?m6d@jYe#Z^S#m&q^BYO99%)$(hi_nYN6LjN~`gEznrm(I^) zZTXM6*|}NBO#XhPv}7(Z)}`_*Q(_ZVYwejoEPpEcWC(gfe!hFr6yss;FEcKtFdnAo zf5^|_n(*D)8bOC;skW# z19->BBKL4)8ujV)3;43$q7SE_O()@zc!`|j$*ai6cy#L1Xx%3{djxrc^JmbsFN5Nf z(XL;B=rig0485yGyvI_2jP|k>-fQ1 z(CfS0moVa~VC6et=Zj$DL$Kx>!TT%GI+r8EIl2nD694bDcrC`DEAB?dp>HI2vQFs^ zQpd2p6-{vsc`w0Z+zV^uP*x%B79tRB&;c#*f~=1P)HrB}mGO(Lif3e9ViV1|sue4p ziDw*21fzTIDER&fAbt;Yjaa?(USBleAhgw$wC+0Ecr%*mpXjH1X|q-yz7E!Z%x~$= z0F5&|=NkF-^3C&`V#n=>J-0uYd@QJZ4iT8afze+Hr#D7Fl71b9b$AnBi{42;KLB1o ziaZQAco;9;Q~0-E;ycob#8E#$mw$mp$xka1PyH0m@DV!wQ*`_E{P(=CdA!rA!Vp;fg4Iw71ar!ns#2JMge(_aVS+E`>zB#j{ z4NPdK)b6RS_-&4ZxAz98F9DgaW4?_g9`;CTJe>VixYP%!Y4|69ASU)Ro}0O__aB+D zGfDYA^&ezL>L;SBRq6W7=@pAJyFO?2;rM)9#NRT=a$H$~?NT8w`wO+qMt&wby9B8# zW23Ldnu!hIbsK{co51v1!}r?Y3)&Vvum=e8H*~>qu*6>cPQgH;u{YtHmqvIIYhV(n z_aW%_8IeJL!U!yy&fgcDO-smUq)f*)_z%{>40o%*4h zF62+rA6<1WS58Gc983J+a5Th0K|}0;ZfHkTVk7Rh26tTs-IqoC6+rFTJn84yNpJCd zPto>qwE4#LNFv$;Sp$0x9Q;K5Y=1@PcA>xbNbkwfepqIIrRV>~_5jX1v$myuk&U;( zOScJelUBU1=ILhXCcME$NMo$J^bqEq@1P6bL=Q~BcQ_W-FaUqe(O`cU5Pb(wye*jDGGKmFnEYC)HB+mn zRsqXbfX^?*F9qb_FaO4;^9HMc?}gJ}Lo{_5csh*zNLEbT2x8xg+=ASUMSCMN=6bOG z8m?Xe$`1zZ&tdJtap3L|NcUg^pGI0gFnKUCfSGnK$b1a;bXTl{U9k_g#$&osVJ*0O zwlJF+JthAJYbD+&^7fBl>YC+Wvi+RnkMi#lqn!XI+zUdCCPF(3|G)q^-WmB5!Q%t- z`{wtC_jTsC4m##{$?rtub_=-R26z}&19g|o7hsO!jbe;H2KFfaI4d^?KSUKcz6$Gr z)(5Xz;&W=rUS6j)@+*PX_3|mux*C~>RD;_lI?o4WZ@@holYf2q>iV$N#`#sb=dv)? zdLVrUi^7@h3Zz!$e*q)E0QD!sg5N-6yaFG75d?h}c03+V{W$Ujy5KohqRJ-t6jc2d zzAL7yXPC_s%%K%|T3$b}^y&vli56&N&KOFDcG4$7| z`JUi#Kh{HC0qWj@Ej9+ZgMPgkzse|ZS#l*_n4xg!0r+M5BNuXXDXBM*_rd(rypQ*J zH~-1c4i+09x&=mU=({A*_xf;ZKBWPPmqNs^8{>y*foE_#khpVEn;(fbw-q`kGeXYA zmb{#iH5!KT5PacZL@J->i@Ues-Jh|_YgUnU^Ye!=?O8DKFJRg4AyctS4O4gd>SrUx8oq;#5CYx}S}l!@dve&iWz!S(k7TvuFtM zrK`w&J)W66!@7qT;aunZ!RU>d=d#6x+O0rr&G>dec47i2p#_!myY^KdpRcls0Gy(B#h4Kgx)H7iGM# zQ@$6TJdUF~DLtAxZ=&WK*-CCkZc1NI{;TjT4}jO6jThr+P_YZ`+lroOj_zs-?lob% ze$ZpBz{joVtxm+1yV350`8zP(mFMU{|FxmVnt+xK(+$vzD+JA{y^dC*?^i=3HsZ^k z#{8|3UX?WIPFv41o3~`Tb16#8i`JC4p!>B&Hs@$V>RyAo>aqT1Ht+Qd{37qdtY1t$ zgI<3U84tUjkb0igiLb)9C9i_EFOf2VYY(UH!796*F>)hc^N%QwnA;dJ_u@T&1Z(Ya zaQQ)UjV0HOjJ%;?-1R2fes-!Cmfz_}PmVMO&re+dyBLTcW+y*p{_wZ3}If+imy_k8R-Ze}TPs1nc+1i`t!EqB#|oe;)PpM_XLT zwh#W(Q>o`ju6Hf$fp2F!O1HqH(*&GYt*{a-d?}b*hGl7md;wOMK~wNEOq}Pj+l4j~ z{v#(?-z+Tp8F2M)`Q4DO;DR5aPu^nP-ScqA$KjJ>LAjCmmiy<=1^asB|4uybSXK-k zffy$C;I~T7L7(&sD})Atd{OPyIy)D0Xyf?s$zHh%&*d{VLO5&47AU!C$h8zJZf!GbPnl?l~ zZ-=Jd8(Zpd-nKCMc(zBQ;Sc4_AB2B=zYtsBm%V)DhhUo>R=oCCN}2ckVD^7yoE(5( z@DN7AajbAWlabJmHH=rlCdM(^p2j=!D&y~M{N$7HnY@d-GbD{;B$S@xjNi=MzD-j$1K{C z0=FB0<>JJ$Y&OHD*#=hJ4%wFDHt0=RJF0WDuy0MSmO)x^q~nc2^k&qs4tHu4^ufxs z$Stb|#kOrsdFkJsgSOlgy|Xv|^F7h4imdM#R-9OCSE2OMu<<q-uXSe`7vmVYhfUlfc0V^y%<*~ zhE?tdvf6PUMyS?5Y6ax(tUKO~?e2_Q9d`^~8hLWI+BhusY# z?`1)TaAqExlY>4v3Y_^HD|fn}H+DvUY>!si8oeT|BE8ZU&9Y_CGn?V3ZO*q_O|T!< zB+9TFJ|0E(_)#J}IZFp$&m6n~Ut>Q^%YTUfW(v^&MFq4P`#J20r&;y+B%0x|BL2OM zZ$T?Srv_ibrPbjvfK>c4_KMU4|c*XM#f&x+C)xzo$! zdY!y)Q}<-r@IGz%gnGUJX}?BSeMcK-pt)wkq4|LU_TR&&zvimR*PMMHWJbs{CwMpH zuadW_5&mjLI-3*cY#t(=>(L`kSdG#Q%c>>cKx`g*Y^%^~TM|W4@0n~x&u$xhY&+1? zJK$^EA;eg^po{sxM6lotrTg<%2Vubt33l8d-txe3KA7#r#5nb~^@gR@{}HUcOL@1$ zLH4VN42@uWWl=^jPOigtyp`35<5(^K2s~o~C9PFJ@31lbqQ=Rx4N(EE58 zQcw8R`7o&gpz$REl?TA`FM#cv^y6xuU<34JPKt#M0?UWP*RH_=xS1JyTUY^oE5|nk zrZ*g<9|G>HmVPiaF}kz3&*`wUQ;H&Hc1n?-ox~hJ5teo$E1pjZEKb_wZ0>m>_qv=r zUC(NZf5IHcffDy7D*y3$a=g8p)vROZ%U$%YF1OZN$5 z;tWQ>3E=x-{A%5Sj7u?%JsGvT@GXgb&$KBXn&!bPzaExrW1_9ABddYtE5qKG!&9?N z(O2^mX#Q_l&omHv3X#}Xf?xf9(0DZ7nd|V$49BxRq&Q256fVV{)_fe!-qHsZuW1es zVAhI__Y0o;K5%^b;(H=z!q3ma9zU1uS;e$7i1D5dYd;wa-~^c5v4Iup=onbx!SK9p z$bo_T85=yT$Q1X7Dej36UlCl{3T;5AmI1@p2d~xz$<|?O$khU0?dIg!hP)O5*ad&V z&fwFI$WEl~%y|c51Gd&~&#=aGKQzYy-1%Tyaty6IiB_J(Uh6Ur4{h6@RiIsHLq}Mx zN_OFVKgx9DZ-1m)X!pS(?*ZVT){O4Wil#0^1a}QKjzykZ(Q=bkv~&}m#6&Ua<{|M>Wm|`$l_-wVhTSbfZ7o7?G$r?1lv^{DZi0?$RqPAJLxj~k^7I|C zm-eECV%_5A`nu^1-q{&gXQyJHox=8X-dbIFqHz1*Aksd$UtuV40xda&! z_Co`HkH80W1KM4&&%4q04;LO~z1JhK?+3tcvG7O1@241h&%?xDWE8#udw&yd7X^{0eYJz7{s|?mI%35I#G$L=KV(xYE z$FE-|h5xrI`aYXcw%L5yfY{dt#adcp zA#9Ir&N(X=D4Vc5JS*1 zmw^w8=ib2g=(hy98U1k+et;WEyOwJsz#d_UVTq0|3)*QQC^Ha5849*sK_0CVGEFp^ zuW{}Scy<>kHZFB{&=&Wwm6+ZT%N$ES;hXL!{iSkaD0yetjxCnGoAP%DM6`O0p{nd{ zv{6xLdCRV%4Z{M$4Fui#qwCJ*se7Q?)E_5I;ls1JxK-Fa~k;h;3EylQ=eXy1Fj zCqt#pmm#a_JaS#cGppCkf*L;mxvvc|@$1o$*VAt|g#J?xN|bVAct$;`BIg!27b0Iu z4-N*E2eFlHW?q#md7cr-NcwbCc)HYa({1phZwc>c zgGXgE{v?|*!fbrCz`HK=-heSC{9Y^Ix3IfG@UkzRS`tQ{g_GyuH}YG~fRW4Z@-ZxX z3JmBSX3tw#z>+tE9sCYylY=GvE;-&O-}}t2$y}SrTvJ5#71+~raPlXanGa(hKY%>Q z(ZkGB&DO`UlcPL|FZC&MKLhL1tks%?=Qx^xJQL*Ekn$YoPf_Qi)O9af<#x2n4d|6? zgIve)O>nr;aQ{1k+|KdsoQ)yxIMzwri`)Zd+y!#n!Ij(Qm%j!Ecnxwjyin2KLGZ`E zV35UsP3QCiway~toFLLYXNR-%uqlLP7gFXTFzX_Y`@uYWQ{Hl&1)lXH@42KIAGN&7 zJs_}F>B7s1YDgQ3m0lG%>PUQCYS-0hPVvTPunk|jzIx$g}SQO*mNISFX=O1bapO%(i24Q z0j{6Q_H^{|Ib}U^aq$_4@_bj(cH`Q&^Iq;m#_)!25AW%Y@TTtKUELMlkzECCl{v(VaA2kyg8$$Wl!}xrwI6`e?nmwrz`XS?4HYNYL2)5*xjPU7rXTA;onYlRe zGR%Wzi(Gy!Fuxh|Mjo22m|;7B-0k_+c4s*KU+~pzSDbO0XC_*gAf(@#*=YWpotT}S zz-xJQ_QP*;AdLTS$iayA60NmA0{rgI-{G9;OTw;{=!`YJD|g$Gdu==aUYnvXG@DzX zH8x>>x8=(A!Oqy3dvz?@;%1e*WxgA2*d?^v>~dM=?Gj~VyGv9`$6c^h^i8ALCdYs^ zN5c)fBS+9e{|?fQ4i4?zm)7b#Wa&1udeoX-i|vulGHunBdiSRn{>C$Rr&h09Pkw0d zXB}Qlmp|(SWMgPY`c*yI36Xb2 z%-ZXg}bj1 zL}^Q7&t`)bm={)#{Y*5A{~8gIZ+_N%Kg}n>|1uROHI+H#GPS4!-a{WqBYcRb#^&1R z?B&~$E|DgAlX7X}Bdp+ov z*XHkEBk!xEY4+RPm!J0SASw~{)?{?lR8U|V2%y<7&ypd)40N94dt!E9gALMsQ_1~K zXzeSs_hst*sGj;U5^IFdtdN(xkej}PLl{PZwqm8kBTcMJ_-$DIzyVlbipt* zh>K|k(+koJ63eBCdlb`BBhf?BxfX+$rWJk*(IsPxI#<7FAgwQXm=;Tn1qi{P;@PzR z@a1B!+S?M(&|9Ab$1u+RF>l2DgT_eoj$On>B=XF^%iiDq+kvGRKlz|IKK;5zl-{55 zm?^{grAjV zD_@l6v+~HAX&5j-_RQL#g4PRd1VXe#he%qYMWVC3PND4o0+ zEmAwBZ7YfEIN4i5S7~nP$KBCx(#Bnjp18d^lOMZtQU7%WYbC;4=}+m-T`Adt^6iVL z?saN!NZc-9uyEUvdz<3%RtG_4q9vvruLi=hzR$88|)0p9=DZU{Z{R1=jr=Sxx zQ#DulPequ)T461lNt)v;^6Ph)KL9sm3%!nJ65>k(yb^TO8|Vf@HboL6wzwWuWJ9); zP)M3Yb;z1}IcN>DiJmFy4QY+>h;*p5g|JlC(~IG5uN3v7tTE|G=_At~&yaIG+V&y5 zCHKI!#}*NL4EQZ3Zd_YdQ$+H+VBNBx?xT!+EZW+UEIY~lTvO!bZfZ3C?)8e{t8NqH z`p)<72%6&V5Iub`*ohC)F4>62@9i1=3EfJRohVB&Y7S{+Nz}|q2B2P-PSN$(i%els z&=_JX>TShzr8k^0sDz2~o2U-gNgwi-#UrK{(O=ll&qO1>*4|NGjA@wVnU7Jw2(hJM)?@x|9Pmuo z?Ce!5w}rd%TJ42jS&Ug?kr1Kp9`jMwFjiRIp@_j&PS%4;%7QYBN%qoF!5WZlVAg@` z6Gh7pDOy(tVZ#_t-qpm- z5xKP{y#so~bdAOARkwbfN%eN4Re$9<6v3DLt!Ph}6`}Z$w31@1d$QG&?;FHg){WeU zU@vvYB9e_{KEIRk37O~bczWX~EEQWf;C1UcEN<(OTiJUq?pIWrtcz=+C(t{$!bB9uTeyKX%2mk{xk{0>&hrO&T^> zdJ-QZt{ayZE}ICI%vTk?S5Jf2&h#VS7jl=2_`i9wh0Nmg|AfWg0>i!@510Hv=J&D4 zX^pHz>t$`ttl0*(X4Z0R?X2BN_mb~P zwO$wM5zoIZuzX|p!gXQ#KZD5L&>CD{for9_1*NT!kIpoJdFrGe%w~|^XIxR&NZUx? zXltIkd*~I3w2Z_wlJdywuQ-5d4Xw*e+K|rl^*(D#v?!!AWEYsGFw4@cKk0C3aan(+ z1!QjsUmatOH#l>U?JMgbO1YgN)-orcZ!X}hP}U>Lr3f4Ou(+_en^=~MYpE~R6KNOowU{5TJT+d6+DGltsuo8?w*uS)qkBdXV>kboo>6y`r_jB3=9_n)eA0dr zcbCY5GHC&iH*dZ3b-i`-;^d2=kzVf@wDx_Pj4Z)35>Q;oZ;Wp+iDgRMh! z--qq>R52xqVa{T$zQ|;it~0YO4BOm8<$fyjRLNH*{g!-U*J9)GQ%PT0gvXKEP+RdH zae0XyMOOcG(2?pX^J&?UVm|KWdNH7F^tri@Pwn=8j z&7*J5dcwVZ=CM;8*gQn?5}CLc$2?R1E`7CwzQ+<*mu3-9UzX7!y|;n&CdntrowF)vN8dA@4T< zE93zvwM%6A%L8TmkAj^d50RoQvQ}I^AjQS481sQECSiVX(|lsIrtMsd8LE4HJ!&M! zWYbF)S8>FZj*)hWo-MO~48OHC47cdLEFiOnY#ok8LsUXKCEn*HG^@pXbRW~Z^8ZUr zANp44Z?OSy(Pa3yqD}I~$U7qMiDJ7Zvh);xl1vL;5?Orm^_kB{cAoWA)ShyT4J8$$m%rY`5mS3lOR{)F zd?C0rfW-=|jMXD6AzBVQg za$xtD)hVlE6~>m2sboY)F&z^dZ5nUoqVXqN#o}0Idq|^5j~H(;jgquT{JQI!c+ISn zqHz*-Vr*t-D26STX1-YYW1~;jZ4T)g!|;gWLS;!r6XA(a#%H#0JfeEUb)_dU-fR^` z!tMJ7Lvo?HBYG(!MGutx3uf0Sc5WVQw+o_&!nnG}2i(FiHZ3mg{tIz?p{?V(A+8~< zECR35nYVdAENboeZPAQ~*!n8Uy&>jZG5utk>b?1`cfX3?#adBr+o-P6xQKYYw4wXJ z9b+T1Pb;Fk`c?=pyp~ug`C`l>_%1MR^U>Qc;+U6STZ`k&49vtigui+B;%FSWe)07K z;_{KTM2;eymzP~)qfh+DBLHFq&M}PfB<&CVdQUZ$hHPkQ2h$CX{I+-Yo@DG|?JkkK zD`sqS(PD5(9pTVY-x<3_kA;#+&gzJk+(*4*D{alQWyrf(5o6^^amH(9o%d?*E%X+c#`0B@wc8WY zNJ*48Y!{#RXH(lvAKDt==tXn8LEa+sS((4g?E|xIycbMcM15)&g0}7{QZK|_@jlVr zqlR%E5K&yLUaN!*yB(<&)k%(xO2}>)Z|uRa+w1|uVe=bVv{gPI|0m0|oaJcTI?Hh@ zRjBTkgUTln+$>+8rR5$Xvk^QZ?CUV(^*2lx$M&ztgd1Xm7Bv&L7}plUluD9;8=@Gd zBy(CGrB}_M&|3$nW1{Tr|xR& zW87cu_i&GywKQ^HE8%Nu%x`WjHhaUY5gpr8xD`^qw`a4v*}ctQt`rmL3fVCpb$6C+ zoZa42GQMdTZzvyIq5JC0YO(jJcNrPGGxE6d%k4C?)ntoFT%$+pPxfC@EYfTE?g(!f zZ@wAdN~R*7HSs-Sy>VIR!Pb(R?XkAliW$}B_j12Uti`-u?)MUB7i*W;ap|_)Gv*#I z_n+C9CUI-^mex^zRYXE$NVFP#=btn&*I*_H|7^{pNM%yO6R7}(YUuc>^|1W)s3+`Z}W7s(Z%77 zI~s3vP2g)Nl1x*m;a&whPHvrvnuj57lj)J0Ms`|?l8#%_Es^KS&)^tXs;!*kS~;uB zj-B_Ku2PzNjBGuKt&mJcxm_>#m8AE`bb>__%rB$pgNZbS`ACw?-*~^_dMVl)%DcvM zw04ee`cyp?MbDnJwG5lh)^dNE`{&GC=pH=Vo5#>twHSY?m<4F;ywobRE2Yk~f+ut$M5w8+9CGpIWU3FSeNyQwLaO(SckPvTv@&jt!{~aHF-WNiRW<3%55sM zo|LK_Np5Yv5&1$S7K_o5X?fH0uIWv~yEnx=C|<_-w8ob)Y2(r{ZmX!QqO-#6C>F8x zbdPRXeAZVvM(;`VeVZq~bY9t6spXqo^=vD8EYcOX&Xc0naGg*ZBS?B$qJO@0-g^nt zCi3N&NK=@eh}yus#g6Dn-?3>9<+o^o+4zPxc0a?Q(iZtW<6RB4JQAsr9@&U{d)A0y zF_K_$0rx`0*1CSt-db-SsiaS$W{bUN%=2JvjJ@JI)SlS98p%Gk_BjuB?d|9BY5Y0N z!(l$K^1J9f;<=-EIaXVIrM!1a-*&vO@`+`A|CUeQkoR zDE1ZkR$gl$WY5O+5lKXMJh#1Y-shwHEVWmfv-~8lkE~vdeZeB;@7Uq!;ohH!9?2SI z=_f6GLn5J=EPq|KJGxEbXrp}+hs>W8YbsswCvTAWf7DZsyuNzLJ=+oeleUCw0rNCl zO@3@z!E#4b7N+`B*mFdmMm+M~j473mJfHT-J~o_nzeQxw_AcW#>h^dPuO>%<^MWMP zwsBR?yv4pP&C1vaQoJaR9@iPt3Q>GSNUwO?W4lTtPI1h74r_O*Hc5vml++}?nx|A- z7;BOR>}whv@in`AWrOdP=H-o%xn}V=t7A^oJFaKU;*P#tKZ@43Yc17L8)liEU-K?r z*6tg(=F9aMwc(vb0L=Gqs4tyRj&Q2OpDxz!Z4;&m<(%b49pm-JyWB_w^QpdyawrR+14HA!#Y{ zI?iez>om-bU+Sqf(Mw)BzUArVyQ|;v4)7VhFroT!>7m{owGVt zCikVe>`KJah|Q%(Ni5YJ9q;{VtbNw3c&w(~YHRr6^e5Ot&GoYX`Og%M-dSmg&fx+P9_n~j~$VOJ*H!7GUKRwY9du>O6i%`y|{aCTj@So zi{GcT#m>+D3H*Ff_Fg7Ft4ButUbauR!rNy(okZdIHflRkZogLkE|ceRw3HwD=!s*; zjyzp{qDo@pR-;&BTC>(?^rG%_=wnaR zJgV9E)}z*+G1gx5$a~!1KyCZvJ!`&+_=eT;BzpVP>4{?>s4wGLJc@m6N6ArJ{#I(9 z8;(1MYj5)*ZY#~1nC7#~XQTO4b(Wm9KC>N*ov}J5J90R~Q+mv+qnbIB$n$$2mttIP zXUV(9CrGB29@kcnuQsWPsIh#k|F<1^Ki0NiwA!t7=^OHQT)K~=z(>e}_CA`lw=w49 zFpkig%p+b)9>shMKB_q<7L-!Ct_77S*C&49#^i@@9eRnh2>KIVy zE7uUO+fjKP<<~3cDlcW{$v(5=_-0DqWK6fC(yKa4{kGuAvWEn9o5eSR!#Uy9&9$9xu6o~xxXhRS(rOI6L4uK68H_sRB@>!Z?~HP8Gm zNfatSj;N-inwVA-9hGAFlGh@KPp0~D&3Y@}t!BQ(O(}gIKdV%WU$cGe7w~(?Bjq!ybiUPg7UyYAJgbzF#KOwxX{nX*xVBWU zskZBuUXwvDIKS7beX`w)a$M=zYo?Xfl)T@9k4wvypOu!4&sdsX`I$eQmR5andWzXG4ifSAK0;#GQymm5*Z1N=M~oYT|h^r95{$ zuT;0E#a!h_o=YX z^4?3n4S&DMee!zc<7Dni$CXQ!w?pT#l{%`FBc@qQJf7DqZ?%=?@U~QHXL*|EUHpA{ zO|_rLx?>)ni#6x1r9^C~lp|jCqe{6Jp60F4eqsNTc^7=V@caH5bJX_C)^DZ#=Xn>_ zr*=J#kHtM&wE7pE%iCIMzu=O;*VV;+>R+3`6eTL1{o0yK%l%&cV)c~P{rjGIAN;w6eb)civR8MuDce#U;O7-)E=(O zx1b*QEpv9!(6dsm-*Q(gyP#+KBd-1SC-^hf@JICMpY0xtk63@+5{v$B{=Col-)s4z z_ug;GxiCEWEp`95$}X(Ub=Pa_fkk~IwUz(l(-*bH|6BQfEh_x6Pw{J8`TtwFKlZKs zFW>EtpZ^QHW3smvmbdQu;`BhWXX}oCuO9gS4Lkp@Jn!%I{_E=b^*yjS=v9~ZkLiJ5 z|2FGN{Pr0nJ+Np9_P>(r_kaGn z8tZ!C_wRxK9%@?6zkiGCYOL#lKVJ|0o_gue_w;owuIqui9;oYqx*n+Ofw~^3>w&r+ zsOy2c9;oYqx*n+Ofw~^3>w&r+sOy2c9;oYqx*n+Ofw~^3>w&r+sOy2c9;oYqx*n+O Ufw~^3>w&r+sOy3M8$Iy<0QhKq1^@s6 literal 0 HcmV?d00001 diff --git a/sounds/fp/car_door_close.wav.import b/sounds/fp/car_door_close.wav.import new file mode 100644 index 00000000..54099645 --- /dev/null +++ b/sounds/fp/car_door_close.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://behnkjw2pxo25" +path="res://.godot/imported/car_door_close.wav-e377f3cc3bc10ef9b5410cd543f9328a.sample" + +[deps] + +source_file="res://sounds/fp/car_door_close.wav" +dest_files=["res://.godot/imported/car_door_close.wav-e377f3cc3bc10ef9b5410cd543f9328a.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=1 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=0 diff --git a/sounds/fp/car_door_open.wav b/sounds/fp/car_door_open.wav new file mode 100644 index 0000000000000000000000000000000000000000..d09870ee40a8058ae2b3ff4220322a6c923785c3 GIT binary patch literal 66194 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 np.ndarray: return norm_peak(fade_edges(out, 0.0005, 0.1), -1.0) +## A car door's handle and its latch: small, stiff plastic and steel parts. +HANDLE = [(1720.0, 0.007, 1.0), (2630.0, 0.005, 0.6), (4180.0, 0.003, 0.35)] +LATCH = [(980.0, 0.018, 1.0), (1530.0, 0.012, 0.7), (2710.0, 0.008, 0.45), (4350.0, 0.005, 0.25)] +## The door's own skin and frame: smaller, stiffer and better damped than the whole body. +DOOR_PANEL = [(f * 1.45, d * 0.7, a) for f, d, a in PANEL] + + +def synth_car_door_open() -> np.ndarray: + """The player's car door opening: the handle pulled, the latch letting go with a clunk, the + rubber seal peeling off and the door check clicking twice as the door swings wide.""" + rng = rng_for("car_door_open") + n = int(0.75 * SR) + out = np.zeros(n) + add_at(out, 0.45 * knock(rng, HANDLE, 1.0, 0.4), 0, wrap=False) + free = int(rng.uniform(0.07, 0.09) * SR) + add_at(out, 0.9 * knock(rng, LATCH, rng.uniform(0.97, 1.03), 0.6), free, wrap=False) + add_at(out, 0.55 * thud(rng, int(0.2 * SR), 150.0, 110.0, 0.035), free, wrap=False) + sl = int(0.25 * SR) + seal = bandpass(rng.standard_normal(sl), 180.0, 1400.0) * env_ad(sl, 0.012, 0.06) + add_at(out, 0.35 * seal / (np.max(np.abs(seal)) + 1e-9), free + int(0.01 * SR), wrap=False) + for tk, amp in [(0.36, 0.22), (0.55, 0.16)]: + add_at(out, amp * knock(rng, LATCH, rng.uniform(0.62, 0.7), 0.3), int(tk * SR), wrap=False) + out = stereo_reverb(out, rng, 0.45, 0.08, 5000.0, circular=False).mean(axis=1) + return norm_peak(fade_edges(out, 0.0005, 0.06), -1.0) + + +def synth_car_door_close() -> np.ndarray: + """The slam: the door's weight meeting the body (a low, cushioned thud and the cabin's air + pushed out), the latch catching twice, the skin ringing briefly and the trim rattling.""" + rng = rng_for("car_door_close") + n = int(0.9 * SR) + out = np.zeros(n) + out += 1.0 * thud(rng, n, 92.0, 58.0, 0.07) + out += 0.45 * thud(rng, n, 170.0, 120.0, 0.035) + out += 0.4 * lowpass(rng.standard_normal(n), 260.0) * env_ad(n, 0.003, 0.05) / 0.2 + add_at(out, 0.5 * knock(rng, LATCH, 1.0, 0.8), int(0.002 * SR), wrap=False) + second = int(rng.uniform(0.012, 0.018) * SR) + add_at(out, 0.35 * knock(rng, LATCH, rng.uniform(1.08, 1.14), 0.6), second, wrap=False) + add_at(out, 0.28 * knock(rng, DOOR_PANEL, rng.uniform(0.98, 1.02), 0.0), int(0.003 * SR), wrap=False) + rattle = grains(rng, int(0.12 * SR), 220.0, 0.06, 2500.0, 9000.0, 0.03) + add_at(out, 0.06 * rattle / (np.max(np.abs(rattle)) + 1e-9), int(0.01 * SR), wrap=False) + out = stereo_reverb(out, rng, 0.6, 0.12, 4500.0, circular=False).mean(axis=1) + return norm_peak(fade_edges(out, 0.0005, 0.1), -1.0) + + # ------------------------------------------------------------------ UI def bell_note(f: float, dur: float, index: float = 2.0, ratio: float = 1.4, decay: float = 0.4) -> np.ndarray: @@ -1033,7 +1078,8 @@ def synth_alert_stinger() -> np.ndarray: ## Sounds stored as WAV (started often; see the module docstring). WAV_SOUNDS = {"engine_idle", "engine_run", "siren_two_tone", "horn_short", "horn_long", "chat_blip", - "chat_blip_soft", "alert_stinger", "crash_heavy", "crash_light", "prop_clatter", "prop_clang"} + "chat_blip_soft", "alert_stinger", "crash_heavy", "crash_light", "prop_clatter", "prop_clang", + "car_door_open", "car_door_close"} def is_wav(name: str) -> bool: @@ -1065,6 +1111,8 @@ def catalogue() -> dict[str, tuple[object, bool]]: "crash_light": (synth_crash_light, False), "prop_clatter": (synth_prop_clatter, False), "prop_clang": (synth_prop_clang, False), + "car_door_open": (synth_car_door_open, False), + "car_door_close": (synth_car_door_close, False), } for s in SURFACES: for k in range(1, STEP_VARIANTS + 1): diff --git a/tools/tests/fp_audio_test.gd b/tools/tests/fp_audio_test.gd index 9f03a3be..139d9e1f 100644 --- a/tools/tests/fp_audio_test.gd +++ b/tools/tests/fp_audio_test.gd @@ -2,9 +2,9 @@ extends SceneTree ## Slice I audio (docs/fp/slices/I-audio.md, acceptance tests): the assets and their loop flags, ## the AudioDirector's tick throttle and on-foot routing, the initial scan, emitter settings, the ## 24-player cap and stealing, manual doppler, horns, sirens, footsteps by surface (polling and the -## stepped signal), ambience beds, settings, the pause muffle, the talk duck, UI cues, crashes and -## knocked props, the frame budget and teardown. Headless runs the Dummy audio driver, so this -## asserts node state. +## stepped signal), ambience beds, settings, the pause muffle, the talk duck, UI cues, crashes, +## knocked props and car doors, the frame budget and teardown. Headless runs the Dummy audio +## driver, so this asserts node state. const K := preload("res://tools/tests/fp_kit.gd") const D := preload("res://scripts/sim/sim_defs.gd") @@ -43,6 +43,7 @@ func _init() -> void: _check_pause_and_duck(ctx, f) _check_ui(ctx, f) _check_impacts(ctx, f) + _check_doors(ctx, f) _check_budget(ctx, f) var st: Dictionary = f.self_test() K.check(fails, bool(st["ok"]), "self_test: %s" % st["detail"]) @@ -654,6 +655,27 @@ func _check_impacts(ctx: FpContext, f: FpFeature) -> void: _steps(f, 3.5) +## car_door plays at the door: the latch as it opens (DOOR_OPEN_DB under the slam), the slam as +## it shuts. +func _check_doors(ctx: FpContext, f: FpFeature) -> void: + var at := _em(f).listener() + Vector3(0.05, 0.0, 0.0) + var open := _shot(f, func() -> void: ctx.bus.car_door.emit(at, true)) + var slam := _shot(f, func() -> void: ctx.bus.car_door.emit(at, false)) + K.check( + fails, + _is_shot(open, "door", "car_door_open") and _is_shot(slam, "door", "car_door_close"), + "car doors: opening plays car_door_open, shutting car_door_close, both started" + ) + K.check( + fails, + open != null and slam != null and is_equal_approx(open.db, slam.db + BANK.DOOR_OPEN_DB), + "car doors: the latch plays under the slam (%.1f dB, %.1f dB)" % [ + open.db if open != null else NAN, slam.db if slam != null else NAN + ] + ) + _steps(f, 3.5) + + ## The one-shot that fire() starts (after one step), or null. func _shot(f: FpFeature, fire: Callable) -> EM.Src: var em := _em(f) diff --git a/tools/tests/fp_car_doors_test.gd b/tools/tests/fp_car_doors_test.gd new file mode 100644 index 00000000..ec43b90c --- /dev/null +++ b/tools/tests/fp_car_doors_test.gd @@ -0,0 +1,423 @@ +extends SceneTree +## Car doors (traffic's car_doors.gd, the drive feature's car_doors flag): every car model's +## driver's door cut out of its body and swung on its hinge; the hero walking round a stopped car +## to that door, opening it, climbing in and shutting it, and the climb out once the car has +## braked to a stop; car_door on the bus; turning back on the way, a blocked door and the flag +## off. Time runs through climb_step(1/60) and the player's update_view. + +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 DOORS := preload("res://scripts/fp/traffic/car_doors.gd") +const DT := 1.0 / 60.0 +const KMH := WS.M / 3.6 +const IN_PHASES := ["walk", "align", "open", "step", "turn", "sit", "swing", "close"] +const OUT_PHASES := ["open", "swing", "stand", "away", "close"] + +var fails: Array[String] = [] +var ctx: FpContext +var tr: FpFeature +var drive: FpFeature +var lane: LG.Lane = null +## A car model whose door opens. +var door_model: int = -1 +## [at, open] of every car_door on the bus. +var slams: Array = [] + + +func _init() -> void: + await process_frame + ctx = K.make_ctx(get_root(), {"view": true, "grow": true, "camera_mode": "first"}) + ctx.player.set_process(false) + tr = K.make_feature(ctx, "traffic") + drive = K.make_feature(ctx, "drive") + if tr == null or drive == null: + K.check(fails, false, "the traffic and drive features are registered") + K.finish(self, "FP_CAR_DOORS_TEST", fails) + return + tr.set("auto_step", false) + tr.set("ambient_enabled", false) + drive.set_process(false) + ctx.bus.car_door.connect(func(at: Vector3, open: bool) -> void: slams.append([at, open])) + FeatureFlags.set_override("car_doors", true) + _check_split() + if door_model >= 0 and _find_spot(): + _check_in() + _check_out() + _check_brake() + _check_turn_back() + _check_blocked() + _check_flag_off() + K.finish(self, "FP_CAR_DOORS_TEST", fails) + + +# ------------------------------------------------------------------ the door + + +## Every car model traffic hands out: a driver's door between shut lines round the seat, cut out +## of the body, that swings out on its hinge. +func _check_split() -> void: + var render: RefCounted = tr.get("render") + var bad: Array[String] = [] + var n := 0 + for m: int in tr.get("_car_models"): + var mo: RefCounted = render.call("model", m) + var key := str(mo.get("key")) + var d: DOORS.Door = DOORS.of(key, mo.get("scene")) + n += 1 + var why := d.why if not d.ok else _door_wrong(d, mo.get("scene")) + if not why.is_empty(): + bad.append("%s: %s" % [key.get_file(), why]) + elif door_model < 0: + door_model = m + K.check( + fails, + n >= 5 and bad.is_empty(), + "every car model has a driver's door that opens (%d models) %s" % [n, str(bad)] + ) + + +## "" when door d is sound: its hinge line ahead of the seat and its rear edge behind it, the +## handle by that edge on the driver's side, the door's triangles out of the body, and a pivot +## that swings the rear edge out (and back shut). +func _door_wrong(d: DOORS.Door, scene: PackedScene) -> String: + var pts := DOORS.points(d) + var hinge: Vector3 = pts["hinge"] + var seat: Vector3 = pts["seat"] + var rear: Vector3 = pts["rear"] + var handle: Vector3 = pts["handle"] + if not (hinge.z > seat.z and rear.z < seat.z and hinge.x > 0.0 and handle.x > 0.0): + return "the lines are not round the seat on +X (%s)" % str(pts) + var out := (pts["open_rear"] as Vector3).x - rear.x + if absf(handle.z - rear.z) > 0.5 * WS.M or out < 0.5 * WS.M: + return "the handle or the open door is off (%s)" % str(pts) + if d.door_tris < 50 or d.door_mesh.get_aabb().size.z > DOORS.DOOR_MAX + 0.1 * WS.M: + return "the cut is off (%d door triangles)" % d.door_tris + var node := scene.instantiate() as Node3D + var ok := DOORS.pose(node, d, 1.0) + var pivot := node.get_meta("door") as Node3D if node.has_meta("door") else null + var swung := pivot != null and is_equal_approx(pivot.rotation.y, -DOORS.OPEN_ANGLE) + DOORS.pose(node, d, 0.0) + var shut := pivot != null and is_zero_approx(pivot.rotation.y) + node.free() + if not (ok and swung and shut): + return "posing failed (ok %s, open %s, shut %s)" % [ok, swung, shut] + return "" + + +# ------------------------------------------------------------------ the climb + + +## The hero walks round the car from its far side, opens the driver's door, climbs in, shuts it +## and drives. +func _check_in() -> void: + var c := _place_car() + _put_hero(c, -1.0) + var changed: Array = [] + var cb := func(on: bool) -> void: changed.append(on) + ctx.bus.drive_changed.connect(cb) + slams.clear() + K.check(fails, drive.call("board") == true, "F by a car starts getting in") + var entry: RefCounted = drive.get("entry") + var route: PackedVector3Array = entry.call("route") + K.check( + fails, + str(entry.get("kind")) == "in" and not bool(drive.get("active")) and route.size() >= 3, + "the hero sets off round the car on foot (%d route points)" % route.size() + ) + K.check(fails, changed == [true], "drive_changed(true) as it sets off") + var car := _car() + var r := _climb(car, 15.0) + K.check( + fails, + r["phases"] == IN_PHASES, + "it walks, reaches, opens, steps in, turns, sits, swings in, shuts: %s" % str(r["phases"]) + ) + K.check( + fails, + float(r["near"]) > WS.PLAYER_RADIUS * 0.5, + "the walk keeps off the car's body (%.2f m at the closest)" % (float(r["near"]) / WS.M) + ) + K.check( + fails, float(r["wide"]) > 0.99 and car.door == 0.0, "the door opened wide and shut again" + ) + K.check( + fails, _slams_ok(car), "car_door: it opens, then slams shut, at the door (%s)" % str(slams) + ) + K.check( + fails, + bool(drive.get("active")) and float(r["secs"]) < 14.0, + "then the hero drives (after %.1f s)" % float(r["secs"]) + ) + _check_seat(car) + ctx.bus.drive_changed.disconnect(cb) + + +## Sat at the wheel: hips on the driver's seat, sitting, the drive camera on. +func _check_seat(car: VH.Car) -> void: + tr.call("sync_visuals", DT) + for _k in range(30): + drive.call("drive_step", 0.0, 0.0, 0.0, 0.0, DT) + ctx.player.update_view(DT) + var rig: RefCounted = ctx.player.avatar.get("rig") + var seat: Vector3 = (tr.call("door_info", -1) as Dictionary)["seat"] + var want := Transform3D(Basis(Vector3.UP, car.yaw), car.pos) * seat + var off := ((rig.call("bone_world", "Hips") as Vector3) - want) / WS.M + var st := str(rig.call("state")) + K.check( + fails, + off.length() < 0.05 and st == "sit" and ctx.player.view_override, + "the hero sits on the driver's seat (%.2f m off, %s)" % [off.length(), st] + ) + + +## Stopped, F gets out the long way: the door opens, the hero swings out, stands up, steps clear +## and shuts it, and walks on with its own camera. +func _check_out() -> void: + var car := _car() + var p := ctx.player + slams.clear() + K.check(fails, drive.call("leave") == true, "F behind the wheel starts getting out") + var r := _climb(car, 12.0) + K.check( + fails, + r["phases"] == OUT_PHASES, + "it opens, swings out, stands up, steps away, shuts: %s" % str(r["phases"]) + ) + K.check( + fails, float(r["wide"]) > 0.99 and car.door == 0.0, "the door opened wide and shut again" + ) + K.check(fails, _slams_ok(car), "car_door: open, then the slam (%s)" % str(slams)) + var side := _side_of(car, p.position) + K.check( + fails, + side > car.half_w + WS.PLAYER_RADIUS and side < car.half_w + 1.6 * WS.M, + "the hero stands beside the driver's door (%.2f m out)" % ((side - car.half_w) / WS.M) + ) + K.check( + fails, not ctx.collision.is_blocked(p.position, WS.PLAYER_RADIUS * 0.8), "on free ground" + ) + var st := str((p.avatar.get("rig") as RefCounted).call("state")) + K.check( + fails, + not bool(drive.get("active")) and p.input_enabled and not p.view_override, + "walking input and the hero's own camera are back" + ) + K.check( + fails, + st == "loco" and p.camera_mode == "first", + "standing (%s), in the first-person view it had" % st + ) + K.check( + fails, + int(tr.call("driven_id")) == -1 and not car.dead and float(r["secs"]) < 8.0, + "the car stays where it was left (%.1f s)" % float(r["secs"]) + ) + + +## Rolling slowly, F brakes to a stop before the door opens. +func _check_brake() -> void: + var c := _place_car() + _put_hero(c, 1.0) + drive.call("enter") + var model: RefCounted = drive.get("model") + for _k in range(roundi(1.5 / DT)): + drive.call("drive_step", 0.35, 0.0, 0.0, 0.0, DT) + var v0 := float(model.call("speed")) + K.check(fails, drive.call("leave") == true, "F at %.0f km/h gets out once stopped" % (v0 / KMH)) + var entry: RefCounted = drive.get("entry") + var stopped := INF + for _k in range(roundi(4.0 / DT)): + drive.call("climb_step", DT) + ctx.player.update_view(DT) + if str(entry.get("kind")) == "out": + stopped = absf(float(model.call("speed"))) + break + K.check( + fails, + v0 > 8.0 * KMH and stopped < 0.2 * WS.M, + "it brakes from %.0f km/h to a stop first (%.2f m/s)" % [v0 / KMH, stopped / WS.M] + ) + drive.call("exit", true) + + +## F on the way to the door turns back: on foot where the hero stands, the car handed back. +func _check_turn_back() -> void: + var c := _place_car() + var p := ctx.player + _put_hero(c, -1.0) + drive.call("board") + for _k in range(roundi(0.6 / DT)): + drive.call("climb_step", DT) + p.update_view(DT) + var at := p.position + drive.call("_input", _press("vehicle_enter")) + K.check( + fails, + not bool(drive.call("_climbing")) and not bool(drive.get("active")) and p.input_enabled, + "F on the way to the door turns back" + ) + K.check( + fails, + p.position.distance_to(at) < 0.05 * WS.M and int(tr.call("driven_id")) == -1, + "where the hero stands, the car back with traffic" + ) + + +## A wall where the door swings: the hero gets in at once, and out at once on another side. +func _check_blocked() -> void: + var c := _place_car() + var b := Basis(Vector3.UP, c.yaw) + var mid := c.pos + b * Vector3(c.half_w + 0.35 * WS.M, 0.0, 0.0) + var ext := Vector3(0.12, 0.0, 0.12) * WS.M + (b * Vector3(0.0, 0.0, c.length * 0.4)).abs() + _put_hero(c, -1.0) + ctx.collision.add_test_box( + AABB(mid - Vector3(ext.x, 0.0, ext.z), Vector3(ext.x * 2.0, 1.5 * WS.M, ext.z * 2.0)) + ) + drive.call("board") + K.check( + fails, + bool(drive.get("active")) and not bool(drive.call("_climbing")), + "no room for the door: the hero gets in at once" + ) + var car := _car() + drive.call("leave") + for _k in range(10): + drive.call("climb_step", DT) + K.check( + fails, + not bool(drive.get("active")) and car != null and _side_of(car, ctx.player.position) < 0.0, + "and out at once on the other side" + ) + ctx.collision.clear_test_boxes() + + +## The flag off: in and out at once. +func _check_flag_off() -> void: + FeatureFlags.set_override("car_doors", false) + var c := _place_car() + _put_hero(c, 1.0) + drive.call("board") + var at_once_in := bool(drive.get("active")) and not bool(drive.call("_climbing")) + drive.call("leave") + var at_once_out := not bool(drive.get("active")) and not bool(drive.call("_climbing")) + K.check(fails, at_once_in and at_once_out, "car_doors off: F gets in and out at once") + FeatureFlags.set_override("car_doors", true) + + +# ------------------------------------------------------------------ helpers + + +func _vh() -> VH: + return tr.get("vehicles") + + +func _car() -> VH.Car: + return _vh().by_id.get(int(drive.get("car_id"))) + + +## A lane on a straight road with free ground all round a car standing halfway along it. +func _find_spot() -> bool: + var lg: LG = tr.get("lanes") + var dims: Vector3 = tr.call("dims", door_model) + for i: int in lg.tiles.keys(): + var t: LG.TileInfo = lg.tiles[i] + if t.piece != "straight" or t.bridge: + continue + for e: int in [0, 1]: + var seq: Array[int] = [i] + var ls: Array = lg.lanes_along(seq, LG.OPP[e], e) if t.mask & (1 << e) else [] + for l: LG.Lane in ls: + if _roomy(l, dims): + lane = l + return true + K.check(fails, false, "fixture: a straight road with room round a car") + return false + + +## Free ground 1.3 m off the body of a car of dims halfway along l: beside it at three points on +## both sides, in front of it and behind it. +func _roomy(l: LG.Lane, dims: Vector3) -> bool: + var c := _vh().make("car", "ambient", dims) + _vh().place(c, l, 0.5) + var xf := Transform3D(Basis(Vector3.UP, c.yaw), c.pos) + _vh().remove(c) + var w := dims.x * 0.5 + 1.3 * WS.M + var h := dims.z * 0.5 + 1.3 * WS.M + var around: Array[Vector3] = [Vector3(0.0, 0.0, h), Vector3(0.0, 0.0, -h)] + for z: float in [-0.5, 0.0, 0.5]: + around.append(Vector3(w, 0.0, z * dims.z)) + around.append(Vector3(-w, 0.0, z * dims.z)) + for q: Vector3 in around: + var pt := xf * q + var g := ctx.collision.walk_height(pt) + if is_nan(g) or ctx.collision.is_blocked(Vector3(pt.x, g, pt.z), WS.PLAYER_RADIUS): + return false + return true + + +## A fresh stopped car of door_model on the lane, every other vehicle gone, the hero on foot. +func _place_car() -> VH.Car: + if bool(drive.get("active")) or bool(drive.call("_climbing")): + drive.call("exit", true) + for o: VH.Car in _vh().cars.duplicate(): + _vh().remove(o) + ctx.collision.dynamic_boxes = [] + var c := _vh().make("car", "ambient", tr.call("dims", door_model)) + c.model = door_model + _vh().place(c, lane, 0.5) + c.v = 0.0 + tr.call("sync_visuals", DT) + return c + + +## The hero on foot 1.1 m out from car c's body: on its driver's side (side 1) or the far side +## (-1). +func _put_hero(c: VH.Car, side: float) -> void: + var xf := Transform3D(Basis(Vector3.UP, c.yaw), c.pos) + ctx.player.teleport(xf * Vector3(side * (c.half_w + 1.1 * WS.M), 0.0, 0.1 * WS.M), 0.0) + + +## How far p is out along car c's driver's side (+X in its frame; negative on the far side). +func _side_of(c: VH.Car, p: Vector3) -> float: + return (p - c.pos).dot(Basis(Vector3.UP, c.yaw).x) + + +## Runs the climb in or out until it ends (or max_s): the phases in order, the widest door, the +## hero's least distance to the car's body on the walk, and the seconds it took. +func _climb(car: VH.Car, max_s: float) -> Dictionary: + var phases: Array = [] + var wide := 0.0 + var near := INF + var secs := 0.0 + var entry: RefCounted = drive.get("entry") + var k := 0 + while secs < max_s and bool(drive.call("_climbing")): + drive.call("climb_step", DT) + ctx.player.update_view(DT) + if k % 4 == 0: + tr.call("sync_visuals", DT * 4.0) + k += 1 + secs += DT + var ph := str(entry.get("phase")) + if not ph.is_empty() and (phases.is_empty() or str(phases.back()) != ph): + phases.append(ph) + wide = maxf(wide, car.door) + if ph == "walk": + near = minf(near, float(tr.call("_body_dist", car, ctx.player.position))) + return {"phases": phases, "wide": wide, "near": near, "secs": secs} + + +## Two car_doors since the last clear: open, then shut, both at the driver's side of car. +func _slams_ok(car: VH.Car) -> bool: + if slams.size() != 2 or not bool(slams[0][1]) or bool(slams[1][1]): + return false + return _side_of(car, slams[0][0]) > 0.0 and _side_of(car, slams[1][0]) > 0.0 + + +func _press(action: String) -> InputEventAction: + var e := InputEventAction.new() + e.action = action + e.pressed = true + return e diff --git a/tools/tests/fp_car_doors_test.gd.uid b/tools/tests/fp_car_doors_test.gd.uid new file mode 100644 index 00000000..eec9228c --- /dev/null +++ b/tools/tests/fp_car_doors_test.gd.uid @@ -0,0 +1 @@ +uid://sga374emu25r diff --git a/tools/tests/fp_drive_test.gd b/tools/tests/fp_drive_test.gd index 0ecf4cf6..e52d2567 100644 --- a/tools/tests/fp_drive_test.gd +++ b/tools/tests/fp_drive_test.gd @@ -3,8 +3,9 @@ extends SceneTree ## it along a straight road, lane traffic stopping behind it, crashing into a wall (car_impact on ## the bus), sliding along a wall it meets at a glance, knocking a lane car off its lane (which ## finds its way back), 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). +## seated hero, keys and pad bindings, and teardown. Getting in and out is the instant way (the +## car_doors flag off; fp_car_doors_test covers the walk to the door). 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 D := preload("res://scripts/sim/sim_defs.gd") @@ -46,6 +47,7 @@ func _init() -> void: tr.set("ambient_enabled", false) drive.set_process(false) it.set_process(false) + FeatureFlags.set_override("car_doors", false) ctx.bus.car_impact.connect( func(at: Vector3, v: float, what: String) -> void: impacts.append([at, v, what]) )