diff --git a/README.md b/README.md index 6e9785ca..32febe67 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. `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 (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. The orbit view (`GRIDFALL_VIEW=orbit`): diff --git a/data/feature_flags.json b/data/feature_flags.json index 2edc5e32..2a052c32 100644 --- a/data/feature_flags.json +++ b/data/feature_flags.json @@ -15,6 +15,7 @@ "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."}, + "props": {"default": true, "description": "First-person feature: street props the player's car knocks over. Bins, benches, planters, mailboxes, bike racks and parking meters hit hard enough tumble off, come to rest and can be hit again; they stand up again out of sight. Slower hits and lamps, trees, bollards and hydrants hold like walls."}, "gmi_live": {"default": true, "description": "Kill switch for live GMI Cloud calls. Off: every call uses the replay cache or heuristics, as with GRIDFALL_OFFLINE=1."}, "analytics": {"default": false, "description": "Local product analytics to user://ops/analytics.jsonl (also GRIDFALL_ANALYTICS=1). Nothing is sent anywhere."}, "error_tracking": {"default": false, "description": "Write contextual error records to user://ops/errors.jsonl (also GRIDFALL_ERROR_LOG=1). In-memory error counters are always on."}, diff --git a/docs/api/index.md b/docs/api/index.md index 60369369..eb1c861d 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -25,12 +25,14 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too ## [scripts/fp/](scripts.fp.md) - [asset_paths.gd](scripts.fp.md#scriptsfpasset_pathsgd): First-person asset paths +- [body_contacts.gd](scripts.fp.md#scriptsfpbody_contactsgd): Contacts of a box body on the ground plane (a car) with the walker's world: CollisionWorld's boxes and cylinders that reach above the body's step and start below its roof, and ground it cannot drive onto (water and the bridge parapets, where walk_height is NAN, or a rise above WS.STEP_HEIGHT) - [city_directory.gd](scripts.fp.md#scriptsfpcity_directorygd) (`CityDirectory`): Human place names for the city: streets for road runs, addresses for lots, business and landmark names, district names - [fp_context.gd](scripts.fp.md#scriptsfpfp_contextgd) (`FpContext`): Service locator for first-person slices - [fp_feature.gd](scripts.fp.md#scriptsfpfp_featuregd) (`FpFeature`): A first-person feature module owned by one slice - [fp_features.gd](scripts.fp.md#scriptsfpfp_featuresgd): Every first-person feature, in setup order - [fp_mode.gd](scripts.fp.md#scriptsfpfp_modegd) (`FpMode`): First-person composition root: builds the context, bus, player, collision, HUD and every registered feature, owns time controls, pause, the mouse and the FP save section - [game_bus.gd](scripts.fp.md#scriptsfpgame_busgd) (`GameBus`): Signals that cross first-person slices +- [obb2d.gd](scripts.fp.md#scriptsfpobb2dgd): Oriented boxes and circles on the ground plane, as Vector2(x, z): overlap tests, contacts and inertia for the player's car body and the street props it knocks over - [road_topology.gd](scripts.fp.md#scriptsfproad_topologygd) (`RoadTopology`): Static road-tile helpers shared by collision, traffic, the directory, the minimap and the streetscape - [world_scale.gd](scripts.fp.md#scriptsfpworld_scalegd): First-person world scale @@ -47,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) 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 and knocked props) 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) @@ -69,6 +71,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 - [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 @@ -111,6 +114,11 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [poster_worker.gd](scripts.fp.posters.md#scriptsfppostersposter_workergd): One background thread that runs poster image jobs in order (slice D): decode, resize, WebP, mipmaps and VRAM compression - [wall_spot_baker.gd](scripts.fp.posters.md#scriptsfpposterswall_spot_bakergd): Offline bake of wall-poster spots (slice D) +## [scripts/fp/props/](scripts.fp.props.md) + +- [props_feature.gd](scripts.fp.props.md#scriptsfppropsprops_featuregd): Street props the player's car knocks over (integration feature "props") +- [tumble.gd](scripts.fp.props.md#scriptsfppropstumblegd): A street prop knocked over (props feature): its bounding box flies off, tips over about one of its own level axes, lands, rolls on over its edges while it comes down hard, slides to a stop and lies on a face + ## [scripts/fp/render/](scripts.fp.render.md) - [hlod.gd](scripts.fp.render.md#scriptsfprenderhlodgd): Visibility ranges and building LODs (slice G2) @@ -147,6 +155,7 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [people.gd](scripts.fp.traffic.md#scriptsfptrafficpeoplegd): Animated people from HC's roster (docs/fp/slices/E-traffic.md deliverable 9, TPS_PLAN §1.10-1.11) - [people_kit.gd](scripts.fp.traffic.md#scriptsfptrafficpeople_kitgd): Asset building for people.gd: the layered clips (a box held in both hands, a phone at the ear), the shared T0 AnimationTree, and HC's lod1 meshes as dither-fading T3 MultiMesh layers - [render.gd](scripts.fp.traffic.md#scriptsfptrafficrendergd): Draws E's agents (docs/fp/slices/E-traffic.md §3 Draw calls) +- [shoves.gd](scripts.fp.traffic.md#scriptsfptrafficshovesgd): Cars the player's car knocks (slice E): a hit car slides off with the momentum it was given and spins, braking on its tyres, and stops against walls and other cars - [sidewalks.gd](scripts.fp.traffic.md#scriptsfptrafficsidewalksgd): Pedestrian network (docs/fp/slices/E-traffic.md §3 Pedestrians): sidewalk polylines whose centrelines sit at the middle of WS.PED_BAND from the road centreline, joined around junction corners, around corner tiles and around cul-de-sacs, plus a zebra edge across every arm of a tee or cross - [traffic_feature.gd](scripts.fp.traffic.md#scriptsfptraffictraffic_featuregd): Traffic and pedestrians on lanes (slice E, docs/fp/slices/E-traffic.md): one road-agent system for first person - [trips.gd](scripts.fp.traffic.md#scriptsfptraffictripsgd): Resident trips (docs/fp/slices/E-traffic.md §4): the AI link diff --git a/docs/api/scripts.fp.audio.md b/docs/api/scripts.fp.audio.md index 89930d30..c2534b12 100644 --- a/docs/api/scripts.fp.audio.md +++ b/docs/api/scripts.fp.audio.md @@ -55,7 +55,7 @@ Slice I's sound assets (sounds/fp/, made by tools/audio/synth_fp_sfx.py): names, - `STEP_VARIANTS = 6` - `SPLASH_VARIANTS = 4`: The wet layer played over a step on soaked paving: footstep_splash_. - `LOOPS = [ "engine_idle", "engine_run", "siren_two_tone", "construction_loop", "fire_crackle", "...`: Every looping asset (their .import files set loop=true). -- `ONE_SHOTS = ["horn_short", "horn_long", "chat_blip", "chat_blip_soft", "alert_stinger"]` +- `ONE_SHOTS = [ "horn_short", "horn_long", "chat_blip", "chat_blip_soft", "alert_stinger", "crash_hea...` - `WAV_SOUNDS = [ "engine_idle", "engine_run", "siren_two_tone", "horn_short", "horn_long", "chat_blip"...`: Stored as WAV (with every footstep_*); everything else is .ogg. - `BUS_MUSIC = &"Music"`: Bus names (created by audio_feature.setup when missing). - `BUS_SFX = &"SFX"` @@ -71,6 +71,13 @@ Slice I's sound assets (sounds/fp/, made by tools/audio/synth_fp_sfx.py): names, - `BLIP_SOFT_DB = -19.0` - `BLIP_CHATTER_DB = -24.0` - `STINGER_DB = -12.0` +- `CRASH_MPS = 6.0`: The player's car hitting walls and cars (car_impact): crash_heavy from CRASH_MPS (m/s of closing speed) up, at full level from CRASH_FULL_MPS; crash_light below, at full level from CRASH_MPS. Knocked props (prop_knocked) are at full level from KNOCK_FULL_MPS. A softer hit keeps at least IMPACT_MIN_GAIN of the level. +- `CRASH_FULL_MPS = 14.0` +- `KNOCK_FULL_MPS = 6.0` +- `IMPACT_MIN_GAIN = 0.3` +- `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` ### Functions @@ -87,7 +94,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 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 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. ### Constants @@ -131,7 +138,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) 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 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. ### Constants @@ -205,6 +212,7 @@ Positional city sound (slice I): logical sources (engines, horns, sirens, constr - `static func engine_pitch(speed: float) -> float`: Engine pitch for a speed in u/s: 0.8 near standstill to 1.4 at about 95 km/h (1.1 at 50 km/h). - `static func estimate_db(type: String, db: float, d: float) -> float`: Estimated level (dB) of s at distance d: volume + inverse-distance gain (capped at max_db) and the linear fade to zero at max_distance, as AudioStreamPlayer3D computes them. - `func horn(p: Vector3, stream_name: String = "horn_short", _speed: float = 0.`: A one-shot horn at p (tests call it directly). +- `func shot(type: String, p: Vector3, stream_name: String, trim_db: float, pitch: float) -> Src`: A positional one-shot of `type` (a crash, a knocked prop) at p playing stream_name, trim_db on the type's level. ## `scripts/fp/audio/footsteps.gd` diff --git a/docs/api/scripts.fp.drive.md b/docs/api/scripts.fp.drive.md index bc755c49..d7279981 100644 --- a/docs/api/scripts.fp.drive.md +++ b/docs/api/scripts.fp.drive.md @@ -4,17 +4,51 @@ [Index](index.md) +## `scripts/fp/drive/car_body.gd` + +extends `RefCounted` + +The player's car as a rigid box on the ground plane. Each step it moves by the handling model's velocity in short substeps and resolves its contacts, deepest first: walls, posts and water edges (body_contacts.gd), other vehicles (traffic's bodies_near, pushed through bump_car) and street props (the props feature knocks them over when hit hard enough, else they stand firm). A contact pushes the body out and, while closing, gives an impulse at the contact point, so the car bounces a little off what it hits head-on, slides along what it hits at a glance and spins when it is hit off-centre. Dynamic boxes (stopped cars listed for the walker) are not used: the vehicles themselves are. + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `OBB = preload("res://scripts/fp/obb2d.gd")` +- `BC = preload("res://scripts/fp/body_contacts.gd")` +- `MODEL = preload("res://scripts/fp/drive/drive_model.gd")` +- `RESTITUTION = 0.25`: Share of the closing speed the car bounces back with; contacts closing slower than REST_SPEED do not bounce (a car pushed against a wall rests on it). +- `REST_SPEED = 1.0 * WS.M` +- `PROP_BOUNCE = 0.1`: Bounce off a street prop as it is knocked away. +- `FRICTION = 0.3`: Friction along a contact: a car scraping a wall loses some speed but keeps sliding. +- `SUBSTEP = 0.5`: A substep moves at most this share of the half width, or turns at most TURN_STEP (rad). +- `TURN_STEP = 0.08` +- `ITERATIONS = 4`: Contacts resolved per substep. +- `MAX_PUSH = 0.02`: The most one contact pushes the body out at once (u, at least a substep's travel): a car that starts overlapping something eases out instead of jumping. +- `MASS = 1.0`: Mass of the player's car; other bodies are given relative to a sedan (1.0). +- `IMPACT_SPEED = 1.5 * WS.M`: Hits closing faster than this are listed in hits. + +### Properties + +- `half`: Half width and half length (u), and height. +- `hits: Array`: The last move's hits closing faster than IMPACT_SPEED: [point Vector2, speed (u/s), what], what "wall" (anything fixed), "car" or "prop". + +### Functions + +- `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/drive_feature.gd` extends `FpFeature` -Getting into cars and driving them. Near a parked or stopped car, F (Y on a pad) gets in: the hero sits in the driver's seat and the camera chases the car. W / S or the triggers drive and brake (S reverses once stopped), A / D or the left stick steer, Space (A) is the handbrake, the mouse or right stick look around, V switches to the driver's view, and F again gets out beside the driver's door. The car belongs to the traffic feature (lane traffic stops for it and it stays where it is left); it collides with the walker's world as three circles along its body. +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. ### Constants - `WS = preload("res://scripts/fp/world_scale.gd")` - `MODEL = preload("res://scripts/fp/drive/drive_model.gd")` - `HUD = preload("res://scripts/fp/drive/drive_hud.gd")` +- `BODY = preload("res://scripts/fp/drive/car_body.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. @@ -37,6 +71,7 @@ Getting into cars and driving them. Near a parked or stopped car, F (Y on a pad) - `EYE_INBOARD = 0.6`: The driver view sits a little toward the middle so the door pillar does not fill the left side. - `SETTLE = 0.3`: Ground following: the body rises onto a curb at once and settles down at this rate (u/s). - `PROMPT_TICK = 0.15` +- `IMPACT_GAP = 0.25`: car_impact goes out at most this often (s), for the hardest hit. ### Properties @@ -82,7 +117,7 @@ The drive feature's HUD: the get-in / get-out key chip above the minimap line an extends `RefCounted` -Arcade handling for the player's car. Throttle and brake set the speed along the heading (brake reverses once stopped), the steering angle turns the car through a bicycle model, and the tyres bleed off sideways speed at a rate set by the surface, so a handbrake turn slides. World units (WS.M per metre) and seconds; the heading at yaw 0 is +Z, as for every vehicle. +Arcade handling for the player's car. Throttle and brake set the speed along the heading (brake reverses once stopped), the steering angle turns the car through a bicycle model, and the tyres bleed off sideways speed at a rate set by the surface, so a handbrake turn slides. Knocks (car_body.gd) add to the velocity and to a spin the tyres damp out. World units (WS.M per metre) and seconds; the heading at yaw 0 is +Z, as for every vehicle. ### Constants @@ -102,11 +137,13 @@ Arcade handling for the player's car. Throttle and brake set the speed along the - `GRIP = 9.0`: Sideways speed lost per second (1/s): tyres on asphalt, and on the handbrake. - `GRIP_HANDBRAKE = 1.4` - `SURFACES = { "asphalt": [1.0, 1.0], "bridge": [1.0, 1.0], "sidewalk": [0.9, 0.8], "lot": [0.85, 0....`: Grip and top-speed factors off the carriageway. -- `BOUNCE = 0.2`: Share of the speed kept (and reversed) after a crash. +- `SPIN_GRIP = 3.0`: Spin from knocks lost per second (1/s): tyres gripping, and on the handbrake. +- `SPIN_GRIP_HANDBRAKE = 0.8` ### Properties - `accel: float`: Signed acceleration along the heading over the last step (brake lights). +- `spin: float`: Yaw rate from knocks (rad/s, positive turns the heading toward +X at yaw 0), on top of the steering. ### Functions @@ -114,4 +151,3 @@ Arcade handling for the player's car. Throttle and brake set the speed along the - `func heading() -> Vector2` - `func speed() -> float`: Signed speed along the heading (u/s, negative in reverse). - `func step(throttle: float, brake: float, steer_in: float, handbrake: float, surface: String, dt: float) -> Vector2`: One step: throttle, brake and handbrake 0..1, steer -1 (left) .. 1 (right). Returns the xz displacement; yaw and vel move on. -- `func crash() -> void`: A crash: the car stops dead and rebounds a little. diff --git a/docs/api/scripts.fp.md b/docs/api/scripts.fp.md index 2c512140..164307fa 100644 --- a/docs/api/scripts.fp.md +++ b/docs/api/scripts.fp.md @@ -61,6 +61,29 @@ First-person asset paths. Slice H owns this list and produces the files; every c - `static func first_existing(paths: Array) -> String`: First path in the list that load_scene() can load, or "". - `static func load_scene(path: String) -> PackedScene`: The PackedScene at path, or null. Returns non-null only when ResourceLoader.exists(path) is true, load(path) is non-null and the result is a PackedScene. Cached per path. +## `scripts/fp/body_contacts.gd` + +extends `RefCounted` + +Contacts of a box body on the ground plane (a car) with the walker's world: CollisionWorld's boxes and cylinders that reach above the body's step and start below its roof, and ground it cannot drive onto (water and the bridge parapets, where walk_height is NAN, or a rise above WS.STEP_HEIGHT). Shared by the player's car (drive) and the cars it shoves (traffic). Points are Vector2(x, z); contacts are OBB2D's [normal, depth, point], the normal pushing the body out. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `OBB = preload("res://scripts/fp/obb2d.gd")` +- `PROBES = [0.004, 0.012, 0.025, 0.045, 0.07]`: A blocked outline sample looks for drivable ground this far out along an axis (u), then bisects between the last blocked and the first free probe. +- `BISECT = 4` +- `AXES = [Vector2(1, 0), Vector2(-1, 0), Vector2(0, 1), Vector2(0, -1)]` + +### Functions + +- `static func gather(col: CollisionWorld, c: Vector2, reach: float, feet: float, height: float) -> Dictionary`: The obstacles a body with its feet at `feet`, `height` tall, can meet within `reach` of c: {"boxes": Array[AABB], "cyls": PackedFloat32Array of (x, z, r), "ground": true when the tiles around c hold ground it cannot drive onto (outline samples are tested only then)}. +- `static func contacts(col: CollisionWorld, obs: Dictionary, c: Vector2, yaw: float, h: Vector2, feet: float) -> Array`: Contacts of the box (centre c, yaw, half extents h = (half width, half length)) with the gathered obstacles, and with undrivable ground when the gather found some, in no order. +- `static func ground(col: CollisionWorld, c: Vector2, u: Vector2, v: Vector2, h: Vector2, feet: float) -> Array`: Contacts with ground the box cannot drive onto, from samples round its outline: a blocked sample is pushed back along the axis (every such edge in the city runs along x or z), toward the box's middle, where drivable ground is nearest. +- `static func outline(c: Vector2, u: Vector2, v: Vector2, h: Vector2) -> PackedVector2Array`: Twelve points round the box: its corners, the middles of its ends and three more along each side. +- `static func drivable(col: CollisionWorld, p: Vector2, feet: float) -> bool`: True when a body with its feet at `feet` can drive onto the ground at p. + ## `scripts/fp/city_directory.gd` class_name `CityDirectory` · extends `RefCounted` @@ -219,6 +242,29 @@ Signals that cross first-person slices. Emitters and listeners are listed in doc - `world_ready()`: The world was rebuilt and every feature ran on_world_rebuilt(). - `card_action(action: String, tile: int)`: A button on a place card: action "ask_here" (C answers through brain.inspect_tile). Emitted by B. - `drive_changed(active: bool)`: The hero got into a car (true) or out of it (false). Emitted by the drive feature. +- `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. +- `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` + +extends `RefCounted` + +Oriented boxes and circles on the ground plane, as Vector2(x, z): overlap tests, contacts and inertia for the player's car body and the street props it knocks over. A box is its centre, its unit axes u and v (perpendicular) and its half extents along them, Vector2(along u, along v). A contact is [normal, depth, point]: the unit normal that pushes the first shape out of the second, how far it has to go, and a point of contact inside the overlap. + +### Constants + +- `EPS = 1e-6`: A corner this close outside the other box's faces still counts as inside it (u). + +### Functions + +- `static func side_axis(yaw: float) -> Vector2`: A box's local +X axis at yaw (heading +Z at yaw 0, as for every vehicle). +- `static func heading(yaw: float) -> Vector2`: A box's local +Z axis at yaw: its heading. +- `static func box_inertia(h: Vector2, m: float) -> float`: Moment of inertia about its centre of a solid box with half extents h and mass m. +- `static func near_rect(p: Vector2, r: float, lo: Vector2, hi: Vector2) -> bool`: True when the circle round p of radius r reaches into the rectangle lo..hi. +- `static func box_box(ca: Vector2, ua: Vector2, va: Vector2, ha: Vector2, cb: Vector2, ub: Vector2, vb: Vector2, hb: Vector2) -> Array`: Contact of box A (centre ca, axes ua va, half extents ha) with box B, or [] when they do not overlap. The normal is the separating axis of least overlap; the point is the mean of the corners of either box inside the other (the midpoint of the centres when none are). +- `static func box_aabb(ca: Vector2, ua: Vector2, va: Vector2, ha: Vector2, b: AABB) -> Array`: box_box() with the xz footprint of an axis-aligned world box. +- `static func box_circle(ca: Vector2, ua: Vector2, va: Vector2, ha: Vector2, c: Vector2, r: float) -> Array`: Contact of box A with the circle round c of radius r, or [] when they do not overlap. The point is the box's point nearest the circle's centre. +- `static func corners_inside(c: Vector2, u: Vector2, v: Vector2, h: Vector2, oc: Vector2, ou: Vector2, ov: Vector2, oh: Vector2) -> PackedVector2Array`: The corners of box O (centre oc, axes ou ov, half extents oh) that lie inside box C. ## `scripts/fp/road_topology.gd` diff --git a/docs/api/scripts.fp.props.md b/docs/api/scripts.fp.props.md new file mode 100644 index 00000000..e5a5c0e2 --- /dev/null +++ b/docs/api/scripts.fp.props.md @@ -0,0 +1,90 @@ + + +# `scripts/fp/props/` + +[Index](index.md) + +## `scripts/fp/props/props_feature.gd` + +extends `FpFeature` + +Street props the player's car knocks over (integration feature "props"). Bins, benches, planters, mailboxes, bike racks and parking meters hit hard enough for their kind fall: CityView knocks the batched prop down (not drawn, its collider out of CollisionWorld) and a copy of its meshes tumbles off (tumble.gd) over the walker's ground and off its walls, rolls over its edges and comes to rest on a face, where the car can hit it again. Hit slower, a prop holds and blocks like a wall; lamps, trees, bollards, hydrants, utility boxes and signs always hold. A fallen prop stands in its place again once it has lain RESPAWN seconds out of sight and away from the player, and at once when its tile is drawn again. The car's body (drive/car_body.gd) asks knockables_near() and knock(); prop_knocked on the bus reports every fall. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `TUMBLE = preload("res://scripts/fp/props/tumble.gd")` +- `OBB = preload("res://scripts/fp/obb2d.gd")` +- `KINDS = { "bin": [0.02, 0.8], "mailbox": [0.06, 2.5], "bench": [0.06, 2.0], "bench_park": [0.06...`: Knockable StreetDressing kinds: [mass (a sedan is 1), the slowest a hit may send the prop flying (m/s) for it to give way]. +- `MAX_DOWN = 24`: At most this many props lie fallen at once; past it the oldest stands up again. +- `RESPAWN = 20.0`: A fallen prop stands up again after lying this long (s) out of sight, with the player at least RESPAWN_CLEAR (u) from its place and no car on it. +- `RESPAWN_CLEAR = 1.0` +- `STEP = 1.0 / 60.0`: Tumbles move in steps of STEP (s), at most MAX_STEPS a frame. +- `MAX_STEPS = 4` +- `KICK_REPORT = 1.0 * WS.M`: Hits on a fallen prop slower than this (u/s) are not reported on the bus. +- `PHOTO_BACK = 0.16`: The photo shot: the camera stands PHOTO_BACK (u) in front of the prop, which a hit sends along its sidewalk at PHOTO_FLY (u/s). +- `PHOTO_FLY = 4.0 * WS.M` + +### Properties + +- `down: Array[Dictionary]`: Fallen props, oldest first: {"tile", "slot", "kind", "t": TUMBLE, "node": Node3D, "age": s}. + +### Functions + +- `func feature_id() -> String` +- `func setup(p_ctx: FpContext) -> void` +- `func on_world_rebuilt() -> void`: Boot, load or New City: every holder is new and every prop stands. +- `func teardown() -> void` +- `func self_test() -> Dictionary` +- `func knockables_near(p: Vector3, r: float) -> Array`: The props within r of p (feet) that a car can knock over, standing or fallen: {"c": Vector2 (xz), "r": reach round c, "box": AABB (world; standing boxes), "mass", "tile", "slot"}. +- `func knock(i: int, slot: int, at: Vector3, fly: Vector3) -> bool`: Knocks street prop `slot` of tile i over (fallen, hits it again): pushed at world point `at`, it flies off at the level velocity fly (u/s). False when it holds (not knockable, or standing and sent off slower than its kind gives way at) or, fallen, already moves away that fast. +- `func stand_all() -> void`: Stands every fallen prop up again. +- `func photo_shots() -> Array`: The photo shot: the knockable prop nearest the spawn point knocked along its sidewalk, seen from the road in front of it where it comes to rest. +- `func photo_prepare() -> void`: Photo tour: the shot's prop knocked over and already at rest. + +## `scripts/fp/props/tumble.gd` + +extends `RefCounted` + +A street prop knocked over (props feature): its bounding box flies off, tips over about one of its own level axes, lands, rolls on over its edges while it comes down hard, slides to a stop and lies on a face. The props feature draws it from xform() and gives it the ground (CollisionWorld.ground_height) and the walls (CollisionWorld.body_blocked). + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `GRAVITY = 9.8 * WS.M` +- `LIFT = 0.3`: A knocked prop leaves the ground at LIFT of its speed and tips at TIP rad/s for every m/s of speed per metre of its height, at most TIP_MAX; knocked over, at least TIP_MIN (enough to fall over any edge). +- `TIP = 0.6` +- `TIP_MIN = 4.0` +- `TIP_MAX = 10.0` +- `LONG = 1.5`: A prop whose footprint is LONG times longer one way than the other falls over a long side, whichever way it is pushed: a bench hit end-on does not stand up on its end. +- `SPIN = 0.5`: Hit off-centre it spins about the vertical at SPIN rad/s per m/s, at most SPIN_MAX. +- `SPIN_MAX = 6.0` +- `BOUNCE = 0.3`: A landing faster than BOUNCE_MIN bounces back with BOUNCE of its speed; every landing keeps LAND_SPIN of the spin. +- `BOUNCE_MIN = 0.8 * WS.M` +- `LAND_SPIN = 0.6` +- `FRICTION = 0.55`: Sliding friction on the ground (share of g), the braking of its spin there (rad/s^2) and the share of its speed a wall sends back. +- `SPIN_BRAKE = 8.0` +- `WALL_BOUNCE = 0.3` +- `ROLL_ON = 7.0`: Slamming onto a face while tipping faster than ROLL_ON (rad/s), it rolls on with ROLL_KEEP of that; short of the energy to get over an edge it rocks back at ROCK (rad/s). +- `ROLL_KEEP = 0.45` +- `ROCK = 0.5` +- `REST_SPEED = 0.05 * WS.M`: Flat on a face, sliding slower than REST_SPEED and turning slower than REST_SPIN it rests; after MAX_LIFE (s) it rests anyway. +- `REST_SPIN = 0.3` +- `MAX_LIFE = 8.0` + +### Properties + +- `start: Transform3D`: Where the prop stood: its base at the origin, yaw and uniform scale in the basis. +- `centre`: The centre of its box in its own scaled frame, and the box's half extents. +- `pos`: The box's world centre and velocity (u/s). +- `axis`: The level axis of its own frame it tips about (+-X or +-Z), the tip angle and its rate. +- `grounded: bool`: On the ground; lying on a face (angle a multiple of PI/2, no tip); at rest for good. + +### Functions + +- `func launch(xf: Transform3D, bb: AABB, at: Vector3, fly: Vector3) -> void`: Knocks over the prop that stands at xf with local bounds bb: pushed at world point `at`, it flies off at the level velocity fly (u/s). +- `func kick(at: Vector3, fly: Vector3) -> void`: Hits the prop (again, as it lies or tumbles): pushed at world point `at`, it gets the level velocity fly (u/s) on top of its own, leaves the ground, tips on when the push runs along its tip and spins when the push is off-centre. +- `func step(dt: float, floor_at: Callable, blocked: Callable) -> bool`: Moves the prop on by dt over the ground floor_at(p: Vector3) -> float (NAN reads as the datum), bouncing off what blocked(p: Vector3, radius: float, height: float) -> bool finds in the way of its box standing at p. False once it rests. +- `func xform() -> Transform3D`: The prop's frame now (scale included), for drawing. +- `func drop() -> float`: Height of the box's centre above its lowest point. diff --git a/docs/api/scripts.fp.street.md b/docs/api/scripts.fp.street.md index 1245e85e..10d8987a 100644 --- a/docs/api/scripts.fp.street.md +++ b/docs/api/scripts.fp.street.md @@ -33,7 +33,7 @@ First-person light profile (slice G1): shadow distance and cascades, SSAO and SS extends `RefCounted` -Street-level dressing that CityView adds to its tile holders in fp_mode (slice G1): furniture and street trees on road sidewalks, street-name signs at junctions, road wear and puddle decals, crosswalk ramps, sidewalk aprons in front of lots, vacant lots with lease boards and billboards, the bus shelter, and the street-level glass and leaf materials. Every blocking item is listed in its holder's `colliders` meta (holder-local, `pos` = centre of the base) before tile_built fires, and every prop root carries `prop` meta. Frequent small items are batched per CHUNK x CHUNK tiles into MultiMeshes under CityView's StreetProps root; flush() rebuilds the chunks whose tiles changed. +Street-level dressing that CityView adds to its tile holders in fp_mode (slice G1): furniture and street trees on road sidewalks, street-name signs at junctions, road wear and puddle decals, crosswalk ramps, sidewalk aprons in front of lots, vacant lots with lease boards and billboards, the bus shelter, and the street-level glass and leaf materials. Every blocking item is listed in its holder's `colliders` meta (holder-local, `pos` = centre of the base; batched items add their `slot` in placements()) before tile_built fires, and every prop root carries `prop` meta. Frequent small items are batched per CHUNK x CHUNK tiles into MultiMeshes under CityView's StreetProps root; flush() rebuilds the chunks whose tiles changed. ### Constants @@ -65,6 +65,8 @@ Street-level dressing that CityView adds to its tile holders in fp_mode (slice G - `func flush() -> void`: Rebuilds the MultiMeshes of every chunk whose tiles changed since the last flush. - `func placed_count(i: int) -> int`: Number of batched items placed on tile i (tests). - `func placements(i: int) -> Array`: The batched placements on tile i: Array of [kind, world Transform3D] (tests, A's debugging). +- `func set_down(i: int, slot: int, down: bool) -> void`: Knocks batched item `slot` of tile i down (or stands it up again), its index in placements(i) and its collider's "slot": a street prop knocked over on foot is not drawn and its collider is marked "down" (CollisionWorld skips it; the caller invalidates the tile there). Its chunk is redrawn at once. +- `func kit_of(kind: String) -> Dictionary`: The mesh parts of a batched kind ({"mesh", "xform", "mat"} each) and their bounds, as {"parts", "aabb"}; {} for an unknown kind. - `func dress_road(i: int, holder: Node3D, mask: int, kind_name: String, model: Node3D) -> void`: Furniture, signs, ramps and decals for road tile i (holder-local). kind_name is CityView's piece name (straight, lights, corner, tee, cross, end, bridge); model is the road GLB instance. - `func remount_cross_signs() -> void`: Re-hangs the blades of every built cross tile after the traffic feature appeared or went: its NE pole replaces G1's own (or G1's comes back), and the holder's `colliders` meta follows. Tiles built at boot, before the features exist, need it; the streetscape feature calls it from setup, which runs before FpMode rebuilds collision. - `func light_pool(parent: Node3D, p: Vector3) -> Decal`: A pool of lamplight under a bulb at parent-local p: an emissive decal over the ground it falls on, shown after dark (night_glow). It fades out beyond 8 u. diff --git a/docs/api/scripts.fp.traffic.md b/docs/api/scripts.fp.traffic.md index fd363b2f..85289220 100644 --- a/docs/api/scripts.fp.traffic.md +++ b/docs/api/scripts.fp.traffic.md @@ -509,6 +509,54 @@ Draws E's agents (docs/fp/slices/E-traffic.md §3 Draw calls). Vehicles: the ful - `func finish_static() -> void`: Writes the static (parked) layers. - `func clear() -> void`: Frees every pooled and live node. +## `scripts/fp/traffic/shoves.gd` + +extends `RefCounted` + +Cars the player's car knocks (slice E): a hit car slides off with the momentum it was given and spins, braking on its tyres, and stops against walls and other cars. A lane car brakes hard on its lane (stunned, brake lights on) while its body slides off it as a drawn offset; after a pause, longer the harder it was hit, its driver steers the body back onto the lane and drives on. Parked cars and the cars the player left move for good, riding up onto a curb they are knocked over. While a knocked car stands off its lane or its parking slot it is a block for lane traffic, like the player's car; a parked car left in the way is put back in its slot (towed) once it has stood out of sight for a while. + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `OBB = preload("res://scripts/fp/obb2d.gd")` +- `BC = preload("res://scripts/fp/body_contacts.gd")` +- `VH = preload("res://scripts/fp/traffic/vehicles.gd")` +- `SLIDE_BRAKE = 6.9 * WS.M`: Sliding tyres brake a knocked body at this rate (about 0.7 g) and its spin at SPIN_BRAKE (1/s); a spin below MIN_SPIN (rad/s) stops. +- `SPIN_BRAKE = 4.0` +- `MIN_SPIN = 0.02` +- `HOLD = 2.5`: A knocked lane car waits this long once its body stops, plus HOLD_PER_MPS for every m/s the hit gave it, at most HOLD_MAX (s). +- `HOLD_PER_MPS = 0.5` +- `HOLD_MAX = 12.0` +- `RETURN_SPEED = 1.2 * WS.M`: Then its body steers back onto the lane at this speed (u/s) and turn rate (rad/s), the car staying stunned at least STUN_MIN (s) at a time until it is back. +- `RETURN_TURN = 0.6` +- `STUN_MIN = 0.1` +- `MASS = {"car": 1.0, "bus": 6.0, "truck": 4.0, "emergency": 1.6, "bike": 0.12}`: Masses relative to a sedan (the player's car is 1), by kind; a car pulling into a slot does not give way (FIXED_MASS). +- `SEDAN_LENGTH = 0.24` +- `FIXED_MASS = 50.0` +- `OFF_BLOCK = 0.3 * WS.M`: A body off its lane or slot by more than this (u), or turned by more than OFF_TURN (rad), blocks lane traffic. +- `OFF_TURN = 0.2` +- `TOUCH = 0.001`: A move into an overlap deeper than this (u) is refused and the slide stops. +- `TOW_AFTER = 30.0`: A parked car knocked into the way is towed back to its slot after standing this long (s) out of sight. + +### Properties + +- `visible: Callable`: (p: Vector3) -> bool, true when p could be on screen (no towing in sight); unset: never. + +### Inner classes + +- `Shove` + +### Functions + +- `func setup(p_vehicles: VH, p_collision: CollisionWorld) -> void` +- `func clear() -> void` +- `static func mass_of(c: VH.Car) -> float`: Mass of c relative to a sedan. +- `func body_vel(c: VH.Car) -> Vector2`: The velocity of c's body now (u/s, xz): its lane motion plus its slide. +- `func is_shoved(c: VH.Car) -> bool`: True while c slides or makes its way back to its lane. +- `func bump(c: VH.Car, j: Vector2, at: Vector2) -> void`: The player's car hit c with impulse j (u/s times a sedan's mass) at xz point `at`: c's body takes the change in velocity and spin. A lane car keeps what it can of the part along its heading as lane speed (it never rolls back along its lane) and slides off with the rest. +- `func step(dt: float) -> void`: Moves every knocked body on by dt: slides, stops, waits and steers back; tows parked cars left in the way out of sight. +- `func blocks() -> Array`: Lane blocks for vehicles.gd (_gather_blocks): [xz centre, heading, half length, half width, xz velocity, car id] of every knocked lane car off its lane and every parked car knocked out of its slot (the player's own cars are blocks already). + ## `scripts/fp/traffic/sidewalks.gd` extends `RefCounted` @@ -579,6 +627,7 @@ Traffic and pedestrians on lanes (slice E, docs/fp/slices/E-traffic.md): one roa - `PPL = preload("res://scripts/fp/traffic/people.gd")` - `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")` - `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. @@ -591,6 +640,7 @@ Traffic and pedestrians on lanes (slice E, docs/fp/slices/E-traffic.md): one roa ### Properties - `people: PPL`: HC's animated people (deliverable 9); ok == false draws the legacy static people instead. +- `shoves: SHOVES`: Cars the player's car knocked: their slides, stuns and the way back onto their lanes. - `jitter_enabled: bool`: Test hooks: departure jitter on or off; auto_step false keeps _process from advancing time. - `ambient_enabled: bool`: Test hook: false stops the ambient filler (no spawns, no removals). @@ -632,7 +682,9 @@ Traffic and pedestrians on lanes (slice E, docs/fp/slices/E-traffic.md): one roa - `func visible_near(p: Vector3, r: float) -> Array`: Visible named residents near p (on foot, in a vehicle, or standing outdoors; never indoors). - `func is_resident_visible(id: String) -> bool` - `func pick(origin: Vector3, dir: Vector3, max_dist: float) -> Dictionary`: Interaction pick (S0 §10): residents on foot, ambient pedestrians, vehicles (parked included). -- `func blockers_near(p: Vector3, r: float) -> Array`: Optional, for A: world AABBs of stopped vehicles near p (parked cars included). While the player drives, moving vehicles block too, and the player's own car does not. +- `func blockers_near(p: Vector3, r: float) -> Array`: Optional, for A: world AABBs of stopped vehicles near p (parked cars included), never the car the player drives (its body meets the others through bodies_near). +- `func bodies_near(p: Vector3, r: float) -> Array`: For the drive feature's car body: every vehicle body that may reach within r of p, as [id, xz centre, yaw, Vector2(half width, half length), xz velocity, mass (a sedan is 1)]. The car the player drives is not listed. +- `func bump_car(id: int, j: Vector2, at: Vector2) -> void`: The player's car hit vehicle id with impulse j (xz, u/s times a sedan's mass) at xz point at: the car is knocked (shoves.gd). - `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. @@ -737,9 +789,11 @@ Every road vehicle (docs/fp/slices/E-traffic.md §3): ambient filler, resident c - `LOD_DT = 1.0 / 10.0` - `FAR_SPEED = 0.9`: Data-only vehicles beyond the traffic radius cruise at this share of their own speed. - `PLAYER = "player"`: Role of the player's own cars: never on a lane, posed by the drive feature or left standing. +- `STUN_BRAKE = 6.0 * WS.M`: A car the player's car hit brakes this hard while stunned (its driver stands on the brakes). ### Properties +- `extra_blocks: Callable`: Returns more blocks each step (shoves.gd: cars knocked off their lanes or slots). - `parked_version: int`: Bumped whenever the set of parked cars changes (the renderer's static layer rebuilds). - `on_arrive: Callable`: Called with (car) when a vehicle reaches its goal (owner reacts), and when one is removed. diff --git a/docs/api/scripts.fp.world.md b/docs/api/scripts.fp.world.md index 5e4a99a3..514742eb 100644 --- a/docs/api/scripts.fp.world.md +++ b/docs/api/scripts.fp.world.md @@ -44,6 +44,7 @@ Analytic on-foot collision (slice A; no physics engine, D2): walkable ground hei - `func setup(p_ctx: FpContext) -> void`: Stores the context; colliders are computed lazily from ctx.view. - `func rebuild_all() -> void`: Drops every cached collider (after view.setup). +- `func invalidate(i: int) -> void`: Invalidates tile i's colliders: its holder changed (a street prop knocked down or put back). - `func on_tile_built(i: int, _holder: Node3D) -> void`: Invalidates tile i's colliders. - `func on_tile_cleared(i: int) -> void`: Invalidates tile i's colliders. - `func add_test_box(b: AABB) -> void`: Test hook: a world box that blocks like any other (a ledge, a ceiling), listed in boxes() of every tile it overlaps. diff --git a/docs/api/scripts.world.md b/docs/api/scripts.world.md index cb8403eb..b96afb2d 100644 --- a/docs/api/scripts.world.md +++ b/docs/api/scripts.world.md @@ -157,6 +157,9 @@ Renders the CitySim grid in 3D: Kenney roads picked by neighbor mask, zone lots - `func site_decks(i: int) -> Array[Dictionary]`: Where people stand on site i's own parts as drawn now (ConstructionKit.decks_of: the pit, the slab as poured, the frame's floors shown so far, the scaffold's boards), in world space. - `func site_bounds(i: int) -> AABB`: Merged world-space bound of site_boxes(i): contains every blocking part the construction kit drew for the site on tile i, and can reach past the tile. AABB() when no site is drawn. For movement use site_boxes(i): this bound also covers the gaps between parts (the hoarding gate). - `func tile_holder(i: int) -> Node3D`: The Node3D holding everything drawn on tile i, or null. +- `func street_placements(i: int) -> Array`: The batched street props of tile i on foot: Array of [kind, world Transform3D], indexed by their colliders' "slot". +- `func set_street_prop_down(i: int, slot: int, down: bool) -> void`: Knocks batched street prop `slot` of tile i down (not drawn, its collider marked "down") or stands it up again. CollisionWorld needs invalidate(i) after. +- `func street_kit(kind: String) -> Dictionary`: A batched street prop kind's mesh parts ({"mesh", "xform", "mat"} each) and their bounds, as {"parts", "aabb"}; {} for an unknown kind. - `func set_camera(c: Camera3D) -> void`: Camera used for picking, shake and avatar picking (orbit rig camera or the FP camera). - `func set_night(n: float) -> void` - `func set_overlay(mode: String) -> void` diff --git a/docs/fp/ARCHITECTURE.md b/docs/fp/ARCHITECTURE.md index ec869900..2ee4d27d 100644 --- a/docs/fp/ARCHITECTURE.md +++ b/docs/fp/ARCHITECTURE.md @@ -101,6 +101,7 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th - `city_brain.gd` (AI activity): `stagger_mayors` (the activity feature sets it on foot, and teardown restores it). When it is on, `_on_day_started` queues the day's mayor turns in `turns` (`scripts/ai/mayor_turns.gd`, in an order that rotates daily) instead of running them at dawn. `_process` starts the k-th turn once `sim.day_progress()` reaches `MayorTurns.at(k)`, which spreads the turns over the first 90% of the day, and only while no sliced job or spread work is in flight (`MayorTurns.sim_idle`), so its actions never force a synchronous finish. `_on_day_ended` runs any turn still waiting; a mayor still deciding keeps the turn and takes it in `_day_gate` once free. `turns.next_turn()` reads the queue, and saves keep it under `turns` (`due` and `day`). Decision entries also carry `proposal` (the ordinance proposed this turn) and `votes` (the votes cast this turn). `GameBus.ping` reasons starting `ai:` are coloured by `hud_style.ping_color()` on the minimap and compass, and `FpHud` does not toast them. - `city_sim.gd` and `sim_defs.gd` (crews): `apply_player_order(mi, a)` is `apply_action` for the phone. The sites an order opens go to the player's `D.CREWS` crews instead of the contractors, and the order is kept in `crew_jobs` (`total` and `done` in crew seconds, `crew` -1 while it waits). `advance_crews(seconds)` moves the crews on. `_process` feeds it the frame time at game speed, holding time back while a sliced tick or spread work runs. Its sites go through their stages, finished orders open, and waiting orders take the crews that free up (`crew_job_started` / `crew_job_finished`, `cancelled` when a bulldoze clears the sites). `crew_schedule()`, `crew_wait()`, `crews_free()` and `crew_job_at(i)` answer the HUD, and saves keep the orders. `D.crew_seconds` / `D.build_crew_seconds` give the times: `CREW_MIN_S` (a park) to `CREW_MAX_S` (the airport) for buildings, `CREW_TILE_S` per road or bridge tile. - `construction_kit.gd` and `city_view.gd` (crews): `ConstructionKit.deck()` marks where people stand on a site's own parts: the ground among the rebar, the slab as poured, the slab a frame stands on (`SLAB_H`), every frame floor (each storey at least `FLOOR_H`) and the scaffold's boards, with the posts to keep clear. `decks_of()` reads the decks that show. The hoarding's gate faces the frontage road (else side `h % 4`) and is the `Site` root's `gate` meta. Each spoil berm blocks on its own, so the pit inside is open ground. `CityView.site_gate(i)` and `site_decks(i)` (world space) read them. +- `street_dressing.gd`, `city_view.gd` and `collision_world.gd` (props): each batched street prop's `colliders` entry carries its `slot`, its index in `StreetDressing.placements(i)`. `StreetDressing.set_down(i, slot, down)` hides a batched prop (its chunk is redrawn at once) and marks its collider `down`, which `CollisionWorld` skips; `kit_of(kind)` gives a kind's mesh parts and bounds. On foot `CityView.street_placements(i)`, `set_street_prop_down(i, slot, down)` and `street_kit(kind)` expose them, and `CollisionWorld.invalidate(i)` drops a tile's cached colliders after such a change. Traffic's `blockers_near` lists only stopped vehicles, also while the player drives: the player's car meets the others as bodies (`bodies_near`). - `FpMode.DEFAULT_SETTINGS` (D13): `camera_mode: "third"`, `tps_fov: 60.0`, `tps_distance_m: 3.2`, `shoulder: 1` (right; -1 left), `auto_shoulder: false`, `hero: "m"` (or `"f"`). `"fov"` stays the first-person FOV. `apply_settings(patch, persist := true)`: `persist = false` changes the settings for this run without writing `user://gridfall_fp_settings.json` (the photo tour and `fp_bench` use it). ## 5. Slices (built in parallel after S0) @@ -112,18 +113,19 @@ 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. 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. 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), and a UI chat blip. 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) 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. | | **Integration: city overview** | `scripts/fp/overview/**`, `tools/tests/fp_overview_test.gd` | Tab on foot: an aerial camera over the city (through `FpPlayer.set_view_override`, so `player.camera` stays the one camera) with a marker per open construction site and a site list. N / B pick sites, F walks the hero to a vantage point by the site, G sets a waypoint, Tab or Esc return. "Planner view" leaves for orbit; Tab in orbit comes back to where the hero stood (`Main._fp_saved`). | -| **Integration: driving** | `scripts/fp/drive/**`, `tools/tests/fp_drive_test.gd` | F by a car takes it over (its resident's trip ends; traffic hands over a `player`-role car of the same model that the AI no longer steps). An arcade bicycle model (`drive_model.gd`: throttle, brake, reverse, speed-scaled steering, grip by surface, handbrake slide) moves it through `TrafficFeature.drive_car`; the hero sits in the driver's seat. Chase camera (sweep-checked) or driver view on V, a speedometer and gear chip, crashes bounce off `CollisionWorld` boxes. AI cars and walkers yield to player cars as oriented boxes. F again gets out on the driver's side when slow (under 8.5 m/s) and there is room; a left car is removed once out of range and out of sight. | +| **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: 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. | +| **Integration: props** | `scripts/fp/props/**`, `tools/tests/fp_props_test.gd` | Street props the player's car knocks over. A bin, bench, planter, mailbox, bike rack or parking meter hit hard enough for its kind (`KINDS`: its mass, and the slowest the hit may send it flying for it to give way) falls: CityView knocks the batched prop down (not drawn, its collider marked `down`) and a copy of its meshes tumbles off (`tumble.gd`: it flies, tips over about one of its level axes, lands, rolls over its edges, slides and comes to rest on a face) over the walker's ground and off its walls. A fallen prop can be hit again. Slower hits, and lamps, trees, bollards, hydrants, utility boxes and signs, hold like walls. At most 24 lie fallen at once. A fallen prop stands up in its place again once it has lain 20 s and neither it nor its place is in sight, with the player away and no car on its place, and at once when its tile is drawn again. `prop_knocked` on the bus reports every fall (I plays the clatter). | ## 6. Rules for every slice diff --git a/docs/fp/S0-foundation.md b/docs/fp/S0-foundation.md index 40625e45..f675f519 100644 --- a/docs/fp/S0-foundation.md +++ b/docs/fp/S0-foundation.md @@ -677,6 +677,14 @@ signal modal_changed(active: bool) signal world_ready() ## A button on a place card: action "ask_here" (C answers through brain.inspect_tile). Emitted by B. signal card_action(action: String, tile: int) +## The hero got into a car (true) or out of it (false). Emitted by the drive feature. +signal drive_changed(active: bool) +## 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) +## 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) ``` | Signal | Payload | Emitted by | Typical listeners | @@ -697,6 +705,9 @@ signal card_action(action: String, tile: int) | `modal_changed` | any modal UI is open | FpContext | A, B | | `world_ready` | none | FpMode after `on_world_rebuilt` | B (minimap full repaint), B, C, D (close transient UI, §7.2) | | `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) | +| `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 2c0c668a..3950e750 100644 --- a/docs/fp/slices/I-audio.md +++ b/docs/fp/slices/I-audio.md @@ -122,6 +122,7 @@ Settings map `music_volume_db` to `Music` and `sfx_volume_db` to `SFX` and `Ambi | `night_insects` | | | `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 | 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 f95e49f8..9343c60c 100644 --- a/scripts/fp/audio/audio_bank.gd +++ b/scripts/fp/audio/audio_bank.gd @@ -19,10 +19,14 @@ const LOOPS: Array[String] = [ "engine_idle", "engine_run", "siren_two_tone", "construction_loop", "fire_crackle", "river_loop", "river_bed", "fountain_loop", "plant_hum", "park_birds", "night_insects", "traffic_hum", "rain", ] -const ONE_SHOTS: Array[String] = ["horn_short", "horn_long", "chat_blip", "chat_blip_soft", "alert_stinger"] +const ONE_SHOTS: Array[String] = [ + "horn_short", "horn_long", "chat_blip", "chat_blip_soft", "alert_stinger", "crash_heavy", + "crash_light", "prop_clatter", "prop_clang", +] ## 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", + "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", ] ## Bus names (created by audio_feature.setup when missing). @@ -52,6 +56,21 @@ const BLIP_DB := -16.0 const BLIP_SOFT_DB := -19.0 const BLIP_CHATTER_DB := -24.0 const STINGER_DB := -12.0 +## The player's car hitting walls and cars (car_impact): crash_heavy from CRASH_MPS (m/s of +## closing speed) up, at full level from CRASH_FULL_MPS; crash_light below, at full level from +## CRASH_MPS. Knocked props (prop_knocked) are at full level from KNOCK_FULL_MPS. A softer hit +## keeps at least IMPACT_MIN_GAIN of the level. +const CRASH_MPS := 6.0 +const CRASH_FULL_MPS := 14.0 +const KNOCK_FULL_MPS := 6.0 +const IMPACT_MIN_GAIN := 0.3 +## The one-shot a knocked prop of each kind plays (KNOCK_DEFAULT for the metal ones), at most one +## per KNOCK_GAP (s). +const KNOCK_STREAMS := { + "bin": "prop_clatter", "bench_park": "prop_clatter", "planter": "crash_light", +} +const KNOCK_DEFAULT := "prop_clang" +const KNOCK_GAP := 0.1 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 52e78cf8..2bb639a3 100644 --- a/scripts/fp/audio/audio_feature.gd +++ b/scripts/fp/audio/audio_feature.gd @@ -1,7 +1,8 @@ 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), +## (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. @@ -52,6 +53,8 @@ var _last_stinger: float = -INF var _duck_target: float = 0.0 var _sfx_db: float = 0.0 var _talking: Dictionary = {} +var _last_knock: float = -INF +var _rng := RandomNumberGenerator.new() var _links: Array = [] # [Signal, Callable] var _muffle: Tween @@ -62,6 +65,7 @@ func feature_id() -> String: func setup(p_ctx: FpContext) -> void: super.setup(p_ctx) + _rng.seed = 4421 BANK.preload_all() _make_buses() emitters = EMITTERS.new() @@ -95,6 +99,8 @@ func setup(p_ctx: FpContext) -> void: _link(bus.conversation_changed, _on_conversation_changed) _link(bus.player_tile_changed, _on_player_tile_changed) _link(bus.time_scale_changed, _on_time_scale_changed) + _link(bus.car_impact, _on_car_impact) + _link(bus.prop_knocked, _on_prop_knocked) if ctx.sim != null: _link(ctx.sim.event_started, emitters.on_event) _link(ctx.sim.event_ended, emitters.on_event_ended) @@ -308,6 +314,32 @@ func _on_time_scale_changed(s: int) -> void: emitters.time_scale = s +## The player's car hit a wall or a car (car_impact): a crash at the contact from BANK.CRASH_MPS +## of closing speed, a bump below, louder the harder it closes. +func _on_car_impact(at: Vector3, speed: float, _what: String) -> void: + var mps := speed / WS.M + var heavy := mps >= BANK.CRASH_MPS + var full := BANK.CRASH_FULL_MPS if heavy else BANK.CRASH_MPS + var s := "crash_heavy" if heavy else "crash_light" + emitters.shot("crash", at, s, _impact_db(mps / full), _rng.randf_range(0.92, 1.05)) + + +## A street prop knocked over or hit again (prop_knocked): its kind's clatter at the hit, at most +## one per BANK.KNOCK_GAP. +func _on_prop_knocked(at: Vector3, kind: String, speed: float) -> void: + if _now - _last_knock < BANK.KNOCK_GAP: + return + _last_knock = _now + var s := str(BANK.KNOCK_STREAMS.get(kind, BANK.KNOCK_DEFAULT)) + var trim := _impact_db(speed / WS.M / BANK.KNOCK_FULL_MPS) + emitters.shot("knock", at, s, trim, _rng.randf_range(0.94, 1.06)) + + +## 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)) + + func _on_site_started(site: Dictionary) -> void: emitters.add_site(int(site["i"])) diff --git a/scripts/fp/audio/emitters.gd b/scripts/fp/audio/emitters.gd index 23b3b481..fb639997 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) share a pool of at most -## MAX_PLAYERS AudioStreamPlayer3D. +## 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). @@ -27,6 +27,8 @@ const TYPES := { "plant": {"max": 6.0, "db": -2.0, "near": 6.0, "stream": "plant_hum"}, "river": {"max": 6.0, "db": -5.0, "near": 6.0, "stream": "river_loop"}, "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"}, } ## 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 @@ -99,6 +101,7 @@ var agents_provider: Callable = Callable() var time_scale: int = 1 var enabled: bool = true var horns_played: int = 0 +var shots_played: int = 0 var walker_steps: int = 0 ## 0 day .. 1 night (SkyDirector.night); construction falls quiet at night. var night: float = 0.0 @@ -117,6 +120,7 @@ var _next_horn: float = -INF # no horn before this time (the city-wide var _honked: Dictionary = {} # agent id -> time of its last horn var _now: float = 0.0 var _horn_seq: int = 0 +var _shot_seq: int = 0 var _banks: Dictionary = {} # Vector2i chunk -> PackedVector3Array of bank points var _static_near: Array[Src] = [] var _static_dirty: bool = true @@ -749,6 +753,17 @@ func horn(p: Vector3, stream_name: String = "horn_short", _speed: float = 0.0) - "pitch": _rng.randf_range(0.96, 1.04), "trim_db": _rng.randf_range(-2.0, 1.0), "ttl": 2.5}) +## A positional one-shot of `type` (a crash, a knocked prop) at p playing stream_name, trim_db +## on the type's level. +func shot(type: String, p: Vector3, stream_name: String, trim_db: float, pitch: float) -> Src: + _shot_seq += 1 + shots_played += 1 + _force = true + var opts := {"stream": stream_name, "one_shot": true, "pitch": pitch, "trim_db": trim_db} + opts["ttl"] = 3.0 + return request("shot:%d" % _shot_seq, type, p, opts) + + func _traffic() -> FpFeature: var f := ctx.feature("traffic") if ctx != null else null return f if f != null and f.has_method("agents_near") else null diff --git a/scripts/fp/body_contacts.gd b/scripts/fp/body_contacts.gd new file mode 100644 index 00000000..c965657b --- /dev/null +++ b/scripts/fp/body_contacts.gd @@ -0,0 +1,149 @@ +extends RefCounted +## Contacts of a box body on the ground plane (a car) with the walker's world: CollisionWorld's +## boxes and cylinders that reach above the body's step and start below its roof, and ground it +## cannot drive onto (water and the bridge parapets, where walk_height is NAN, or a rise above +## WS.STEP_HEIGHT). Shared by the player's car (drive) and the cars it shoves (traffic). Points are +## Vector2(x, z); contacts are OBB2D's [normal, depth, point], the normal pushing the body out. + +const D := preload("res://scripts/sim/sim_defs.gd") +const WS := preload("res://scripts/fp/world_scale.gd") +const OBB := preload("res://scripts/fp/obb2d.gd") + +## A blocked outline sample looks for drivable ground this far out along an axis (u), then +## bisects between the last blocked and the first free probe. +const PROBES: Array[float] = [0.004, 0.012, 0.025, 0.045, 0.07] +const BISECT := 4 +const AXES: Array[Vector2] = [Vector2(1, 0), Vector2(-1, 0), Vector2(0, 1), Vector2(0, -1)] + + +## The obstacles a body with its feet at `feet`, `height` tall, can meet within `reach` of c: +## {"boxes": Array[AABB], "cyls": PackedFloat32Array of (x, z, r), "ground": true when the tiles +## around c hold ground it cannot drive onto (outline samples are tested only then)}. +static func gather( + col: CollisionWorld, c: Vector2, reach: float, feet: float, height: float +) -> Dictionary: + var boxes: Array[AABB] = [] + var cyls := PackedFloat32Array() + var seen := {} + var top := feet + WS.STEP_HEIGHT + var head := feet + height + var rough := false + var cx := roundi(c.x) + var cz := roundi(c.y) + for dz in range(-1, 2): + for dx in range(-1, 2): + if not D.in_bounds(cx + dx, cz + dz): + continue + var i := D.idx(cx + dx, cz + dz) + rough = rough or _rough(col, i, feet) + for b: AABB in col.boxes(i): + if b.end.y <= top or b.position.y >= head or seen.has(b): + continue + var lo := Vector2(b.position.x, b.position.z) + if OBB.near_rect(c, reach, lo, Vector2(b.end.x, b.end.z)): + seen[b] = true + boxes.append(b) + var cy := col.cylinders(i) + for k in range(0, cy.size(), 4): + var q := Vector2(cy[k], cy[k + 1]) + if cy[k + 3] > top and q.distance_to(c) < reach + cy[k + 2]: + cyls.append_array([q.x, q.y, cy[k + 2]]) + return {"boxes": boxes, "cyls": cyls, "ground": rough} + + +## Contacts of the box (centre c, yaw, half extents h = (half width, half length)) with the +## gathered obstacles, and with undrivable ground when the gather found some, in no order. +static func contacts( + col: CollisionWorld, obs: Dictionary, c: Vector2, yaw: float, h: Vector2, feet: float +) -> Array: + var u := OBB.side_axis(yaw) + var v := OBB.heading(yaw) + var reach := h.length() + var out: Array = [] + for b: AABB in obs["boxes"]: + if OBB.near_rect(c, reach, Vector2(b.position.x, b.position.z), Vector2(b.end.x, b.end.z)): + var hit := OBB.box_aabb(c, u, v, h, b) + if not hit.is_empty(): + out.append(hit) + var cy: PackedFloat32Array = obs["cyls"] + for k in range(0, cy.size(), 3): + var q := Vector2(cy[k], cy[k + 1]) + if q.distance_to(c) < reach + cy[k + 2]: + var hit := OBB.box_circle(c, u, v, h, q, cy[k + 2]) + if not hit.is_empty(): + out.append(hit) + if bool(obs["ground"]): + out.append_array(ground(col, c, u, v, h, feet)) + return out + + +## Contacts with ground the box cannot drive onto, from samples round its outline: a blocked +## sample is pushed back along the axis (every such edge in the city runs along x or z), toward +## the box's middle, where drivable ground is nearest. +static func ground( + col: CollisionWorld, c: Vector2, u: Vector2, v: Vector2, h: Vector2, feet: float +) -> Array: + var out: Array = [] + for s: Vector2 in outline(c, u, v, h): + if drivable(col, s, feet): + continue + var best := INF + var n := Vector2.ZERO + for ax: Vector2 in AXES: + if ax.dot(c - s) < -1e-6: + continue + var d := _free_along(col, s, ax, feet, best) + if d < best: + best = d + n = ax + if n != Vector2.ZERO: + out.append([n, best, s]) + return out + + +## Twelve points round the box: its corners, the middles of its ends and three more along each +## side. +static func outline(c: Vector2, u: Vector2, v: Vector2, h: Vector2) -> PackedVector2Array: + var out := PackedVector2Array() + for sx: float in [-1.0, 1.0]: + for f: float in [-1.0, -0.5, 0.0, 0.5, 1.0]: + out.append(c + u * (h.x * sx) + v * (h.y * f)) + for f: float in [-1.0, 1.0]: + out.append(c + v * (h.y * f)) + return out + + +## True when a body with its feet at `feet` can drive onto the ground at p. +static func drivable(col: CollisionWorld, p: Vector2, feet: float) -> bool: + var g := col.walk_height(Vector3(p.x, feet, p.y)) + return not is_nan(g) and g - feet <= WS.STEP_HEIGHT + 1e-6 + + +## Distance along ax from s to drivable ground (bisected), INF past the last probe or `limit`. +static func _free_along( + col: CollisionWorld, s: Vector2, ax: Vector2, feet: float, limit: float +) -> float: + var lo := 0.0 + for d: float in PROBES: + if d >= limit: + return INF + if drivable(col, s + ax * d, feet): + var hi := d + for _k in range(BISECT): + var mid := (lo + hi) * 0.5 + if drivable(col, s + ax * mid, feet): + hi = mid + else: + lo = mid + return hi + lo = d + return INF + + +## True when tile i may hold ground a body at `feet` cannot drive onto: water (bridges included) +## or a surface above its step. +static func _rough(col: CollisionWorld, i: int, feet: float) -> bool: + var sim := col.ctx.sim if col.ctx != null else null + if sim != null and sim.terrain[i] == D.WATER: + return true + return not drivable(col, Vector2(D.tx(i), D.ty(i)), feet) diff --git a/scripts/fp/body_contacts.gd.uid b/scripts/fp/body_contacts.gd.uid new file mode 100644 index 00000000..c89a5120 --- /dev/null +++ b/scripts/fp/body_contacts.gd.uid @@ -0,0 +1 @@ +uid://cugfjryvtiisn diff --git a/scripts/fp/drive/car_body.gd b/scripts/fp/drive/car_body.gd new file mode 100644 index 00000000..8a71a78f --- /dev/null +++ b/scripts/fp/drive/car_body.gd @@ -0,0 +1,248 @@ +extends RefCounted +## The player's car as a rigid box on the ground plane. Each step it moves by the handling model's +## velocity in short substeps and resolves its contacts, deepest first: walls, posts and water +## edges (body_contacts.gd), other vehicles (traffic's bodies_near, pushed through bump_car) and +## street props (the props feature knocks them over when hit hard enough, else they stand firm). +## A contact pushes the body out and, while closing, gives an impulse at the contact point, so the +## car bounces a little off what it hits head-on, slides along what it hits at a glance and spins +## when it is hit off-centre. Dynamic boxes (stopped cars listed for the walker) are not used: the +## vehicles themselves are. + +const WS := preload("res://scripts/fp/world_scale.gd") +const OBB := preload("res://scripts/fp/obb2d.gd") +const BC := preload("res://scripts/fp/body_contacts.gd") +const MODEL := preload("res://scripts/fp/drive/drive_model.gd") + +## Share of the closing speed the car bounces back with; contacts closing slower than REST_SPEED +## do not bounce (a car pushed against a wall rests on it). +const RESTITUTION := 0.25 +const REST_SPEED := 1.0 * WS.M +## Bounce off a street prop as it is knocked away. +const PROP_BOUNCE := 0.1 +## Friction along a contact: a car scraping a wall loses some speed but keeps sliding. +const FRICTION := 0.3 +## A substep moves at most this share of the half width, or turns at most TURN_STEP (rad). +const SUBSTEP := 0.5 +const TURN_STEP := 0.08 +## Contacts resolved per substep. +const ITERATIONS := 4 +## The most one contact pushes the body out at once (u, at least a substep's travel): a car that +## starts overlapping something eases out instead of jumping. +const MAX_PUSH := 0.02 +## Mass of the player's car; other bodies are given relative to a sedan (1.0). +const MASS := 1.0 +## Hits closing faster than this are listed in hits. +const IMPACT_SPEED := 1.5 * WS.M + +## Half width and half length (u), and height. +var half := Vector2(0.045, 0.12) +var height: float = 0.075 +var inertia: float = OBB.box_inertia(Vector2(0.045, 0.12), MASS) +## The last move's hits closing faster than IMPACT_SPEED: [point Vector2, speed (u/s), what], +## what "wall" (anything fixed), "car" or "prop". +var hits: Array = [] +var _c := Vector2.ZERO +var _feet: float = 0.0 +var _omega: float = 0.0 +var _push: float = MAX_PUSH +var _reach: float = 0.0 +var _col: CollisionWorld +var _traffic: Object +var _props: Object +var _obs: Dictionary = {} +var _cars: Array = [] +var _knock: Array = [] + + +func setup(half_w: float, length: float, p_height: float) -> void: + half = Vector2(half_w, length * 0.5) + height = p_height + inertia = OBB.box_inertia(half, MASS) + + +## 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. +func move( + model: MODEL, + pos: Vector3, + yaw0: float, + dt: float, + col: CollisionWorld, + traffic: Object, + props: Object +) -> Vector3: + hits.clear() + _col = col + _traffic = traffic if traffic != null and traffic.has_method("bump_car") else null + _props = props if props != null and props.has_method("knock") else null + _c = Vector2(pos.x, pos.z) + _feet = pos.y + var turn := angle_difference(yaw0, model.yaw) + var travel := model.vel.length() * dt + var n := maxi(1, ceili(maxf(travel / (half.x * SUBSTEP), absf(turn) / TURN_STEP))) + _push = maxf(MAX_PUSH, travel / float(n) * 1.5) + _reach = half.length() + travel + 0.05 + _omega = turn / dt + _gather(pos) + for k in range(n): + model.yaw = wrapf(yaw0 + turn * float(k + 1) / float(n), -PI, PI) + _c += model.vel * (dt / float(n)) + for _it in range(ITERATIONS): + if not _resolve(model): + break + _col = null + _traffic = null + _props = null + _cars = [] + _knock = [] + return Vector3(_c.x, pos.y, _c.y) + + +func _gather(pos: Vector3) -> void: + _obs = BC.gather(_col, _c, _reach, _feet, height) + _cars = _traffic.call("bodies_near", pos, _reach + 0.2) if _traffic != null else [] + _knock = _props.call("knockables_near", pos, _reach) if _props != null else [] + + +## Resolves the deepest contact of the body as it stands; false when it touches nothing. +func _resolve(model: MODEL) -> bool: + var u := OBB.side_axis(model.yaw) + var v := OBB.heading(model.yaw) + if _knock_props(model, u, v): + return true + var best: Array = [] + var other: Array = [] + for hit: Array in BC.contacts(_col, _obs, _c, model.yaw, half, _feet): + if best.is_empty() or float(hit[1]) > float(best[1]): + best = hit + for car: Array in _cars: + var hit := _car_contact(car, u, v) + if not hit.is_empty() and (best.is_empty() or float(hit[1]) > float(best[1])): + best = hit + other = car + if best.is_empty(): + return false + var nrm: Vector2 = best[0] + var p: Vector2 = best[2] + _c += nrm * minf(float(best[1]), _push) + if other.is_empty(): + _impulse(model, nrm, p, Vector2.ZERO, Vector2.ZERO, 0.0, 0.0, "wall") + return true + var mb := float(other[5]) + var ib := 1.0 / OBB.box_inertia(other[3], mb) + var cb: Vector2 = other[1] + var j := _impulse(model, nrm, p, other[4], p - cb, 1.0 / mb, ib, "car") + if j != Vector2.ZERO: + other[4] = (other[4] as Vector2) - j / mb + _traffic.call("bump_car", int(other[0]), -j, p) + return true + + +## Contact with vehicle `car` ([id, centre, yaw, half extents, velocity, mass]), or []. +func _car_contact(car: Array, u: Vector2, v: Vector2) -> Array: + var cb: Vector2 = car[1] + var hb: Vector2 = car[3] + var reach := half.length() + hb.length() + if cb.distance_squared_to(_c) > reach * reach: + return [] + var yb := float(car[2]) + return OBB.box_box(_c, u, v, half, cb, OBB.side_axis(yb), OBB.heading(yb), hb) + + +## Applies a contact's impulse (normal nrm pushing the car out, at point p) against a body moving +## at vb, p - its centre = rb, with inverse mass im and inverse inertia ii (0, 0: fixed). Returns +## the impulse on the car (the other body takes its opposite); none while they separate. +func _impulse( + model: MODEL, + nrm: Vector2, + p: Vector2, + vb: Vector2, + rb: Vector2, + im: float, + ii: float, + what: String +) -> Vector2: + var r := p - _c + var rel := model.vel + _perp(r) * _omega - vb + var vn := rel.dot(nrm) + if vn >= 0.0: + return Vector2.ZERO + var ra := _cross(r, nrm) + var rbn := _cross(rb, nrm) + var k := 1.0 / MASS + im + ra * ra / inertia + rbn * rbn * ii + var e := RESTITUTION if -vn > REST_SPEED else 0.0 + var jn := -(1.0 + e) * vn / k + var j := nrm * jn + var vt := rel - nrm * vn + var st := vt.length() + if st > 1e-6: + var t := vt / st + var rt := _cross(r, t) + var rbt := _cross(rb, t) + var kt := 1.0 / MASS + im + rt * rt / inertia + rbt * rbt * ii + j -= t * minf(st / kt, FRICTION * jn) + _apply(model, r, j) + if -vn > IMPACT_SPEED: + hits.append([p, -vn, what]) + return j + + +## Knocks over the first street prop the body runs into hard enough (the props feature decides +## from the speed it would fly off at); true when one fell, and the obstacles are gathered again +## without it. Props that hold stay among the obstacles. +func _knock_props(model: MODEL, u: Vector2, v: Vector2) -> bool: + for kn: Dictionary in _knock: + var q: Vector2 = kn["c"] + var reach := half.length() + float(kn["r"]) + if q.distance_squared_to(_c) > reach * reach: + continue + var hit: Array = ( + OBB.box_aabb(_c, u, v, half, kn["box"]) + if kn.has("box") + else OBB.box_circle(_c, u, v, half, q, float(kn["r"])) + ) + if hit.is_empty(): + continue + var nrm: Vector2 = hit[0] + var p: Vector2 = hit[2] + var r := p - _c + var va := model.vel + _perp(r) * _omega + var vn := va.dot(nrm) + if vn >= 0.0: + continue + var m := float(kn["mass"]) + var ra := _cross(r, nrm) + var jn := -(1.0 + PROP_BOUNCE) * vn / (1.0 / MASS + 1.0 / m + ra * ra / inertia) + # The prop flies off along the push, dragged a little along the car's slide. + var fly := -nrm * (jn / m) + (va - nrm * vn) * 0.5 + var at := Vector3(p.x, _feet, p.y) + if not bool( + _props.call("knock", int(kn["tile"]), int(kn["slot"]), at, Vector3(fly.x, 0.0, fly.y)) + ): + continue + _apply(model, r, nrm * jn) + if -vn > IMPACT_SPEED: + hits.append([p, -vn, "prop"]) + _knock.erase(kn) + _obs = BC.gather(_col, _c, _reach, _feet, height) + return true + return false + + +func _apply(model: MODEL, r: Vector2, j: Vector2) -> void: + model.vel += j / MASS + var dw := _cross(r, j) / inertia + model.spin += dw + _omega += dw + + +## The velocity direction of a point at offset r from the centre while the body's yaw grows. +static func _perp(r: Vector2) -> Vector2: + return Vector2(r.y, -r.x) + + +## Yaw rate an impulse j at offset r gives, times the inertia. +static func _cross(r: Vector2, j: Vector2) -> float: + return r.y * j.x - r.x * j.y diff --git a/scripts/fp/drive/car_body.gd.uid b/scripts/fp/drive/car_body.gd.uid new file mode 100644 index 00000000..b38103ee --- /dev/null +++ b/scripts/fp/drive/car_body.gd.uid @@ -0,0 +1 @@ +uid://tred0d4j24rk diff --git a/scripts/fp/drive/drive_feature.gd b/scripts/fp/drive/drive_feature.gd index ce195a79..15efa860 100644 --- a/scripts/fp/drive/drive_feature.gd +++ b/scripts/fp/drive/drive_feature.gd @@ -4,11 +4,14 @@ extends FpFeature ## brake (S reverses once stopped), A / D or the left stick steer, Space (A) is the handbrake, the ## mouse or right stick look around, V switches to the driver's view, and F again gets out beside ## the driver's door. The car belongs to the traffic feature (lane traffic stops for it and it -## stays where it is left); it collides with the walker's world as three circles along its body. +## 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. 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") ## Reach from the hero to a car's body for the get-in prompt. const ENTER_REACH := 2.0 * WS.M @@ -38,10 +41,13 @@ const EYE_INBOARD := 0.6 ## Ground following: the body rises onto a curb at once and settles down at this rate (u/s). const SETTLE := 0.3 const PROMPT_TICK := 0.15 +## car_impact goes out at most this often (s), for the hardest hit. +const IMPACT_GAP := 0.25 var active: bool = false var car_id: int = -1 var model := MODEL.new() +var body := BODY.new() ## "chase" or "driver". var view: String = "chase" var hud: HUD @@ -57,6 +63,7 @@ var _orbit_t: float = 0.0 var _prev_mode: String = "first" var _target: Dictionary = {} var _t: float = 0.0 +var _impact_t: float = 0.0 func feature_id() -> String: @@ -121,6 +128,7 @@ func enter() -> bool: _pos = info["pos"] _dims = Vector3(float(info["half_w"]), float(info["height"]), float(info["length"])) model.reset(float(info["yaw"]), _dims.z) + body.setup(_dims.x, _dims.z, _dims.y) _seat = null _hero_off = Vector3.ZERO _cam_yaw = model.yaw @@ -239,46 +247,32 @@ func drive_step(throttle: float, brake: float, steer: float, hand: float, dt: fl return var col := ctx.collision var yaw0 := model.yaw - var disp := model.step(throttle, brake, steer, hand, col.surface_at(_pos), dt) - var n := maxi(1, ceili(disp.length() / (_dims.x * 0.8))) - var to := _pos - for _k in range(n): - to = col.move_body( - to, Vector3(disp.x, 0.0, disp.y) / float(n), _dims.x, _dims.y, WS.STEP_HEIGHT - ) - var got := Vector2(to.x - _pos.x, to.z - _pos.z) - if not _ends_clear(to, _pos, yaw0): - model.yaw = yaw0 - to = _pos - got = Vector2.ZERO - if got.length() < disp.length() * 0.7: - if absf(model.speed()) > 2.0 * WS.M and got.length() < disp.length() * 0.2: - model.crash() - else: - model.vel = got / dt + model.step(throttle, brake, steer, hand, col.surface_at(_pos), dt) + var to := body.move(model, _pos, yaw0, dt, col, _traffic(), _props()) var g := col.walk_height(to) if not is_nan(g): to.y = g if g > _pos.y else move_toward(_pos.y, g, SETTLE * dt) _pos = to _traffic().call("drive_car", _pos, model.yaw, model.speed(), model.accel) _ride() + _impact_t -= dt + _report() -## True unless the front or rear of the body (yaw now) runs into something it was clear of at -## from (yaw0): a car that starts against a post can still drive away. -func _ends_clear(to: Vector3, from: Vector3, yaw0: float) -> bool: - var reach := maxf(_dims.z * 0.5 - _dims.x, 0.0) - var h := Vector3(sin(model.yaw), 0.0, cos(model.yaw)) - var h0 := Vector3(sin(yaw0), 0.0, cos(yaw0)) - for k: float in [-1.0, 1.0]: - var e := to + h * (k * reach) - e.y = to.y - if ctx.collision.body_blocked(e, _dims.x * 0.95, _dims.y): - var e0 := from + h0 * (k * reach) - e0.y = from.y - if not ctx.collision.body_blocked(e0, _dims.x * 0.95, _dims.y): - return false - return true +## Puts the last step's hardest hit on the bus (props report their own fall), at most once per +## IMPACT_GAP. +func _report() -> void: + if body.hits.is_empty() or ctx.bus == null or _impact_t > 0.0: + return + var best: Array = [] + for h: Array in body.hits: + if str(h[2]) != "prop" and (best.is_empty() or float(h[1]) > float(best[1])): + best = h + if best.is_empty(): + return + _impact_t = IMPACT_GAP + var p: Vector2 = best[0] + ctx.bus.car_impact.emit(Vector3(p.x, _pos.y + _dims.y * 0.5, p.y), float(best[1]), str(best[2])) ## The hero rides along, body turned to the car's heading, placed so that the seated hips are on @@ -409,6 +403,11 @@ func _traffic() -> FpFeature: return tr +func _props() -> FpFeature: + var f := ctx.feature("props") if ctx != null else null + return f if f != null and f.has_method("knock") else null + + func _toast(text: String) -> void: if ctx.bus != null: ctx.bus.toast.emit(text, "info") diff --git a/scripts/fp/drive/drive_model.gd b/scripts/fp/drive/drive_model.gd index adcd4c77..c196d49a 100644 --- a/scripts/fp/drive/drive_model.gd +++ b/scripts/fp/drive/drive_model.gd @@ -2,7 +2,8 @@ extends RefCounted ## Arcade handling for the player's car. Throttle and brake set the speed along the heading ## (brake reverses once stopped), the steering angle turns the car through a bicycle model, and ## the tyres bleed off sideways speed at a rate set by the surface, so a handbrake turn slides. -## World units (WS.M per metre) and seconds; the heading at yaw 0 is +Z, as for every vehicle. +## Knocks (car_body.gd) add to the velocity and to a spin the tyres damp out. World units (WS.M +## per metre) and seconds; the heading at yaw 0 is +Z, as for every vehicle. const WS := preload("res://scripts/fp/world_scale.gd") @@ -32,8 +33,9 @@ const SURFACES := { "dirt": [0.6, 0.5], "grass": [0.55, 0.45], } -## Share of the speed kept (and reversed) after a crash. -const BOUNCE := 0.2 +## Spin from knocks lost per second (1/s): tyres gripping, and on the handbrake. +const SPIN_GRIP := 3.0 +const SPIN_GRIP_HANDBRAKE := 0.8 var yaw: float = 0.0 var vel := Vector2.ZERO @@ -41,6 +43,9 @@ var steer: float = 0.0 var length: float = 0.24 ## Signed acceleration along the heading over the last step (brake lights). var accel: float = 0.0 +## Yaw rate from knocks (rad/s, positive turns the heading toward +X at yaw 0), on top of the +## steering. +var spin: float = 0.0 func reset(p_yaw: float, p_length: float) -> void: @@ -49,6 +54,7 @@ func reset(p_yaw: float, p_length: float) -> void: vel = Vector2.ZERO steer = 0.0 accel = 0.0 + spin = 0.0 func heading() -> Vector2: @@ -79,7 +85,9 @@ func step( var turn := vf * tan(steer) / maxf(length * WHEELBASE, 1e-4) if handbrake > 0.0: turn *= 1.0 + 0.4 * handbrake - yaw = wrapf(yaw - turn * dt, -PI, PI) + yaw = wrapf(yaw - turn * dt + spin * dt, -PI, PI) + var hb := clampf(handbrake, 0.0, 1.0) + spin *= exp(-lerpf(SPIN_GRIP, SPIN_GRIP_HANDBRAKE, hb) * dt) var h := heading() var side := Vector2(-h.y, h.x) vf = vel.dot(h) @@ -87,18 +95,12 @@ func step( var before := vf vf = _drive(vf, throttle, brake, handbrake, TOP * float(surf[1]), dt) accel = (vf - before) / dt - var grip := lerpf(GRIP * float(surf[0]), GRIP_HANDBRAKE, clampf(handbrake, 0.0, 1.0)) + var grip := lerpf(GRIP * float(surf[0]), GRIP_HANDBRAKE, hb) vs *= exp(-grip * dt) vel = h * vf + side * vs return vel * dt -## A crash: the car stops dead and rebounds a little. -func crash() -> void: - vel *= -BOUNCE - steer *= 0.5 - - ## Speed along the heading after throttle, brake (reverse once stopped), handbrake and coasting. static func _drive( vf: float, throttle: float, brake: float, handbrake: float, top: float, dt: float diff --git a/scripts/fp/fp_features.gd b/scripts/fp/fp_features.gd index ffcc19ad..aa5c8e3f 100644 --- a/scripts/fp/fp_features.gd +++ b/scripts/fp/fp_features.gd @@ -19,4 +19,5 @@ const FEATURES := [ "path": "res://scripts/fp/activity/activity_feature.gd", }, {"id": "crews", "slice": "integration", "path": "res://scripts/fp/crews/crews_feature.gd"}, + {"id": "props", "slice": "integration", "path": "res://scripts/fp/props/props_feature.gd"}, ] diff --git a/scripts/fp/game_bus.gd b/scripts/fp/game_bus.gd index e13ce009..6f2c4089 100644 --- a/scripts/fp/game_bus.gd +++ b/scripts/fp/game_bus.gd @@ -38,3 +38,9 @@ signal world_ready() signal card_action(action: String, tile: int) ## The hero got into a car (true) or out of it (false). Emitted by the drive feature. signal drive_changed(active: bool) +## 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) +## 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/obb2d.gd b/scripts/fp/obb2d.gd new file mode 100644 index 00000000..faf39edc --- /dev/null +++ b/scripts/fp/obb2d.gd @@ -0,0 +1,125 @@ +extends RefCounted +## Oriented boxes and circles on the ground plane, as Vector2(x, z): overlap tests, contacts and +## inertia for the player's car body and the street props it knocks over. A box is its centre, its +## unit axes u and v (perpendicular) and its half extents along them, Vector2(along u, along v). +## A contact is [normal, depth, point]: the unit normal that pushes the first shape out of the +## second, how far it has to go, and a point of contact inside the overlap. + +## A corner this close outside the other box's faces still counts as inside it (u). +const EPS := 1e-6 + + +## A box's local +X axis at yaw (heading +Z at yaw 0, as for every vehicle). +static func side_axis(yaw: float) -> Vector2: + return Vector2(cos(yaw), -sin(yaw)) + + +## A box's local +Z axis at yaw: its heading. +static func heading(yaw: float) -> Vector2: + return Vector2(sin(yaw), cos(yaw)) + + +## Moment of inertia about its centre of a solid box with half extents h and mass m. +static func box_inertia(h: Vector2, m: float) -> float: + return m * (h.x * h.x + h.y * h.y) / 3.0 + + +## True when the circle round p of radius r reaches into the rectangle lo..hi. +static func near_rect(p: Vector2, r: float, lo: Vector2, hi: Vector2) -> bool: + return p.clamp(lo, hi).distance_squared_to(p) < r * r + + +## Contact of box A (centre ca, axes ua va, half extents ha) with box B, or [] when they do not +## overlap. The normal is the separating axis of least overlap; the point is the mean of the +## corners of either box inside the other (the midpoint of the centres when none are). +static func box_box( + ca: Vector2, + ua: Vector2, + va: Vector2, + ha: Vector2, + cb: Vector2, + ub: Vector2, + vb: Vector2, + hb: Vector2 +) -> Array: + var d := cb - ca + var depth := INF + var n := Vector2.ZERO + for ax: Vector2 in [ua, va, ub, vb]: + var reach := ( + ha.x * absf(ua.dot(ax)) + + ha.y * absf(va.dot(ax)) + + hb.x * absf(ub.dot(ax)) + + hb.y * absf(vb.dot(ax)) + ) + var dist := d.dot(ax) + var o := reach - absf(dist) + if o <= 0.0: + return [] + if o < depth: + depth = o + n = -ax if dist > 0.0 else ax + var pts := corners_inside(cb, ub, vb, hb, ca, ua, va, ha) + pts.append_array(corners_inside(ca, ua, va, ha, cb, ub, vb, hb)) + var point := (ca + cb) * 0.5 + if not pts.is_empty(): + point = Vector2.ZERO + for q: Vector2 in pts: + point += q + point /= float(pts.size()) + return [n, depth, point] + + +## box_box() with the xz footprint of an axis-aligned world box. +static func box_aabb(ca: Vector2, ua: Vector2, va: Vector2, ha: Vector2, b: AABB) -> Array: + var c := b.get_center() + var hb := Vector2(b.size.x, b.size.z) * 0.5 + return box_box(ca, ua, va, ha, Vector2(c.x, c.z), Vector2(1.0, 0.0), Vector2(0.0, 1.0), hb) + + +## Contact of box A with the circle round c of radius r, or [] when they do not overlap. The +## point is the box's point nearest the circle's centre. +static func box_circle( + ca: Vector2, ua: Vector2, va: Vector2, ha: Vector2, c: Vector2, r: float +) -> Array: + var d := c - ca + var lx := d.dot(ua) + var lz := d.dot(va) + var qx := clampf(lx, -ha.x, ha.x) + var qz := clampf(lz, -ha.y, ha.y) + var e := Vector2(lx - qx, lz - qz) + var e2 := e.length_squared() + if e2 >= r * r: + return [] + if e2 > 1e-14: + var dist := sqrt(e2) + return [-(ua * e.x + va * e.y) / dist, r - dist, ca + ua * qx + va * qz] + # The centre is inside the box: out through the nearest side. + var sx := 1.0 if lx >= 0.0 else -1.0 + var sz := 1.0 if lz >= 0.0 else -1.0 + var ox := ha.x - absf(lx) + var oz := ha.y - absf(lz) + if ox < oz: + return [-ua * sx, ox + r, ca + ua * (ha.x * sx) + va * lz] + return [-va * sz, oz + r, ca + ua * lx + va * (ha.y * sz)] + + +## The corners of box O (centre oc, axes ou ov, half extents oh) that lie inside box C. +static func corners_inside( + c: Vector2, + u: Vector2, + v: Vector2, + h: Vector2, + oc: Vector2, + ou: Vector2, + ov: Vector2, + oh: Vector2 +) -> PackedVector2Array: + var out := PackedVector2Array() + for sx: float in [-1.0, 1.0]: + for sz: float in [-1.0, 1.0]: + var q := oc + ou * (oh.x * sx) + ov * (oh.y * sz) + var rel := q - c + if absf(rel.dot(u)) <= h.x + EPS and absf(rel.dot(v)) <= h.y + EPS: + out.append(q) + return out diff --git a/scripts/fp/obb2d.gd.uid b/scripts/fp/obb2d.gd.uid new file mode 100644 index 00000000..d4e2d3a9 --- /dev/null +++ b/scripts/fp/obb2d.gd.uid @@ -0,0 +1 @@ +uid://dmtw41jejcsnf diff --git a/scripts/fp/props/props_feature.gd b/scripts/fp/props/props_feature.gd new file mode 100644 index 00000000..6a20bb08 --- /dev/null +++ b/scripts/fp/props/props_feature.gd @@ -0,0 +1,398 @@ +extends FpFeature +## Street props the player's car knocks over (integration feature "props"). Bins, benches, +## planters, mailboxes, bike racks and parking meters hit hard enough for their kind fall: CityView +## knocks the batched prop down (not drawn, its collider out of CollisionWorld) and a copy of its +## meshes tumbles off (tumble.gd) over the walker's ground and off its walls, rolls over its edges +## and comes to rest on a face, where the car can hit it again. Hit slower, a prop holds and blocks +## like a wall; lamps, trees, bollards, hydrants, utility boxes and signs always hold. A fallen +## prop stands in its place again once it has lain RESPAWN seconds out of sight and away from the +## player, and at once when its tile is drawn again. The car's body (drive/car_body.gd) asks +## knockables_near() and knock(); prop_knocked on the bus reports every fall. + +const D := preload("res://scripts/sim/sim_defs.gd") +const WS := preload("res://scripts/fp/world_scale.gd") +const TUMBLE := preload("res://scripts/fp/props/tumble.gd") +const OBB := preload("res://scripts/fp/obb2d.gd") + +## Knockable StreetDressing kinds: [mass (a sedan is 1), the slowest a hit may send the prop +## flying (m/s) for it to give way]. +const KINDS := { + "bin": [0.02, 0.8], + "mailbox": [0.06, 2.5], + "bench": [0.06, 2.0], + "bench_park": [0.06, 2.0], + "rack": [0.04, 2.5], + "meter": [0.03, 3.0], + "planter": [0.3, 3.5], +} +## At most this many props lie fallen at once; past it the oldest stands up again. +const MAX_DOWN := 24 +## A fallen prop stands up again after lying this long (s) out of sight, with the player at least +## RESPAWN_CLEAR (u) from its place and no car on it. +const RESPAWN := 20.0 +const RESPAWN_CLEAR := 1.0 +## Tumbles move in steps of STEP (s), at most MAX_STEPS a frame. +const STEP := 1.0 / 60.0 +const MAX_STEPS := 4 +## Hits on a fallen prop slower than this (u/s) are not reported on the bus. +const KICK_REPORT := 1.0 * WS.M +## The photo shot: the camera stands PHOTO_BACK (u) in front of the prop, which a hit sends along +## its sidewalk at PHOTO_FLY (u/s). +const PHOTO_BACK := 0.16 +const PHOTO_FLY := 4.0 * WS.M + +## Fallen props, oldest first: {"tile", "slot", "kind", "t": TUMBLE, "node": Node3D, "age": s}. +var down: Array[Dictionary] = [] +var _acc: float = 0.0 +## The photo shot's prop: {"tile", "slot", "kind", "xf", "fly", "rest"}, {} without one. +var _photo: Dictionary = {} + + +func feature_id() -> String: + return "props" + + +func setup(p_ctx: FpContext) -> void: + super.setup(p_ctx) + if ctx.view != null: + link_signal(ctx.view.tile_built, _on_tile_built) + link_signal(ctx.view.tile_cleared, _on_tile_cleared) + + +## Boot, load or New City: every holder is new and every prop stands. +func on_world_rebuilt() -> void: + _clear(-1) + + +func teardown() -> void: + unlink_signals() + stand_all() + + +func self_test() -> Dictionary: + if ctx.view == null: + return {"ok": true, "detail": "no city view"} + var bad: Array[String] = [] + for kind: String in KINDS: + var kit := ctx.view.street_kit(kind) + if kit.is_empty() or (kit["parts"] as Array).is_empty(): + bad.append("no %s kit" % kind) + var p := ctx.player_position() + var n := 0 + for k: Dictionary in knockables_near(p, 3.0): + var placed := ctx.view.street_placements(int(k["tile"])) + var slot := int(k["slot"]) + if slot >= placed.size() or not KINDS.has(str(placed[slot][0])): + bad.append("tile %d slot %d is no knockable prop" % [int(k["tile"]), slot]) + n += 1 + var detail := "%d knockable props within 60 m, %d down" % [n, down.size()] + return { + "ok": bad.is_empty(), "detail": detail + ("" if bad.is_empty() else ": " + ", ".join(bad)) + } + + +func _process(delta: float) -> void: + if down.is_empty() or ctx == null or ctx.collision == null: + return + _acc = minf(_acc + delta, STEP * MAX_STEPS) + while _acc >= STEP: + _acc -= STEP + _step_all() + for e: Dictionary in down: + e["age"] = float(e["age"]) + delta + (e["node"] as Node3D).transform = (e["t"] as TUMBLE).xform() + _respawn() + + +# ------------------------------------------------------------------ the car's body + + +## The props within r of p (feet) that a car can knock over, standing or fallen: {"c": Vector2 +## (xz), "r": reach round c, "box": AABB (world; standing boxes), "mass", "tile", "slot"}. +func knockables_near(p: Vector3, r: float) -> Array: + var out: Array = [] + if ctx == null or ctx.view == null: + return out + var c := Vector2(p.x, p.z) + for ty in range(floori(p.z - r - 0.5), ceili(p.z + r + 0.5) + 1): + for tx in range(floori(p.x - r - 0.5), ceili(p.x + r + 0.5) + 1): + if D.in_bounds(tx, ty): + _standing(D.idx(tx, ty), c, r, out) + for e: Dictionary in down: + var t: TUMBLE = e["t"] + var q := Vector2(t.pos.x, t.pos.z) + var qr := _lying_radius(t) + if q.distance_to(c) < r + qr: + var m := float(KINDS[e["kind"]][0]) + out.append({"c": q, "r": qr, "mass": m, "tile": e["tile"], "slot": e["slot"]}) + return out + + +## Knocks street prop `slot` of tile i over (fallen, hits it again): pushed at world point `at`, it +## flies off at the level velocity fly (u/s). False when it holds (not knockable, or standing and +## sent off slower than its kind gives way at) or, fallen, already moves away that fast. +func knock(i: int, slot: int, at: Vector3, fly: Vector3) -> bool: + var speed := Vector2(fly.x, fly.z).length() + for e: Dictionary in down: + if int(e["tile"]) == i and int(e["slot"]) == slot: + return _kick(e, at, fly, speed) + if ctx.view == null: + return false + var placed := ctx.view.street_placements(i) + if slot < 0 or slot >= placed.size(): + return false + var kind := str(placed[slot][0]) + if not KINDS.has(kind) or speed < float(KINDS[kind][1]) * WS.M: + return false + var kit := ctx.view.street_kit(kind) + if kit.is_empty() or (kit["parts"] as Array).is_empty(): + return false + ctx.view.set_street_prop_down(i, slot, true) + if ctx.collision != null: + ctx.collision.invalidate(i) + var t := TUMBLE.new() + t.launch(placed[slot][1], kit["aabb"], at, fly) + var node := _draw(kind, kit) + node.transform = t.xform() + down.append({"tile": i, "slot": slot, "kind": kind, "t": t, "node": node, "age": 0.0}) + if down.size() > MAX_DOWN: + _stand(down.pop_front()) + if ctx.bus != null: + ctx.bus.prop_knocked.emit(at, kind, speed) + return true + + +## Stands every fallen prop up again. +func stand_all() -> void: + for e: Dictionary in down: + _stand(e) + down.clear() + + +## A fallen prop hit again: it moves off at fly unless it already moves away about that fast. +func _kick(e: Dictionary, at: Vector3, fly: Vector3, speed: float) -> bool: + var t: TUMBLE = e["t"] + if speed < 1e-6: + return false + var dir := Vector3(fly.x, 0.0, fly.z) / speed + var cur := Vector3(t.vel.x, 0.0, t.vel.z).dot(dir) + if cur >= speed * 0.9: + return false + t.kick(at, fly - dir * maxf(cur, 0.0)) + e["age"] = 0.0 + if ctx.bus != null and speed >= KICK_REPORT: + ctx.bus.prop_knocked.emit(at, str(e["kind"]), speed) + return true + + +## Appends the knockable props standing on tile i within r of c. +func _standing(i: int, c: Vector2, r: float, out: Array) -> void: + var holder := ctx.view.tile_holder(i) + if holder == null: + return + var hx := holder.transform + for v: Variant in holder.get_meta("colliders", []): + if not (v is Dictionary): + continue + var d: Dictionary = v + var kind := str(d.get("kind", "")) + if not d.has("slot") or bool(d.get("down", false)) or not KINDS.has(kind): + continue + var k := {"mass": float(KINDS[kind][0]), "tile": i, "slot": int(d["slot"])} + if str(d.get("shape", "box")) == "cyl": + var q := hx * (d["pos"] as Vector3) + k["c"] = Vector2(q.x, q.z) + k["r"] = float(d["r"]) + else: + var b := hx * ConstructionKit.collider_aabb(d) + k["box"] = b + k["c"] = Vector2(b.get_center().x, b.get_center().z) + k["r"] = Vector2(b.size.x, b.size.z).length() * 0.5 + if (k["c"] as Vector2).distance_to(c) < r + float(k["r"]): + out.append(k) + + +## A fallen prop's reach on the ground: the mean of its level half extents as it lies. +func _lying_radius(t: TUMBLE) -> float: + var s := absf(sin(t.angle)) + var cs := absf(cos(t.angle)) + if absf(t.axis.x) > 0.5: + return (t.half.x + t.half.z * cs + t.half.y * s) * 0.5 + return (t.half.z + t.half.x * cs + t.half.y * s) * 0.5 + + +# ------------------------------------------------------------------ fallen props + + +func _step_all() -> void: + var floor_at := ctx.collision.walk_height + var blocked := ctx.collision.body_blocked + for e: Dictionary in down: + (e["t"] as TUMBLE).step(STEP, floor_at, blocked) + + +## A copy of kit's meshes for a fallen prop of `kind`, in the feature's world space. +func _draw(kind: String, kit: Dictionary) -> Node3D: + var n := Node3D.new() + n.name = "Fallen_" + kind + var end := WS.VIS_PROP * _vis_mult() + for part: Dictionary in kit["parts"]: + var mi := MeshInstance3D.new() + mi.mesh = part["mesh"] + mi.transform = part["xform"] + if part.get("mat") != null: + mi.material_override = part["mat"] + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + mi.visibility_range_end = end + mi.visibility_range_end_margin = 0.5 + mi.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF + n.add_child(mi) + add_child(n) + return n + + +## Stands up the fallen props that have lain RESPAWN seconds where nobody sees them go. +func _respawn() -> void: + var keep: Array[Dictionary] = [] + for e: Dictionary in down: + if float(e["age"]) >= RESPAWN and _may_stand(e["t"]): + _stand(e) + else: + keep.append(e) + down = keep + + +## True when the fallen prop t and its place are out of sight, the player is RESPAWN_CLEAR away +## and no car stands on its place. +func _may_stand(t: TUMBLE) -> bool: + var spot := t.start.origin + var me := ctx.player_position() + if Vector2(spot.x - me.x, spot.z - me.z).length() < RESPAWN_CLEAR: + return false + if not (_unseen(spot) and _unseen(t.pos)): + return false + var traffic := ctx.feature("traffic") + if traffic == null or not traffic.has_method("bodies_near"): + return true + var r := maxf(t.half.x, t.half.z) + var q := Vector2(spot.x, spot.z) + for b: Array in traffic.call("bodies_near", spot, r): + var yb := float(b[2]) + if not OBB.box_circle(b[1], OBB.side_axis(yb), OBB.heading(yb), b[3], q, r).is_empty(): + return false + return true + + +## Stands fallen prop e up in its place again. +func _stand(e: Dictionary) -> void: + if is_instance_valid(e["node"]): + (e["node"] as Node3D).queue_free() + var i := int(e["tile"]) + if ctx.view != null: + ctx.view.set_street_prop_down(i, int(e["slot"]), false) + if ctx.collision != null and is_instance_valid(ctx.collision): + ctx.collision.invalidate(i) + + +## Drops the fallen props of tile i (every tile when i < 0) without standing them up: their +## holder was drawn again, props standing. +func _clear(i: int) -> void: + var keep: Array[Dictionary] = [] + for e: Dictionary in down: + if i >= 0 and int(e["tile"]) != i: + keep.append(e) + elif is_instance_valid(e["node"]): + (e["node"] as Node3D).queue_free() + down = keep + + +func _on_tile_built(i: int, _holder: Node3D) -> void: + _clear(i) + + +func _on_tile_cleared(i: int) -> void: + _clear(i) + + +## True when the camera cannot see p: behind it, off to a side or past the props' draw distance. +func _unseen(p: Vector3) -> bool: + var cam: Camera3D = null + if ctx.player != null and is_instance_valid(ctx.player): + cam = ctx.player.camera + if cam == null or not cam.is_inside_tree(): + return true + var d := cam.global_position.distance_to(p) + if d > WS.VIS_PROP * _vis_mult(): + return true + if d < 0.08: + return false + return not (cam.is_position_in_frustum(p) or cam.is_position_in_frustum(p + Vector3.UP * 0.05)) + + +func _vis_mult() -> float: + return ctx.view.vis_mult if ctx.view != null else 1.0 + + +# ------------------------------------------------------------------ photo tour + + +## The photo shot: the knockable prop nearest the spawn point knocked along its sidewalk, seen +## from the road in front of it where it comes to rest. +func photo_shots() -> Array: + _photo = _photo_prop() + if _photo.is_empty(): + return [] + var xf: Transform3D = _photo["xf"] + var front := Vector3(xf.basis.z.x, 0.0, xf.basis.z.z).normalized() + var pos := xf.origin + front * PHOTO_BACK + pos.y = ctx.collision.walk_height(pos) + var look: Vector3 = _photo["rest"] - (pos + Vector3(0, WS.EYE, 0)) + var pitch := rad_to_deg(atan2(look.y, Vector2(look.x, look.z).length())) + var shot := {"name": "props", "pos": pos, "yaw": rad_to_deg(atan2(-look.x, -look.z))} + shot.merge({"pitch": pitch, "hour": 11.0, "prepare": "photo_prepare", "restore": "stand_all"}) + return [shot] + + +## Photo tour: the shot's prop knocked over and already at rest. +func photo_prepare() -> void: + if _photo.is_empty(): + return + var xf: Transform3D = _photo["xf"] + if not knock(int(_photo["tile"]), int(_photo["slot"]), xf.origin, _photo["fly"]): + return + var t: TUMBLE = down.back()["t"] + var floor_at := ctx.collision.walk_height + var blocked := ctx.collision.body_blocked + while t.step(STEP, floor_at, blocked): + pass + (down.back()["node"] as Node3D).transform = t.xform() + + +## The standing knockable prop nearest the spawn point with walkable ground in front of it, and +## where a hit along its sidewalk lays it to rest: {"tile", "slot", "kind", "xf", "fly", "rest"}. +func _photo_prop() -> Dictionary: + if ctx.view == null or ctx.collision == null: + return {} + var sp := ( + ctx.mode.spawn_point()["pos"] as Vector3 if ctx.mode != null else ctx.player_position() + ) + var best := {} + var bd := INF + for k: Dictionary in knockables_near(sp, 3.0): + var placed: Array = ctx.view.street_placements(int(k["tile"]))[int(k["slot"])] + var xf: Transform3D = placed[1] + var front := Vector3(xf.basis.z.x, 0.0, xf.basis.z.z).normalized() + var d := xf.origin.distance_to(sp) + if d < bd and not is_nan(ctx.collision.walk_height(xf.origin + front * PHOTO_BACK)): + bd = d + best = {"tile": k["tile"], "slot": k["slot"], "kind": placed[0], "xf": xf} + if best.is_empty(): + return {} + var xf: Transform3D = best["xf"] + best["fly"] = Vector3(xf.basis.x.x, 0.0, xf.basis.x.z).normalized() * PHOTO_FLY + var t := TUMBLE.new() + t.launch(xf, ctx.view.street_kit(str(best["kind"]))["aabb"], xf.origin, best["fly"]) + while t.step(STEP, ctx.collision.walk_height, ctx.collision.body_blocked): + pass + best["rest"] = t.pos + return best diff --git a/scripts/fp/props/props_feature.gd.uid b/scripts/fp/props/props_feature.gd.uid new file mode 100644 index 00000000..c683aead --- /dev/null +++ b/scripts/fp/props/props_feature.gd.uid @@ -0,0 +1 @@ +uid://ug6nahoovaa4 diff --git a/scripts/fp/props/tumble.gd b/scripts/fp/props/tumble.gd new file mode 100644 index 00000000..455d569a --- /dev/null +++ b/scripts/fp/props/tumble.gd @@ -0,0 +1,264 @@ +extends RefCounted +## A street prop knocked over (props feature): its bounding box flies off, tips over about one of +## its own level axes, lands, rolls on over its edges while it comes down hard, slides to a stop +## and lies on a face. The props feature draws it from xform() and gives it the ground +## (CollisionWorld.ground_height) and the walls (CollisionWorld.body_blocked). + +const WS := preload("res://scripts/fp/world_scale.gd") + +const GRAVITY := 9.8 * WS.M +## A knocked prop leaves the ground at LIFT of its speed and tips at TIP rad/s for every m/s of +## speed per metre of its height, at most TIP_MAX; knocked over, at least TIP_MIN (enough to fall +## over any edge). +const LIFT := 0.3 +const TIP := 0.6 +const TIP_MIN := 4.0 +const TIP_MAX := 10.0 +## A prop whose footprint is LONG times longer one way than the other falls over a long side, +## whichever way it is pushed: a bench hit end-on does not stand up on its end. +const LONG := 1.5 +## Hit off-centre it spins about the vertical at SPIN rad/s per m/s, at most SPIN_MAX. +const SPIN := 0.5 +const SPIN_MAX := 6.0 +## A landing faster than BOUNCE_MIN bounces back with BOUNCE of its speed; every landing keeps +## LAND_SPIN of the spin. +const BOUNCE := 0.3 +const BOUNCE_MIN := 0.8 * WS.M +const LAND_SPIN := 0.6 +## Sliding friction on the ground (share of g), the braking of its spin there (rad/s^2) and the +## share of its speed a wall sends back. +const FRICTION := 0.55 +const SPIN_BRAKE := 8.0 +const WALL_BOUNCE := 0.3 +## Slamming onto a face while tipping faster than ROLL_ON (rad/s), it rolls on with ROLL_KEEP of +## that; short of the energy to get over an edge it rocks back at ROCK (rad/s). +const ROLL_ON := 7.0 +const ROLL_KEEP := 0.45 +const ROCK := 0.5 +## Flat on a face, sliding slower than REST_SPEED and turning slower than REST_SPIN it rests; +## after MAX_LIFE (s) it rests anyway. +const REST_SPEED := 0.05 * WS.M +const REST_SPIN := 0.3 +const MAX_LIFE := 8.0 + +## Where the prop stood: its base at the origin, yaw and uniform scale in the basis. +var start: Transform3D +## The centre of its box in its own scaled frame, and the box's half extents. +var centre := Vector3.ZERO +var half := Vector3.ONE * 0.01 +## The box's world centre and velocity (u/s). +var pos := Vector3.ZERO +var vel := Vector3.ZERO +var yaw: float = 0.0 +var yaw_rate: float = 0.0 +## The level axis of its own frame it tips about (+-X or +-Z), the tip angle and its rate. +var axis := Vector3.RIGHT +var angle: float = 0.0 +var rate: float = 0.0 +## On the ground; lying on a face (angle a multiple of PI/2, no tip); at rest for good. +var grounded: bool = false +var flat: bool = false +var resting: bool = false +var life: float = 0.0 +var _scale: float = 1.0 +## Half extents across the tip axis: level (a) and up its own frame (b). +var _a: float = 0.01 +var _b: float = 0.01 + + +## Knocks over the prop that stands at xf with local bounds bb: pushed at world point `at`, it +## flies off at the level velocity fly (u/s). +func launch(xf: Transform3D, bb: AABB, at: Vector3, fly: Vector3) -> void: + start = xf + _scale = xf.basis.get_scale().x + yaw = atan2(xf.basis.z.x, xf.basis.z.z) + centre = bb.get_center() * _scale + half = bb.size * (0.5 * _scale) + pos = xf * bb.get_center() + vel = Vector3.ZERO + yaw_rate = 0.0 + angle = 0.0 + rate = 0.0 + # Its top falls along the push, about the level axis of its own frame most nearly across it; + # a long prop falls over a long side. + var f := Basis(Vector3.UP, -yaw) * Vector3(fly.x, 0.0, fly.z) + var over_z := absf(f.z) >= absf(f.x) + if half.x > half.z * LONG or half.z > half.x * LONG: + over_z = half.x > half.z + if over_z: + axis = Vector3.RIGHT * (1.0 if f.z >= 0.0 else -1.0) + _a = half.z + else: + axis = Vector3.BACK * (-1.0 if f.x >= 0.0 else 1.0) + _a = half.x + _b = half.y + kick(at, fly) + rate = maxf(rate, TIP_MIN) + + +## Hits the prop (again, as it lies or tumbles): pushed at world point `at`, it gets the level +## velocity fly (u/s) on top of its own, leaves the ground, tips on when the push runs along its +## tip and spins when the push is off-centre. +func kick(at: Vector3, fly: Vector3) -> void: + var level := Vector3(fly.x, 0.0, fly.z) + var speed := level.length() + var mps := speed / WS.M + vel += level + Vector3.UP * (speed * LIFT) + var along := signf(axis.cross(Vector3.UP).dot(Basis(Vector3.UP, -yaw) * level)) + var tip := minf(TIP * mps / maxf(half.y * 2.0 / WS.M, 0.3), TIP_MAX) + rate = clampf(rate + along * tip, -TIP_MAX, TIP_MAX) + var turn := Vector3(at.x - pos.x, 0.0, at.z - pos.z).cross(level).y + yaw_rate += signf(turn) * minf(SPIN * mps, SPIN_MAX) + grounded = false + flat = false + resting = false + life = 0.0 + + +## Moves the prop on by dt over the ground floor_at(p: Vector3) -> float (NAN reads as the datum), +## bouncing off what blocked(p: Vector3, radius: float, height: float) -> bool finds in the way +## of its box standing at p. False once it rests. +func step(dt: float, floor_at: Callable, blocked: Callable) -> bool: + if resting: + return false + life += dt + var was := grounded + vel.y -= GRAVITY * dt + _slide(dt, blocked) + pos.y += vel.y * dt + yaw = wrapf(yaw + yaw_rate * dt, -PI, PI) + var d0 := drop() + _tip(dt) + var g := float(floor_at.call(pos)) if floor_at.is_valid() else WS.GROUND_Y + if is_nan(g): + g = WS.GROUND_Y + var low := pos.y - drop() + # On the ground it keeps its edge there as it pivots toward a face, which lowers its centre + # faster than gravity would; ground that falls away (a curb) drops it. Thrown up, it rises + # off the edge it tips over. + var stick := was and vel.y <= 0.0 and low - g <= maxf(d0 - drop(), 0.0) + 1e-5 + if low < g or stick: + pos.y += g - low + grounded = (low <= g + 1e-6 or stick) and vel.y <= 0.0 + if grounded: + if not was: + _land() + else: + vel.y = 0.0 + _rub(dt) + if life >= MAX_LIFE or (grounded and flat and _slow()): + _rest(g) + return not resting + + +## The prop's frame now (scale included), for drawing. +func xform() -> Transform3D: + var r := Basis(Vector3.UP, yaw) * Basis(axis, angle) + return Transform3D(r * Basis.from_scale(Vector3.ONE * _scale), pos - r * centre) + + +## Height of the box's centre above its lowest point. +func drop() -> float: + return _drop_at(angle) + + +func _drop_at(t: float) -> float: + return _b * absf(cos(t)) + _a * absf(sin(t)) + + +## The level move, along x and then along z: a wall in the way sends that part of the velocity +## back. A box that starts in something moves freely until it is out. +func _slide(dt: float, blocked: Callable) -> void: + var feet := pos.y - drop() + var r := maxf(minf(half.x, half.z) * 0.5, 0.002) + var h := drop() * 2.0 + var check := blocked.is_valid() and not bool(blocked.call(Vector3(pos.x, feet, pos.z), r, h)) + for k in range(2): + var d := (vel.x if k == 0 else vel.z) * dt + if d == 0.0: + continue + var to := pos + (Vector3(d, 0.0, 0.0) if k == 0 else Vector3(0.0, 0.0, d)) + if check and bool(blocked.call(Vector3(to.x, feet, to.z), r, h)): + if k == 0: + vel.x *= -WALL_BOUNCE + else: + vel.z *= -WALL_BOUNCE + yaw_rate *= 0.5 + else: + pos = to + + +## The tip: free in the air. On the ground the box pivots on an edge, trading height for tip rate, +## falls toward the next face and slams down on it, lying there or rolling on over it when it +## comes down hard; short of the energy to get over an edge it rocks back. +func _tip(dt: float) -> void: + if not grounded: + angle += rate * dt + return + if flat: + return + var q := PI * 0.5 + if rate == 0.0: + if absf(angle - roundf(angle / q) * q) < 1e-4: + flat = true + return + rate = ROCK if _past_edge() else -ROCK + var next := angle + rate * dt + var w2 := rate * rate - 1.5 * GRAVITY * (_drop_at(next) - drop()) / (_a * _a + _b * _b) + if w2 <= 0.0: + rate = -signf(rate) * ROCK + return + var dir := signf(rate) + rate = dir * sqrt(w2) + # The next face it falls toward, past the one it may lie on now. + var face := (floorf(angle / q + 1e-6) + 1.0) * q + if dir < 0.0: + face = (ceilf(angle / q - 1e-6) - 1.0) * q + if (next - face) * dir < 0.0: + angle = next + elif absf(rate) > ROLL_ON: + rate *= ROLL_KEEP + angle = next + else: + angle = face + rate = 0.0 + flat = true + + +## True when the box's centre has passed the edge it pivots on, toward the next face up. +func _past_edge() -> bool: + var q := PI * 0.5 + var k := floorf(angle / q) + var edge := atan2(_a, _b) if posmod(int(k), 2) == 0 else atan2(_b, _a) + return angle - k * q > edge + + +## Touching down: a hard landing bounces, and every landing takes some of the spin. +func _land() -> void: + vel.y = -vel.y * BOUNCE if -vel.y > BOUNCE_MIN else 0.0 + rate *= LAND_SPIN + yaw_rate *= LAND_SPIN + + +## Ground friction on its slide and its spin. +func _rub(dt: float) -> void: + var v := Vector2(vel.x, vel.z).move_toward(Vector2.ZERO, FRICTION * GRAVITY * dt) + vel.x = v.x + vel.z = v.y + yaw_rate = move_toward(yaw_rate, 0.0, SPIN_BRAKE * dt) + + +func _slow() -> bool: + return Vector2(vel.x, vel.z).length() < REST_SPEED and absf(yaw_rate) < REST_SPIN + + +## At rest on the face nearest its tip, on the ground at g. +func _rest(g: float) -> void: + angle = roundf(angle / (PI * 0.5)) * PI * 0.5 + rate = 0.0 + flat = true + vel = Vector3.ZERO + yaw_rate = 0.0 + pos.y = g + drop() + grounded = true + resting = true diff --git a/scripts/fp/props/tumble.gd.uid b/scripts/fp/props/tumble.gd.uid new file mode 100644 index 00000000..a9e87901 --- /dev/null +++ b/scripts/fp/props/tumble.gd.uid @@ -0,0 +1 @@ +uid://bmwxh8r32ltes diff --git a/scripts/fp/street/street_dressing.gd b/scripts/fp/street/street_dressing.gd index cb232aa7..4f0f9951 100644 --- a/scripts/fp/street/street_dressing.gd +++ b/scripts/fp/street/street_dressing.gd @@ -5,7 +5,8 @@ extends RefCounted ## the bus shelter, and the street-level glass and leaf materials. ## ## Every blocking item is listed in its holder's `colliders` meta (holder-local, `pos` = centre of the -## base) before tile_built fires, and every prop root carries `prop` meta. Frequent small items are +## base; batched items add their `slot` in placements()) before tile_built fires, and every prop +## root carries `prop` meta. Frequent small items are ## batched per CHUNK x CHUNK tiles into MultiMeshes under CityView's StreetProps root; flush() rebuilds ## the chunks whose tiles changed. @@ -69,6 +70,9 @@ var _placed: Dictionary = {} ## Chunk Vector2i -> Array[MultiMeshInstance3D]; dirty chunks rebuild on flush(). var _chunks: Dictionary = {} var _dirty: Dictionary = {} +## Tile -> {slot: true} for batched items knocked down (set_down): not drawn until stood up again +## or the tile is rebuilt. +var _hidden: Dictionary = {} ## Kind -> {"parts": Array, "aabb": AABB}. var _kits: Dictionary = {} var _mats: Dictionary = {} @@ -93,6 +97,7 @@ func _init(p_view: CityView) -> void: ## Drops the batched items of tile i (its holder is being rebuilt or cleared). func forget(i: int) -> void: + _hidden.erase(i) if _placed.has(i): _placed.erase(i) _dirty[_chunk_key(D.tx(i), D.ty(i))] = true @@ -122,6 +127,37 @@ func placements(i: int) -> Array: return _placed.get(i, []) +## Knocks batched item `slot` of tile i down (or stands it up again), its index in placements(i) +## and its collider's "slot": a street prop knocked over on foot is not drawn and its collider is +## marked "down" (CollisionWorld skips it; the caller invalidates the tile there). Its chunk is +## redrawn at once. +func set_down(i: int, slot: int, down: bool) -> void: + var hid: Dictionary = _hidden.get(i, {}) + if down == hid.has(slot): + return + if down: + hid[slot] = true + _hidden[i] = hid + else: + hid.erase(slot) + if hid.is_empty(): + _hidden.erase(i) + var holder := view.tile_holder(i) + if holder != null: + for e: Variant in holder.get_meta("colliders", []): + if e is Dictionary and int((e as Dictionary).get("slot", -1)) == slot: + (e as Dictionary)["down"] = down + var c := _chunk_key(D.tx(i), D.ty(i)) + _build_chunk(c) + _dirty.erase(c) + + +## The mesh parts of a batched kind ({"mesh", "xform", "mat"} each) and their bounds, as +## {"parts", "aabb"}; {} for an unknown kind. +func kit_of(kind: String) -> Dictionary: + return _kit(kind) if KITS.has(kind) else {} + + func _chunk_key(x: int, y: int) -> Vector2i: return Vector2i(floori(float(x) / CHUNK), floori(float(y) / CHUNK)) @@ -136,7 +172,13 @@ func _build_chunk(c: Vector2i) -> void: for tx in range(c.x * CHUNK, c.x * CHUNK + CHUNK): if not D.in_bounds(tx, ty): continue - for e: Array in _placed.get(D.idx(tx, ty), []): + var i := D.idx(tx, ty) + var hid: Dictionary = _hidden.get(i, {}) + var all: Array = _placed.get(i, []) + for k in range(all.size()): + if hid.has(k): + continue + var e: Array = all[k] if not by_kind.has(e[0]): by_kind[e[0]] = [] (by_kind[e[0]] as Array).append(e[1]) @@ -188,11 +230,12 @@ func _put(i: int, kind: String, pos: Vector3, face: Vector2, cols: Array, scale: if r < 0.0: return var bb: AABB = kit["aabb"] + var slot := (_placed[i] as Array).size() - 1 if r > 0.0: - cols.append({"shape": "cyl", "pos": pos, "r": r * scale, "h": bb.end.y * scale, "yaw": yaw, "kind": kind}) + cols.append({"shape": "cyl", "pos": pos, "r": r * scale, "h": bb.end.y * scale, "yaw": yaw, "kind": kind, "slot": slot}) else: var c := Basis(Vector3.UP, yaw) * (Vector3(bb.get_center().x, 0.0, bb.get_center().z) * scale) - cols.append({"shape": "box", "pos": pos + c, "size": bb.size * scale, "yaw": yaw, "kind": kind}) + cols.append({"shape": "box", "pos": pos + c, "size": bb.size * scale, "yaw": yaw, "kind": kind, "slot": slot}) func _kit(kind: String) -> Dictionary: diff --git a/scripts/fp/traffic/shoves.gd b/scripts/fp/traffic/shoves.gd new file mode 100644 index 00000000..5d1d9752 --- /dev/null +++ b/scripts/fp/traffic/shoves.gd @@ -0,0 +1,307 @@ +extends RefCounted +## Cars the player's car knocks (slice E): a hit car slides off with the momentum it was given and +## spins, braking on its tyres, and stops against walls and other cars. A lane car brakes hard on +## its lane (stunned, brake lights on) while its body slides off it as a drawn offset; after a +## pause, longer the harder it was hit, its driver steers the body back onto the lane and drives +## on. Parked cars and the cars the player left move for good, riding up onto a curb they are +## knocked over. While a knocked car stands off its lane or its parking slot it is a block for lane +## traffic, like the player's car; a parked car left in the way is put back in its slot (towed) +## once it has stood out of sight for a while. + +const WS := preload("res://scripts/fp/world_scale.gd") +const OBB := preload("res://scripts/fp/obb2d.gd") +const BC := preload("res://scripts/fp/body_contacts.gd") +const VH := preload("res://scripts/fp/traffic/vehicles.gd") + +## Sliding tyres brake a knocked body at this rate (about 0.7 g) and its spin at SPIN_BRAKE (1/s); +## a spin below MIN_SPIN (rad/s) stops. +const SLIDE_BRAKE := 6.9 * WS.M +const SPIN_BRAKE := 4.0 +const MIN_SPIN := 0.02 +## A knocked lane car waits this long once its body stops, plus HOLD_PER_MPS for every m/s the hit +## gave it, at most HOLD_MAX (s). +const HOLD := 2.5 +const HOLD_PER_MPS := 0.5 +const HOLD_MAX := 12.0 +## Then its body steers back onto the lane at this speed (u/s) and turn rate (rad/s), the car +## staying stunned at least STUN_MIN (s) at a time until it is back. +const RETURN_SPEED := 1.2 * WS.M +const RETURN_TURN := 0.6 +const STUN_MIN := 0.1 +## Masses relative to a sedan (the player's car is 1), by kind; a car pulling into a slot does not +## give way (FIXED_MASS). +const MASS := {"car": 1.0, "bus": 6.0, "truck": 4.0, "emergency": 1.6, "bike": 0.12} +const SEDAN_LENGTH := 0.24 +const FIXED_MASS := 50.0 +## A body off its lane or slot by more than this (u), or turned by more than OFF_TURN (rad), +## blocks lane traffic. +const OFF_BLOCK := 0.3 * WS.M +const OFF_TURN := 0.2 +## A move into an overlap deeper than this (u) is refused and the slide stops. +const TOUCH := 0.001 +## A parked car knocked into the way is towed back to its slot after standing this long (s) out of +## sight. +const TOW_AFTER := 30.0 + + +class Shove: + extends RefCounted + var car: VH.Car + var vel := Vector2.ZERO # slide (u/s) + var spin: float = 0.0 # rad/s + var off := Vector2.ZERO # a lane car's drawn offset from its lane pose + var turn: float = 0.0 # and its drawn yaw offset + var hold: float = 0.0 # s a lane car waits once stopped + var lane: bool = false + + +var vehicles: VH +var collision: CollisionWorld +## (p: Vector3) -> bool, true when p could be on screen (no towing in sight); unset: never. +var visible: Callable +## Car id -> Shove while a knocked car slides or finds its way back to its lane. +var _shoves: Dictionary = {} +## Car id -> Car for parked cars knocked out of their slot, and id -> seconds each has stood still. +var _moved: Dictionary = {} +var _still_t: Dictionary = {} + + +func setup(p_vehicles: VH, p_collision: CollisionWorld) -> void: + vehicles = p_vehicles + collision = p_collision + clear() + + +func clear() -> void: + for s: Shove in _shoves.values(): + s.car.stun_t = 0.0 + _shoves.clear() + _moved.clear() + _still_t.clear() + + +## Mass of c relative to a sedan. +static func mass_of(c: VH.Car) -> float: + var m := float(MASS.get(c.kind, 1.0)) + return m * c.length / SEDAN_LENGTH if c.kind == "car" else m + + +## The velocity of c's body now (u/s, xz): its lane motion plus its slide. +func body_vel(c: VH.Car) -> Vector2: + var v := Vector2(c.vel.x, c.vel.z) + var s: Shove = _shoves.get(c.id) + return v + s.vel if s != null else v + + +## True while c slides or makes its way back to its lane. +func is_shoved(c: VH.Car) -> bool: + return _shoves.has(c.id) + + +## The player's car hit c with impulse j (u/s times a sedan's mass) at xz point `at`: c's body +## takes the change in velocity and spin. A lane car keeps what it can of the part along its +## heading as lane speed (it never rolls back along its lane) and slides off with the rest. +func bump(c: VH.Car, j: Vector2, at: Vector2) -> void: + if c.dead or c.driven or c.far or c.park_t >= 0.0: + return + var m := mass_of(c) + var ctr := Vector2(c.pos.x + c.vis_off.x, c.pos.z + c.vis_off.z) + var dv := j / m + var s: Shove = _shoves.get(c.id) + if s == null: + s = Shove.new() + s.car = c + s.lane = not c.parked and c.role != VH.PLAYER and c.lane != null + s.off = Vector2(c.vis_off.x, c.vis_off.z) + s.turn = c.vis_yaw + _shoves[c.id] = s + if s.lane: + var h := OBB.heading(c.yaw) + var v1 := maxf(0.0, c.v + dv.dot(h)) + dv -= h * (v1 - c.v) + c.v = v1 + c.vel = Vector3(h.x, 0.0, h.y) * v1 + var hard := HOLD + HOLD_PER_MPS * (j.length() / m) / WS.M + s.hold = clampf(maxf(s.hold, hard), HOLD, HOLD_MAX) + c.stun_t = maxf(c.stun_t, s.hold) + s.vel += dv + s.spin += _cross(at - ctr, j) / OBB.box_inertia(Vector2(c.half_w, c.length * 0.5), m) + + +## Moves every knocked body on by dt: slides, stops, waits and steers back; tows parked cars +## left in the way out of sight. +func step(dt: float) -> void: + for id: int in _shoves.keys(): + var s: Shove = _shoves[id] + var c := s.car + if c.dead or c.far or c.driven or c.park_t >= 0.0 or (not s.lane and c.lane != null): + c.stun_t = 0.0 + _shoves.erase(id) + continue + _slide(s, dt) + if s.lane: + if _back(s, dt): + _shoves.erase(id) + elif s.vel == Vector2.ZERO and s.spin == 0.0: + _shoves.erase(id) + for id: int in _moved.keys(): + var c: VH.Car = _moved[id] + if c.dead or not c.parked or c.slot_tile < 0: + _moved.erase(id) + _still_t.erase(id) + elif not _shoves.has(id): + _still_t[id] = float(_still_t.get(id, 0.0)) + dt + if float(_still_t[id]) > TOW_AFTER and _off_slot(c) and not _seen(c): + vehicles.park_at(c, c.slot_tile, c.slot_k) + _moved.erase(id) + _still_t.erase(id) + + +## Lane blocks for vehicles.gd (_gather_blocks): [xz centre, heading, half length, half width, xz +## velocity, car id] of every knocked lane car off its lane and every parked car knocked out of +## its slot (the player's own cars are blocks already). +func blocks() -> Array: + var out: Array = [] + for s: Shove in _shoves.values(): + if s.lane and (s.off.length() > OFF_BLOCK or absf(s.turn) > OFF_TURN): + out.append(_block(s.car)) + for c: VH.Car in _moved.values(): + if _off_slot(c): + out.append(_block(c)) + return out + + +func _block(c: VH.Car) -> Array: + var q := c.pos + c.vis_off + return [ + Vector2(q.x, q.z), + OBB.heading(c.yaw + c.vis_yaw), + c.length * 0.5, + c.half_w, + body_vel(c), + c.id, + ] + + +## True when parked car c stands off its slot's pose. +func _off_slot(c: VH.Car) -> bool: + if c.slot_tile < 0: + return false + var pose: Array = vehicles.lg.slot_pose(c.slot_tile, c.slot_k) + var h: Vector2 = pose[1] + var off := Vector2(c.pos.x, c.pos.z).distance_to(pose[0]) + return off > OFF_BLOCK or absf(angle_difference(c.yaw, atan2(h.x, h.y))) > OFF_TURN + + +func _seen(c: VH.Car) -> bool: + return visible.is_valid() and bool(visible.call(c.pos)) + + +## One step of s's slide, unless it would run into a wall or another car (then it stops there). +func _slide(s: Shove, dt: float) -> void: + if s.vel == Vector2.ZERO and s.spin == 0.0: + return + var c := s.car + var d := s.vel * dt + var dy := s.spin * dt + if not _free(c, d, dy): + s.vel = Vector2.ZERO + s.spin = 0.0 + return + if s.lane: + s.off += d + s.turn = wrapf(s.turn + dy, -PI, PI) + _pose_lane(s) + else: + var q := Vector2(c.pos.x, c.pos.z) + d + c.pos = Vector3(q.x, _ground(c, q), q.y) + c.yaw = wrapf(c.yaw + dy, -PI, PI) + if c.parked: + _moved[c.id] = c + _still_t[c.id] = 0.0 + vehicles.parked_version += 1 + s.vel = s.vel.move_toward(Vector2.ZERO, SLIDE_BRAKE * dt) + s.spin *= exp(-SPIN_BRAKE * dt) + if absf(s.spin) < MIN_SPIN: + s.spin = 0.0 + + +## A lane car once its body stopped: waits, then steers its body back onto the lane. True when it +## is back and drives on. +func _back(s: Shove, dt: float) -> bool: + var c := s.car + if s.vel != Vector2.ZERO or s.spin != 0.0: + c.stun_t = maxf(c.stun_t, s.hold) + return false + if s.hold > 0.0: + s.hold = maxf(0.0, s.hold - dt) + else: + var to := s.off.move_toward(Vector2.ZERO, RETURN_SPEED * dt) + var tt := move_toward(s.turn, 0.0, RETURN_TURN * dt) + if _free(c, to - s.off, tt - s.turn): + s.off = to + s.turn = tt + _pose_lane(s) + if s.hold <= 0.0 and s.off == Vector2.ZERO and s.turn == 0.0: + c.stun_t = 0.0 + return true + c.stun_t = maxf(s.hold, STUN_MIN) + return false + + +## Draws a lane car's body at its offset from its lane pose. +func _pose_lane(s: Shove) -> void: + var c := s.car + var q := Vector2(c.pos.x, c.pos.z) + s.off + c.vis_off = Vector3(s.off.x, _ground(c, q) - c.pos.y, s.off.y) + c.vis_yaw = s.turn + + +## The ground height under xz point q for c's body: walk_height() within a step of the road it +## stands on, else that road. +func _ground(c: VH.Car, q: Vector2) -> float: + var base := c.pos.y + if collision == null: + return base + var g := collision.walk_height(Vector3(q.x, base, q.y)) + return base if is_nan(g) or absf(g - base) > WS.STEP_HEIGHT else g + + +## True when c's body, moved by d and turned by dy, goes no deeper into walls, posts, water or +## other vehicles than it already is. +func _free(c: VH.Car, d: Vector2, dy: float) -> bool: + var q := c.pos + c.vis_off + var c0 := Vector2(q.x, q.z) + var y0 := c.yaw + c.vis_yaw + var h := Vector2(c.half_w, c.length * 0.5) + return _depth(c, c0 + d, y0 + dy, h) <= maxf(_depth(c, c0, y0, h), TOUCH) + + +## The deepest overlap of c's body at centre p, yaw y, with the world and the other vehicles. +func _depth(c: VH.Car, p: Vector2, y: float, h: Vector2) -> float: + var deep := 0.0 + var feet := c.pos.y + c.vis_off.y + if collision != null: + var obs := BC.gather(collision, p, h.length() + 0.02, feet, c.height) + for k: Array in BC.contacts(collision, obs, p, y, h, feet): + deep = maxf(deep, float(k[1])) + var u := OBB.side_axis(y) + var v := OBB.heading(y) + for o: VH.Car in vehicles.cars: + if o == c or o.dead or o.far or (o.parked and o.slot_tile < 0): + continue + var oq := o.pos + o.vis_off + var oc := Vector2(oq.x, oq.z) + var ho := Vector2(o.half_w, o.length * 0.5) + if oc.distance_to(p) > h.length() + ho.length(): + continue + var oy := o.yaw + o.vis_yaw + var k := OBB.box_box(p, u, v, h, oc, OBB.side_axis(oy), OBB.heading(oy), ho) + if not k.is_empty(): + deep = maxf(deep, float(k[1])) + return deep + + +## Yaw an impulse j at offset r gives a body, times its inertia (car_body.gd's convention). +static func _cross(r: Vector2, j: Vector2) -> float: + return r.y * j.x - r.x * j.y diff --git a/scripts/fp/traffic/shoves.gd.uid b/scripts/fp/traffic/shoves.gd.uid new file mode 100644 index 00000000..8ce67c07 --- /dev/null +++ b/scripts/fp/traffic/shoves.gd.uid @@ -0,0 +1 @@ +uid://ba0idonsi1lfc diff --git a/scripts/fp/traffic/traffic_feature.gd b/scripts/fp/traffic/traffic_feature.gd index a067f53f..3061197a 100644 --- a/scripts/fp/traffic/traffic_feature.gd +++ b/scripts/fp/traffic/traffic_feature.gd @@ -22,6 +22,7 @@ const KIT := preload("res://scripts/fp/traffic/mesh_kit.gd") 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 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. @@ -48,6 +49,8 @@ var occupants: OCC var ambient: AMB var trips: TRIPS var fleets: FLEETS +## Cars the player's car knocked: their slides, stuns and the way back onto their lanes. +var shoves: SHOVES var clock: float = 0.0 ## Test hooks: departure jitter on or off; auto_step false keeps _process from advancing time. var jitter_enabled: bool = true: @@ -111,6 +114,7 @@ func setup(p_ctx: FpContext) -> void: ambient = AMB.new() trips = TRIPS.new() fleets = FLEETS.new() + shoves = SHOVES.new() trips.jitter_enabled = jitter_enabled render.setup(_agents_root) contact = CS.new() @@ -189,6 +193,9 @@ func on_world_rebuilt() -> void: vehicles.setup(lanes, junctions, peds, sim.city_seed) vehicles.on_arrive = _on_vehicle_arrived vehicles.on_removed = _on_vehicle_removed + shoves.setup(vehicles, ctx.collision) + shoves.visible = on_screen + vehicles.extra_blocks = shoves.blocks walkers.setup(peds, lanes, junctions, vehicles, sim.city_seed) walkers.hc = people.ok walkers.on_removed = func(w: WK.Walker) -> void: @@ -353,6 +360,7 @@ func step(dt: float) -> void: walkers.player_pos = pp walkers.player_on = has_player vehicles.step(dt) + shoves.step(dt) walkers.step(dt) trips.step(dt) fleets.step(dt) @@ -1143,22 +1151,49 @@ static func _ray_box(o: Vector3, d: Vector3, xf: Transform3D, he: Vector3, h: fl return maxf(t0, 0.0) -## Optional, for A: world AABBs of stopped vehicles near p (parked cars included). While the -## player drives, moving vehicles block too, and the player's own car does not. +## Optional, for A: world AABBs of stopped vehicles near p (parked cars included), never the car +## the player drives (its body meets the others through bodies_near). func blockers_near(p: Vector3, r: float) -> Array: var out: Array = [] if not _built: return out - var driving := _driven != null for c: VH.Car in vehicles.cars: if c.dead or c.far or c.driven or (c.parked and c.slot_tile < 0): continue - if (c.v > 0.1 * WS.M and not driving) or c.pos.distance_to(p) > r + 0.3: + if c.v > 0.1 * WS.M or c.pos.distance_to(p) > r + 0.3: continue out.append(vehicles.aabb(c)) return out +## For the drive feature's car body: every vehicle body that may reach within r of p, as +## [id, xz centre, yaw, Vector2(half width, half length), xz velocity, mass (a sedan is 1)]. The +## car the player drives is not listed. +func bodies_near(p: Vector3, r: float) -> Array: + var out: Array = [] + if not _built: + return out + for c: VH.Car in vehicles.cars: + if c.dead or c.far or c.driven or (c.parked and c.slot_tile < 0): + continue + var q := c.pos + c.vis_off + var reach := r + c.length * 0.5 + c.half_w + if absf(q.x - p.x) > reach or absf(q.z - p.z) > reach: + continue + var m := SHOVES.FIXED_MASS if c.park_t >= 0.0 else SHOVES.mass_of(c) + var hb := Vector2(c.half_w, c.length * 0.5) + out.append([c.id, Vector2(q.x, q.z), c.yaw + c.vis_yaw, hb, shoves.body_vel(c), m]) + return out + + +## The player's car hit vehicle id with impulse j (xz, u/s times a sedan's mass) at xz point at: +## the car is knocked (shoves.gd). +func bump_car(id: int, j: Vector2, at: Vector2) -> void: + var c: VH.Car = vehicles.by_id.get(id) if _built else null + if c != null: + shoves.bump(c, j, at) + + # ------------------------------------------------------------------ the player's car ## The car the player could get into from p: the nearest one they may take whose body is within r diff --git a/scripts/fp/traffic/vehicles.gd b/scripts/fp/traffic/vehicles.gd index 8092786e..5241fad2 100644 --- a/scripts/fp/traffic/vehicles.gd +++ b/scripts/fp/traffic/vehicles.gd @@ -43,6 +43,8 @@ const LOD_DT := 1.0 / 10.0 const FAR_SPEED := 0.9 ## Role of the player's own cars: never on a lane, posed by the drive feature or left standing. const PLAYER := "player" +## A car the player's car hit brakes this hard while stunned (its driver stands on the brakes). +const STUN_BRAKE := 6.0 * WS.M ## One vehicle. @@ -68,6 +70,7 @@ class Car extends RefCounted: var wait_t: float = 0.0 var stalled_s: float = 0.0 var hold: bool = false # conversation: stop and wait + var stun_t: float = 0.0 # s it stays braking after the player's car hit it (shoves.gd) var siren: bool = false var far: bool = false var parked: bool = false @@ -107,10 +110,13 @@ var cars: Array = [] var by_id: Dictionary = {} var player_pos := Vector3(INF, 0, INF) var player_on: bool = false -## What lane traffic stops for, rebuilt each step: the player on foot and the player's cars, each -## [xz centre, unit heading, half length, half width, xz velocity]; half length < 0 is the player -## on foot (a circle). +## What lane traffic stops for, rebuilt each step: the player on foot, the player's cars and the +## extra_blocks, each [xz centre, unit heading, half length, half width, xz velocity] (an extra +## block adds the car id it stands for, which does not stop for itself); half length < 0 is the +## player on foot (a circle). var _blocks: Array = [] +## Returns more blocks each step (shoves.gd: cars knocked off their lanes or slots). +var extra_blocks: Callable ## Bumped whenever the set of parked cars changes (the renderer's static layer rebuilds). var parked_version: int = 0 var _next_id: int = 1 @@ -473,8 +479,10 @@ func _drive(c: Car, dt: float) -> void: vl = pl[1] hard = minf(hard, pg - MIN_GAP - WS.PLAYER_RADIUS) var acc := IDM.acc(c.v, vmax, gap, c.v - vl) - if c.hold: - acc = minf(acc, -IDM.B * 1.5) + var hb := _hold_brake(c) + if hb > 0.0: + acc = minf(acc, -hb) + c.stun_t = maxf(0.0, c.stun_t - dt) var v1 := maxf(0.0, c.v + acc * dt) var ds := (c.v + v1) * 0.5 * dt if ds > hard: @@ -622,11 +630,19 @@ func _stops(c: Car) -> Array: if k == n - 1 and c.goal_lane == null and lg.next_lanes(x).is_empty(): best = minf(best, maxf(d + x.length, 0.0)) d += x.length - if c.hold: - best = minf(best, maxf(c.v * c.v / (2.0 * IDM.B * 1.5), 0.0)) + var hb := _hold_brake(c) + if hb > 0.0: + best = minf(best, c.v * c.v / (2.0 * hb)) return [best] +## How hard c brakes to stand still: stunned by a knock, or held by a conversation; 0 when free. +func _hold_brake(c: Car) -> float: + if c.stun_t > 0.0: + return STUN_BRAKE + return IDM.B * 1.5 if c.hold else 0.0 + + ## Gap from c's front to the player when they stand in its forward corridor, else INF. func _gather_blocks() -> void: _blocks.clear() @@ -640,6 +656,8 @@ func _gather_blocks() -> void: _blocks.append([Vector2(c.pos.x, c.pos.z), h, c.length * 0.5, c.half_w, v]) if player_on and not driving: _blocks.append([Vector2(player_pos.x, player_pos.z), Vector2.ZERO, -1.0, 0.0, Vector2.ZERO]) + if extra_blocks.is_valid(): + _blocks.append_array(extra_blocks.call()) ## [gap, speed] to the nearest block on c's path: the distance from its front to the block's @@ -651,6 +669,8 @@ func _player_gap(c: Car) -> Array: var front := lg.pos_at(c.lane, c.s) var look := c.v * c.v / (2.0 * IDM.B) + 2.0 * WS.M + c.v * 0.5 + 0.1 for b: Array in _blocks: + if b.size() > 5 and int(b[5]) == c.id: + continue if front.distance_to(b[0]) > look + 0.2 + maxf(float(b[2]), 0.0): continue var g := _gap_to(c, b, look) diff --git a/scripts/fp/world/collision_world.gd b/scripts/fp/world/collision_world.gd index 78cef872..8cda24df 100644 --- a/scripts/fp/world/collision_world.gd +++ b/scripts/fp/world/collision_world.gd @@ -74,18 +74,21 @@ func rebuild_all() -> void: _esc_from = Vector3(NAN, NAN, NAN) -## Invalidates tile i's colliders. -func on_tile_built(i: int, _holder: Node3D) -> void: +## Invalidates tile i's colliders: its holder changed (a street prop knocked down or put back). +func invalidate(i: int) -> void: _tiles.erase(i) _bounds.erase(i) _esc_from = Vector3(NAN, NAN, NAN) +## Invalidates tile i's colliders. +func on_tile_built(i: int, _holder: Node3D) -> void: + invalidate(i) + + ## Invalidates tile i's colliders. func on_tile_cleared(i: int) -> void: - _tiles.erase(i) - _bounds.erase(i) - _esc_from = Vector3(NAN, NAN, NAN) + invalidate(i) ## Test hook: a world box that blocks like any other (a ledge, a ceiling), listed in boxes() of @@ -815,15 +818,7 @@ func _model_shapes(path: String, local: AABB) -> Dictionary: func _prop_shapes(i: int, holder: Node3D, props: Array[AABB], cyls: PackedFloat32Array) -> void: var hx := holder.transform if holder.has_meta("colliders"): - for c: Variant in holder.get_meta("colliders", []): - if typeof(c) != TYPE_DICTIONARY: - continue - var d: Dictionary = c - if str(d.get("shape", "box")) == "cyl": - var p := hx * (d.get("pos", Vector3.ZERO) as Vector3) - cyls.append_array([p.x, p.z, float(d.get("r", LAMP_R)), p.y + float(d.get("h", META_CYL_TOP))]) - elif d.has("size"): - props.append(hx * ConstructionKit.collider_aabb(d)) + _meta_shapes(holder, props, cyls) return var sim := ctx.sim var is_road := sim.kind[i] == D.ROAD @@ -871,6 +866,22 @@ func _prop_shapes(i: int, holder: Node3D, props: Array[AABB], cyls: PackedFloat3 cyls.append_array([c.x, c.z, TRUNK_R, WS.SIDEWALK_TOP + 6.0 * WS.M]) +## The holder's `colliders` meta in world space; entries marked "down" (street props knocked +## over) do not block. +func _meta_shapes(holder: Node3D, props: Array[AABB], cyls: PackedFloat32Array) -> void: + var hx := holder.transform + for c: Variant in holder.get_meta("colliders", []): + if typeof(c) != TYPE_DICTIONARY or bool((c as Dictionary).get("down", false)): + continue + var d: Dictionary = c + if str(d.get("shape", "box")) == "cyl": + var p := hx * (d.get("pos", Vector3.ZERO) as Vector3) + var top := p.y + float(d.get("h", META_CYL_TOP)) + cyls.append_array([p.x, p.z, float(d.get("r", LAMP_R)), top]) + elif d.has("size"): + props.append(hx * ConstructionKit.collider_aabb(d)) + + ## One prop model by file name: lamp pole, trunk, box or a footprint cylinder. func _prop(path: String, xf: Transform3D, props: Array[AABB], cyls: PackedFloat32Array) -> void: var f := path.get_file() diff --git a/scripts/world/city_view.gd b/scripts/world/city_view.gd index ad78727a..f27e21de 100644 --- a/scripts/world/city_view.gd +++ b/scripts/world/city_view.gd @@ -1975,6 +1975,24 @@ func tile_holder(i: int) -> Node3D: return _tile_nodes.get(i, null) +## The batched street props of tile i on foot: Array of [kind, world Transform3D], indexed by +## their colliders' "slot". +func street_placements(i: int) -> Array: + return _street.placements(i) + + +## Knocks batched street prop `slot` of tile i down (not drawn, its collider marked "down") or +## stands it up again. CollisionWorld needs invalidate(i) after. +func set_street_prop_down(i: int, slot: int, down: bool) -> void: + _street.set_down(i, slot, down) + + +## A batched street prop kind's mesh parts ({"mesh", "xform", "mat"} each) and their bounds, as +## {"parts", "aabb"}; {} for an unknown kind. +func street_kit(kind: String) -> Dictionary: + return _street.kit_of(kind) + + ## Camera used for picking, shake and avatar picking (orbit rig camera or the FP camera). func set_camera(c: Camera3D) -> void: camera = c diff --git a/sounds/fp/crash_heavy.wav b/sounds/fp/crash_heavy.wav new file mode 100644 index 00000000..e74645e5 Binary files /dev/null and b/sounds/fp/crash_heavy.wav differ diff --git a/sounds/fp/crash_heavy.wav.import b/sounds/fp/crash_heavy.wav.import new file mode 100644 index 00000000..4b1486ec --- /dev/null +++ b/sounds/fp/crash_heavy.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bqjioej28l8l3" +path="res://.godot/imported/crash_heavy.wav-3382c8c62ebccf82755a86cf733acfee.sample" + +[deps] + +source_file="res://sounds/fp/crash_heavy.wav" +dest_files=["res://.godot/imported/crash_heavy.wav-3382c8c62ebccf82755a86cf733acfee.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/crash_light.wav b/sounds/fp/crash_light.wav new file mode 100644 index 00000000..5dc9ddd7 Binary files /dev/null and b/sounds/fp/crash_light.wav differ diff --git a/sounds/fp/crash_light.wav.import b/sounds/fp/crash_light.wav.import new file mode 100644 index 00000000..ebefbf6c --- /dev/null +++ b/sounds/fp/crash_light.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://m04o6cbj1h16" +path="res://.godot/imported/crash_light.wav-8b9787eb542ffecf90233de6f2595d8c.sample" + +[deps] + +source_file="res://sounds/fp/crash_light.wav" +dest_files=["res://.godot/imported/crash_light.wav-8b9787eb542ffecf90233de6f2595d8c.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/prop_clang.wav b/sounds/fp/prop_clang.wav new file mode 100644 index 00000000..02150369 Binary files /dev/null and b/sounds/fp/prop_clang.wav differ diff --git a/sounds/fp/prop_clang.wav.import b/sounds/fp/prop_clang.wav.import new file mode 100644 index 00000000..0ab879d0 --- /dev/null +++ b/sounds/fp/prop_clang.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://hsmo1pd6i78g" +path="res://.godot/imported/prop_clang.wav-e96fc2596a008168b8724ba7b90175d6.sample" + +[deps] + +source_file="res://sounds/fp/prop_clang.wav" +dest_files=["res://.godot/imported/prop_clang.wav-e96fc2596a008168b8724ba7b90175d6.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/prop_clatter.wav b/sounds/fp/prop_clatter.wav new file mode 100644 index 00000000..f2b99e67 Binary files /dev/null and b/sounds/fp/prop_clatter.wav differ diff --git a/sounds/fp/prop_clatter.wav.import b/sounds/fp/prop_clatter.wav.import new file mode 100644 index 00000000..0d7e3f9f --- /dev/null +++ b/sounds/fp/prop_clatter.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cdwdeiwidphgg" +path="res://.godot/imported/prop_clatter.wav-7ec88e4f318359a6d23faf778e0dad7a.sample" + +[deps] + +source_file="res://sounds/fp/prop_clatter.wav" +dest_files=["res://.godot/imported/prop_clatter.wav-7ec88e4f318359a6d23faf778e0dad7a.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/tools/audio/synth_fp_sfx.py b/tools/audio/synth_fp_sfx.py index c92b542f..dfb3ddb2 100644 --- a/tools/audio/synth_fp_sfx.py +++ b/tools/audio/synth_fp_sfx.py @@ -5,7 +5,7 @@ seeds, so a re-run gives the same samples and byte-identical files. Two formats: - WAV (16-bit PCM, imported uncompressed) for every sound the game starts often: footsteps, - horns, UI cues, the engine loops and the siren. Starting an Ogg Vorbis playback costs about + horns, crashes and knocked props, UI cues, the engine loops and the siren. Starting an Ogg Vorbis playback costs about 0.4 ms on the main thread (a decoder per start), a WAV about 1 us, and the audio budget is 0.3 ms per frame. - Ogg Vorbis (ffmpeg libvorbis -q 4, bit-exact) for the long loops started rarely: the beds, @@ -828,6 +828,167 @@ def synth_rain() -> np.ndarray: return norm_rms(out, -20.0) +# ------------------------------------------------------------------ impacts + +def clip_parts(parts: list[tuple[float, float, float]], top: float = 16000.0) -> list[tuple[float, float, float]]: + """Drops the modes above top, so a bright resonance never folds back past Nyquist.""" + return [p for p in parts if p[0] < top] + + +def thud(rng: np.random.Generator, n: int, f0: float, f1: float, decay: float) -> np.ndarray: + """A heavy body striking something: a damped sine falling from f0 to f1 Hz.""" + t = secs(n) + fr = f1 + (f0 - f1) * np.exp(-t / (decay * 0.5)) + ph = 2 * np.pi * np.cumsum(fr) / SR + rng.uniform(0, 2 * np.pi) + return np.sin(ph) * np.exp(-t / decay) * (1 - np.exp(-t / 0.0008)) + + +def crack(rng: np.random.Generator, n: int, decay: float, lo: float, hi: float) -> np.ndarray: + """The first instant of contact: a band of noise dying within a few milliseconds.""" + c = bandpass(rng.standard_normal(n), lo, hi) * np.exp(-secs(n) / decay) + return c / (np.max(np.abs(c)) + 1e-9) + + +def crunch(rng: np.random.Generator, n: int, rate: float, decay: float, lo: float, hi: float) -> np.ndarray: + """Sheet metal and plastic buckling: a burst of crackles thinning out over decay (s).""" + out = np.zeros(n) + for tk in poisson_times(rng, rate, min(n / SR, decay * 5.0)): + w = np.exp(-tk / decay) + if rng.uniform() > w: + continue + gl = int(rng.uniform(0.0004, 0.004) * SR) + 2 + g = rng.standard_normal(gl) * np.exp(-secs(gl) / (gl / SR / 3.0)) + add_at(out, g * min(rng.pareto(2.0) + 0.2, 6.0), int(tk * SR), wrap=False) + out = bandpass(out, lo, hi) + return out / (np.max(np.abs(out)) + 1e-9) + + +def knock(rng: np.random.Generator, parts: list[tuple[float, float, float]], pitch: float, click: float) -> np.ndarray: + """A hollow object struck once: its modes (Hz, decay s, amplitude) at pitch, and a click.""" + longest = max(d for _, d, _ in parts) + n = int(min(longest * 7.0, 1.2) * SR) + s = modes(n, clip_parts([(f * pitch, d, a) for f, d, a in parts]), rng) + s *= 1 - np.exp(-secs(n) / 0.0005) + s += click * crack(rng, n, 0.0012, 1500.0, 9000.0) + return s / (np.max(np.abs(s)) + 1e-9) + + +def shards(rng: np.random.Generator, n: int, count: int, t0: float, spread: float) -> np.ndarray: + """Broken glass raining down: tiny bright resonances, most of them soon after t0 (s).""" + out = np.zeros(n) + for _ in range(count): + tk = t0 + rng.exponential(spread) + f = np.exp(rng.uniform(np.log(2400.0), np.log(8500.0))) + d = rng.uniform(0.006, 0.03) + gl = int(d * 5 * SR) + 8 + parts = [(f, d, 1.0), (f * rng.uniform(1.5, 2.4), d * 0.6, 0.5), (f * rng.uniform(2.6, 3.8), d * 0.4, 0.3)] + s = modes(gl, clip_parts(parts), rng) + 0.3 * rng.standard_normal(gl) * np.exp(-secs(gl) / 0.0006) + add_at(out, s * rng.uniform(0.15, 1.0) ** 2, int(tk * SR), wrap=False) + return out / (np.max(np.abs(out)) + 1e-9) + + +def scrape(rng: np.random.Generator, n: int, start: float, length: float, lo: float, hi: float, ridges: float) -> np.ndarray: + """Something dragged or rolled over asphalt: rough noise pulsing at ridges (Hz).""" + out = np.zeros(n) + sl = int(length * SR) + t = secs(sl) + s = bandpass(rng.standard_normal(sl), lo, hi) * env_ad(sl, 0.015, length * 0.35) + s *= 0.55 + 0.45 * np.abs(np.sin(np.pi * ridges * t + rng.uniform(0, np.pi))) ** 3 + add_at(out, s / (np.max(np.abs(s)) + 1e-9), int(start * SR), wrap=False) + return out + + +## A car body's panels, struck: inharmonic, damped within a tenth of a second. +PANEL = [(182.0, 0.09, 1.0), (311.0, 0.07, 0.8), (468.0, 0.05, 0.6), (757.0, 0.04, 0.45), (1234.0, 0.03, 0.3), (1851.0, 0.02, 0.2)] +## A plastic wheelie bin's hollow body and its lid. +BIN_BODY = [(165.0, 0.06, 1.0), (241.0, 0.05, 0.7), (396.0, 0.035, 0.5), (611.0, 0.025, 0.35), (983.0, 0.015, 0.25)] +BIN_LID = [(1310.0, 0.012, 1.0), (2090.0, 0.008, 0.6), (3270.0, 0.005, 0.3)] +## Small plastic and trim bits a crash throws about. +DEBRIS = [(820.0, 0.012, 1.0), (1370.0, 0.009, 0.6), (2240.0, 0.006, 0.35)] + + +def synth_crash_heavy() -> np.ndarray: + """The player's car hitting a wall or a car hard: a bumper cracking, the body's thud, metal + buckling and ringing, glass raining down and bits of trim skittering away.""" + rng = rng_for("crash_heavy") + n = int(1.5 * SR) + out = np.zeros(n) + out += 0.55 * crack(rng, n, 0.003, 900.0, 9000.0) + out += 0.75 * thud(rng, n, 72.0, 44.0, 0.14) + out += 0.4 * thud(rng, n, 128.0, 82.0, 0.08) + out += 0.35 * lowpass(rng.standard_normal(n), 220.0) * env_ad(n, 0.002, 0.12) / 0.2 + out += 0.9 * crunch(rng, n, 1100.0, 0.08, 700.0, 7500.0) + add_at(out, 0.5 * knock(rng, PANEL, rng.uniform(0.97, 1.03), 0.2), int(0.004 * SR), wrap=False) + add_at(out, 0.3 * knock(rng, PANEL, rng.uniform(1.15, 1.25), 0.0), int(0.05 * SR), wrap=False) + out += 0.3 * shards(rng, n, 44, 0.03, 0.11) + out += 0.16 * shards(rng, n, 14, 0.32, 0.16) + for k in range(6): + tk = rng.uniform(0.12, 0.85) + add_at(out, 0.18 * (1.0 - tk) * knock(rng, DEBRIS, rng.uniform(0.7, 1.4), 0.4), int(tk * SR), wrap=False) + out += 0.08 * scrape(rng, n, 0.06, 0.5, 900.0, 5000.0, 9.0) + out = stereo_reverb(out, rng, 0.7, 0.12, 5000.0, circular=False).mean(axis=1) + return norm_peak(fade_edges(out, 0.0005, 0.12), -1.0) + + +def synth_crash_light() -> np.ndarray: + """A bump: the car nudging a wall, a post or another car's bumper.""" + rng = rng_for("crash_light") + n = int(0.65 * SR) + out = np.zeros(n) + out += 1.0 * thud(rng, n, 96.0, 70.0, 0.05) + out += 0.45 * crack(rng, n, 0.012, 500.0, 3000.0) + add_at(out, 0.5 * knock(rng, [(423.0, 0.02, 1.0), (781.0, 0.015, 0.6), (1352.0, 0.01, 0.4)], 1.0, 0.3), 0, wrap=False) + out += 0.25 * crunch(rng, n, 260.0, 0.03, 1000.0, 6000.0) + add_at(out, 0.22 * knock(rng, [(f, d * 0.5, a) for f, d, a in PANEL], 1.2, 0.0), int(0.003 * SR), wrap=False) + out += 0.1 * scrape(rng, n, 0.01, 0.12, 1500.0, 6000.0, 14.0) + out = stereo_reverb(out, rng, 0.5, 0.08, 5000.0, circular=False).mean(axis=1) + return norm_peak(fade_edges(out, 0.0005, 0.08), -1.0) + + +def synth_prop_clatter() -> np.ndarray: + """A plastic bin knocked flying: the hit, its lid clacking, the fall onto the road, a bounce + or two and a short roll.""" + rng = rng_for("prop_clatter") + n = int(1.1 * SR) + out = np.zeros(n) + add_at(out, 1.0 * knock(rng, BIN_BODY, 1.0, 0.5), 0, wrap=False) + add_at(out, 0.4 * knock(rng, BIN_LID, rng.uniform(0.95, 1.05), 0.6), int(rng.uniform(0.06, 0.09) * SR), wrap=False) + land = rng.uniform(0.28, 0.34) + add_at(out, 0.85 * knock(rng, BIN_BODY, 0.93, 0.7), int(land * SR), wrap=False) + add_at(out, 0.35 * knock(rng, BIN_LID, rng.uniform(0.9, 1.0), 0.8), int((land + 0.012) * SR), wrap=False) + tk = land + for amp in [0.45, 0.25]: + tk += rng.uniform(0.11, 0.18) + add_at(out, amp * knock(rng, BIN_BODY, rng.uniform(0.9, 1.05), 0.5), int(tk * SR), wrap=False) + grit = crunch(rng, int(0.05 * SR), 320.0, 0.02, 1500.0, 7000.0) + add_at(out, 0.25 * grit, int(land * SR), wrap=False) + out += 0.07 * scrape(rng, n, tk + 0.04, 0.4, 700.0, 4500.0, 6.0) + out = stereo_reverb(out, rng, 0.5, 0.1, 5000.0, circular=False).mean(axis=1) + return norm_peak(fade_edges(out, 0.0005, 0.1), -1.0) + + +def synth_prop_clang() -> np.ndarray: + """A metal mailbox, parking meter, bike rack or bench knocked over: the clang of the hit, its + fall onto the road, a bounce and a rattle.""" + rng = rng_for("prop_clang") + n = int(1.2 * SR) + out = np.zeros(n) + out += 0.6 * thud(rng, n, 112.0, 80.0, 0.04) + add_at(out, 1.0 * metal_hit(rng, 418.0, 0.22, 1.0), 0, wrap=False) + land = rng.uniform(0.3, 0.36) + add_at(out, 0.8 * metal_hit(rng, 523.0, 0.18, 1.0), int(land * SR), wrap=False) + add_at(out, 0.3 * crunch(rng, int(0.06 * SR), 380.0, 0.025, 1800.0, 8000.0), int(land * SR), wrap=False) + tk = land + for amp in [0.4, 0.22]: + tk += rng.uniform(0.09, 0.14) + add_at(out, amp * metal_hit(rng, 523.0 * rng.uniform(1.02, 1.1), 0.1, 1.0), int(tk * SR), wrap=False) + rattle = grains(rng, int(0.3 * SR), 160.0, 0.2, 2000.0, 8000.0, 0.08) + add_at(out, 0.15 * rattle / (np.max(np.abs(rattle)) + 1e-9), int(land * SR), wrap=False) + out += 0.06 * scrape(rng, n, tk + 0.03, 0.35, 1200.0, 6500.0, 10.0) + out = stereo_reverb(out, rng, 0.6, 0.1, 5500.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: @@ -872,7 +1033,7 @@ 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"} + "chat_blip_soft", "alert_stinger", "crash_heavy", "crash_light", "prop_clatter", "prop_clang"} def is_wav(name: str) -> bool: @@ -900,6 +1061,10 @@ def catalogue() -> dict[str, tuple[object, bool]]: "chat_blip": (synth_chat_blip, False), "chat_blip_soft": (synth_chat_blip_soft, False), "alert_stinger": (synth_alert_stinger, False), + "crash_heavy": (synth_crash_heavy, False), + "crash_light": (synth_crash_light, False), + "prop_clatter": (synth_prop_clatter, False), + "prop_clang": (synth_prop_clang, 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 3d4611e7..9f03a3be 100644 --- a/tools/tests/fp_audio_test.gd +++ b/tools/tests/fp_audio_test.gd @@ -2,8 +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, 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 and +## knocked props, 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") @@ -41,6 +42,7 @@ func _init() -> void: _check_settings(ctx, f) _check_pause_and_duck(ctx, f) _check_ui(ctx, f) + _check_impacts(ctx, f) _check_budget(ctx, f) var st: Dictionary = f.self_test() K.check(fails, bool(st["ok"]), "self_test: %s" % st["detail"]) @@ -617,6 +619,55 @@ func _check_ui(ctx: FpContext, f: FpFeature) -> void: K.check(fails, int(f.get("stingers_played")) - s0 == 1, "alerts: an event ping plays the stinger once (toasts within 4 s share it)") +## car_impact plays a crash at the contact (a bump below CRASH_MPS, quieter), prop_knocked the +## prop's own clatter (at most one per KNOCK_GAP); each one-shot gets a player and starts. +func _check_impacts(ctx: FpContext, f: FpFeature) -> void: + var em := _em(f) + var at := em.listener() + Vector3(0.1, 0.0, 0.0) + var hard := _shot(f, func() -> void: ctx.bus.car_impact.emit(at, 10.0 * WS.M, "wall")) + var soft := _shot(f, func() -> void: ctx.bus.car_impact.emit(at, 3.0 * WS.M, "car")) + K.check( + fails, + _is_shot(hard, "crash", "crash_heavy") and _is_shot(soft, "crash", "crash_light"), + "impacts: a 10 m/s hit plays crash_heavy, a 3 m/s bump crash_light, both started" + ) + K.check( + fails, + hard != null and soft != null and soft.db < hard.db and hard.db <= EM.TYPES["crash"]["db"], + "impacts: the bump is quieter (%.1f dB < %.1f dB)" % [soft.db, hard.db] + ) + var bin := _shot(f, func() -> void: ctx.bus.prop_knocked.emit(at, "bin", 5.0 * WS.M)) + var n := em.shots_played + ctx.bus.prop_knocked.emit(at, "bin", 5.0 * WS.M) + var again := em.shots_played - n + _steps(f, BANK.KNOCK_GAP + 0.05) + var box := _shot(f, func() -> void: ctx.bus.prop_knocked.emit(at, "mailbox", 4.0 * WS.M)) + K.check( + fails, + ( + _is_shot(bin, "knock", "prop_clatter") + and again == 0 + and _is_shot(box, "knock", "prop_clang") + ), + "impacts: a knocked bin clatters, a second at once stays silent, a mailbox clangs" + ) + _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) + var n := em.shots_played + fire.call() + var s: EM.Src = em.source("shot:%d" % em.shots_played) if em.shots_played == n + 1 else null + f.call("step", DT) + return s + + +func _is_shot(s: EM.Src, type: String, stream_name: String) -> bool: + return s != null and s.type == type and s.stream == BANK.stream(stream_name) and s.started + + func _check_budget(ctx: FpContext, f: FpFeature) -> void: var p := ctx.player var times: Array[float] = [] diff --git a/tools/tests/fp_core_test.gd b/tools/tests/fp_core_test.gd index e4b942ee..bb35caee 100644 --- a/tools/tests/fp_core_test.gd +++ b/tools/tests/fp_core_test.gd @@ -79,7 +79,7 @@ func _check_registry(ctx: FpContext) -> void: ids[str(e["id"])] = true if f is Node: (f as Node).free() - K.check(fails, ok and ids.size() == 14, "all 14 registry entries load as FpFeatures with matching unique ids") + K.check(fails, ok and ids.size() == 15, "all 15 registry entries load as FpFeatures with matching unique ids") var feat := K.make_feature(ctx, "traffic") K.check(fails, feat != null and feat.is_inside_tree() and ctx.feature("traffic") == feat, "make_feature puts the feature in the tree and ctx.features") var st: Dictionary = feat.self_test() if feat != null else {} diff --git a/tools/tests/fp_drive_test.gd b/tools/tests/fp_drive_test.gd index 08d8cb95..0ecf4cf6 100644 --- a/tools/tests/fp_drive_test.gd +++ b/tools/tests/fp_drive_test.gd @@ -1,11 +1,15 @@ extends SceneTree ## Driving (integration feature "drive"): the handling model, getting into a stopped car, driving -## it along a straight road, lane traffic stopping behind it, crashing into a wall, getting out -## beside the door, the car left standing as an obstacle, the seated hero, keys and pad bindings, -## and teardown. Time runs through drive_step(1/60) and the traffic feature's step(1/30). +## 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). const K := preload("res://tools/tests/fp_kit.gd") +const D := preload("res://scripts/sim/sim_defs.gd") const WS := preload("res://scripts/fp/world_scale.gd") +const OBB := preload("res://scripts/fp/obb2d.gd") const LG := preload("res://scripts/fp/traffic/lane_graph.gd") const VH := preload("res://scripts/fp/traffic/vehicles.gd") const MODEL := preload("res://scripts/fp/drive/drive_model.gd") @@ -18,6 +22,10 @@ var tr: FpFeature var drive: FpFeature var lanes: Array = [] var heading := Vector2.ZERO +## The straight road's tiles, in the order heading runs along them. +var road: Array[int] = [] +## [at, closing speed, what] of every car_impact. +var impacts: Array = [] func _init() -> void: @@ -38,6 +46,9 @@ func _init() -> void: tr.set("ambient_enabled", false) drive.set_process(false) it.set_process(false) + ctx.bus.car_impact.connect( + func(at: Vector3, v: float, what: String) -> void: impacts.append([at, v, what]) + ) K.check( fails, drive.self_test().get("ok", false), @@ -52,6 +63,8 @@ func _init() -> void: _check_yield() _check_exit() _check_crash() + _check_glance() + _check_shove() _check_keys() _check_teardown() K.finish(self, "FP_DRIVE_TEST", fails) @@ -157,6 +170,7 @@ func _road() -> bool: return false lanes = lg.lanes_along(best, LG.OPP[best_e], best_e) heading = LG.DIRS[best_e] + road = best return not lanes.is_empty() @@ -414,6 +428,7 @@ func _check_crash() -> void: Vector3(wall_c.x - ext.x, 0.0, wall_c.z - ext.z), Vector3(ext.x * 2.0, 0.2, ext.z * 2.0) ) ctx.collision.add_test_box(wall) + impacts.clear() var deep := -INF var near := (wall_c - start).dot(h3) - 0.5 * WS.M for _k in range(roundi(5.0 / DT)): @@ -427,10 +442,160 @@ func _check_crash() -> void: "the car never drives into the wall (front %.2f m past its face)" % (deep / WS.M) ) K.check(fails, absf(v) < 5.0 * KMH, "and stops against it (%.0f km/h)" % (v / KMH)) + var hit: Array = impacts[0] if not impacts.is_empty() else [Vector3.ZERO, 0.0, ""] + K.check( + fails, + str(hit[2]) == "wall" and float(hit[1]) > 20.0 * KMH, + "car_impact reports the crash (%s at %.0f km/h)" % [str(hit[2]), float(hit[1]) / KMH] + ) ctx.collision.clear_test_boxes() _drive(0.0, 1.0, 0.0, 1.0) +## Meets a wall along the road at 15 degrees and 40 km/h: the car never enters it, turns along it +## and slides on, and car_impact reports only the small speed it closed on the wall at. +func _check_glance() -> void: + _clear_others() + var model: MODEL = drive.get("model") + var dims: Vector3 = drive.get("_dims") + var a := Vector2(D.tx(road[0]), D.ty(road[0])) + var side := Vector2(-heading.y, heading.x) + var face := 0.12 + var mid := a + side * (face + 0.05) + heading * (road.size() * 0.5 - 0.5) + var ext := ( + Vector3(absf(heading.x), 0.0, absf(heading.y)) * (road.size() * 0.5) + + Vector3(absf(side.x), 0.0, absf(side.y)) * 0.05 + ) + ctx.collision.add_test_box( + AABB(Vector3(mid.x - ext.x, 0.0, mid.y - ext.z), Vector3(ext.x * 2.0, 0.2, ext.z * 2.0)) + ) + var dir := heading * cos(deg_to_rad(15.0)) + side * sin(deg_to_rad(15.0)) + var start := Vector3(a.x, ctx.collision.walk_height(Vector3(a.x, 0.0, a.y)), a.y) + drive.set("_pos", start) + model.reset(atan2(dir.x, dir.y), dims.z) + model.vel = dir * (40.0 * KMH) + impacts.clear() + var deep := -INF + for _k in range(roundi(2.0 / DT)): + drive.call("drive_step", 0.4, 0.0, 0.0, 0.0, DT) + deep = maxf(deep, _reach(side, dims) - (a.dot(side) + face)) + var turn := rad_to_deg(absf(model.heading().angle_to(heading))) + K.check( + fails, + deep < 0.1 * WS.M, + "at a glance the car never enters the wall (%.2f m past its face)" % (deep / WS.M) + ) + K.check( + fails, + model.speed() > 20.0 * KMH and turn < 8.0, + "it turns along the wall (%.0f deg) and slides on (%.0f km/h)" % [turn, model.speed() / KMH] + ) + var hit: Array = impacts[0] if not impacts.is_empty() else [Vector3.ZERO, INF, ""] + K.check( + fails, + str(hit[2]) == "wall" and float(hit[1]) < 15.0 * KMH, + "car_impact: it closed on the wall at %.0f km/h" % (float(hit[1]) / KMH) + ) + ctx.collision.clear_test_boxes() + _drive(0.0, 1.0, 0.0, 2.0) + + +## Runs into the side of a lane car crawling along at 25 km/h: it slides off its lane, braking, +## the player's car loses speed, car_impact reports a car and the bodies never overlap. Once the +## player backs off and it has waited, its driver steers it back onto the lane and drives on. +func _check_shove() -> void: + _clear_others() + var vh := _vh() + var model: MODEL = drive.get("model") + var dims: Vector3 = drive.get("_dims") + var m: int = tr.call("car_model_for", "shove-test") + var ai := vh.make("car", "ambient", tr.call("dims", m)) + ai.model = m + vh.place(ai, lanes[1], 0.5) + ai.path = lanes.slice(2) + ai.v0 = 0.5 * WS.M + ai.v = ai.v0 + var q := Vector2(ai.pos.x, ai.pos.z) + var u := OBB.side_axis(ai.yaw) + if (Vector2(roundf(q.x), roundf(q.y)) - q).dot(u) < 0.0: + u = -u + var s2 := q + u * (ai.half_w + dims.z * 0.5 + 1.5 * WS.M) + drive.set("_pos", Vector3(s2.x, ai.pos.y, s2.y)) + model.reset(atan2(-u.x, -u.y), dims.z) + model.vel = -u * (25.0 * KMH) + impacts.clear() + var deep := 0.0 + var off := 0.0 + var stunned := false + for _k in range(roundi(1.0 / (DT * 2.0))): + for _s in range(2): + drive.call("drive_step", 0.0, 0.0, 0.0, 0.0, DT) + deep = maxf(deep, _overlap(ai, dims)) + tr.call("step", DT * 2.0) + off = maxf(off, Vector2(ai.vis_off.x, ai.vis_off.z).length()) + stunned = stunned or ai.stun_t > 0.0 + var cars := impacts.filter(func(h: Array) -> bool: return str(h[2]) == "car") + var v0 := float(cars[0][1]) / KMH if not cars.is_empty() else 0.0 + K.check(fails, not cars.is_empty(), "car_impact reports hitting a car at %.0f km/h" % v0) + K.check( + fails, + off > 0.3 * WS.M and stunned, + "the lane car slides %.1f m off its lane, braking" % (off / WS.M) + ) + K.check( + fails, + absf(model.speed()) < 15.0 * KMH and deep < 0.1 * WS.M, + ( + "the player's car loses speed (%.0f km/h), the bodies never overlap (%.2f m)" + % [absf(model.speed()) / KMH, deep / WS.M] + ) + ) + _drive(0.0, 1.0, 0.0, 2.5) + model.vel = Vector2.ZERO + _drive(0.0, 0.0, 0.0, DT) + var back := false + for _k in range(roundi(20.0 / (DT * 2.0))): + tr.call("step", DT * 2.0) + if ai.vis_off == Vector3.ZERO and ai.vis_yaw == 0.0 and ai.stun_t <= 0.0: + back = true + break + K.check( + fails, + back and not ai.dead and not bool(tr.get("shoves").call("is_shoved", ai)), + "it steers back onto its lane and drives on (%.2f m off)" % (ai.vis_off.length() / WS.M) + ) + _clear_others() + + +## The farthest the player's car's body reaches along the unit vector side (a coordinate along it). +func _reach(side: Vector2, dims: Vector3) -> float: + var p: Vector3 = drive.get("_pos") + var yaw := float((drive.get("model") as MODEL).yaw) + var u := OBB.side_axis(yaw) + var v := OBB.heading(yaw) + var c := Vector2(p.x, p.z).dot(side) + return c + absf(u.dot(side)) * dims.x + absf(v.dot(side)) * dims.z * 0.5 + + +## Depth of the player's car's body in car c's. +func _overlap(c: VH.Car, dims: Vector3) -> float: + var p: Vector3 = drive.get("_pos") + var yaw := float((drive.get("model") as MODEL).yaw) + var q := c.pos + c.vis_off + var cy := c.yaw + c.vis_yaw + var k := OBB.box_box( + Vector2(p.x, p.z), + OBB.side_axis(yaw), + OBB.heading(yaw), + Vector2(dims.x, dims.z * 0.5), + Vector2(q.x, q.z), + OBB.side_axis(cy), + OBB.heading(cy), + Vector2(c.half_w, c.length * 0.5) + ) + return float(k[1]) if not k.is_empty() else 0.0 + + func _check_keys() -> void: var pad := false for e: InputEvent in InputMap.action_get_events("vehicle_enter"): diff --git a/tools/tests/fp_props_test.gd b/tools/tests/fp_props_test.gd new file mode 100644 index 00000000..27ad0ca8 --- /dev/null +++ b/tools/tests/fp_props_test.gd @@ -0,0 +1,527 @@ +extends SceneTree +## Street props the player's car knocks over (integration feature "props"): the tumble on its own, +## the knockable props and their colliders, a soft hit that holds, a hard hit that knocks a bin +## down (not drawn, no longer blocking, prop_knocked on the bus) and sends a copy tumbling to rest +## on a face on the ground, a second hit on the fallen bin, standing up again only out of sight and +## away from the player, a rebuilt tile, the player's car stopping against a bin it creeps into +## and knocking it flying at speed, the photo shot and teardown. + +const K := preload("res://tools/tests/fp_kit.gd") +const D := preload("res://scripts/sim/sim_defs.gd") +const WS := preload("res://scripts/fp/world_scale.gd") +const TUMBLE := preload("res://scripts/fp/props/tumble.gd") +const PROPS := preload("res://scripts/fp/props/props_feature.gd") +const VH := preload("res://scripts/fp/traffic/vehicles.gd") +const LG := preload("res://scripts/fp/traffic/lane_graph.gd") +const MODEL := preload("res://scripts/fp/drive/drive_model.gd") +const DT := 1.0 / 60.0 +const KMH := WS.M / 3.6 +## The car starts this far from the bin toward the road's middle, facing it. +const RUN_UP := 0.28 + +var fails: Array[String] = [] +var ctx: FpContext +var f: FpFeature +var tr: FpFeature +var drive: FpFeature +## [at, kind, speed] of every prop_knocked and [at, speed, what] of every car_impact. +var knocked: Array = [] +var impacts: Array = [] +## The bin under test: its knockables_near() entry plus "xf" (its placement), "face" (unit, level, +## toward the road) and "along" (unit, level, along the street). +var bin: Dictionary = {} +## Batched bin instances drawn while every bin stands. +var standing: int = 0 + + +func _init() -> void: + await process_frame + ctx = K.make_ctx(get_root(), {"view": true, "grow": true}) + ctx.player.set_process(false) + # Street props are drawn on foot only. + ctx.view.set_fp_mode(true) + if ctx.view.has_method("flush_rebuild_queue"): + ctx.view.call("flush_rebuild_queue") + ctx.collision.rebuild_all() + tr = K.make_feature(ctx, "traffic") + f = K.make_feature(ctx, "props") + drive = K.make_feature(ctx, "drive") + K.check(fails, f != null and f.feature_id() == "props", "the props feature is registered") + if f == null or tr == null or drive == null: + K.finish(self, "FP_PROPS_TEST", fails) + return + tr.set("auto_step", false) + tr.set("ambient_enabled", false) + f.set_process(false) + drive.set_process(false) + ctx.bus.prop_knocked.connect( + func(at: Vector3, kind: String, v: float) -> void: knocked.append([at, kind, v]) + ) + ctx.bus.car_impact.connect( + func(at: Vector3, v: float, what: String) -> void: impacts.append([at, v, what]) + ) + var st := f.self_test() + K.check(fails, bool(st.get("ok", false)), "self_test: %s" % st.get("detail", "")) + _check_tumble() + bin = _find_bin() + K.check(fails, not bin.is_empty(), "fixture: a curb bin on a straight road, clear in front") + if bin.is_empty(): + K.finish(self, "FP_PROPS_TEST", fails) + return + standing = _instances("bin") + _check_listing() + _check_hold() + _check_knock() + _check_rest() + _check_kick() + _check_respawn() + _check_rebuild() + _check_car() + _check_photo() + _check_teardown() + K.finish(self, "FP_PROPS_TEST", fails) + + +# ------------------------------------------------------------------ the tumble alone + + +## On flat ground: a 1 m box hit at 5 m/s flies, tips over and rests on a face with its lowest +## point on the ground, down the push; a wall 1 m off stops it. +func _check_tumble() -> void: + var flat := func(_p: Vector3) -> float: return 0.0 + var open := func(_p: Vector3, _r: float, _h: float) -> bool: return false + var bb := AABB(Vector3(-0.3, 0.0, -0.3) * WS.M, Vector3(0.6, 1.0, 0.6) * WS.M) + var t := TUMBLE.new() + t.launch(Transform3D(), bb, Vector3(0.0, 0.5, -0.3) * WS.M, Vector3(0.0, 0.0, 5.0 * WS.M)) + var n := _settle(t, flat, open) + K.check( + fails, + t.resting and n < roundi(TUMBLE.MAX_LIFE / DT), + "tumble: a box hit at 5 m/s comes to rest (%.1f s)" % (n * DT) + ) + K.check( + fails, + _on_face(t) and absf(wrapf(t.angle, -PI, PI)) > 0.1, + "tumble: tipped over onto a face (%.0f deg)" % rad_to_deg(t.angle) + ) + K.check(fails, absf(t.pos.y - t.drop()) < 1e-5, "tumble: its lowest point on the ground") + K.check( + fails, + t.pos.z > 1.0 * WS.M and absf(t.pos.x) < 0.2 * WS.M, + "tumble: down the push (%.1f m on, %.2f m aside)" % [t.pos.z / WS.M, t.pos.x / WS.M] + ) + var wall := func(p: Vector3, r: float, _h: float) -> bool: return p.z + r > 1.0 * WS.M + var w := TUMBLE.new() + w.launch(Transform3D(), bb, Vector3(0.0, 0.5, -0.3) * WS.M, Vector3(0.0, 0.0, 5.0 * WS.M)) + var far := 0.0 + for _k in range(roundi(TUMBLE.MAX_LIFE / DT)): + if not w.step(DT, flat, wall): + break + far = maxf(far, w.pos.z) + K.check(fails, far < 1.0 * WS.M, "tumble: a wall 1 m off stops it (%.2f m)" % (far / WS.M)) + # A bench-shaped box hit end-on falls over a long side, not onto its end. + var long := AABB(Vector3(-0.9, 0.0, -0.25) * WS.M, Vector3(1.8, 0.8, 0.5) * WS.M) + var b := TUMBLE.new() + b.launch(Transform3D(), long, Vector3(-0.9, 0.4, 0.0) * WS.M, Vector3(5.0 * WS.M, 0.0, 0.0)) + _settle(b, flat, open) + var up := absf(b.xform().basis.x.normalized().y) + K.check( + fails, + b.resting and _on_face(b) and absf(wrapf(b.angle, -PI, PI)) > 0.1 and up < 0.05, + "tumble: a long box hit end-on falls over a long side (%.0f deg)" % rad_to_deg(b.angle) + ) + + +# ------------------------------------------------------------------ the feature + + +func _check_listing() -> void: + var i := int(bin["tile"]) + K.check( + fails, + bin.has("box") and is_equal_approx(float(bin["mass"]), float(PROPS.KINDS["bin"][0])), + "a standing bin is listed with its box and a bin's mass" + ) + K.check( + fails, ctx.collision.boxes(i).has(bin["box"]), "its box is one of CollisionWorld's boxes" + ) + var bad: Array[String] = [] + var p := Vector3(D.tx(i), 0.0, D.ty(i)) + for k: Dictionary in f.call("knockables_near", p, 2.0): + var kind := str(ctx.view.street_placements(int(k["tile"]))[int(k["slot"])][0]) + if not PROPS.KINDS.has(kind): + bad.append(kind) + K.check(fails, bad.is_empty(), "lamps, trees, bollards and hydrants are not listed " + str(bad)) + + +func _check_hold() -> void: + var slow: Vector3 = bin["along"] * (0.5 * WS.M) + K.check( + fails, + not f.call("knock", bin["tile"], bin["slot"], _spot(), slow) and _down().is_empty(), + "a bin sent off at 0.5 m/s holds" + ) + K.check( + fails, + ctx.collision.boxes(int(bin["tile"])).has(bin["box"]) and knocked.is_empty(), + "and still blocks, nothing on the bus" + ) + + +func _check_knock() -> void: + var fly: Vector3 = bin["along"] * (5.0 * WS.M) + var parts := (ctx.view.street_kit("bin")["parts"] as Array).size() + K.check(fails, f.call("knock", bin["tile"], bin["slot"], _spot(), fly), "a hard hit knocks it") + K.check( + fails, + _down().size() == 1 and knocked.size() == 1 and str(knocked[0][1]) == "bin", + "one bin down, prop_knocked(bin) on the bus (%s)" % str(knocked) + ) + K.check( + fails, + knocked.size() == 1 and absf(float(knocked[0][2]) - 5.0 * WS.M) < 1e-4, + "at the speed it flies off" + ) + K.check( + fails, + _instances("bin") == standing - parts, + "the batched bin is not drawn (%d -> %d)" % [standing, _instances("bin")] + ) + K.check( + fails, + not ctx.collision.boxes(int(bin["tile"])).has(bin["box"]), + "and no longer blocks the walker or the car" + ) + var listed := _listed() + K.check( + fails, not listed.is_empty() and not listed.has("box"), "it is listed fallen, without a box" + ) + K.check( + fails, + not f.call("knock", bin["tile"], bin["slot"], _spot(), fly), + "hit again at once, it already flies off that fast" + ) + + +func _check_rest() -> void: + var t: TUMBLE = _down()[0]["t"] + var n := 0 + while not t.resting and n < roundi(12.0 / DT): + f.call("_process", DT) + n += 1 + var e: Dictionary = _down()[0] + var node: Node3D = e["node"] + var low := t.pos.y - t.drop() + var g := ctx.collision.walk_height(t.pos) + var moved := Vector2(t.pos.x - _spot().x, t.pos.z - _spot().z).length() + K.check(fails, t.resting, "the fallen bin comes to rest (%.1f s)" % (n * DT)) + K.check(fails, _on_face(t), "lying on a face (%.0f deg)" % rad_to_deg(t.angle)) + K.check( + fails, + not is_nan(g) and absf(low - g) < 1e-4, + "on the ground (%.3f m off)" % (absf(low - g) / WS.M) + ) + K.check(fails, moved > 1.0 * WS.M, "away from its place (%.1f m)" % (moved / WS.M)) + K.check( + fails, + node.is_inside_tree() and node.transform.is_equal_approx(t.xform()), + "a copy of its meshes is drawn where it lies" + ) + K.check( + fails, + node.get_child_count() == (ctx.view.street_kit("bin")["parts"] as Array).size(), + "with every part of the bin" + ) + + +func _check_kick() -> void: + var t: TUMBLE = _down()[0]["t"] + var from := t.pos + var fly: Vector3 = bin["face"] * (3.0 * WS.M) + K.check( + fails, + f.call("knock", bin["tile"], bin["slot"], t.pos, fly) and not t.resting, + "the fallen bin hit again moves off" + ) + K.check( + fails, + knocked.size() == 2 and _down().size() == 1, + "reported again, still one bin down (%d reports)" % knocked.size() + ) + _settle(t, ctx.collision.walk_height, ctx.collision.body_blocked) + var moved := Vector2(t.pos.x - from.x, t.pos.z - from.z).length() + K.check(fails, moved > 0.3 * WS.M, "and comes to rest elsewhere (%.1f m)" % (moved / WS.M)) + + +func _check_respawn() -> void: + var e: Dictionary = _down()[0] + var spot := _spot() + var face: Vector3 = bin["face"] + var along: Vector3 = bin["along"] + e["age"] = PROPS.RESPAWN + 1.0 + _stand_at(spot + face * 0.3, spot) + f.call("_process", DT) + K.check(fails, _down().size() == 1, "it does not stand up with the player 6 m away") + var far := spot + face * 0.325 + along * 1.5 + _stand_at(far, spot) + f.call("_process", DT) + K.check(fails, _down().size() == 1, "nor in sight of the player 30 m off") + _stand_at(far, far + (far - spot)) + f.call("_process", DT) + K.check(fails, _down().is_empty(), "out of sight after %.0f s it stands up" % PROPS.RESPAWN) + K.check( + fails, + _instances("bin") == standing and ctx.collision.boxes(int(bin["tile"])).has(bin["box"]), + "drawn and blocking in its place again" + ) + K.check(fails, _listed().has("box"), "and listed standing") + + +func _check_rebuild() -> void: + var fly: Vector3 = bin["along"] * (3.0 * WS.M) + f.call("knock", bin["tile"], bin["slot"], _spot(), fly) + var node: Node3D = _down()[0]["node"] if not _down().is_empty() else null + ctx.view._build_tile(int(bin["tile"]), false) + ctx.view._street.flush() + K.check( + fails, + _down().is_empty() and (node == null or node.is_queued_for_deletion()), + "a rebuilt tile drops its fallen props" + ) + bin.merge(_listed(), true) + K.check( + fails, + bin.has("box") and _instances("bin") == standing, + "and stands them in their places (%d bins drawn)" % _instances("bin") + ) + + +## The player's car creeping into the bin for 8 s stops against it; at 25 km/h it knocks the bin +## flying ahead of it and drives on. +func _check_car() -> void: + if not _enter(): + return + var model: MODEL = drive.get("model") + var face: Vector3 = bin["face"] + var start := _spot() + face * RUN_UP + start.y = ctx.collision.walk_height(start) + var yaw := atan2(-face.x, -face.z) + var dims: Vector3 = drive.get("_dims") + var reports := knocked.size() + drive.set("_pos", start) + model.reset(yaw, dims.z) + for _k in range(roundi(8.0 / DT)): + model.vel = model.heading() * (2.0 * KMH) + drive.call("drive_step", 0.0, 0.0, 0.0, 0.0, DT) + var p: Vector3 = drive.get("_pos") + var box: AABB = bin["box"] + var side := (box.get_center() - _spot()).dot(face) + absf(box.size.dot(face)) * 0.5 + var gap := RUN_UP - (start - p).dot(face) - dims.z * 0.5 - side + K.check( + fails, + _down().is_empty() and knocked.size() == reports, + "a car creeping into the bin at 2 km/h does not knock it over" + ) + K.check( + fails, + absf(gap) < 0.05 * WS.M, + "it stops against the bin (%.2f m off its side)" % (gap / WS.M) + ) + drive.set("_pos", start) + model.reset(yaw, dims.z) + model.vel = model.heading() * (25.0 * KMH) + var before := model.speed() + var hit := -1 + for k in range(roundi(1.5 / DT)): + drive.call("drive_step", 0.3, 0.0, 0.0, 0.0, DT) + if hit < 0 and not _down().is_empty(): + hit = k + if hit >= 0 and k >= hit + 3: + break + K.check( + fails, + _down().size() == 1 and int(_down()[0]["tile"]) == int(bin["tile"]), + "at 25 km/h the car knocks the bin over" + ) + K.check( + fails, + knocked.size() == reports + 1 and float(knocked[-1][2]) > 5.0 * WS.M, + "prop_knocked: it flies off faster than 5 m/s" + ) + K.check( + fails, + model.speed() > 0.7 * before, + "the car drives on (%.0f -> %.0f km/h)" % [before / KMH, model.speed() / KMH] + ) + var props_hit := impacts.filter(func(h: Array) -> bool: return str(h[2]) == "prop") + K.check(fails, props_hit.is_empty(), "car_impact leaves the prop to prop_knocked") + if not _down().is_empty(): + var t: TUMBLE = _down()[0]["t"] + K.check( + fails, + t.vel.dot(-face) > model.speed() * 0.9, + "the bin flies ahead of the car (%.1f m/s)" % (t.vel.dot(-face) / WS.M) + ) + for _k in range(roundi(2.0 / DT)): + drive.call("drive_step", 0.0, 1.0, 0.0, 0.0, DT) + drive.call("exit", true) + f.call("stand_all") + + +func _check_photo() -> void: + var shots: Array = f.photo_shots() + K.check( + fails, + shots.size() == 1 and str(shots[0].get("name", "")) == "props", + 'one photo shot, "props"' + ) + if shots.size() != 1: + return + f.call(str(shots[0]["prepare"])) + var rest: Vector3 = (f.get("_photo") as Dictionary).get("rest", Vector3.ZERO) + var ok := _down().size() == 1 and (_down()[0]["t"] as TUMBLE).resting + K.check(fails, ok, "its prepare knocks a prop over, at rest") + if ok: + var off := (_down()[0]["t"] as TUMBLE).pos.distance_to(rest) + K.check(fails, off < 1.0 * WS.M, "where the shot looks (%.2f m off)" % (off / WS.M)) + f.call(str(shots[0]["restore"])) + K.check(fails, _down().is_empty(), "its restore stands it up again") + + +func _check_teardown() -> void: + f.call("knock", bin["tile"], bin["slot"], _spot(), bin["along"] * (3.0 * WS.M)) + f.teardown() + K.check( + fails, + ( + _down().is_empty() + and _instances("bin") == standing + and ctx.collision.boxes(int(bin["tile"])).has(bin["box"]) + ), + "teardown stands every fallen prop up again" + ) + + +# ------------------------------------------------------------------ helpers + + +func _down() -> Array: + return f.get("down") + + +## The bin's base in its place. +func _spot() -> Vector3: + return (bin["xf"] as Transform3D).origin + + +## The bin's knockables_near() entry now, or {}. +func _listed() -> Dictionary: + for k: Dictionary in f.call("knockables_near", _spot(), 0.05): + if int(k["tile"]) == int(bin["tile"]) and int(k["slot"]) == int(bin["slot"]): + return k + return {} + + +## Batched instances of kind drawn now (every part of every instance). +func _instances(kind: String) -> int: + var n := 0 + for c: Node in ctx.view._street.root.get_children(): + var mmi := c as MultiMeshInstance3D + if mmi != null and not mmi.is_queued_for_deletion() and mmi.get_meta("street_kind") == kind: + n += mmi.multimesh.instance_count + return n + + +## The player at p, looking at `look`, the camera placed. +func _stand_at(p: Vector3, look: Vector3) -> void: + ctx.player.teleport(p, 0.0) + ctx.player.look_toward(look + Vector3(0, 0.5 * WS.M, 0)) + ctx.player.update_view(DT) + + +func _settle(t: TUMBLE, floor_at: Callable, blocked: Callable) -> int: + var n := 0 + while t.step(DT, floor_at, blocked): + n += 1 + return n + + +func _on_face(t: TUMBLE) -> bool: + var q := PI * 0.5 + return absf(t.angle - roundf(t.angle / q) * q) < 1e-3 + + +## A bin in the curb band of a plain straight road whose way in from the road's middle is clear +## for a car; {} when the city has none. +func _find_bin() -> Dictionary: + for i in range(ctx.sim.kind.size()): + if ctx.sim.kind[i] != D.ROAD or RoadTopology.is_bridge(ctx.sim, i): + continue + var piece := str(RoadTopology.piece(RoadTopology.mask(ctx.sim, D.tx(i), D.ty(i)))[0]) + if piece != "straight" and piece != "lights": + continue + for k: Dictionary in f.call("knockables_near", Vector3(D.tx(i), 0.0, D.ty(i)), 0.5): + var b := _bin_on(i, k) + if not b.is_empty(): + return b + return {} + + +func _bin_on(i: int, k: Dictionary) -> Dictionary: + if int(k["tile"]) != i or not k.has("box"): + return {} + var placed: Array = ctx.view.street_placements(i)[int(k["slot"])] + if str(placed[0]) != "bin": + return {} + var xf: Transform3D = placed[1] + var face := Vector3(xf.basis.z.x, 0.0, xf.basis.z.z).normalized() + var across := (xf.origin - Vector3(D.tx(i), xf.origin.y, D.ty(i))).dot(face) + if absf(absf(across) - 0.325) > 0.01: + return {} + var b := k.duplicate() + b.merge({"xf": xf, "face": face, "along": Vector3(face.z, 0.0, -face.x)}) + return b if _clear_run(b) else {} + + +## True when nothing but the bin stands in a car's way from RUN_UP out to just past it. +func _clear_run(b: Dictionary) -> bool: + var i := int(b["tile"]) + var slot := int(b["slot"]) + var face: Vector3 = b["face"] + var spot := (b["xf"] as Transform3D).origin + ctx.view.set_street_prop_down(i, slot, true) + ctx.collision.invalidate(i) + var ok := true + var s := -0.06 + while ok and s <= RUN_UP: + var p := spot + face * s + var g := ctx.collision.walk_height(p) + ok = not is_nan(g) and not ctx.collision.body_blocked(Vector3(p.x, g, p.z), 0.06, 0.07) + s += 0.02 + ctx.view.set_street_prop_down(i, slot, false) + ctx.collision.invalidate(i) + return ok + + +## Gets the hero into a car on the bin's road, every other vehicle gone. +func _enter() -> bool: + var vh: VH = tr.get("vehicles") + for c: VH.Car in vh.cars.duplicate(): + vh.remove(c) + var lg: LG = tr.get("lanes") + var seq: Array[int] = [int(bin["tile"])] + var lanes := lg.lanes_along(seq) + K.check(fails, not lanes.is_empty(), "fixture: a lane on the bin's road") + if lanes.is_empty(): + return false + var m: int = tr.call("car_model_for", "props-test") + var c := vh.make("car", "ambient", tr.call("dims", m)) + c.model = m + vh.place(c, lanes[0], 0.5) + c.v = 0.0 + ctx.player.teleport( + c.pos + Basis(Vector3.UP, c.yaw) * Vector3(c.half_w + 0.6 * WS.M, 0, 0), 0.0 + ) + var ok: bool = drive.call("enter") + K.check(fails, ok, "fixture: the hero gets into a car") + return ok diff --git a/tools/tests/fp_props_test.gd.uid b/tools/tests/fp_props_test.gd.uid new file mode 100644 index 00000000..691e7d7f --- /dev/null +++ b/tools/tests/fp_props_test.gd.uid @@ -0,0 +1 @@ +uid://cohhxsg2gxhku diff --git a/tools/tests/fp_traffic_test.gd b/tools/tests/fp_traffic_test.gd index 3dab5ef9..14eb9207 100644 --- a/tools/tests/fp_traffic_test.gd +++ b/tools/tests/fp_traffic_test.gd @@ -1,7 +1,8 @@ extends SceneTree ## Slice E acceptance (docs/fp/slices/E-traffic.md, Acceptance tests): fixtures, the lane graph ## (arms, shared endpoints, right-hand offsets beside parked cars, corner radii, carriageway, -## rebuild on tile_cleared only), car following, parking clearance, traffic lights, tee yield, +## rebuild on tile_cleared only), car following, parking clearance, a parked car the player's car +## knocks out of its slot (and its tow back), traffic lights, tee yield, ## player yield, a resident driver (from trip_started to a curb slot, position_of, pick, ## visible_near), a walking trip on sidewalks and zebras, conversations, save and load, the ## CityLife and ResidentAvatars hooks, and API compatibility. Time runs through feature.step(1/30). @@ -18,6 +19,8 @@ const JN := preload("res://scripts/fp/traffic/junctions.gd") const IDM := preload("res://scripts/fp/traffic/idm.gd") const TRIPS := preload("res://scripts/fp/traffic/trips.gd") const PPL := preload("res://scripts/fp/traffic/people.gd") +const SHOVES := preload("res://scripts/fp/traffic/shoves.gd") +const OBB := preload("res://scripts/fp/obb2d.gd") const DT := 1.0 / 30.0 var fails: Array[String] = [] @@ -54,6 +57,7 @@ func _init() -> void: _check_ambient_spawns() _check_following() _check_parking_clearance() + _check_parked_shove() _check_lights() _check_night_lamps() _check_tee() @@ -837,6 +841,53 @@ func _check_parking_clearance() -> void: K.check(fails, overlaps == 0, "parking clearance: no moving AABB overlaps a parked AABB in 60 s (%d) %s" % [overlaps, worst]) +# ------------------------------------------------------------------ shoves + +## The player's car knocks a parked car toward the road (bump_car): it slides off its slot for +## good, braking on its tyres, and blocks lane traffic until, having stood out of sight for +## TOW_AFTER s, it is towed back to its slot. +func _check_parked_shove() -> void: + _quiet() + var c: VH.Car = null + for p: VH.Car in _vh().cars: + if p.parked and not p.dead and not p.far and p.slot_tile >= 0: + c = p + break + K.check(fails, c != null, "fixture: a parked car") + if c == null: + return + var sh: SHOVES = f.get("shoves") + var home := c.pos + var yaw0 := c.yaw + var q := Vector2(c.pos.x, c.pos.z) + var side := OBB.side_axis(c.yaw) + if (Vector2(roundf(q.x), roundf(q.y)) - q).dot(side) < 0.0: + side = -side + f.call("bump_car", c.id, side * (4.0 * WS.M * SHOVES.mass_of(c)), q) + var shoved := sh.is_shoved(c) + _step(3.0) + var moved := Vector2(c.pos.x - home.x, c.pos.z - home.z).length() + K.check( + fails, + shoved and not sh.is_shoved(c) and moved > 0.5 * WS.M and moved < 3.0 * WS.M, + "shoves: a parked car hit at 4 m/s slides %.1f m off its slot and stops" % (moved / WS.M) + ) + var blocks := sh.blocks().filter(func(b: Array) -> bool: return int(b[5]) == c.id) + K.check(fails, not blocks.is_empty(), "shoves: out of its slot it blocks lane traffic") + sh.visible = func(_p: Vector3) -> bool: return false + _step(SHOVES.TOW_AFTER + 1.0) + K.check( + fails, + ( + c.pos.distance_to(home) < 0.01 * WS.M + and is_equal_approx(c.yaw, yaw0) + and sh.blocks().is_empty() + ), + "shoves: having stood out of sight %.0f s it is towed back to its slot" % SHOVES.TOW_AFTER + ) + sh.visible = Callable(f, "on_screen") + + # ------------------------------------------------------------------ lights ## The asset poles' pedestrian heads (Walk_*, DontWalk_*) are bound to the zebra they look