diff --git a/README.md b/README.md index 869ee260..74d1bdcc 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: your character walks round to the driver's door, opens it, climbs in and shuts it (`F` on the way turns back). W/S throttle and brake or reverse, A/D steer, Space handbrake, V switches chase and driver view, and `F` again, once the car slows down, brakes to a stop and climbs out the same way. The car is solid: it bounces off walls, scrapes along them at a glance, shoves the cars it hits (they slide off, then pull back into their lane) and sends bins, benches, planters and other light street props flying. `B` opens the build phone: `1`-`6` pick road, homes, shops, industry, a building (mouse wheel picks which) or bulldoze, left click places on the tile ahead (roads and zones take a start and an end click), right click undoes the start. Each order is paid by the district that owns the land, as if its mayor had ordered it, and one of your two building crews builds it in real time (see Staged construction). The phone says how many crews are free and how long the order takes, a card under the top bar counts down your orders, and `E` on a worker gets a word about the job. The AI activity feed (top right) shows each mayor turn, council ruling, Chaos Director pick, grid order and Gazette front page as it lands. A card shows who decided, the model they run on, whether the answer came live, from a replay, from a fallback or from the offline planner, what they did and why. On foot the mayors take their turns spread over the day. A decision with a place raises a light beacon there. `J` cycles the feed between full, brief and hidden, and `G` routes the GPS to the newest decision with a place. +- **First person:** WASD or the arrow keys walk, Shift runs, mouse to look (click to capture the pointer), `]` and `[` change time speed (1x, 2x, 4x, 8x), `T` skips to the next morning, `Esc` pauses, `F1` hides the HUD, `F5` saves, `F9` loads. `F` by a car gets in: your character walks round to the driver's door, opens it, climbs in and shuts it (`F` on the way turns back). W/S throttle and brake or reverse, A/D steer, Space handbrake, V switches chase and driver view, and `F` again, once the car slows down, brakes to a stop and climbs out the same way. The car is solid: it bounces off walls, scrapes along them at a glance, shoves the cars it hits (they slide off, then pull back into their lane) and sends bins, benches, planters and other light street props flying. People it runs into stagger back, or go down when it is going fast. On foot, left click shoves whoever stands in front of you. Hold right click to put your guard up (fists up, the camera in closer), and left click then throws a jab and a cross in turn. A light blow makes people reel and step back. A hard one, the third punch in a row or a fast car knocks them down: they lie a while, get up, then shout at you, run off or walk back, and the people nearby who saw it run off or shout too. A resident who was hit remembers it and their mood drops (a shove least, a car most), and the ones who saw it remember it too. Floor someone or keep punching and the police drive out from the nearest station; someone a car knocks down gets an ambulance. The one who was hit and a witness say so in the chat, a crew called to the scene posts a City Alert card, and you hear each blow, swing and fall. `1`-`5` wave, nod, shake your head, shout and dance, and the people around answer. `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 aea62490..bf365aff 100644 --- a/data/feature_flags.json +++ b/data/feature_flags.json @@ -17,6 +17,7 @@ "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."}, + "actions": {"default": true, "description": "First-person feature: the hero's body actions. Left mouse (right trigger) shoves whoever stands in front; with the guard up (right mouse or left trigger held) it throws a jab and a cross in turn. People reel or go down, the street reacts and the city hears of it. Keys 1-5 wave, nod, shake the head, shout and dance, and the people around answer."}, "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 a1805073..61671efe 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -36,6 +36,10 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [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 +## [scripts/fp/actions/](scripts.fp.actions.md) + +- [actions_feature.gd](scripts.fp.actions.md#scriptsfpactionsactions_featuregd): Body actions (integration slice; docs/fp/ARCHITECTURE.md, Body actions): the hero's fists and gestures on foot + ## [scripts/fp/activity/](scripts.fp.activity.md) - [activity_beacons.gd](scripts.fp.activity.md#scriptsfpactivityactivity_beaconsgd): World beacons for the AI activity layer: one per agent key ("mayor:1", "council", "chaos"), a tall light column in the agent's colour over the tile its latest decision touched, with a ring on the ground that pulses while the decision is new and dims once it is old @@ -49,7 +53,7 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [ambience.gd](scripts.fp.audio.md#scriptsfpaudioambiencegd): Area ambience (slice I): looping 2D beds on the Ambience bus (traffic hum, park birds, river, the quiet residential bed, night insects and rain), weighted by what lies around the player and by the weather, and cross-faded over CROSSFADE_S - [audio_bank.gd](scripts.fp.audio.md#scriptsfpaudioaudio_bankgd): Slice I's sound assets (sounds/fp/, made by tools/audio/synth_fp_sfx.py): names, a load-once stream cache and the mix - [audio_feature.gd](scripts.fp.audio.md#scriptsfpaudioaudio_featuregd): City sound on foot (slice I) -- [emitters.gd](scripts.fp.audio.md#scriptsfpaudioemittersgd): Positional city sound (slice I): logical sources (engines, horns, sirens, construction, fire, fountains, power plants, river banks, passers-by's footsteps, crashes, knocked props and car doors) share a pool of at most MAX_PLAYERS AudioStreamPlayer3D +- [emitters.gd](scripts.fp.audio.md#scriptsfpaudioemittersgd): Positional city sound (slice I): logical sources (engines, horns, sirens, construction, fire, fountains, power plants, river banks, passers-by's footsteps, crashes, knocked props, car doors, blows and swings) 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) @@ -58,6 +62,7 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [chat_feature.gd](scripts.fp.chat.md#scriptsfpchatchat_featuregd): Global chat and face-to-face conversations (slice C) - [chat_feed.gd](scripts.fp.chat.md#scriptsfpchatchat_feedgd) (`ChatFeed`): The global chat's message store and drip queue (slice C §1) - [chat_text.gd](scripts.fp.chat.md#scriptsfpchatchat_textgd): Text helpers for the global chat and conversations (slice C): the display filter every line passes before it is shown or put into a prompt, dedupe normalisation, the checks the reply validators use (URLs, model names, sentence count), trimming, relative times and initials +- [street_lines.gd](scripts.fp.chat.md#scriptsfpchatstreet_linesgd): What the chat hears when the visitor hits someone in the street (game bus person_hit; see docs/fp/ARCHITECTURE.md, Body actions): the one who was hit says so (a shove, a punch, being knocked down, a car) and one of the residents who saw it adds a line, and a crew called to the scene (incidents.gd: police after an assault, an ambulance for someone a car knocked down) posts a City Alert card - [talk_dialog.gd](scripts.fp.chat.md#scriptsfpchattalk_dialoggd): Face-to-face conversation with a resident (slice C §4), and the "Ask someone here" answer ## [scripts/fp/crews/](scripts.fp.crews.md) @@ -97,6 +102,7 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [body_bake.gd](scripts.fp.player.md#scriptsfpplayerbody_bakegd): Bakes the walking collision of every building and zone model into body_table.gd (slice A) - [body_shapes.gd](scripts.fp.player.md#scriptsfpplayerbody_shapesgd): Walking collision of a building or zone model from its own geometry (slice A deliverable 2) - [body_table.gd](scripts.fp.player.md#scriptsfpplayerbody_tablegd): Generated by body_bake.gd from 114 models; do not edit by hand +- [hero_fk.gd](scripts.fp.player.md#scriptsfpplayerhero_fkgd): Forward kinematics over a body's own clip keys, without a tree or a scene: where the last bone of a chain sits at a phase of one clip or of two clips blended (rotations slerped, positions lerped) - [hero_rig.gd](scripts.fp.player.md#scriptsfpplayerhero_riggd): HC's animated hero for the player (slice A stage A2; A-player.md 6.9, TPS_PLAN 2.9) - [placeholder_puppet.gd](scripts.fp.player.md#scriptsfpplayerplaceholder_puppetgd): The legacy person (models/real/people/person_*.glb) re-rigged as a jointed puppet, so the placeholder hero walks, runs, crouches and jumps until HC's animated hero replaces it (stage A2) - [player.gd](scripts.fp.player.md#scriptsfpplayerplayergd) (`FpPlayer`): The on-foot player (slice A): feet at `position`, a kinematic controller against the analytic CollisionWorld (no physics bodies, D2) and the one current Camera3D @@ -150,12 +156,14 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [fleets.gd](scripts.fp.traffic.md#scriptsfptrafficfleetsgd): City fleets on lanes in first person (docs/fp/slices/E-traffic.md §5): buses on 2-4 seeded closed lines through the bus stops (they pull in at each shelter, people board and alight), delivery and garbage trucks, and emergency crews that run to incidents with sirens (through red lights at walking pace once the box is clear), wait at the scene, and drive back to the station - [heap.gd](scripts.fp.traffic.md#scriptsfptrafficheapgd): Binary min-heap of (priority, int item) for the lane and sidewalk A* searches - [idm.gd](scripts.fp.traffic.md#scriptsfptrafficidmgd): Intelligent Driver Model (Treiber 2000) in world units (docs/fp/slices/E-traffic.md §3) +- [incidents.gd](scripts.fp.traffic.md#scriptsfptrafficincidentsgd): What a blow in the street means for the city (traffic_feature.gd hit_walker): a resident who was hit remembers it and their mood drops (a shove least, a car most, more when knocked down), and residents who saw it remember it too - [junctions.gd](scripts.fp.traffic.md#scriptsfptrafficjunctionsgd): Junction control (docs/fp/slices/E-traffic.md §2): 4-way traffic lights on cross tiles (N-S green, amber, all-red, E-W green, ...), priority to the through road on tees, pedestrian walk phases, and the street furniture that shows them: light poles at the four corners, pedestrian signals at both ends of every zebra, and stop or yield signs on tee stems - [lane_graph.gd](scripts.fp.traffic.md#scriptsfptrafficlane_graphgd): Right-hand lane graph over the road tiles (docs/fp/slices/E-traffic.md §1) - [mesh_kit.gd](scripts.fp.traffic.md#scriptsfptrafficmesh_kitgd): Builds merged, vertex-coloured meshes for E's procedural stand-ins (traffic lights, signs, the far LOD of every vehicle and person model) - [occupants.gd](scripts.fp.traffic.md#scriptsfptrafficoccupantsgd): Drivers seated in the moving vehicles nearest the camera - [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 +- [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; the clips reactions.gd plays (blows, falls, running off) and how their time runs +- [reactions.gd](scripts.fp.traffic.md#scriptsfptrafficreactionsgd): People hit in the street (docs/fp/ARCHITECTURE.md, Body actions): shoved or punched by the player, or struck by the car they drive - [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 diff --git a/docs/api/scripts.fp.actions.md b/docs/api/scripts.fp.actions.md new file mode 100644 index 00000000..71382e51 --- /dev/null +++ b/docs/api/scripts.fp.actions.md @@ -0,0 +1,56 @@ + + +# `scripts/fp/actions/` + +[Index](index.md) + +## `scripts/fp/actions/actions_feature.gd` + +extends `FpFeature` + +Body actions (integration slice; docs/fp/ARCHITECTURE.md, Body actions): the hero's fists and gestures on foot. The strike button (left mouse, right trigger) shoves whoever stands in front; with the guard up (right mouse or left trigger held: fists up, the body squared to where the camera looks, the camera in closer) it throws a jab and a cross in turn instead. A blow lands when the rig's fist is furthest out (HeroRig.strike_time) on the person nearest in front within REACH, through the traffic feature's hit_walker: they reel or go down, the people around react and the city hears of it. Keys 1-5 wave, nod, shake the head, shout and dance, and the people around answer (traffic answer_emote). The bus hears every swing and emote (hero_action). + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `SWINGS = { "jab": ["punch_jab", "punch", 0.45, 0.4, 0.55, 0.2], "cross": ["punch_cross", "punch"...`: Swings: [rig clip, hit_walker cause, strength 0..1, seconds until the next swing may start, seconds the clip plays, contact time (s) when the rig cannot measure it]. +- `EMOTES = { "emote_wave": ["wave", 0.0], "emote_yes": ["yes", 0.0], "emote_no": ["no", 2.0], "emo...`: Emote input actions: [emote, seconds it plays (0 for the clip's own length)]. +- `INPUTS = [ "act_strike", "act_aim", "emote_wave", "emote_yes", "emote_no", "emote_shout", "emote...` +- `REACH = 1.2 * WS.M`: Who a swing can reach: on their feet within REACH (feet to feet) inside the cone ahead (the cosine); at contact still within REACH * HOLD_REACH. +- `CONE = 0.4` +- `HOLD_REACH = 1.3` +- `RUN_BONUS = 0.3`: Running adds up to this much strength (at WS.RUN). +- `BUFFER_S = 0.2`: A press this close to the end of a swing queues the next one (s); punches alternate jab and cross within COMBO_S of the last. +- `COMBO_S = 1.0` +- `EMOTE_GAP = 1.0` +- `FACE_S = 0.35`: The body faces whoever a swing went for at least this long (s). +- `KICK = Vector2(-0.015, 0.025)`: The view kick when a blow lands (pitch, roll; radians) and the camera arm while the guard is up (TpsRig.arm_scale). +- `GUARD_ARM = 0.8` +- `PHOTO_FRAMES = 8`: Photo tour: frames to wait for the tour's teleport, how far round the hero to look for someone to square up to, how far in front of them the hero stands, and how far to their left (so the camera over the right shoulder sees them past the hero). +- `PHOTO_R = 30.0 * WS.M` +- `PHOTO_GAP = 0.85 * WS.M` +- `PHOTO_SIDE = 0.55 * WS.M` + +### Properties + +- `guarding: bool`: The guard is up. +- `swing: String`: The swing under way ("jab", "cross", "shove") or "". +- `last_hit: Dictionary`: hit_walker's info for the last blow that landed; swings and blows landed so far; how many people answered the last emote. + +### Functions + +- `func feature_id() -> String` +- `func setup(p_ctx: FpContext) -> void` +- `func on_world_rebuilt() -> void` +- `func from_dict(_d: Dictionary) -> void` +- `func teardown() -> void` +- `func self_test() -> Dictionary` +- `func can_act() -> bool`: Whether the hero may swing, guard or emote now: on foot and in control of the body, nothing modal open, not talking, not in photo mode, the phone put away. +- `func strike() -> bool`: Starts a swing: a shove, or with the guard up the next punch of the combo. A press near the end of a swing queues the next. False when none starts now. +- `func set_guard(on: bool) -> void`: Raises or drops the guard for scripted play (tests, the photo tour); held input does the same. +- `func emote(e: String) -> bool`: Plays an emote ("wave", "yes", "no", "shout", "dance") and lets the people around answer it. False when the hero cannot act now, swings or guards, the last emote was just now, or the dance is asked for on the move. +- `func cancel() -> void`: Ends the swing, the guard and any action on the rig, and gives the body and camera back. +- `func tick(dt: float) -> void`: One step: held input (or set_guard) raises the guard, the swing lands its blow, and the body faces the target or the aim. +- `func photo_shots() -> Array` +- `func photo_prepare() -> void`: Third person; once the tour has put the hero down, it squares up to the nearest person, who reels from a blow. +- `func photo_restore() -> void` diff --git a/docs/api/scripts.fp.audio.md b/docs/api/scripts.fp.audio.md index 5486381f..3460a2cb 100644 --- a/docs/api/scripts.fp.audio.md +++ b/docs/api/scripts.fp.audio.md @@ -79,6 +79,12 @@ Slice I's sound assets (sounds/fp/, made by tools/audio/synth_fp_sfx.py): names, - `KNOCK_DEFAULT = "prop_clang"` - `KNOCK_GAP = 0.1` - `DOOR_OPEN_DB = -7.0`: The player's car door (car_door): the latch letting go plays this far under the slam. +- `HIT_FULL = 0.6`: People hit in the street (person_hit): the blow's thump is at full level from HIT_FULL strength (0..1), a car's lower and HIT_CAR_DB louder; someone knocked down lands BODY_FALL_S later (body_fall, BODY_FALL_DB on the thump's level). The hero's swing (hero_action) whooshes WHOOSH_DB under the thump, a shove SHOVE_DB lower still. +- `HIT_CAR_DB = 4.0` +- `BODY_FALL_S = 0.45` +- `BODY_FALL_DB = 2.0` +- `WHOOSH_DB = -8.0` +- `SHOVE_DB = -6.0` ### Functions @@ -95,7 +101,7 @@ Slice I's sound assets (sounds/fp/, made by tools/audio/synth_fp_sfx.py): names, extends `FpFeature` -City sound on foot (slice I). Owns the Music, SFX, Ambience and UI buses while on foot, puts the AudioDirector's music under the street (set_fp), and runs three parts: positional emitters (engines, horns, sirens, construction, fire, fountains, plants, the river, passers-by's steps, the player's car crashing, the street props it knocks over and its doors), the area beds (with rain from G1's weather) and the hero's footsteps (splashing on wet paving), plus the chat blip and alert stingers. Pausing muffles the music (the only thing still playing); talking to someone ducks the beds. +City sound on foot (slice I). Owns the Music, SFX, Ambience and UI buses while on foot, puts the AudioDirector's music under the street (set_fp), and runs three parts: positional emitters (engines, horns, sirens, construction, fire, fountains, plants, the river, passers-by's steps, the player's car crashing, the street props it knocks over and its doors, people hit in the street and the hero's swings), 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 @@ -139,7 +145,7 @@ City sound on foot (slice I). Owns the Music, SFX, Ambience and UI buses while o extends `Node3D` -Positional city sound (slice I): logical sources (engines, horns, sirens, construction, fire, fountains, power plants, river banks, passers-by's footsteps, crashes, knocked props and car doors) share a pool of at most MAX_PLAYERS AudioStreamPlayer3D. Every refresh scores the sources by their estimated level at the listener and gives the players to the loudest; the farthest or quietest lose theirs first. Doppler is computed by hand (the built-in effect works in world units against 343 u/s and is inaudible at 1 u = 20 m). A grown city registers hundreds of static sources (every park's fountain, every site), so the per-frame work touches only the sources holding a player, the refresh only the volatile ones (agents, one-shots, sirens, river) and the static ones near the listener. +Positional city sound (slice I): logical sources (engines, horns, sirens, construction, fire, fountains, power plants, river banks, passers-by's footsteps, crashes, knocked props, car doors, blows and swings) share a pool of at most MAX_PLAYERS AudioStreamPlayer3D. Every refresh scores the sources by their estimated level at the listener and gives the players to the loudest; the farthest or quietest lose theirs first. Doppler is computed by hand (the built-in effect works in world units against 343 u/s and is inaudible at 1 u = 20 m). A grown city registers hundreds of static sources (every park's fountain, every site), so the per-frame work touches only the sources holding a player, the refresh only the volatile ones (agents, one-shots, sirens, river) and the static ones near the listener. ### Constants diff --git a/docs/api/scripts.fp.chat.md b/docs/api/scripts.fp.chat.md index c17d4e69..63d8a5e4 100644 --- a/docs/api/scripts.fp.chat.md +++ b/docs/api/scripts.fp.chat.md @@ -37,7 +37,7 @@ What is really going on in the city, for chat answers (slice C): what the distri extends `FpFeature` -Global chat and face-to-face conversations (slice C). Normalises every city source (residents, mayors, the Gazette, the council, incidents, openings, arrivals, deals) into the ChatFeed drip queue, runs ChatterMinds rounds about real events once a minute, answers the player's chat lines, and opens the talk dialogue on talk_requested. Owns the chat panel (chat_slot) and the talk card (overlay). Budgets (D12) are enforced here on the feed clock: one round per 60 s unpaused, one player-reply call per coalesced burst (6 per minute), one talk call per line and one inspect_tile per 20 s. While the player is being answered the drip holds everyday lines and the panel shows who is typing; a status card says when live replies fail or come back. +Global chat and face-to-face conversations (slice C). Normalises every city source (residents, mayors, the Gazette, the council, incidents, openings, arrivals, deals, people hit in the street) into the ChatFeed drip queue, runs ChatterMinds rounds about real events once a minute, answers the player's chat lines, and opens the talk dialogue on talk_requested. Owns the chat panel (chat_slot) and the talk card (overlay). Budgets (D12) are enforced here on the feed clock: one round per 60 s unpaused, one player-reply call per coalesced burst (6 per minute), one talk call per line and one inspect_tile per 20 s. While the player is being answered the drip holds everyday lines and the panel shows who is typing; a status card says when live replies fail or come back. ### Constants @@ -45,6 +45,7 @@ Global chat and face-to-face conversations (slice C). Normalises every city sour - `T = preload("res://scripts/fp/chat/chat_text.gd")` - `PANEL = preload("res://scripts/fp/ui/chat_panel.gd")` - `TALK = preload("res://scripts/fp/chat/talk_dialog.gd")` +- `STREET = preload("res://scripts/fp/chat/street_lines.gd")` - `ROUND_MS = 60000` - `PLAYER_DEBOUNCE_MS = 3000` - `PLAYER_COALESCE_MS = 10000` @@ -174,6 +175,27 @@ Text helpers for the global chat and conversations (slice C): the display filter - `static func mix(s: String) -> int`: h(s) with its bits mixed (a murmur-style finaliser). Godot's String hash is djb2, whose value modulo 3 depends only on the last character, so "hash(id + day) % 3" would make every letter of a day public or none; mix() spreads it. - `static func district_badge(d: int) -> String`: Two-or-three letter badge for district d ("NW", "NE", "CTR", "SW", "SE"); "" outside them. +## `scripts/fp/chat/street_lines.gd` + +extends `RefCounted` + +What the chat hears when the visitor hits someone in the street (game bus person_hit; see docs/fp/ARCHITECTURE.md, Body actions): the one who was hit says so (a shove, a punch, being knocked down, a car) and one of the residents who saw it adds a line, and a crew called to the scene (incidents.gd: police after an assault, an ambulance for someone a car knocked down) posts a City Alert card. One victim-and-witness pair per GAP_MS on the feed clock, so a flurry of jabs reads as one scene; a harder blow inside the gap still posts, and so does every crew. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `T = preload("res://scripts/fp/chat/chat_text.gd")` +- `GAP_MS = 8000` +- `SEVERITY = {"shove": 0, "punch": 1, "car": 2, "down": 3, "car_down": 3}` +- `VICTIM = { "shove": [ "Some visitor just shoved me on {street}. Who does that?", "Got pushed out...`: What the one who was hit says ({street}: where). +- `WITNESS = { "shove": [ "Did a visitor just shove {who} on {street}?", "Some visitor's pushing peo...`: What a resident who saw it says ({who}: the one hit, by first name, or "someone"). +- `CREW = { "police": "Police called to {street} after a visitor attacked people", "ambulance": "...` + +### Functions + +- `func setup(p_ctx: FpContext, p_feed: ChatFeed) -> void` +- `func on_person_hit(at: Vector3, cause: String, info: Dictionary) -> void`: person_hit (game bus): queues the victim's line and a witness's, and a crew's City Alert card. + ## `scripts/fp/chat/talk_dialog.gd` extends `Control` diff --git a/docs/api/scripts.fp.drive.md b/docs/api/scripts.fp.drive.md index dd242b67..3405d831 100644 --- a/docs/api/scripts.fp.drive.md +++ b/docs/api/scripts.fp.drive.md @@ -8,7 +8,7 @@ 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. +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). People on foot are struck through traffic's hit_walker and barely slow the car. 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 @@ -26,11 +26,16 @@ The player's car as a rigid box on the ground plane. Each step it moves by the h - `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. +- `PERSON_R = 0.3 * WS.M`: People: a body this wide (u) is struck when the car closes on them faster than PERSON_SPEED, each at most once per PERSON_COOL seconds, as hard as the closing speed over PERSON_FULL (1 knocks anyone down). They weigh PERSON_MASS of a sedan, so the car loses little speed. +- `PERSON_SPEED = 0.5 * WS.M` +- `PERSON_FULL = 12.0 * WS.M` +- `PERSON_COOL = 1.5` +- `PERSON_MASS = 0.08` ### 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". +- `hits: Array`: The last move's hits closing faster than IMPACT_SPEED: [point Vector2, speed (u/s), what], what "wall" (anything fixed), "car", "prop" or "person". ### Functions diff --git a/docs/api/scripts.fp.md b/docs/api/scripts.fp.md index 78fe1a4b..be781cb1 100644 --- a/docs/api/scripts.fp.md +++ b/docs/api/scripts.fp.md @@ -245,6 +245,8 @@ Signals that cross first-person slices. Emitters and listeners are listed in doc - `car_impact(at: Vector3, speed: float, what: String)`: The player's car hit something hard: the contact point, the closing speed (u/s) and what was hit, "wall" (anything fixed) or "car". Emitted by the drive feature. - `car_door(at: Vector3, open: bool)`: The driver's door of the player's car opened (true) or slammed shut (false), at its handle. Emitted by the drive feature. - `prop_knocked(at: Vector3, kind: String, speed: float)`: A street prop was knocked over at a point (its StreetDressing kind: "bin", "bench" ...) and flies off at speed (u/s). Emitted by the props feature. +- `person_hit(at: Vector3, cause: String, info: Dictionary)`: Someone on foot was hit at a point (their feet): cause "shove" or "punch" (the hero) or "car" (the car the player drives); info is traffic_feature.gd hit_walker()'s {"walker", "resident_id", "name", "down", "street", "strength", "witnesses", "crew"}. Emitted by traffic. +- `hero_action(action: String, at: Vector3)`: The hero swung or gestured: action "jab", "cross", "shove" or an emote ("wave", "yes", "no", "shout", "dance"), at their feet. Emitted by the actions feature. ## `scripts/fp/obb2d.gd` diff --git a/docs/api/scripts.fp.player.md b/docs/api/scripts.fp.player.md index ab49b617..f441353f 100644 --- a/docs/api/scripts.fp.player.md +++ b/docs/api/scripts.fp.player.md @@ -65,6 +65,18 @@ Generated by body_bake.gd from 114 models; do not edit by hand. Re-run the bake - `BODIES = { "res://models/buildings/airport.glb": [AABB(Vector3(-0.47000, 0.00000, -0.47000), Vec...` +## `scripts/fp/player/hero_fk.gd` + +extends `RefCounted` + +Forward kinematics over a body's own clip keys, without a tree or a scene: where the last bone of a chain sits at a phase of one clip or of two clips blended (rotations slerped, positions lerped). HeroRig measures heel contacts, standing heights and strike times with it. The root stays at rest (the mixer extracts its motion); position keys scale by the skeleton's motion_scale, as the mixer applies them. + +### Functions + +- `func chain_to(b: int) -> Array[int]`: The bones from the root down to b. +- `func forward_of(root: Node3D) -> Vector3`: The +Z of root (the body's scene root above the skeleton) in skeleton space. +- `func end_pos(a0: Animation, a1: Animation, w: float, chain: Array, ph: float) -> Vector3`: The position (skeleton metres) of the chain's last bone at phase ph of clip a0 blended toward a1 by w. + ## `scripts/fp/player/hero_rig.gd` extends `RefCounted` @@ -76,9 +88,11 @@ HC's animated hero for the player (slice A stage A2; A-player.md 6.9, TPS_PLAN 2 - `WS = preload("res://scripts/fp/world_scale.gd")` - `AP = preload("res://scripts/fp/asset_paths.gd")` - `SM = preload("res://scripts/fp/player/spring_math.gd")` +- `FK = preload("res://scripts/fp/player/hero_fk.gd")` - `LOCO = ["walk", "walk_fast", "run", "run_fast"]`: Locomotion loops in blend order; HC starts each on the left-heel strike. - `GESTURES = ["interact", "wave", "phone", "door_try"]`: Upper-body one-shots (6.9 OneShot). -- `CLIPS = ["idle", "walk", "walk_fast", "run", "run_fast", "crouch_idle", "crouch_walk", "jump_st...`: Every clip the tree plays. +- `ACTIONS = ["punch_jab", "punch_cross", "push", "yes", "no", "shout"]`: Body actions over the gestures (the actions feature): punches, the shove and emotes on the upper body with quick fades; the dance on the whole body. +- `CLIPS = [ "idle", "walk", "walk_fast", "run", "run_fast", "crouch_idle", "crouch_walk", "jump_s...`: Every clip the tree plays. - `TS_MIN = 0.5`: Foot-slide correction: TimeScale = clamp(v / v_anim, TS_MIN, TS_MAX). - `TS_MAX = 1.5` - `XF_JUMP = 0.08`: Cross-fades (s): into the jump, jump to fall, into the landing, landing out, crouch, talk, sit, and walking off a ledge into the fall. @@ -112,6 +126,15 @@ HC's animated hero for the player (slice A stage A2; A-player.md 6.9, TPS_PLAN 2 - `STEP_MIN_MOVE = 0.25`: Footsteps sound while the walk blend is at least this strong. - `SHOT_IN = 0.25`: Gesture one-shot fades (s). - `SHOT_OUT = 0.35` +- `ACT_IN = 0.06`: Action one-shot fades (a punch lands at once), the dance's, and the guard's halflife (s). +- `ACT_OUT = 0.2` +- `DANCE_IN = 0.3` +- `DANCE_OUT = 0.4` +- `HL_GUARD = 0.06` +- `GUARD_CLIP = "punch_jab"`: The guard (fists up) is GUARD_CLIP held at GUARD_AT seconds on the upper body. +- `GUARD_AT = 0.0` +- `STRIKE_SAMPLES = 48`: strike_time samples this many poses over a clip's opening seconds. +- `STRIKE_WITHIN = 1.2` - `SIT_SPEED = 1.5`: sit_down and stand_up play this much faster than authored (in and out of a car seat). - `SIT_CLIPS = ["sit_down", "stand_up"]` - `LOOK_YAW = deg_to_rad(70.0)`: Head look (6.9): Head limits (yaw, pitch), the Neck's share and limit, the modifier's own interpolation, the influence fade when the target leaves the limits, and the halflife that smooths a jumping target. @@ -136,6 +159,14 @@ HC's animated hero for the player (slice A stage A2; A-player.md 6.9, TPS_PLAN 2 - `P_GESTURE = "parameters/gesture/transition_request"` - `P_SHOT = "parameters/shot/request"` - `P_SHOT_ACTIVE = "parameters/shot/active"` +- `P_ACTION = "parameters/action/transition_request"` +- `P_ACT = "parameters/act/request"` +- `P_ACT_ACTIVE = "parameters/act/active"` +- `P_DANCE = "parameters/dance/request"` +- `P_DANCE_ACTIVE = "parameters/dance/active"` +- `P_GUARD = "parameters/guard/blend_amount"` +- `P_GUARD_SEEK = "parameters/guard_at/seek_request"` +- `P_GUARD_HOLD = "parameters/guard_hold/scale"` ### Properties @@ -146,6 +177,7 @@ HC's animated hero for the player (slice A stage A2; A-player.md 6.9, TPS_PLAN 2 - `move: float`: The idle-to-walk weight of the last update. - `contacts: Array[Dictionary]`: Foot contacts of the last update: [{"left": bool, "pos": Vector3 (world, the foot's ground point)}]. - `ground_fix: float`: How far the body is lowered to put the soles on the ground (skeleton metres; _update_ground). +- `guard: float`: How far the guard is up (0..1). - `look_weight: float`: Head-look influence (0..1) and whether the modifier last found its target within its limits. - `head_forward: Vector3`: The head's forward (world) as the look-at modifiers left it, from the last frame they ran. @@ -158,6 +190,11 @@ HC's animated hero for the player (slice A stage A2; A-player.md 6.9, TPS_PLAN 2 - `func play_gesture(g: String) -> void`: Plays an upper-body gesture over the current state ("interact", "wave", "phone", "door_try"). - `func gesture_active() -> bool` - `func stop_gesture() -> void`: Fades the playing gesture out before its end. +- `func play_action(a: String, secs: float = 0.0) -> bool`: Plays a body action: one of ACTIONS on the upper body over whatever the legs do, or "dance" on the whole body (standing only; moving ends it). secs > 0 fades it out that long after the start; otherwise a clip plays to its end, a loop for one cycle and the dance until stopped. +- `func action_active() -> bool`: True while an action or the dance plays (fading out included). +- `func stop_action() -> void`: Fades the playing action and the dance out. +- `func set_guard(on: bool) -> void`: Raises (on) or drops the guard: fists up on the upper body, eased over HL_GUARD. +- `func strike_time(clip: String, within: float = STRIKE_WITHIN) -> float`: When clip's fists (or palms) are furthest out in front within its first within seconds: where a punch or the shove lands (s). Measured once per clip on this body's keys and cached. - `func travel(clip: String, t: float) -> float`: How far clip has carried its root t seconds after its state began (0..1 of the whole clip's horizontal root travel; sit_down and stand_up play at SIT_SPEED). The tree drops root motion, so whoever moves the body through a clip (into a car seat) follows this curve; a clip without travel runs linearly. - `func root_travel(clip: String) -> float`: How far clip carries the body along the ground over its whole length (world units). - `func set_look_target(p: Vector3) -> void`: The world point the head turns toward; NAN components let the head return to the clip. @@ -245,6 +282,7 @@ The on-foot player (slice A): feet at `position`, a kinematic controller against - `FP_SPRINT_FOV = 4.0` - `HL_FOV = 0.25` - `FP_PITCH_LIMIT = deg_to_rad(WS.PITCH_LIMIT_DEG)` +- `HL_KICK = 0.07`: A view kick (kick_view) dies away with this halflife (s). - `MODE_BLEND = 0.35` - `STICK_YAW = deg_to_rad(270.0)` - `STICK_YAW_FAST = deg_to_rad(400.0)` @@ -279,6 +317,7 @@ The on-foot player (slice A): feet at `position`, a kinematic controller against - `cam_yaw: float`: View angles (radians). Movement input is rotated by cam_yaw; forward() looks along them. - `camera_mode: String`: "third" (over the shoulder) or "first" (eye height). - `view_override: bool`: True while another owner (the city overview) places the camera through set_view_override(). +- `aim_yaw: float`: Third person: while not NAN the body turns toward this yaw instead of its travel (the actions feature: the guard faces where the camera looks, a blow faces its target). ### Functions @@ -296,6 +335,7 @@ The on-foot player (slice A): feet at `position`, a kinematic controller against - `func speed() -> float`: Horizontal speed in u/s. - `func simulate(wish: Vector2, sprint: bool, delta: float, crouch: bool = false, jump: bool = false) -> void`: One deterministic movement step; _process calls it with the held input, tests call it directly. wish: x = strafe right, y = forward, each -1..1, relative to the view (cam_yaw). While ctx.is_modal(), simulate itself zeroes wish and jump (gravity and ground snap still run). crouch and jump are held states (a jump fires on the press, buffered JUMP_BUFFER s). In photo mode it flies the camera instead (jump up, crouch down). Any delta is safe: the step is split so that no collision sub-step moves farther than PLAYER_RADIUS (a 0.008 u hoarding grown by the radius is only 0.038 u thick), and a velocity component whose move was blocked drops to zero. - `func update_view(delta: float) -> void`: Places the camera for this frame: the first-person eye (bob, step smoothing), the rig, the conversation shot or the photo camera, through any running blend; updates the avatar (lean, fade, shadow-only). _process calls it every frame; tests driving simulate() call it with their own dt. +- `func kick_view(d_pitch: float, d_roll: float) -> void`: Jolts the view by d_pitch (positive up) and d_roll (radians; positive tips left), dying away over HL_KICK: a blow landing. - `func look(d_yaw: float, d_pitch: float) -> void`: Mouse or stick look (radians): turns the view; in first person the body turns with it. - `func mouse_look(event: InputEventMouseMotion) -> void`: Turns the view by a mouse motion at mouse_sensitivity radians per screen pixel. Reads screen_relative: `relative` is scaled by the window's content-scale stretch (canvas_items, 1600x900 base), so look speed would change with the window size. Applied raw, never smoothed. - `func set_camera_mode(m: String, blend: bool = true) -> void`: Switches between "first" and "third" (V); blend eases over MODE_BLEND s. Does not write the settings (toggle_camera_mode does). @@ -473,6 +513,7 @@ Over-the-shoulder follow camera (D13; docs/fp/slices/A-player.md 6.5-6.6): an an - `yaw: float`: View angles (radians). FpPlayer writes them on look input and reads them back after update(). - `zoom: float`: Arm length goal before pitch scaling (settings.tps_distance_m, then the wheel). +- `arm_scale: float`: Factor on that goal (the actions feature pulls the arm in while the guard is up). - `side: float`: Shoulder goal: 1 right, -1 left. - `assist_target: Vector3`: World point the soft assist leans toward (NAN components: none). - `agent_boxes: Array[AABB]`: Agent boxes (world AABBs) that pull the arm in hard when the camera would end inside one. diff --git a/docs/api/scripts.fp.traffic.md b/docs/api/scripts.fp.traffic.md index 9cb135c5..ecfff07b 100644 --- a/docs/api/scripts.fp.traffic.md +++ b/docs/api/scripts.fp.traffic.md @@ -137,13 +137,14 @@ City fleets on lanes in first person (docs/fp/slices/E-traffic.md §5): buses on - `MAX_CREWS = 10` - `ASTAR_PER_FRAME = 4`: Crew routes are A* builds: at most this many per frame (the feature refills the budget), the rest wait in `pending` (docs/fp/slices/E-traffic.md §1). - `CURB_LAT = 0.055` +- `CALL_MAX = 240.0`: A crew answering a call (stay > 0) gives up after this long (s) from setting off. - `JOBS = {"bus": "bus driver", "truck": "truck driver", "garbage_truck": "sanitation worker", "f...` ### Properties - `stop_sides: Dictionary`: tile -> side index of its shelter - `last_dispatch_fail: String`: Why the last crew build sent nobody ("" when it sent a crew); for tests and logs. -- `pending: Array`: Crew requests waiting for an A* slot: [kind, station, target]; crews waiting to route home. +- `pending: Array`: Crew requests waiting for an A* slot: [kind, station, target, stay]; crews waiting to route home. - `astar_left: int`: A* builds left this frame. - `retry_t: float`: Seconds before stale lines may be rebuilt again, and the current wait (it doubles while the rebuild keeps failing: a failed A* is the expensive case). @@ -163,7 +164,7 @@ City fleets on lanes in first person (docs/fp/slices/E-traffic.md §5): buses on - `func count(role: String) -> int` - `func fleet_size(kind: String) -> int`: Fleet size for CityLife's census (kind as CityLife names it). - `func spawn_fleet(kind: String, start: Vector2i) -> void`: Adds one vehicle of a fleet (CityLife.external.spawn_fleet). -- `func dispatch_crew(kind: String, station: Vector2i, target: Vector2i) -> void`: Queues a crew (CityLife.external.dispatch_crew); it leaves when an A* slot is free. +- `func dispatch_crew(kind: String, station: Vector2i, target: Vector2i, stay: float = 0.0) -> void`: Queues a crew (CityLife.external.dispatch_crew); it leaves when an A* slot is free. A crew called with stay > 0 answers a call, not a sim event: it stays that many seconds at the scene. - `func step(dt: float) -> void` ## `scripts/fp/traffic/heap.gd` @@ -200,6 +201,31 @@ Intelligent Driver Model (Treiber 2000) in world units (docs/fp/slices/E-traffic - `static func acc(v: float, v0: float, gap: float, dv: float) -> float`: Acceleration for speed v toward desired speed v0 with a leader gap (bumper to bumper) closing at dv = v - v_leader. gap = INF means a free road. - `static func approach_speed(v_end: float, d: float) -> float`: Speed a car may hold at distance d before a point it must pass at v_end (comfortable braking). +## `scripts/fp/traffic/incidents.gd` + +extends `RefCounted` + +What a blow in the street means for the city (traffic_feature.gd hit_walker): a resident who was hit remembers it and their mood drops (a shove least, a car most, more when knocked down), and residents who saw it remember it too. A punch that knocks someone down, or the ASSAULT-th punch within ASSAULT_S, calls the police; someone a car knocks down, an ambulance (a crew from the nearest station that stays at the scene a while, fleets.gd), each kind at most once per CREW_COOL. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `WK = preload("res://scripts/fp/traffic/walkers.gd")` +- `MOOD = {"shove": -0.5, "punch": -1.0, "car": -2.0}` +- `DOWN_MOOD = 1.5` +- `SAW_MOOD = -0.3` +- `ASSAULT = 3` +- `ASSAULT_S = 60.0` +- `CREW_COOL = 90.0` +- `STAY = {"police": 40.0, "ambulance": 30.0}` +- `HIT_LINES = { "shove": "got shoved by a visitor %s", "punch": "was punched by a visitor %s", "down"...`: Memory lines (residents.gd remember): what happened to them and what they saw (%s is where). +- `SAW_LINES = { "shove": "saw a visitor shove someone %s", "punch": "saw a visitor punch someone %s",...` + +### Functions + +- `func setup(p_f: Object) -> void` +- `func report(w: WK.Walker, at: Vector3, strength: float, cause: String, res: Dictionary, seen: Array) -> Dictionary`: The person_hit info for walker w hit by cause at world point at (res: reactions.gd hit()'s result, seen: the walkers who saw it): {"walker", "resident_id", "name", "down", "street", "strength", "witnesses" (their resident ids), "crew" ("police", "ambulance" or "")}. + ## `scripts/fp/traffic/junctions.gd` extends `RefCounted` @@ -427,7 +453,7 @@ Animated people from HC's roster (docs/fp/slices/E-traffic.md deliverable 9, TPS - `PERSON_FADE = 0.4`: People fade out over this last stretch before VIS_PERSON (T3). - `CULL_UP = 0.9 * WS.M`: Frustum test: a sphere around the body (metres above the feet, radius). - `CULL_R = 1.3 * WS.M` -- `STATES = [ &"idle", &"idle_look", &"idle_wait", &"walk", &"walk_carry", &"walk_phone", &"carry_i...` +- `STATES = KIT.STATES` - `LOCO = [&"walk", &"walk_carry", &"walk_phone"]` - `STILL = [&"", &"carry_idle", &"phone"]` - `BOX_COLOR = Color(0.62, 0.47, 0.3)` @@ -468,7 +494,7 @@ Animated people from HC's roster (docs/fp/slices/E-traffic.md deliverable 9, TPS extends `RefCounted` -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. +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; the clips reactions.gd plays (blows, falls, running off) and how their time runs. ### Constants @@ -476,13 +502,18 @@ Asset building for people.gd: the layered clips (a box held in both hands, a pho - `CARRY_AT = 0.0` - `PHONE_AT = 0.5` - `NEEDS = [ &"idle", &"walk", &"talk", &"talk_2", &"phone", &"carry", &"idle_look", &"idle_wait",...` +- `STATES = [ &"idle", &"idle_look", &"idle_wait", &"walk", &"walk_carry", &"walk_phone", &"carry_i...`: Every clip the T0 tree holds: HC's walking and standing clips, the layered ones, and the reactions (reactions.gd). +- `ONE_SHOTS = [ &"knockback", &"get_up", &"hit_head", &"hit_chest", &"wave", &"yes" ]`: Reactions that play once and hold their last frame (the rest loop), the blows among them, and the quicker crossfade into a blow (s): it lands at once. +- `BLOWS = [&"knockback", &"hit_head", &"hit_chest"]` +- `REACT_XFADE = 0.08` - `T3_SHADER = """` ### Functions - `static func layered_library(hc: AnimationLibrary) -> Variant`: A copy of HC's library with walk_carry, carry_idle and walk_phone added; the name of the first missing clip in NEEDS instead when it lacks one. - `static func layer(base: Animation, over: Animation, bones: Array[String], at: float, nm: String) -> Animation`: base with the rotations of `bones` replaced by over's pose at time `at` (constant keys). -- `static func build_tree(states: Array[StringName], xfade: float) -> AnimationNodeBlendTree`: The T0 tree, shared by every body: state machine (every state reaches every other with a crossfade) -> TimeSeek (handovers from T1) -> TimeScale (playback rate) -> output. +- `static func build_tree(states: Array[StringName], xfade: float) -> AnimationNodeBlendTree`: The T0 tree, shared by every body: state machine (every state reaches every other with a crossfade, REACT_XFADE into a blow) -> TimeSeek (handovers from T1) -> TimeScale (playback rate) -> output. +- `static func clip_time(clip: StringName, t: float, ln: float) -> float`: Time t into a clip of length ln: one-shots stop on their last frame, loops wrap. - `static func t3_shader() -> Shader` - `static func lod_mesh(ps: PackedScene, shader: Shader) -> Array`: The first mesh of a lod1 scene as [ArrayMesh with the fade shader, its transform in the scene]; [] when the scene has no mesh. - `static func t3_layer(nm: String, mesh: Mesh) -> MultiMeshInstance3D`: An empty, hidden T3 layer for mesh (custom data carries the fade). @@ -490,6 +521,49 @@ Asset building for people.gd: the layered clips (a box held in both hands, a pho - `static func crc32(s: String) -> int`: CRC-32 (IEEE, as zlib) of s's UTF-8 bytes. - `static func pose(sk: Skeleton3D, bone: int) -> Transform3D`: Bone pose in skeleton space from the local poses. +## `scripts/fp/traffic/reactions.gd` + +extends `RefCounted` + +People hit in the street (docs/fp/ARCHITECTURE.md, Body actions): shoved or punched by the player, or struck by the car they drive. A light blow makes the person reel (hit_chest, hit_head) and slide back a little; a hard one (DOWN), the HITS_DOWN-th punch in a row or a fast car knocks them down (knockback), and they lie a while before they get up (get_up). Then they shake their head or shout at whoever did it, run off, or walk back to where they were. People within WITNESS_R who see it run off or shout too. While a walker stands where the blow left it its state is "react" (it keeps its edge and plan; the slide and the walk back move only its drawn offset), and a run gives it its own state back at RUN_F times its pace with the run clip. What follows a blow for the city (memory, mood, crews) is incidents.gd's. + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `AP = preload("res://scripts/fp/asset_paths.gd")` +- `WK = preload("res://scripts/fp/traffic/walkers.gd")` +- `LENGTHS = {&"knockback": 0.833, &"get_up": 1.54, &"hit_head": 0.417, &"hit_chest": 0.333}`: Clip lengths (s) in HC's libraries (the female clips match); setup() reads them again. +- `ONCE = [&"knockback", &"get_up", &"hit_head", &"hit_chest"]`: Clips that play once: the walker does not turn while they run (it may be lying down). +- `WITNESS_R = 8.0 * WS.M` +- `DOWN = 0.7`: A blow this strong (0..1) knocks a person down, and so does the HITS_DOWN-th punch within HITS_S seconds. +- `HITS_DOWN = 3` +- `HITS_S = 8.0` +- `RUN_F = 2.6` +- `BACK_V = 1.1 * WS.M` +- `BODY_R = 0.25 * WS.M` +- `BODY_H = 1.7 * WS.M` +- `SEE = [0.3, 0.6, 0.85]`: Share of the people around who react, and of those the share who run off, by severity (a shove, a punch, a knock-down). +- `FLEE = [0.15, 0.5, 0.6]` +- `ANSWERS = { "wave": [10.0, 0.75, 0.0, &"wave", 2.8], "yes": [5.0, 0.75, 0.0, &"yes", 2.5], "no": ...`: Who answers an emote of the hero's: [range (m), the cosine of the cone ahead, share, clip, seconds]. A share of 0 is the nearest person in the cone; otherwise each of those around answers with that chance, at most ANSWER_MAX of them. +- `ANSWER_MAX = 4` + +### Inner classes + +- `Reaction`: One person reacting: the steps still to come, [mode, clip, seconds] with mode "place" (stand and play the clip), "back" (walk back to the walker's own place) or "run". + +### Functions + +- `func setup(p_wk: WK, p_collision: CollisionWorld, seed: int) -> void` +- `func clear() -> void` +- `func hit(w: WK.Walker, src: Vector3, push: Vector3, strength: float, cause: String) -> Dictionary`: w takes a blow from src (a world point) along push (xz, any length) of strength 0..1, cause "shove", "punch" or "car". Returns {"down": bool, "clip": StringName}, or {} when w cannot take one now (hidden, in a conversation, or down already). +- `func witness(w: WK.Walker, src: Vector3, severity: int) -> Array`: People within WITNESS_R of w who see what happened to it (severity 0 a shove, 1 a punch, 2 a knock-down) and are not reacting already: some run off, some shout at src (shake their head at a shove). Returns them. +- `func answer_emote(p: Vector3, fwd: Vector3, emote: String) -> Array`: People who answer the hero's emote ("wave", "yes", "no", "shout", "dance"; ANSWERS) made at p facing fwd (xz): after a moment they turn to p and wave, nod or shake their head. Returns them. +- `func step(dt: float) -> void` +- `func blocks() -> Array`: Lane blocks for vehicles.gd: the people hit, until they walk or run on, as circles at their drawn feet (on a sidewalk they are clear of every lane). +- `func is_down(w: WK.Walker) -> bool`: True while w is knocked down (lying or getting up). +- `func count() -> int` +- `func pose(w: WK.Walker, clip: StringName, t: float, secs: float = 60.0) -> void`: Stages w playing clip from t seconds in, for secs (photos, tests). The caller releases its body so the next one starts at t. + ## `scripts/fp/traffic/render.gd` extends `RefCounted` @@ -675,6 +749,8 @@ Traffic and pedestrians on lanes (slice E, docs/fp/slices/E-traffic.md): one roa - `OCC = preload("res://scripts/fp/traffic/occupants.gd")` - `SHOVES = preload("res://scripts/fp/traffic/shoves.gd")` - `DOORS = preload("res://scripts/fp/traffic/car_doors.gd")` +- `REACT = preload("res://scripts/fp/traffic/reactions.gd")` +- `INCIDENTS = preload("res://scripts/fp/traffic/incidents.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. @@ -683,11 +759,13 @@ Traffic and pedestrians on lanes (slice E, docs/fp/slices/E-traffic.md): one roa - `PHOTO_BACK = 1.3`: The traffic photo stands this far from the junction centre along the arm. - `LINE_RETRY = 10.0`: Seconds between attempts to rebuild bus lines that no longer match the roads. - `CLAIM_SPEED = 0.5 * WS.M`: The player can take a parked car, their own, or an ambient car stopped below this speed. +- `STATION_FAR = 10`: A crew called with no station in town sets off from a road at most this many tiles away. ### 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. +- `reactions: REACT`: People hit in the street (reel, fall, get up, run off) and what that means for the city. - `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). @@ -732,6 +810,11 @@ Traffic and pedestrians on lanes (slice E, docs/fp/slices/E-traffic.md): one roa - `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 walkers_near(p: Vector3, r: float) -> Array`: People on foot within r of p, for the player's blows and the car body: [{"id", "pos" (the drawn feet), "yaw" (facing (sin, 0, cos)), "down", "resident_id"}]. +- `func hit_walker(id: int, src: Vector3, push: Vector3, strength: float, cause: String) -> Dictionary`: Person id (walkers_near) takes a blow from src (a world point) along push, of strength 0..1, cause "shove", "punch" or "car": they react and the people around see it (reactions.gd), the city hears of it (incidents.gd) and the bus emits person_hit. Returns that signal's info, {} when they could not be hit now. +- `func answer_emote(p: Vector3, fwd: Vector3, emote: String) -> int`: The hero's emote ("wave", "yes", "no", "shout", "dance") at p facing fwd: the people who answer it (reactions.gd answer_emote). Returns how many do. +- `func pose_walker(id: int, clip: StringName, t: float) -> bool`: Photo and test hook: person id plays reaction clip from t seconds in (held, sim paused or not). +- `func call_crew(kind: String, at: Vector3, stay: float) -> bool`: Calls a crew ("police" or "ambulance") to world point at, from the road beside the nearest police station or hospital (a road some way off when the city has none), to stay that many seconds once there (fleets.gd). False when no road is near at. - `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. @@ -930,7 +1013,8 @@ People on foot (docs/fp/slices/E-traffic.md §3 Pedestrians): they walk the side - `func rebuild_posts(sim: CitySim) -> void`: Rebuilds the post obstacles: the posts baked into lit straights (model-local (±0.4, ∓0.2), roads.py) and the traffic-light poles at the corners of cross tiles (junctions.gd POLE_AT). - `func step(dt: float) -> void` - `func zebra_cars(i: int, arm: int, secs: float) -> bool`: True when a vehicle is on, or within `secs` of, the zebra across `arm` of junction tile i. +- `func turn_from(w: Walker, src: Vector3) -> void`: Turns an ambient walker round on its edge when src lies ahead (it runs off from there); one on a plan, a free leg or a zebra keeps its way. - `func start_plan(w: Walker, plan: Array) -> void`: Starts w on a plan from where it stands (first step a free leg to the network or an edge). -- `func ease_visuals(w: Walker, delta: float) -> void`: Eases the drawn stride toward the walking speed and the fade-in toward 1 (real seconds, so a paused tour still settles). +- `func ease_visuals(w: Walker, delta: float) -> void`: Eases the drawn stride toward the walking speed and the fade-in toward 1 (real seconds, so a paused tour still settles). A reaction moves the drawn offset and sets the drawn speed itself. - `func draw_xform(w: Walker) -> Transform3D`: Draw transform of w: the legs and arms swing in the people shader (render.gd), and the body bobs with them, highest as the legs pass each other. - `func draw_phase(w: Walker) -> float`: Stride phase as drawn (carried on over the time the simulation LOD has not stepped yet). diff --git a/docs/api/scripts.fp.ui.md b/docs/api/scripts.fp.ui.md index 97884867..9dd0ded1 100644 --- a/docs/api/scripts.fp.ui.md +++ b/docs/api/scripts.fp.ui.md @@ -87,7 +87,7 @@ One chat message drawn as a single Control (slice C §2): a resident, mayor, pre - `CHIP_H = 15.0` - `CARD_ICON = 22.0` - `ROLE_TAGS = {"mayor": "MAYOR", "press": "PRESS", "council": "COUNCIL"}` -- `INCIDENT_ICONS = { "fire": "fire", "blackout": "bolt", "power_line": "bolt", "sewage_burst": "drop", "wa...`: Card icon per incident type (refs.type). +- `INCIDENT_ICONS = { "fire": "fire", "blackout": "bolt", "power_line": "bolt", "sewage_burst": "drop", "wa...`: Card icon per incident type (refs.type; "street": a crew called after someone was hit). ### Properties diff --git a/docs/fp/ARCHITECTURE.md b/docs/fp/ARCHITECTURE.md index 784980e0..49619ccc 100644 --- a/docs/fp/ARCHITECTURE.md +++ b/docs/fp/ARCHITECTURE.md @@ -97,7 +97,7 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th - `city_sim.gd`: `var day_seconds: float = SimDefs.BASE_DAY_SECONDS`, read wherever `BASE_DAY_SECONDS` is used today. - `city_brain.gd` and `sim_defs.gd`: `MAYORS[i]["display"]` (human name). The display name replaces the model name in prompts, `city_log`, `owner_name()` and inspector text. `model` stays for the API. The offline Gazette editorial and the inspector prompt stop saying "AI mayors". - S0's own FP files also carry shared contracts that slices build on: `GameBus.card_action`, `CityDirectory.planned_name`, `CollisionWorld.boxes()`, `AP.load_scene()` and the GLB node-name prefixes, `FpPlayer.photo_mode`, and the `fp_kit` test context. S0 §23 (D-S0-17 to D-S0-22) lists them. -- `project.godot`: FP input actions (`move_forward`, `move_back`, `move_left`, `move_right`, `sprint`, `crouch`, `jump`, `interact`, `chat_open`, `map_toggle`, `pause_menu`, `time_faster`, `time_slower`, `time_skip`, `photo_mode`, `hud_toggle`, `quick_save`, `quick_load`) and, for D13, the camera actions `view_toggle` (V), `shoulder_swap` (Q), `zoom_in` / `zoom_out` (mouse wheel up / down) and `look_left` / `look_right` / `look_up` / `look_down` (gamepad right stick, dead zone 0.15). The existing `camera_*` actions stay. The integration overview adds `overview_toggle` (Tab, gamepad Back), `site_next` (N), `site_prev` (B), `site_go` (F) and `site_waypoint` (G). Driving adds `vehicle_enter` (F, gamepad Y; on foot it only takes the press with a car in reach, so Y still opens the map elsewhere), `drive_throttle` (right trigger) and `drive_brake` (left trigger); on the keyboard the move keys drive. The phone adds `phone_toggle` (B); while it is open it reads 1-6, the mouse buttons and the wheel itself. The AI activity feed adds `ai_feed` (J, keyboard only: every pad button is taken on foot) and reuses `site_waypoint` (G) on foot to route to the newest decision; the pad's Y keeps opening the map. +- `project.godot`: FP input actions (`move_forward`, `move_back`, `move_left`, `move_right`, `sprint`, `crouch`, `jump`, `interact`, `chat_open`, `map_toggle`, `pause_menu`, `time_faster`, `time_slower`, `time_skip`, `photo_mode`, `hud_toggle`, `quick_save`, `quick_load`) and, for D13, the camera actions `view_toggle` (V), `shoulder_swap` (Q), `zoom_in` / `zoom_out` (mouse wheel up / down) and `look_left` / `look_right` / `look_up` / `look_down` (gamepad right stick, dead zone 0.15). The existing `camera_*` actions stay. The integration overview adds `overview_toggle` (Tab, gamepad Back), `site_next` (N), `site_prev` (B), `site_go` (F) and `site_waypoint` (G). Driving adds `vehicle_enter` (F, gamepad Y; on foot it only takes the press with a car in reach, so Y still opens the map elsewhere), `drive_throttle` (right trigger) and `drive_brake` (left trigger); on the keyboard the move keys drive. The phone adds `phone_toggle` (B); while it is open it reads 1-6, the mouse buttons and the wheel itself. The AI activity feed adds `ai_feed` (J, keyboard only: every pad button is taken on foot) and reuses `site_waypoint` (G) on foot to route to the newest decision; the pad's Y keeps opening the map. Body actions add `act_strike` (left mouse, right trigger), `act_aim` (right mouse, left trigger; on foot only, as the drive feature reads the triggers at the wheel) and `emote_wave`, `emote_yes`, `emote_no`, `emote_shout`, `emote_dance` (1-5, keyboard only; the open phone reads 1-6 itself). - `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. @@ -109,23 +109,24 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th | Slice | Owns (exclusive edit rights) | Delivers | |---|---|---| | **S0 Foundation** (serial, first) | `main.gd`, `project.godot` `[input]`, `scripts/fp/{fp_mode,fp_context,game_bus,fp_feature,fp_features,world_scale,asset_paths,city_directory}.gd`, the §4 seams, a stub file per slice, `tools/gate.sh`, `tools/tests/{parse_all,fp_smoke}*`, `tools/tests/fp_kit.gd` | On-foot boot with a basic walking camera. Clutter hidden. All seams. The gate. D13 hooks: camera settings and actions, the kit's first-person default. | -| **A Player & interaction** | `scripts/fp/player/**` (including `tps_rig.gd`, `spring_math.gd`, `player_avatar.gd`), `scripts/fp/world/collision_world.gd`, `scripts/fp/interact/**` | Kinematic controller: walk, run, crouch, jump, step onto curbs, head bob (first person), building and prop collision, water edge. **Third-person over-the-shoulder camera** (analytic spring arm, collision sweep, recenter, shoulder swap, zoom), **V toggle** to first person, the **hero avatar** (placeholder until HC, then HC's animated hero with head look), the **conversation two-shot**. Interaction with residents, buildings, posters and cars (raises `GameBus` events). Photo mode (free-fly). | +| **A Player & interaction** | `scripts/fp/player/**` (including `tps_rig.gd`, `spring_math.gd`, `player_avatar.gd`), `scripts/fp/world/collision_world.gd`, `scripts/fp/interact/**` | Kinematic controller: walk, run, crouch, jump, step onto curbs, head bob (first person), building and prop collision, water edge. **Third-person over-the-shoulder camera** (analytic spring arm, collision sweep, recenter, shoulder swap, zoom), **V toggle** to first person, the **hero avatar** (placeholder until HC, then HC's animated hero with head look), the **conversation two-shot**. Interaction with residents, buildings, posters and cars (raises `GameBus` events). Photo mode (free-fly). For Integration: body actions, the hero rig's action layer (`play_action`, `set_guard`, `strike_time`; `hero_fk.gd` measures the clips), `FpPlayer.aim_yaw` (the body squared to a direction) and `kick_view`, and `TpsRig.arm_scale`. | | **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. | +| **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. Street lines (`street_lines.gd`, on `person_hit`): the one the visitor hit and a witness say so, and a crew called to the scene posts a City Alert card. | | **D Posters** | `scripts/ai/gmi_image_client.gd`, `scripts/fp/posters/**`, `tools/gmi/generate_posters.py`, `assets/posters/**` | Async HY image client (submit, poll, download, `user://` cache keyed by prompt hash). Poster director: mayor campaigns, Gazette front pages, shop ads using `CityDirectory` names, incident notices, ordinance notices. Surfaces: billboards, bus shelters, walls, hoardings at construction sites. Night lighting. The pre-baked offline set. | -| **E Traffic & pedestrians** | `scripts/fp/traffic/**`, `scripts/world/city_life.gd`, `scripts/world/resident_avatars.gd` | One road-agent system. Residents and ambient walkers drawn with HC's roster bodies and shared animation libraries in four LOD tiers (legacy static people until HC lands). Lane graph (right-hand traffic, turn arcs). IDM car-following. Intersections with traffic lights (4-way) and yield (T). Resident drivers whose trips come from their minds (`trip_started`). Ambient filler traffic and pedestrians on sidewalks. Buses, trucks and emergency crews on lanes. Agents yield to the player. Spinning wheels where the model allows. Cars the player's car hits are knocked (`shoves.gd`; `bodies_near` lists the vehicle bodies for the drive feature and `bump_car` takes its impulses): they slide and spin off and stop against walls and other cars. A lane car brakes hard, waits longer the harder it was hit, then steers back onto its lane and drives on, and lane traffic stops for it meanwhile. A parked car knocked out of its slot rides up curbs, stays where it stops and is towed back to its slot once it has stood still 30 s and is out of sight. Each car model's driver's door is cut out of its body between its shut lines and hung on its hinge (`car_doors.gd`), so the driven car's door swings open (`door_info`, `set_door`). Keeps `vehicle_counts()`, `visible_count()`, `pick()`, `position_of()` and `select()`. | +| **E Traffic & pedestrians** | `scripts/fp/traffic/**`, `scripts/world/city_life.gd`, `scripts/world/resident_avatars.gd` | One road-agent system. Residents and ambient walkers drawn with HC's roster bodies and shared animation libraries in four LOD tiers (legacy static people until HC lands). Lane graph (right-hand traffic, turn arcs). IDM car-following. Intersections with traffic lights (4-way) and yield (T). Resident drivers whose trips come from their minds (`trip_started`). Ambient filler traffic and pedestrians on sidewalks. Buses, trucks and emergency crews on lanes. Agents yield to the player. Spinning wheels where the model allows. Cars the player's car hits are knocked (`shoves.gd`; `bodies_near` lists the vehicle bodies for the drive feature and `bump_car` takes its impulses): they slide and spin off and stop against walls and other cars. A lane car brakes hard, waits longer the harder it was hit, then steers back onto its lane and drives on, and lane traffic stops for it meanwhile. A parked car knocked out of its slot rides up curbs, stays where it stops and is towed back to its slot once it has stood still 30 s and is out of sight. Each car model's driver's door is cut out of its body between its shut lines and hung on its hinge (`car_doors.gd`), so the driven car's door swings open (`door_info`, `set_door`). People on foot take blows (`walkers_near`, `hit_walker`; Integration: body actions): `reactions.gd` makes them reel, fall and get up, then shout, run off or walk back, with the people around reacting too, and `incidents.gd` puts it in the residents' memories and moods and calls the police or an ambulance (`call_crew`, a `fleets.gd` crew that stays at the scene). `person_hit` on the bus reports each blow, and `answer_emote` has the people around answer the hero's emotes. Keeps `vehicle_counts()`, `visible_count()`, `pick()`, `position_of()` and `select()`. | | **F Sim performance** | `scripts/sim/city_sim.gd`, `scripts/sim/residents.gd`, `scripts/ai/resident_minds.gd` (perf only) | Time-sliced tick (frame budget ≤ 4 ms in first person, synchronous path kept for tests). A `tick_progress` signal. No stall over 50 ms. | | **G1 Streetscape & look** | `scripts/world/city_view.gd`, `scripts/world/construction_kit.gd`, `scripts/world/sky_director.gd`, `scenes/main-environment.tres`, `shaders/**`, `textures/real/**` and `env/**` (contents and `.import` params) | Scale fixes (REAL_SCALE, people, vehicles, machines via `WS`). Street-level ground: anti-tiling PBR, curbs, lane markings, crosswalks, sidewalk detail. Street furniture density. Street-name signs at intersections. First-person light profile (shadow distance, SSAO and SSIL radii in units, fog, volumetric fog for night and smog). Weather (clear, overcast, rain with wet roads). A budgeted view-rebuild queue. | | **G2 Render performance** | `scripts/fp/render/**`, `project.godot` `[rendering]`, `tools/optimize_imports.py`, the `.import` params under `models/**` **except `models/characters/**`** (HC) | Visibility ranges, HLOD and impostors, occluders (applied by a post-pass on `tile_built`). Quality presets (Low, Medium, High) applied at runtime: FSR2, shadow atlas, SSAO, SSIL. VRAM texture compression and mipmaps on imports (an allow-list keeps HC's retarget settings). `fp_bench` perf gate, including a third-person street shot and the animated-people budget. | | **H Assets** | `tools/blender/**`, `models/real/**`, `scripts/fp/asset_paths.gd` (path list only, except HC's entries) | Vehicles at 1.0× with separate wheel nodes. Traffic lights, street-name signs, bus shelter with a poster panel, billboard with a UV'd poster surface, wall poster frame, crosswalk and curb pieces, fixed door heights. All validated by `validate_real.py`. | -| **HC Characters** (D13) | `tools/characters/**`, `models/characters/**` **including its `.import` files** (the BoneMap retarget settings are content; exception to rule 11), the character entries of `scripts/fp/asset_paths.gd` (`CHARACTERS_DIR`, `CHAR_BODIES_DIR`, `CHAR_ROSTER`, `CHAR_LIB_M`, `CHAR_LIB_F`, `HERO_M`, `HERO_F`, `PERSON_TPS_ANIMS`), `tools/tests/fp_characters_test.gd` | Realistic clothed people from Microsoft Rocketbox (MIT): 2 hero bodies and 38 resident bodies plus far LODs, on Godot's humanoid skeleton. Mocap clips (Rocketbox, plus Quaternius UAL CC0 for jump, crouch, interact, drive, carry) in one shared animation library per gender. `roster.json` with job pools and name genders. Licences and provenance. | -| **I Audio** | `scripts/fp/audio/**`, `sounds/fp/**`, `scripts/audio_director.gd` | 3D positional engines, horns and sirens, footsteps by surface (in step with the hero's stride when the avatar animates), area ambience (traffic hum, park birds, river, construction), a UI chat blip, crashes where the player's car hits a wall or a car (`car_impact`, heavier and louder with the closing speed) a clatter or clang by kind for each street prop it knocks over (`prop_knocked`), and its driver's door opening and slamming shut (`car_door`). Assets synthesized procedurally or with the existing ACE-Step tool. | +| **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 and the body actions' punches, hit reactions, getting up, dance and shout) in one shared animation library per gender. `roster.json` with job pools and name genders. Licences and provenance. | +| **I Audio** | `scripts/fp/audio/**`, `sounds/fp/**`, `scripts/audio_director.gd` | 3D positional engines, horns and sirens, footsteps by surface (in step with the hero's stride when the avatar animates), area ambience (traffic hum, park birds, river, construction), a UI chat blip, crashes where the player's car hits a wall or a car (`car_impact`, heavier and louder with the closing speed) a clatter or clang by kind for each street prop it knocks over (`prop_knocked`), and its driver's door opening and slamming shut (`car_door`), the thump of a blow on someone in the street and the fall of someone knocked down (`person_hit`), and a whoosh for each of the hero's swings (`hero_action`). 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`, `tools/tests/fp_car_doors_test.gd` | F by a car takes it over (its resident's trip ends; traffic hands over a `player`-role car of the same model that the AI no longer steps). With the `car_doors` flag the hero gets in the long way (`car_entry.gd`): it walks round the car to the driver's door (the shorter clear way round its front or rear, past other cars, jogging while far), reaches for the handle (`door_try`), pulls the door open and swings it wide, steps into the opening, sits onto the sill (`sit_down`, its root travel walked back so the feet stay planted), swings round into the seat, and the door slams (`car_door` on the bus). From the reach on, a camera beside the door watches and blends into the chase camera. F on the way to the door turns back. An arcade bicycle model (`drive_model.gd`: throttle, brake, reverse, speed-scaled steering, grip by surface, handbrake slide) moves it through `TrafficFeature.drive_car`; the hero sits in the driver's seat. Chase camera (sweep-checked) or driver view on V, a speedometer and gear chip. The car is a rigid box (`car_body.gd`, on the shared `scripts/fp/obb2d.gd` and `scripts/fp/body_contacts.gd`): it moves in short substeps and each time resolves its deepest contact, with `CollisionWorld`'s walls, posts and water edges, with other vehicles (traffic's `bodies_near`, impulses handed back through `bump_car`) and with street props (the props feature knocks them over when hit hard enough). So it bounces a little off what it hits head-on, slides along what it hits at a glance and spins when hit off-centre; `car_impact` on the bus reports the hard hits. AI cars and walkers yield to player cars as oriented boxes. F again gets out on the driver's side when slow (under 8.5 m/s) and there is room: the long way, the car brakes to a stop, the door opens, the hero swings out onto the sill, stands up (`stand_up`), steps clear and the door shuts. With no room for the door or no clear way round (or without the flag) F gets in and out at once. A left car is removed once out of range and out of sight. | +| **Integration: driving** | `scripts/fp/drive/**`, `tools/tests/fp_drive_test.gd`, `tools/tests/fp_car_doors_test.gd` | F by a car takes it over (its resident's trip ends; traffic hands over a `player`-role car of the same model that the AI no longer steps). With the `car_doors` flag the hero gets in the long way (`car_entry.gd`): it walks round the car to the driver's door (the shorter clear way round its front or rear, past other cars, jogging while far), reaches for the handle (`door_try`), pulls the door open and swings it wide, steps into the opening, sits onto the sill (`sit_down`, its root travel walked back so the feet stay planted), swings round into the seat, and the door slams (`car_door` on the bus). From the reach on, a camera beside the door watches and blends into the chase camera. F on the way to the door turns back. An arcade bicycle model (`drive_model.gd`: throttle, brake, reverse, speed-scaled steering, grip by surface, handbrake slide) moves it through `TrafficFeature.drive_car`; the hero sits in the driver's seat. Chase camera (sweep-checked) or driver view on V, a speedometer and gear chip. The car is a rigid box (`car_body.gd`, on the shared `scripts/fp/obb2d.gd` and `scripts/fp/body_contacts.gd`): it moves in short substeps and each time resolves its deepest contact, with `CollisionWorld`'s walls, posts and water edges, with other vehicles (traffic's `bodies_near`, impulses handed back through `bump_car`) and with street props (the props feature knocks them over when hit hard enough). So it bounces a little off what it hits head-on, slides along what it hits at a glance and spins when hit off-centre; `car_impact` on the bus reports the hard hits. It strikes the people on foot it closes on faster than 0.5 m/s (traffic's `walkers_near` and `hit_walker`, as hard as the closing speed over 12 m/s, each at most once per 1.5 s), and they barely slow it. AI cars and walkers yield to player cars as oriented boxes. F again gets out on the driver's side when slow (under 8.5 m/s) and there is room: the long way, the car brakes to a stop, the door opens, the hero swings out onto the sill, stands up (`stand_up`), steps clear and the door shuts. With no room for the door or no clear way round (or without the flag) F gets in and out at once. A left car is removed once out of range and out of sight. | | **Integration: phone** | `scripts/fp/phone/**`, `tools/tests/fp_phone_test.gd` | B opens a phone with a Build app: road, homes, shops, industry, a building from the service list, and bulldoze, on the tile ahead of the hero (the camera's pitch sets the distance, 0.6 to 6 tiles). Each order goes through `CitySim.apply_player_order` for the mayor who owns that tile, so it is paid from that district's treasury and follows the mayors' rules. Its construction sites go to the player's crews (below; with the `crews` flag off, `apply_action` hands them to the contractors). Unclaimed land is refused. A line on the phone says how many crews are free and how long the order would take, or when a crew frees up for it. `build_rules.gd` mirrors the sim's checks for the preview: a cyan or amber ghost over the tiles, the owner, the price and why not. Roads and zones take two clicks (start or corner, then end). Put away while driving or under a menu. | | **Integration: AI activity** | `scripts/fp/activity/**`, `tools/tests/fp_activity_test.gd` | Shows the city's AI at work from the street. Every mayor turn, council ruling, Chaos Director pick, Grid Operator order and Gazette front page lands as a card in a feed at the top right (J cycles full, brief and hidden). A card names who decided and the model they run on, and says whether the answer came live, from a replay, from a fallback after a failed live call, or from the offline planner. It also shows what they did and why, any votes, and where. A decision with a place raises a light beacon in the agent's colour (tagged within 30 tiles) and pings the compass and minimap (`ai:`, no toast). G routes the GPS to the newest decision with a place. On foot it turns on `CityBrain.stagger_mayors`, so the five mayors take their turns spread over the day and the header line says who is deciding or who goes next. The feed hides under modals (not under the chat input), and the beacons hide in photo mode. | | **Integration: crews** | `scripts/fp/crews/**`, `tools/tests/fp_crews_test.gd`, `tools/tests/fp_crew_jobs_test.gd` | Phone orders go to the player's two building crews, which build them in real time: 2 to 5 minutes for a building at speed 1, roads by the tile. A third order waits for a free crew. Each crew is on its site to see (`crew_rig.gd`): a foreman by the gate, two builders whose clips follow the stage, and a hauler carrying crates from a pile to the work. They use the trades bodies from HC's roster (`crew_bodies.gd`, loaded in the background), with dust while they work and a board over the gate naming the crew, the order, the stage and the time left. Where they stand and walk comes from the site's `CrewGrid` (`crew_grid.gd`). It is a grid of cells 0.8 m apart with nodes on the ground inside the hoarding and on the kit's decks (on a narrow scaffold board, along its middle). Each node is clear of every collision box and cylinder near the tile, except the boxes that enclose its deck (the slab under it, the frame or scaffold round it), and of the deck's posts and of the decks above. Nodes link by steps up to 1 m and climbs over berms and formwork. `crew_spots.gd` picks the door inside the gate, the foreman's spot and the pile beside it, and work spots nearest the middle that the door reaches. It also picks a drop point the hauler reaches without walking into the crew. Where a finished building fills its lot, the crew works from the scaffold. A crew that takes an order while you play walks in through the gate. The crew is laid out again when its site is redrawn for a new stage (a worker walks over when it can), and a road crew moves along the road. A card under the top bar lists the orders with their stage, progress and countdown, and the ones waiting. E on a worker gets a word about the job, the site's interact label says which crew builds it, and a toast says when an order is done. | | **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). | +| **Integration: body actions** | `scripts/fp/actions/**`, `tools/tests/fp_actions_test.gd`, `tools/tests/fp_chat_street_test.gd` | The hero's fists and gestures on foot (flag `actions`). Left click (right trigger) shoves whoever stands in front. With the guard up (right mouse or left trigger held: fists up, the body squared to where the camera looks, the camera arm at 80%) it throws a jab and a cross in turn, starting again from the jab a second after the last. A swing lands when the rig's fist is furthest out (`HeroRig.strike_time`, measured on the clip with `hero_fk.gd`) on the nearest person on their feet within 1.2 m in the cone ahead, through traffic's `hit_walker`, as hard as the swing (harder when running), and kicks the view. Keys 1-5 wave, nod, shake the head, shout and dance, and the people around answer (traffic's `answer_emote`). Nothing swings in a car, in photo mode or the overview, in a conversation, under a modal or with the phone open, and the click that captures the mouse throws nothing. The presses come as events; a trigger counts once per pull. The person hit (traffic's `reactions.gd`) reels (`hit_chest`, `hit_head`) and slides back from a light blow. A blow of 0.7 or more, the third punch within 8 s or a fast car knocks them down (`knockback`): they lie a while, get up (`get_up`), then shake their head or shout at whoever did it, run off or walk back to where they were. Of the people within 8 m, up to 85% (by how bad it was) react: some run off, the rest shout or shake their heads. `incidents.gd` gives a resident who was hit a memory and a mood drop (a shove 0.5, a punch 1, a car 2, half as much again when knocked down), and those who saw it a memory and a drop of 0.3. A punch that knocks someone down, or the third within 60 s, calls the police; someone a car knocks down gets an ambulance. The crew (`call_crew`, a `fleets.gd` crew) drives out from the road by the nearest police station or hospital (from a road up to 10 tiles off when the city has none), stays at the scene a while, and each kind comes at most once per 90 s. `person_hit` on the bus reports every blow: C's `street_lines.gd` posts the victim's line and a witness's (one scene per 8 s of feed time; a harder blow still posts) and a City Alert card for a crew, and I plays the blow and the fall. `hero_action` reports each swing (I's whoosh) and emote. The car the player drives strikes people too (`car_body.gd`, cause `"car"`). | ## 6. Rules for every slice diff --git a/docs/fp/S0-foundation.md b/docs/fp/S0-foundation.md index 509d9d3d..fad48727 100644 --- a/docs/fp/S0-foundation.md +++ b/docs/fp/S0-foundation.md @@ -688,6 +688,13 @@ signal car_door(at: Vector3, open: bool) ## A street prop was knocked over at a point (its StreetDressing kind: "bin", "bench" ...) and ## flies off at speed (u/s). Emitted by the props feature. signal prop_knocked(at: Vector3, kind: String, speed: float) +## Someone on foot was hit at a point (their feet): cause "shove" or "punch" (the hero) or "car" +## (the car the player drives); info is traffic_feature.gd hit_walker()'s {"walker", +## "resident_id", "name", "down", "street", "strength", "witnesses", "crew"}. Emitted by traffic. +signal person_hit(at: Vector3, cause: String, info: Dictionary) +## The hero swung or gestured: action "jab", "cross", "shove" or an emote ("wave", "yes", "no", +## "shout", "dance"), at their feet. Emitted by the actions feature. +signal hero_action(action: String, at: Vector3) ``` | Signal | Payload | Emitted by | Typical listeners | @@ -712,6 +719,8 @@ signal prop_knocked(at: Vector3, kind: String, speed: float) | `car_impact` | `at`: the contact point, at half the car's height. `speed`: the closing speed (u/s), at least 1.5 m/s. `what`: `"wall"` or `"car"`. The hardest hit of a step, at most one every 0.25 s; knocked props report themselves | Integration: driving | I (crash sound at the contact) | | `car_door` | `at`: the driver's door handle. `open`: `true` as the latch lets go (the pull on the handle getting in, the door starting to open getting out), `false` as the door slams shut | Integration: driving (the `car_doors` climb in and out) | I (the latch and the slam at the door) | | `prop_knocked` | `at`: where the car hit it. `kind`: its StreetDressing kind. `speed`: how fast it flies off (u/s); a fallen prop hit again reports from 1 m/s | Integration: props | I (clatter or clang by kind) | +| `person_hit` | `at`: the person's drawn feet. `cause`: `"shove"` or `"punch"` (the hero) or `"car"` (the car the player drives). `info`: `walker` (the walker id), `resident_id` and `name` (`""` for an ambient walker), `down` (knocked down), `street` (the street label, may be `""`), `strength` 0..1, `witnesses` (the resident ids of those who saw it), `crew` (`"police"`, `"ambulance"` or `""`: the crew this blow called) | E (`hit_walker`, called by Integration: body actions and by the drive feature's car body) | C (the victim's and a witness's lines, a crew's City Alert card), I (the blow and the fall) | +| `hero_action` | `action`: `"jab"`, `"cross"` or `"shove"` as a swing starts, or the emote as it starts (`"wave"`, `"yes"`, `"no"`, `"shout"`, `"dance"`). `at`: the hero's feet | Integration: body actions | I (the whoosh of a swing) | 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/HC-characters.md b/docs/fp/slices/HC-characters.md index 4e33fd00..faf73feb 100644 --- a/docs/fp/slices/HC-characters.md +++ b/docs/fp/slices/HC-characters.md @@ -107,7 +107,7 @@ Measured on `Male_Adult_06` with the scratch converter (`.../scratchpad/tps/veri 3. **Phase-align** loops so frame 0 is the left-heel strike (lowest left-foot z with near-zero foot velocity). Two loops must be aligned before a BlendSpace blends them, or the legs scissor. 4. Record `speed_mps` (the measured `Root` speed) as a custom property; it becomes the Godot meta `speed_mps`. -**UAL clips:** `models/characters/anim/ual.glb` holds the 17 clips of deliverable 6, copied out of `UAL1_Standard.glb` and `UAL2_Standard.glb` (in place; root `root` → `Root`), with the mannequin mesh deleted. +**UAL clips:** `models/characters/anim/ual.glb` holds the UAL clips of deliverable 6 and the body actions' clips (23 in all, `UAL_CLIPS` in `build_clips.py`), copied out of `UAL1_Standard.glb` and `UAL2_Standard.glb` (in place; root `root` → `Root`), with the mannequin mesh deleted. ### 6. Canonical clip set @@ -131,6 +131,7 @@ Measured on `Male_Adult_06` with the scratch converter (`.../scratchpad/tps/veri | `crouch_idle`, `crouch_walk` | UAL1 Crouch_Idle_Loop, Crouch_Fwd_Loop | same | loop | 0 / **0.75** | | `interact`, `push`, `pickup`, `drive` | UAL1 Interact, Push_Loop, PickUp_Table, Driving_Loop | same | mixed | – | | `carry`, `phone_idle`, `fold_arms`, `yes`, `no`, `climb_1m`, `knockback` | UAL2 Walk_Carry_Loop, Idle_TalkingPhone_Loop, Idle_FoldArms_Loop, Yes, Idle_No_Loop, ClimbUp_1m, Hit_Knockback | same | mixed | Walk_Carry ≈ walk | +| `punch_jab`, `punch_cross`, `hit_head`, `hit_chest`, `dance`, `get_up`, `shout` (body actions) | UAL1 Punch_Jab, Punch_Cross, Hit_Head, Hit_Chest, Dance_Loop; UAL2 LayToIdle, Idle_Rail_Call | same | one-shot (`dance` and `shout` loop) | – | ### 7. Godot import (HC owns these `.import` files) and `AP` entries diff --git a/docs/fp/slices/I-audio.md b/docs/fp/slices/I-audio.md index fb1f75a8..ef6e296f 100644 --- a/docs/fp/slices/I-audio.md +++ b/docs/fp/slices/I-audio.md @@ -124,6 +124,7 @@ Settings map `music_volume_db` to `Music` and `sfx_volume_db` to `SFX` and `Ambi | `chat_blip`, `chat_blip_soft`, `alert_stinger` | | | `crash_heavy`, `crash_light`, `prop_clatter`, `prop_clang` | the player's car hitting walls and cars (`car_impact`) and street props knocked over (`prop_knocked`); WAV | | `car_door_open`, `car_door_close` | the player's car door (`car_door`): the handle, the latch letting go, the seal and the door check as it opens; the slam, the latch catching and the panel as it shuts; WAV | +| `punch_hit`, `body_fall`, `punch_whoosh` | people hit in the street (`person_hit`): the thump of a punch, a shove or a car at the chest, scaled by the blow's strength (a car's lower and louder), and someone knocked down landing 0.45 s later; the hero's swing (`hero_action`: a jab, a cross or a shove, not an emote) whooshing at the shoulder; WAV, emitter type `hit` | 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/models/characters/anim/clips.json b/models/characters/anim/clips.json index 179ad227..bbe254bd 100644 --- a/models/characters/anim/clips.json +++ b/models/characters/anim/clips.json @@ -42,6 +42,16 @@ "speed_mps": 0.6292 } }, + "dance": { + "u": { + "kind": "ual", + "loop": true, + "source": "quaternius:UAL1/Dance_Loop", + "frames": 25, + "seconds": 1.0, + "speed_mps": 0.0 + } + }, "door_try": { "m": { "kind": "static", @@ -90,6 +100,36 @@ "speed_mps": 0.0 } }, + "get_up": { + "u": { + "kind": "ual", + "loop": false, + "source": "quaternius:UAL2/LayToIdle", + "frames": 38, + "seconds": 1.5417, + "speed_mps": 0.0 + } + }, + "hit_chest": { + "u": { + "kind": "ual", + "loop": false, + "source": "quaternius:UAL1/Hit_Chest", + "frames": 9, + "seconds": 0.3333, + "speed_mps": 0.0 + } + }, + "hit_head": { + "u": { + "kind": "ual", + "loop": false, + "source": "quaternius:UAL1/Hit_Head", + "frames": 11, + "seconds": 0.4167, + "speed_mps": 0.0 + } + }, "idle": { "m": { "kind": "static", @@ -270,6 +310,26 @@ "speed_mps": 0.0 } }, + "punch_cross": { + "u": { + "kind": "ual", + "loop": false, + "source": "quaternius:UAL1/Punch_Cross", + "frames": 25, + "seconds": 1.0, + "speed_mps": 0.0 + } + }, + "punch_jab": { + "u": { + "kind": "ual", + "loop": false, + "source": "quaternius:UAL1/Punch_Jab", + "frames": 22, + "seconds": 0.875, + "speed_mps": 0.0 + } + }, "push": { "u": { "kind": "ual", @@ -380,6 +440,16 @@ "speed_mps": 0.0 } }, + "shout": { + "u": { + "kind": "ual", + "loop": true, + "source": "quaternius:UAL2/Idle_Rail_Call", + "frames": 61, + "seconds": 2.5, + "speed_mps": 0.0 + } + }, "sit": { "m": { "kind": "static", diff --git a/models/characters/anim/clips/u_dance.res b/models/characters/anim/clips/u_dance.res new file mode 100644 index 00000000..40075e59 Binary files /dev/null and b/models/characters/anim/clips/u_dance.res differ diff --git a/models/characters/anim/clips/u_get_up.res b/models/characters/anim/clips/u_get_up.res new file mode 100644 index 00000000..cc5ffe1e Binary files /dev/null and b/models/characters/anim/clips/u_get_up.res differ diff --git a/models/characters/anim/clips/u_hit_chest.res b/models/characters/anim/clips/u_hit_chest.res new file mode 100644 index 00000000..25cf0a29 Binary files /dev/null and b/models/characters/anim/clips/u_hit_chest.res differ diff --git a/models/characters/anim/clips/u_hit_head.res b/models/characters/anim/clips/u_hit_head.res new file mode 100644 index 00000000..1fb8b5a4 Binary files /dev/null and b/models/characters/anim/clips/u_hit_head.res differ diff --git a/models/characters/anim/clips/u_punch_cross.res b/models/characters/anim/clips/u_punch_cross.res new file mode 100644 index 00000000..a5699463 Binary files /dev/null and b/models/characters/anim/clips/u_punch_cross.res differ diff --git a/models/characters/anim/clips/u_punch_jab.res b/models/characters/anim/clips/u_punch_jab.res new file mode 100644 index 00000000..9029f53e Binary files /dev/null and b/models/characters/anim/clips/u_punch_jab.res differ diff --git a/models/characters/anim/clips/u_shout.res b/models/characters/anim/clips/u_shout.res new file mode 100644 index 00000000..67ca22f2 Binary files /dev/null and b/models/characters/anim/clips/u_shout.res differ diff --git a/models/characters/anim/lib_f.tres b/models/characters/anim/lib_f.tres index 76baf497..618c50a5 100644 --- a/models/characters/anim/lib_f.tres +++ b/models/characters/anim/lib_f.tres @@ -1,86 +1,100 @@ [gd_resource type="AnimationLibrary" format=3] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_carry.res" id="1_oqw5s"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_climb_1m.res" id="2_low2x"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_crouch_idle.res" id="3_gnjx7"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_crouch_walk.res" id="4_kvepg"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_door_try.res" id="5_b3wu5"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_drive.res" id="6_0wlyg"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_fall.res" id="7_t6nqb"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_fold_arms.res" id="8_0skoq"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_idle.res" id="9_ip7e5"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_idle_look.res" id="10_x1be5"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_idle_wait.res" id="11_nv4rb"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_interact.res" id="12_8li0o"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_jump_start.res" id="13_b36u2"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_knock.res" id="14_ofcld"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_knockback.res" id="15_ghonb"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_land.res" id="16_gadsl"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_no.res" id="17_h3apn"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_phone.res" id="18_hwh8e"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_phone_idle.res" id="19_pxrho"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_pickup.res" id="20_g3snm"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_push.res" id="21_3l0uv"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_run.res" id="22_6e340"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_run_fast.res" id="23_enyhb"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_run_start.res" id="24_qgek3"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_run_stop.res" id="25_f7pj5"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_sit.res" id="26_k4pae"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_sit_down.res" id="27_6ubva"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_stand_up.res" id="28_446ks"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_talk.res" id="29_33ka6"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_talk_2.res" id="30_y2tpj"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_turn_l180_walk.res" id="31_3wwip"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_turn_l90.res" id="32_h56a0"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_turn_r180_walk.res" id="33_8mlgv"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_turn_r90.res" id="34_hp1pt"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_walk.res" id="35_7ajpk"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_walk_fast.res" id="36_4vrwa"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_walk_start.res" id="37_7k85i"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_walk_stop.res" id="38_crajn"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/f_wave.res" id="39_8kxnf"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_yes.res" id="40_hh0te"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_carry.res" id="1_0skoq"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_climb_1m.res" id="2_ip7e5"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_crouch_idle.res" id="3_x1be5"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_crouch_walk.res" id="4_nv4rb"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_dance.res" id="5_8li0o"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_door_try.res" id="6_b36u2"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_drive.res" id="7_ofcld"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_fall.res" id="8_ghonb"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_fold_arms.res" id="9_gadsl"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_get_up.res" id="10_h3apn"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_hit_chest.res" id="11_hwh8e"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_hit_head.res" id="12_pxrho"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_idle.res" id="13_g3snm"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_idle_look.res" id="14_3l0uv"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_idle_wait.res" id="15_6e340"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_interact.res" id="16_enyhb"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_jump_start.res" id="17_qgek3"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_knock.res" id="18_f7pj5"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_knockback.res" id="19_k4pae"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_land.res" id="20_6ubva"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_no.res" id="21_446ks"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_phone.res" id="22_33ka6"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_phone_idle.res" id="23_y2tpj"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_pickup.res" id="24_3wwip"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_punch_cross.res" id="25_h56a0"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_punch_jab.res" id="26_8mlgv"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_push.res" id="27_hp1pt"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_run.res" id="28_7ajpk"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_run_fast.res" id="29_4vrwa"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_run_start.res" id="30_7k85i"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_run_stop.res" id="31_crajn"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_shout.res" id="32_8kxnf"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_sit.res" id="33_hh0te"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_sit_down.res" id="34_eqc0o"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_stand_up.res" id="35_a2uwb"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_talk.res" id="36_kkrc2"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_talk_2.res" id="37_qd27v"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_turn_l180_walk.res" id="38_1idye"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_turn_l90.res" id="39_gplja"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_turn_r180_walk.res" id="40_ajnkw"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_turn_r90.res" id="41_sox2c"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_walk.res" id="42_67u11"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_walk_fast.res" id="43_rphp5"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_walk_start.res" id="44_3c78d"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_walk_stop.res" id="45_cuh4e"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/f_wave.res" id="46_n48ax"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_yes.res" id="47_1vp02"] [resource] _data = { -&"carry": ExtResource("1_oqw5s"), -&"climb_1m": ExtResource("2_low2x"), -&"crouch_idle": ExtResource("3_gnjx7"), -&"crouch_walk": ExtResource("4_kvepg"), -&"door_try": ExtResource("5_b3wu5"), -&"drive": ExtResource("6_0wlyg"), -&"fall": ExtResource("7_t6nqb"), -&"fold_arms": ExtResource("8_0skoq"), -&"idle": ExtResource("9_ip7e5"), -&"idle_look": ExtResource("10_x1be5"), -&"idle_wait": ExtResource("11_nv4rb"), -&"interact": ExtResource("12_8li0o"), -&"jump_start": ExtResource("13_b36u2"), -&"knock": ExtResource("14_ofcld"), -&"knockback": ExtResource("15_ghonb"), -&"land": ExtResource("16_gadsl"), -&"no": ExtResource("17_h3apn"), -&"phone": ExtResource("18_hwh8e"), -&"phone_idle": ExtResource("19_pxrho"), -&"pickup": ExtResource("20_g3snm"), -&"push": ExtResource("21_3l0uv"), -&"run": ExtResource("22_6e340"), -&"run_fast": ExtResource("23_enyhb"), -&"run_start": ExtResource("24_qgek3"), -&"run_stop": ExtResource("25_f7pj5"), -&"sit": ExtResource("26_k4pae"), -&"sit_down": ExtResource("27_6ubva"), -&"stand_up": ExtResource("28_446ks"), -&"talk": ExtResource("29_33ka6"), -&"talk_2": ExtResource("30_y2tpj"), -&"turn_l180_walk": ExtResource("31_3wwip"), -&"turn_l90": ExtResource("32_h56a0"), -&"turn_r180_walk": ExtResource("33_8mlgv"), -&"turn_r90": ExtResource("34_hp1pt"), -&"walk": ExtResource("35_7ajpk"), -&"walk_fast": ExtResource("36_4vrwa"), -&"walk_start": ExtResource("37_7k85i"), -&"walk_stop": ExtResource("38_crajn"), -&"wave": ExtResource("39_8kxnf"), -&"yes": ExtResource("40_hh0te") +&"carry": ExtResource("1_0skoq"), +&"climb_1m": ExtResource("2_ip7e5"), +&"crouch_idle": ExtResource("3_x1be5"), +&"crouch_walk": ExtResource("4_nv4rb"), +&"dance": ExtResource("5_8li0o"), +&"door_try": ExtResource("6_b36u2"), +&"drive": ExtResource("7_ofcld"), +&"fall": ExtResource("8_ghonb"), +&"fold_arms": ExtResource("9_gadsl"), +&"get_up": ExtResource("10_h3apn"), +&"hit_chest": ExtResource("11_hwh8e"), +&"hit_head": ExtResource("12_pxrho"), +&"idle": ExtResource("13_g3snm"), +&"idle_look": ExtResource("14_3l0uv"), +&"idle_wait": ExtResource("15_6e340"), +&"interact": ExtResource("16_enyhb"), +&"jump_start": ExtResource("17_qgek3"), +&"knock": ExtResource("18_f7pj5"), +&"knockback": ExtResource("19_k4pae"), +&"land": ExtResource("20_6ubva"), +&"no": ExtResource("21_446ks"), +&"phone": ExtResource("22_33ka6"), +&"phone_idle": ExtResource("23_y2tpj"), +&"pickup": ExtResource("24_3wwip"), +&"punch_cross": ExtResource("25_h56a0"), +&"punch_jab": ExtResource("26_8mlgv"), +&"push": ExtResource("27_hp1pt"), +&"run": ExtResource("28_7ajpk"), +&"run_fast": ExtResource("29_4vrwa"), +&"run_start": ExtResource("30_7k85i"), +&"run_stop": ExtResource("31_crajn"), +&"shout": ExtResource("32_8kxnf"), +&"sit": ExtResource("33_hh0te"), +&"sit_down": ExtResource("34_eqc0o"), +&"stand_up": ExtResource("35_a2uwb"), +&"talk": ExtResource("36_kkrc2"), +&"talk_2": ExtResource("37_qd27v"), +&"turn_l180_walk": ExtResource("38_1idye"), +&"turn_l90": ExtResource("39_gplja"), +&"turn_r180_walk": ExtResource("40_ajnkw"), +&"turn_r90": ExtResource("41_sox2c"), +&"walk": ExtResource("42_67u11"), +&"walk_fast": ExtResource("43_rphp5"), +&"walk_start": ExtResource("44_3c78d"), +&"walk_stop": ExtResource("45_cuh4e"), +&"wave": ExtResource("46_n48ax"), +&"yes": ExtResource("47_1vp02") } diff --git a/models/characters/anim/lib_m.tres b/models/characters/anim/lib_m.tres index 79de6b38..62cf7d66 100644 --- a/models/characters/anim/lib_m.tres +++ b/models/characters/anim/lib_m.tres @@ -1,86 +1,100 @@ [gd_resource type="AnimationLibrary" format=3] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_carry.res" id="1_243gn"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_climb_1m.res" id="2_7t3d5"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_crouch_idle.res" id="3_o8wtc"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_crouch_walk.res" id="4_tcrrw"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_door_try.res" id="5_k6s6h"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_drive.res" id="6_6w0lv"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_fall.res" id="7_n02qs"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_fold_arms.res" id="8_uy1kx"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_idle.res" id="9_lkaxu"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_idle_look.res" id="10_esux0"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_idle_wait.res" id="11_y7ji3"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_interact.res" id="12_43fde"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_jump_start.res" id="13_ushye"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_knock.res" id="14_sbsa7"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_knockback.res" id="15_v2hsc"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_land.res" id="16_qbvid"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_no.res" id="17_ntea5"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_phone.res" id="18_1i0d3"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_phone_idle.res" id="19_1dws4"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_pickup.res" id="20_xmpiw"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_push.res" id="21_nm53i"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_run.res" id="22_wpbby"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_run_fast.res" id="23_suwkb"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_run_start.res" id="24_r55r1"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_run_stop.res" id="25_x7rlq"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_sit.res" id="26_yusn5"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_sit_down.res" id="27_7cf1d"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_stand_up.res" id="28_fcdh8"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_talk.res" id="29_aq7rn"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_talk_2.res" id="30_bcdpu"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_turn_l180_walk.res" id="31_xyssm"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_turn_l90.res" id="32_ei77u"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_turn_r180_walk.res" id="33_c4lhw"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_turn_r90.res" id="34_cua2r"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_walk.res" id="35_nu5s3"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_walk_fast.res" id="36_mpraf"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_walk_start.res" id="37_cl64j"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_walk_stop.res" id="38_fb22m"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/m_wave.res" id="39_e2evx"] -[ext_resource type="Animation" path="res://models/characters/anim/clips/u_yes.res" id="40_mvrfi"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_carry.res" id="1_uy1kx"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_climb_1m.res" id="2_lkaxu"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_crouch_idle.res" id="3_esux0"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_crouch_walk.res" id="4_y7ji3"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_dance.res" id="5_43fde"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_door_try.res" id="6_ushye"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_drive.res" id="7_sbsa7"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_fall.res" id="8_v2hsc"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_fold_arms.res" id="9_qbvid"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_get_up.res" id="10_ntea5"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_hit_chest.res" id="11_1i0d3"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_hit_head.res" id="12_1dws4"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_idle.res" id="13_xmpiw"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_idle_look.res" id="14_nm53i"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_idle_wait.res" id="15_wpbby"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_interact.res" id="16_suwkb"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_jump_start.res" id="17_r55r1"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_knock.res" id="18_x7rlq"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_knockback.res" id="19_yusn5"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_land.res" id="20_7cf1d"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_no.res" id="21_fcdh8"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_phone.res" id="22_aq7rn"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_phone_idle.res" id="23_bcdpu"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_pickup.res" id="24_xyssm"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_punch_cross.res" id="25_ei77u"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_punch_jab.res" id="26_c4lhw"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_push.res" id="27_cua2r"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_run.res" id="28_nu5s3"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_run_fast.res" id="29_mpraf"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_run_start.res" id="30_cl64j"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_run_stop.res" id="31_fb22m"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_shout.res" id="32_e2evx"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_sit.res" id="33_mvrfi"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_sit_down.res" id="34_u21yj"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_stand_up.res" id="35_kai0f"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_talk.res" id="36_ovmll"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_talk_2.res" id="37_t3qh7"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_turn_l180_walk.res" id="38_1vos1"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_turn_l90.res" id="39_88sfj"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_turn_r180_walk.res" id="40_s4bho"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_turn_r90.res" id="41_8kpy6"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_walk.res" id="42_vqnir"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_walk_fast.res" id="43_k1d8b"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_walk_start.res" id="44_k5b1w"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_walk_stop.res" id="45_fxm67"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/m_wave.res" id="46_h1ix6"] +[ext_resource type="Animation" path="res://models/characters/anim/clips/u_yes.res" id="47_b4lj3"] [resource] _data = { -&"carry": ExtResource("1_243gn"), -&"climb_1m": ExtResource("2_7t3d5"), -&"crouch_idle": ExtResource("3_o8wtc"), -&"crouch_walk": ExtResource("4_tcrrw"), -&"door_try": ExtResource("5_k6s6h"), -&"drive": ExtResource("6_6w0lv"), -&"fall": ExtResource("7_n02qs"), -&"fold_arms": ExtResource("8_uy1kx"), -&"idle": ExtResource("9_lkaxu"), -&"idle_look": ExtResource("10_esux0"), -&"idle_wait": ExtResource("11_y7ji3"), -&"interact": ExtResource("12_43fde"), -&"jump_start": ExtResource("13_ushye"), -&"knock": ExtResource("14_sbsa7"), -&"knockback": ExtResource("15_v2hsc"), -&"land": ExtResource("16_qbvid"), -&"no": ExtResource("17_ntea5"), -&"phone": ExtResource("18_1i0d3"), -&"phone_idle": ExtResource("19_1dws4"), -&"pickup": ExtResource("20_xmpiw"), -&"push": ExtResource("21_nm53i"), -&"run": ExtResource("22_wpbby"), -&"run_fast": ExtResource("23_suwkb"), -&"run_start": ExtResource("24_r55r1"), -&"run_stop": ExtResource("25_x7rlq"), -&"sit": ExtResource("26_yusn5"), -&"sit_down": ExtResource("27_7cf1d"), -&"stand_up": ExtResource("28_fcdh8"), -&"talk": ExtResource("29_aq7rn"), -&"talk_2": ExtResource("30_bcdpu"), -&"turn_l180_walk": ExtResource("31_xyssm"), -&"turn_l90": ExtResource("32_ei77u"), -&"turn_r180_walk": ExtResource("33_c4lhw"), -&"turn_r90": ExtResource("34_cua2r"), -&"walk": ExtResource("35_nu5s3"), -&"walk_fast": ExtResource("36_mpraf"), -&"walk_start": ExtResource("37_cl64j"), -&"walk_stop": ExtResource("38_fb22m"), -&"wave": ExtResource("39_e2evx"), -&"yes": ExtResource("40_mvrfi") +&"carry": ExtResource("1_uy1kx"), +&"climb_1m": ExtResource("2_lkaxu"), +&"crouch_idle": ExtResource("3_esux0"), +&"crouch_walk": ExtResource("4_y7ji3"), +&"dance": ExtResource("5_43fde"), +&"door_try": ExtResource("6_ushye"), +&"drive": ExtResource("7_sbsa7"), +&"fall": ExtResource("8_v2hsc"), +&"fold_arms": ExtResource("9_qbvid"), +&"get_up": ExtResource("10_ntea5"), +&"hit_chest": ExtResource("11_1i0d3"), +&"hit_head": ExtResource("12_1dws4"), +&"idle": ExtResource("13_xmpiw"), +&"idle_look": ExtResource("14_nm53i"), +&"idle_wait": ExtResource("15_wpbby"), +&"interact": ExtResource("16_suwkb"), +&"jump_start": ExtResource("17_r55r1"), +&"knock": ExtResource("18_x7rlq"), +&"knockback": ExtResource("19_yusn5"), +&"land": ExtResource("20_7cf1d"), +&"no": ExtResource("21_fcdh8"), +&"phone": ExtResource("22_aq7rn"), +&"phone_idle": ExtResource("23_bcdpu"), +&"pickup": ExtResource("24_xyssm"), +&"punch_cross": ExtResource("25_ei77u"), +&"punch_jab": ExtResource("26_c4lhw"), +&"push": ExtResource("27_cua2r"), +&"run": ExtResource("28_nu5s3"), +&"run_fast": ExtResource("29_mpraf"), +&"run_start": ExtResource("30_cl64j"), +&"run_stop": ExtResource("31_fb22m"), +&"shout": ExtResource("32_e2evx"), +&"sit": ExtResource("33_mvrfi"), +&"sit_down": ExtResource("34_u21yj"), +&"stand_up": ExtResource("35_kai0f"), +&"talk": ExtResource("36_ovmll"), +&"talk_2": ExtResource("37_t3qh7"), +&"turn_l180_walk": ExtResource("38_1vos1"), +&"turn_l90": ExtResource("39_88sfj"), +&"turn_r180_walk": ExtResource("40_s4bho"), +&"turn_r90": ExtResource("41_8kpy6"), +&"walk": ExtResource("42_vqnir"), +&"walk_fast": ExtResource("43_k1d8b"), +&"walk_start": ExtResource("44_k5b1w"), +&"walk_stop": ExtResource("45_fxm67"), +&"wave": ExtResource("46_h1ix6"), +&"yes": ExtResource("47_b4lj3") } diff --git a/models/characters/anim/ual.glb b/models/characters/anim/ual.glb index f16f82b6..c3b42ba2 100644 Binary files a/models/characters/anim/ual.glb and b/models/characters/anim/ual.glb differ diff --git a/project.godot b/project.godot index 4c2e2eca..2268c3c0 100644 --- a/project.godot +++ b/project.godot @@ -270,6 +270,43 @@ ai_feed={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null) ] } +act_strike={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null) +] +} +act_aim={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":4,"axis_value":1.0,"script":null) +] +} +emote_wave={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null) +] +} +emote_yes={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":50,"key_label":0,"unicode":50,"location":0,"echo":false,"script":null) +] +} +emote_no={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":51,"key_label":0,"unicode":51,"location":0,"echo":false,"script":null) +] +} +emote_shout={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":52,"key_label":0,"unicode":52,"location":0,"echo":false,"script":null) +] +} +emote_dance={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":53,"key_label":0,"unicode":53,"location":0,"echo":false,"script":null) +] +} [rendering] diff --git a/scripts/fp/actions/actions_feature.gd b/scripts/fp/actions/actions_feature.gd new file mode 100644 index 00000000..71f7bce6 --- /dev/null +++ b/scripts/fp/actions/actions_feature.gd @@ -0,0 +1,443 @@ +extends FpFeature +## Body actions (integration slice; docs/fp/ARCHITECTURE.md, Body actions): the hero's fists and +## gestures on foot. The strike button (left mouse, right trigger) shoves whoever stands in front; +## with the guard up (right mouse or left trigger held: fists up, the body squared to where the +## camera looks, the camera in closer) it throws a jab and a cross in turn instead. A blow lands +## when the rig's fist is furthest out (HeroRig.strike_time) on the person nearest in front within +## REACH, through the traffic feature's hit_walker: they reel or go down, the people around react +## and the city hears of it. Keys 1-5 wave, nod, shake the head, shout and dance, and the people +## around answer (traffic answer_emote). The bus hears every swing and emote (hero_action). + +const WS := preload("res://scripts/fp/world_scale.gd") + +## Swings: [rig clip, hit_walker cause, strength 0..1, seconds until the next swing may start, +## seconds the clip plays, contact time (s) when the rig cannot measure it]. +const SWINGS := { + "jab": ["punch_jab", "punch", 0.45, 0.4, 0.55, 0.2], + "cross": ["punch_cross", "punch", 0.6, 0.5, 0.65, 0.29], + "shove": ["push", "shove", 0.35, 0.6, 0.55, 0.15], +} +## Emote input actions: [emote, seconds it plays (0 for the clip's own length)]. +const EMOTES := { + "emote_wave": ["wave", 0.0], + "emote_yes": ["yes", 0.0], + "emote_no": ["no", 2.0], + "emote_shout": ["shout", 2.0], + "emote_dance": ["dance", 8.0], +} +const INPUTS: Array[String] = [ + "act_strike", "act_aim", "emote_wave", "emote_yes", "emote_no", "emote_shout", "emote_dance" +] +## Who a swing can reach: on their feet within REACH (feet to feet) inside the cone ahead (the +## cosine); at contact still within REACH * HOLD_REACH. +const REACH := 1.2 * WS.M +const CONE := 0.4 +const HOLD_REACH := 1.3 +## Running adds up to this much strength (at WS.RUN). +const RUN_BONUS := 0.3 +## A press this close to the end of a swing queues the next one (s); punches alternate jab and +## cross within COMBO_S of the last. +const BUFFER_S := 0.2 +const COMBO_S := 1.0 +const EMOTE_GAP := 1.0 +## The body faces whoever a swing went for at least this long (s). +const FACE_S := 0.35 +## The view kick when a blow lands (pitch, roll; radians) and the camera arm while the guard is +## up (TpsRig.arm_scale). +const KICK := Vector2(-0.015, 0.025) +const GUARD_ARM := 0.8 +## Photo tour: frames to wait for the tour's teleport, how far round the hero to look for someone +## to square up to, how far in front of them the hero stands, and how far to their left (so the +## camera over the right shoulder sees them past the hero). +const PHOTO_FRAMES := 8 +const PHOTO_R := 30.0 * WS.M +const PHOTO_GAP := 0.85 * WS.M +const PHOTO_SIDE := 0.55 * WS.M + +## The guard is up. +var guarding: bool = false +## The swing under way ("jab", "cross", "shove") or "". +var swing: String = "" +## hit_walker's info for the last blow that landed; swings and blows landed so far; how many +## people answered the last emote. +var last_hit: Dictionary = {} +var swings: int = 0 +var hits: int = 0 +var answered: int = 0 +var _forced_guard: bool = false +var _driving: bool = false +## Input action -> its trigger is past the dead zone (_pressed). +var _pad_held: Dictionary = {} +var _t: float = 0.0 +var _len: float = 0.0 +var _hit_at: float = 0.0 +var _landed: bool = true +var _target: int = -1 +var _queued: bool = false +var _combo_t: float = INF +var _next_punch: String = "jab" +var _emote_gap: float = 0.0 +var _face_t: float = 0.0 +var _face_yaw: float = NAN + + +func feature_id() -> String: + return "actions" + + +func setup(p_ctx: FpContext) -> void: + super.setup(p_ctx) + if ctx.bus != null: + link_signal(ctx.bus.drive_changed, _on_drive_changed) + + +func on_world_rebuilt() -> void: + cancel() + + +func from_dict(_d: Dictionary) -> void: + cancel() + + +func teardown() -> void: + cancel() + unlink_signals() + + +func self_test() -> Dictionary: + var missing: Array[String] = [] + for a: String in INPUTS: + if not InputMap.has_action(a): + missing.append(a) + if not missing.is_empty(): + return {"ok": false, "detail": "missing inputs %s" % str(missing)} + return {"ok": true, "detail": "%d swings, %d blows landed" % [swings, hits]} + + +## Whether the hero may swing, guard or emote now: on foot and in control of the body, nothing +## modal open, not talking, not in photo mode, the phone put away. +func can_act() -> bool: + var p := ctx.player if ctx != null else null + if p == null or _driving or p.photo_mode or p.view_override or p.in_conversation(): + return false + if ctx.is_modal(): + return false + var phone := ctx.feature("phone") + return phone == null or not bool(phone.get("open")) + + +## Starts a swing: a shove, or with the guard up the next punch of the combo. A press near the end +## of a swing queues the next. False when none starts now. +func strike() -> bool: + if not can_act(): + return false + if not swing.is_empty(): + _queued = _queued or _len - _t <= BUFFER_S + return false + var kind := "shove" + if guarding: + kind = _next_punch if _combo_t < COMBO_S else "jab" + _next_punch = "cross" if kind == "jab" else "jab" + _combo_t = 0.0 + var s: Array = SWINGS[kind] + swing = kind + _t = 0.0 + _len = float(s[3]) + _hit_at = float(s[5]) + _landed = false + var rig := _rig() + if rig != null: + rig.call("stop_action") + rig.call("play_action", str(s[0]), float(s[4])) + if kind != "shove": + _hit_at = minf(float(rig.call("strike_time", str(s[0]))), _len) + var feet := ctx.player.position + var who := _find_target(_aim_dir()) + _target = int(who.get("id", -1)) + if not who.is_empty(): + var d: Vector3 = (who["pos"] as Vector3) - feet + _face_yaw = atan2(-d.x, -d.z) + _face_t = maxf(_len, FACE_S) + swings += 1 + if ctx.bus != null: + ctx.bus.hero_action.emit(kind, feet) + return true + + +## Raises or drops the guard for scripted play (tests, the photo tour); held input does the same. +func set_guard(on: bool) -> void: + _forced_guard = on + + +## Plays an emote ("wave", "yes", "no", "shout", "dance") and lets the people around answer it. +## False when the hero cannot act now, swings or guards, the last emote was just now, or the +## dance is asked for on the move. +func emote(e: String) -> bool: + if not can_act() or not swing.is_empty() or guarding or _emote_gap > 0.0: + return false + var secs := -1.0 + for a: String in EMOTES: + if str(EMOTES[a][0]) == e: + secs = float(EMOTES[a][1]) + if secs < 0.0: + return false + var rig := _rig() + var av := ctx.player.avatar + if e == "wave": + if av != null and av.has_method("play_gesture"): + av.call("play_gesture", "wave") + elif rig != null and not bool(rig.call("play_action", e, secs)): + return false + _emote_gap = EMOTE_GAP + var feet := ctx.player.position + var tr := ctx.feature("traffic") + answered = int(tr.call("answer_emote", feet, _aim_dir(), e)) if tr != null else 0 + if ctx.bus != null: + ctx.bus.hero_action.emit(e, feet) + return true + + +## Ends the swing, the guard and any action on the rig, and gives the body and camera back. +func cancel() -> void: + swing = "" + _queued = false + _landed = true + _target = -1 + _forced_guard = false + _face_t = 0.0 + if guarding or _rig() != null: + _set_guarding(false) + var rig := _rig() + if rig != null and bool(rig.call("action_active")): + rig.call("stop_action") + _hand_back() + + +func _process(delta: float) -> void: + if ctx == null or ctx.player == null: + return + tick(minf(delta, 0.1)) + + +## One step: held input (or set_guard) raises the guard, the swing lands its blow, and the body +## faces the target or the aim. +func tick(dt: float) -> void: + var held := _input_live() and Input.is_action_pressed("act_aim") + var want := (_forced_guard or held) and can_act() + # Every frame: a teleport snaps the rig's guard down, and the body may change. + _set_guarding(want) + _advance(dt) + _face(dt) + + +## The strike and emote presses come as events (S0 §7.2), taken while the hero can act; a press +## the hero cannot act on passes on to the rest of the game. +func _unhandled_input(event: InputEvent) -> void: + if ctx == null or ctx.player == null: + return + var press := "" + for a: String in INPUTS: + if a != "act_aim" and _pressed(event, a): + press = a + if press.is_empty() or not _input_live() or not can_act(): + return + if press == "act_strike": + strike() + else: + emote(str(EMOTES[press][0])) + if is_inside_tree(): + get_viewport().set_input_as_handled() + + +## Whether event presses action: a key or button going down (not an echo), or a trigger crossing +## its dead zone. Every motion event past the dead zone reads as pressed, so _pad_held keeps +## the trigger's state between events. +func _pressed(event: InputEvent, action: String) -> bool: + if not event.is_action(action): + return false + var down := event.is_action_pressed(action) + if event is InputEventJoypadMotion: + var was := bool(_pad_held.get(action, false)) + _pad_held[action] = down + return down and not was + return down and not event.is_echo() + + +## Player input drives the actions while it is enabled and, in a window, the mouse is captured. +## The click that captures it reaches this feature before FpMode (children first) and throws no +## punch. +func _input_live() -> bool: + var mouse := ( + DisplayServer.get_name() == "headless" or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED + ) + return ctx.player.input_enabled and mouse + + +func _advance(dt: float) -> void: + _combo_t += dt + _emote_gap = maxf(0.0, _emote_gap - dt) + if swing.is_empty(): + return + if not can_act(): + cancel() + return + _t += dt + if not _landed and _t >= _hit_at: + _land() + if _t >= _len: + swing = "" + if _queued: + _queued = false + strike() + + +## The blow lands on the swing's target if they are still in reach and on their feet. +func _land() -> void: + _landed = true + var tr := ctx.feature("traffic") + if tr == null or _target < 0: + return + var feet := ctx.player.position + var who := {} + for w: Dictionary in tr.call("walkers_near", feet, REACH * HOLD_REACH): + if int(w["id"]) == _target: + who = w + if who.is_empty() or bool(who["down"]): + return + var d: Vector3 = (who["pos"] as Vector3) - feet + d.y = 0.0 + var s: Array = SWINGS[swing] + var strength := float(s[2]) + RUN_BONUS * clampf(ctx.player.speed() / WS.RUN, 0.0, 1.0) + var info: Dictionary = tr.call("hit_walker", _target, feet, d.normalized(), strength, str(s[1])) + if info.is_empty(): + return + last_hit = info + hits += 1 + var side := {"jab": 1.0, "cross": -1.0}.get(swing, 0.0) as float + ctx.player.kick_view(KICK.x, KICK.y * side) + + +## Third person: the body squares up to the camera while guarding and faces whoever a swing went +## for; the camera closes in on the guard. +func _face(dt: float) -> void: + var p := ctx.player + _face_t = maxf(0.0, _face_t - dt) + if _face_t > 0.0 and not is_nan(_face_yaw): + p.aim_yaw = _face_yaw + elif guarding: + p.aim_yaw = p.cam_yaw + else: + p.aim_yaw = NAN + if p.rig != null: + p.rig.arm_scale = GUARD_ARM if guarding else 1.0 + + +func _set_guarding(on: bool) -> void: + guarding = on + var rig := _rig() + if rig != null: + rig.call("set_guard", on) + + +func _hand_back() -> void: + var p := ctx.player if ctx != null else null + if p == null: + return + p.aim_yaw = NAN + if p.rig != null: + p.rig.arm_scale = 1.0 + + +## The person on their feet nearest the hero within REACH inside the cone around dir (walkers_near's +## entry), {} for none. +func _find_target(dir: Vector3) -> Dictionary: + var tr := ctx.feature("traffic") + if tr == null: + return {} + var feet := ctx.player.position + var best := {} + var bd := INF + for w: Dictionary in tr.call("walkers_near", feet, REACH): + var d: Vector3 = (w["pos"] as Vector3) - feet + d.y = 0.0 + var dist := d.length() + var ahead := dist < 1e-5 or d.dot(dir) / dist >= CONE + if ahead and not bool(w["down"]) and dist < bd: + bd = dist + best = w + return best + + +## Where a swing looks for its target and an emote is aimed: along the camera, level. +func _aim_dir() -> Vector3: + var y := ctx.player.cam_yaw + return Vector3(-sin(y), 0.0, -cos(y)) + + +## The hero's animated rig (HeroRig), or null for the placeholder body. +func _rig() -> RefCounted: + var av := ctx.player.avatar if ctx != null and ctx.player != null else null + if av == null or not av.has_method("is_animated") or not bool(av.call("is_animated")): + return null + var r: Variant = av.get("rig") + if r is RefCounted and (r as RefCounted).has_method("play_action"): + return r + return null + + +func _on_drive_changed(active: bool) -> void: + _driving = active + if active: + cancel() + + +# --- Photo tour + + +func photo_shots() -> Array: + if ctx.feature("traffic") == null or ctx.mode == null: + return [] + var sp: Dictionary = ctx.mode.spawn_point() + var shot := {"name": "actions_guard", "pos": sp["pos"], "yaw": float(sp["yaw"]), "pitch": -6.0} + shot.merge({"hour": 10.5, "prepare": "photo_prepare", "restore": "photo_restore"}) + return [shot] + + +## Third person; once the tour has put the hero down, it squares up to the nearest person, who +## reels from a blow. +func photo_prepare() -> void: + ctx.mode.apply_settings({"camera_mode": "third"}, false) + ctx.player.set_camera_mode("third", false) + _photo_stage.call_deferred() + + +func photo_restore() -> void: + cancel() + ctx.mode.apply_settings({"camera_mode": "first"}, false) + ctx.player.set_camera_mode("first", false) + + +func _photo_stage() -> void: + for _k in range(PHOTO_FRAMES): + await get_tree().process_frame + var tr := ctx.feature("traffic") + if tr == null: + return + var p := ctx.player + var best := {} + var bd := INF + for w: Dictionary in tr.call("walkers_near", p.position, PHOTO_R): + var dist := (w["pos"] as Vector3).distance_to(p.position) + if not bool(w["down"]) and dist < bd: + bd = dist + best = w + set_guard(true) + if best.is_empty(): + return + var wy := float(best["yaw"]) + var facing := Vector3(sin(wy), 0.0, cos(wy)) + # The hero faces them (its yaw wy looks along -facing), with them to its right. + var right := Vector3(cos(wy), 0.0, -sin(wy)) + var at := (best["pos"] as Vector3) + facing * PHOTO_GAP - right * PHOTO_SIDE + p.teleport(at, rad_to_deg(wy), -6.0) + tr.call("pose_walker", int(best["id"]), &"hit_head", 0.2) diff --git a/scripts/fp/actions/actions_feature.gd.uid b/scripts/fp/actions/actions_feature.gd.uid new file mode 100644 index 00000000..42921a73 --- /dev/null +++ b/scripts/fp/actions/actions_feature.gd.uid @@ -0,0 +1 @@ +uid://8h7mi5we7b88 diff --git a/scripts/fp/audio/audio_bank.gd b/scripts/fp/audio/audio_bank.gd index 6802a23b..5d66e954 100644 --- a/scripts/fp/audio/audio_bank.gd +++ b/scripts/fp/audio/audio_bank.gd @@ -21,13 +21,14 @@ const LOOPS: Array[String] = [ ] const ONE_SHOTS: Array[String] = [ "horn_short", "horn_long", "chat_blip", "chat_blip_soft", "alert_stinger", "crash_heavy", - "crash_light", "prop_clatter", "prop_clang", "car_door_open", "car_door_close", + "crash_light", "prop_clatter", "prop_clang", "car_door_open", "car_door_close", "punch_hit", + "punch_whoosh", "body_fall", ] ## Stored as WAV (with every footstep_*); everything else is .ogg. const WAV_SOUNDS: Array[String] = [ "engine_idle", "engine_run", "siren_two_tone", "horn_short", "horn_long", "chat_blip", "chat_blip_soft", "alert_stinger", "crash_heavy", "crash_light", "prop_clatter", "prop_clang", - "car_door_open", "car_door_close", + "car_door_open", "car_door_close", "punch_hit", "punch_whoosh", "body_fall", ] ## Bus names (created by audio_feature.setup when missing). @@ -74,6 +75,16 @@ const KNOCK_DEFAULT := "prop_clang" const KNOCK_GAP := 0.1 ## The player's car door (car_door): the latch letting go plays this far under the slam. const DOOR_OPEN_DB := -7.0 +## People hit in the street (person_hit): the blow's thump is at full level from HIT_FULL strength +## (0..1), a car's lower and HIT_CAR_DB louder; someone knocked down lands BODY_FALL_S later +## (body_fall, BODY_FALL_DB on the thump's level). The hero's swing (hero_action) whooshes +## WHOOSH_DB under the thump, a shove SHOVE_DB lower still. +const HIT_FULL := 0.6 +const HIT_CAR_DB := 4.0 +const BODY_FALL_S := 0.45 +const BODY_FALL_DB := 2.0 +const WHOOSH_DB := -8.0 +const SHOVE_DB := -6.0 static var _cache: Dictionary = {} static var _legacy_loop: AudioStream = null diff --git a/scripts/fp/audio/audio_feature.gd b/scripts/fp/audio/audio_feature.gd index c98cb73e..caaa0ede 100644 --- a/scripts/fp/audio/audio_feature.gd +++ b/scripts/fp/audio/audio_feature.gd @@ -2,7 +2,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, -## the player's car crashing, the street props it knocks over and its doors), +## the player's car crashing, the street props it knocks over and its doors, people hit in the +## street and the hero's swings), ## 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. @@ -54,6 +55,7 @@ var _duck_target: float = 0.0 var _sfx_db: float = 0.0 var _talking: Dictionary = {} var _last_knock: float = -INF +var _falls: Array = [] # [due on _now, feet] of people knocked down var _rng := RandomNumberGenerator.new() var _links: Array = [] # [Signal, Callable] var _muffle: Tween @@ -102,6 +104,8 @@ func setup(p_ctx: FpContext) -> void: _link(bus.car_impact, _on_car_impact) _link(bus.prop_knocked, _on_prop_knocked) _link(bus.car_door, _on_car_door) + _link(bus.person_hit, _on_person_hit) + _link(bus.hero_action, _on_hero_action) if ctx.sim != null: _link(ctx.sim.event_started, emitters.on_event) _link(ctx.sim.event_ended, emitters.on_event_ended) @@ -129,6 +133,7 @@ func to_dict() -> Dictionary: ## Volumes live in the settings; a load re-registers the world's sources (initial scan). func from_dict(_d: Dictionary) -> void: emitters.full_scan() + _falls.clear() _talking.clear() _duck_target = 0.0 _player_seq = -1 @@ -147,6 +152,7 @@ func teardown() -> void: _muffle = null if ctx != null and ctx.audio != null and is_instance_valid(ctx.audio): ctx.audio.set_fp(false) + _falls.clear() if emitters != null: emitters.clear() if ambience != null: @@ -193,6 +199,9 @@ func _process(delta: float) -> void: func step(dt: float) -> void: _now += dt emitters.night = ambience.night + while not _falls.is_empty() and float(_falls[0][0]) <= _now: + var fall: Array = _falls.pop_front() + emitters.shot("hit", fall[1], "body_fall", BANK.BODY_FALL_DB, _rng.randf_range(0.92, 1.04)) emitters.step(dt) ambience.step(dt) footsteps.poll(dt) @@ -349,6 +358,32 @@ static func _impact_db(k: float) -> float: return linear_to_db(clampf(k, BANK.IMPACT_MIN_GAIN, 1.0)) +## Someone hit in the street (person_hit): the blow's thump at their chest by its strength (a +## shove's duller, a car's lower and louder); someone knocked down lands BANK.BODY_FALL_S later. +func _on_person_hit(at: Vector3, cause: String, info: Dictionary) -> void: + var car := cause == "car" + var trim := _impact_db(float(info.get("strength", 0.5)) / BANK.HIT_FULL) + var pitch := _rng.randf_range(0.92, 1.08) * (0.85 if cause == "shove" else 1.0) + if car: + trim += BANK.HIT_CAR_DB + pitch = _rng.randf_range(0.72, 0.8) + emitters.shot("hit", at + Vector3(0.0, 1.3 * WS.M, 0.0), "punch_hit", trim, pitch) + if bool(info.get("down", false)): + _falls.append([_now + BANK.BODY_FALL_S, at]) + + +## The hero's swing (hero_action): a whoosh at their shoulder, a jab's higher, a shove's lower and +## softer. Emotes make no sound here. +func _on_hero_action(action: String, at: Vector3) -> void: + if action not in ["jab", "cross", "shove"]: + return + var shove := action == "shove" + var trim := BANK.WHOOSH_DB + (BANK.SHOVE_DB if shove else 0.0) + var pitch := 0.8 if shove else (1.1 if action == "jab" else 0.95) + var p := at + Vector3(0.0, 1.4 * WS.M, 0.0) + emitters.shot("hit", p, "punch_whoosh", trim, pitch * _rng.randf_range(0.96, 1.04)) + + 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 7a90ab42..532f4f9c 100644 --- a/scripts/fp/audio/emitters.gd +++ b/scripts/fp/audio/emitters.gd @@ -1,7 +1,7 @@ extends Node3D ## Positional city sound (slice I): logical sources (engines, horns, sirens, construction, fire, -## fountains, power plants, river banks, passers-by's footsteps, crashes, knocked props and car -## doors) share a pool of at most MAX_PLAYERS AudioStreamPlayer3D. +## fountains, power plants, river banks, passers-by's footsteps, crashes, knocked props, car +## doors, blows and swings) 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). @@ -30,6 +30,7 @@ const TYPES := { "crash": {"max": 10.0, "db": -6.0, "near": 6.0, "stream": "crash_heavy"}, "knock": {"max": 4.0, "db": -10.0, "near": 6.0, "stream": "prop_clang"}, "door": {"max": 3.0, "db": -12.0, "near": 6.0, "stream": "car_door_close"}, + "hit": {"max": 1.5, "db": -14.0, "near": 6.0, "stream": "punch_hit"}, } ## Residents on foot (E's "pedestrian" agents): positional footsteps for the WALKER_MAX nearest ## walkers within WALKER_RADIUS of the hero, one per stride of their travel (E has no foot-contact diff --git a/scripts/fp/chat/chat_feature.gd b/scripts/fp/chat/chat_feature.gd index af64feef..2e692424 100644 --- a/scripts/fp/chat/chat_feature.gd +++ b/scripts/fp/chat/chat_feature.gd @@ -1,17 +1,19 @@ extends FpFeature ## Global chat and face-to-face conversations (slice C). Normalises every city source (residents, -## mayors, the Gazette, the council, incidents, openings, arrivals, deals) into the ChatFeed drip -## queue, runs ChatterMinds rounds about real events once a minute, answers the player's chat -## lines, and opens the talk dialogue on talk_requested. Owns the chat panel (chat_slot) and the -## talk card (overlay). Budgets (D12) are enforced here on the feed clock: one round per 60 s -## unpaused, one player-reply call per coalesced burst (6 per minute), one talk call per line and -## one inspect_tile per 20 s. While the player is being answered the drip holds everyday lines and -## the panel shows who is typing; a status card says when live replies fail or come back. +## mayors, the Gazette, the council, incidents, openings, arrivals, deals, people hit in the +## street) into the ChatFeed drip queue, runs ChatterMinds rounds about real events once a +## minute, answers the player's chat lines, and opens the talk dialogue on talk_requested. Owns +## the chat panel (chat_slot) and the talk card (overlay). Budgets (D12) are enforced here on the +## feed clock: one round per 60 s unpaused, one player-reply call per coalesced burst (6 per +## minute), one talk call per line and one inspect_tile per 20 s. While the player is being +## answered the drip holds everyday lines and the panel shows who is typing; a status card says +## when live replies fail or come back. const D := preload("res://scripts/sim/sim_defs.gd") const T := preload("res://scripts/fp/chat/chat_text.gd") const PANEL := preload("res://scripts/fp/ui/chat_panel.gd") const TALK := preload("res://scripts/fp/chat/talk_dialog.gd") +const STREET := preload("res://scripts/fp/chat/street_lines.gd") const ROUND_MS := 60000 const PLAYER_DEBOUNCE_MS := 3000 @@ -56,6 +58,7 @@ var feed: ChatFeed var minds: ChatterMinds var panel: PANEL var talk: TALK +var street_lines: STREET ## The pinned Gazette: {"headline", "editorial", "stories": [{"title", "body"}], "day"} or {}. var pinned: Dictionary = {} ## Chatter topics, newest first. @@ -90,6 +93,8 @@ func setup(p_ctx: FpContext) -> void: feed.name = "Feed" add_child(feed) feed.setup(ctx) + street_lines = STREET.new() + street_lines.setup(ctx, feed) minds = ChatterMinds.new() minds.name = "ChatterMinds" add_child(minds) @@ -131,6 +136,7 @@ func setup(p_ctx: FpContext) -> void: _link(ctx.bus.player_tile_changed, _on_player_tile_changed) _link(ctx.bus.pause_changed, _on_pause_changed) _link(ctx.bus.world_ready, _on_world_ready) + _link(ctx.bus.person_hit, street_lines.on_person_hit) _snapshot_taxes() diff --git a/scripts/fp/chat/street_lines.gd b/scripts/fp/chat/street_lines.gd new file mode 100644 index 00000000..439ea4dd --- /dev/null +++ b/scripts/fp/chat/street_lines.gd @@ -0,0 +1,158 @@ +extends RefCounted +## What the chat hears when the visitor hits someone in the street (game bus person_hit; see +## docs/fp/ARCHITECTURE.md, Body actions): the one who was hit says so (a shove, a punch, being +## knocked down, a car) and one of the residents who saw it adds a line, and a crew called to +## the scene (incidents.gd: police after an assault, an ambulance for someone a car knocked down) +## posts a City Alert card. One victim-and-witness pair per GAP_MS on the feed clock, so a flurry +## of jabs reads as one scene; a harder blow inside the gap still posts, and so does every crew. + +const D := preload("res://scripts/sim/sim_defs.gd") +const T := preload("res://scripts/fp/chat/chat_text.gd") +const GAP_MS := 8000 +const SEVERITY := {"shove": 0, "punch": 1, "car": 2, "down": 3, "car_down": 3} +## What the one who was hit says ({street}: where). +const VICTIM := { + "shove": + [ + "Some visitor just shoved me on {street}. Who does that?", + "Got pushed out of the way on {street}. Manners, people.", + "Did that visitor just shove me? On {street}, in broad daylight.", + ], + "punch": + [ + "A visitor just punched me on {street}! What is wrong with people?", + "Got hit in the face on {street}. By a visitor. Unbelievable.", + "Someone on {street} just swung at me. I'm okay. I think.", + ], + "down": + [ + "Knocked flat on {street} by some visitor. Call the police!", + "I'm on the ground on {street}. A visitor just floored me.", + "Just got knocked down on {street}. My head's ringing.", + ], + "car": + [ + "A car just clipped me on {street}! Watch where you're driving!", + "Nearly got run over on {street}. Still shaking.", + "Some visitor drove right into me on {street}.", + ], + "car_down": + [ + "Hit by a car on {street} and knocked flat. Somebody call an ambulance.", + "A visitor's car sent me flying on {street}.", + "Got run down on {street}. Everything hurts.", + ], +} +## What a resident who saw it says ({who}: the one hit, by first name, or "someone"). +const WITNESS := { + "shove": + [ + "Did a visitor just shove {who} on {street}?", + "Some visitor's pushing people around on {street}.", + "Saw a visitor shove {who} on {street}. Not cool.", + ], + "punch": + [ + "A visitor just punched {who} on {street}! Somebody stop them.", + "Fight on {street}. Some visitor hit {who}.", + "Stay away from {street}, there's a visitor throwing punches.", + ], + "down": + [ + "A visitor just knocked {who} flat on {street}. Call the police!", + "Someone's down on {street}. That visitor floored them.", + "Is {who} okay? A visitor knocked them down on {street}.", + ], + "car": + [ + "A car just hit {who} on {street}!", + "Did you see that? A visitor drove into {who} on {street}.", + "Slow down on {street}! A car just clipped someone.", + ], + "car_down": + [ + "A car just ran {who} down on {street}. Call an ambulance!", + "Someone got hit by a car on {street}. They're on the ground.", + "That visitor's car sent {who} flying on {street}!", + ], +} +const CREW := { + "police": "Police called to {street} after a visitor attacked people", + "ambulance": "Ambulance called to {street}: a car hit someone", +} + +var ctx: FpContext +var feed: ChatFeed +var _last_ms: int = -GAP_MS +var _last_sev: int = -1 +var _rng := RandomNumberGenerator.new() + + +func setup(p_ctx: FpContext, p_feed: ChatFeed) -> void: + ctx = p_ctx + feed = p_feed + _last_ms = feed.now_ms - GAP_MS + _last_sev = -1 + _rng.seed = (ctx.sim.city_seed if ctx.sim != null else 7) * 4813 + 17 + + +## person_hit (game bus): queues the victim's line and a witness's, and a crew's City Alert card. +func on_person_hit(at: Vector3, cause: String, info: Dictionary) -> void: + var down := bool(info.get("down", false)) + var key := ("car_down" if down else "car") if cause == "car" else ("down" if down else cause) + if not VICTIM.has(key): + return + var street := str(info.get("street", "")) + var where := street if not street.is_empty() else "the street" + var sev := int(SEVERITY[key]) + if feed.now_ms - _last_ms >= GAP_MS or sev > _last_sev: + _last_ms = feed.now_ms + _last_sev = sev + _say(info, key, where) + var crew := str(info.get("crew", "")) + if not CREW.has(crew): + return + var refs := {"type": "street", "crew": crew} + var tile := _tile(at) + if tile >= 0: + refs["tile"] = tile + var d := ctx.sim.district[tile] if tile >= 0 else -1 + var text := str(CREW[crew]).format({"street": where}) + feed.enqueue(feed.make("system", "City Alert", "system", d, text, "incident", -1, refs)) + + +func _say(info: Dictionary, key: String, where: String) -> void: + var r := _resident(str(info.get("resident_id", ""))) + var who := T.first_name(str(r["name"])) if not r.is_empty() else "someone" + if not r.is_empty(): + var said := _pick(VICTIM[key]).format({"street": where}) + feed.enqueue(_msg(r, said), {"exempt": true}) + for rid: Variant in info.get("witnesses", []): + var s := _resident(str(rid)) + if s.is_empty() or (not r.is_empty() and str(s["id"]) == str(r["id"])): + continue + var saw := _pick(WITNESS[key]).format({"street": where, "who": who}) + feed.enqueue(_msg(s, saw), {"exempt": true}) + return + + +func _msg(r: Dictionary, text: String) -> Dictionary: + var d := ctx.sim.district[int(r["home"])] + var refs := {"job": str(r.get("job_title", ""))} + return feed.make(str(r["id"]), str(r["name"]), "resident", d, text, "react", -1, refs) + + +func _resident(rid: String) -> Dictionary: + if rid.is_empty() or ctx.residents == null: + return {} + return ctx.residents.find(rid) + + +func _pick(lines: Array) -> String: + return str(lines[_rng.randi_range(0, lines.size() - 1)]) + + +static func _tile(at: Vector3) -> int: + var x := roundi(at.x) + var y := roundi(at.z) + return D.idx(x, y) if D.in_bounds(x, y) else -1 diff --git a/scripts/fp/chat/street_lines.gd.uid b/scripts/fp/chat/street_lines.gd.uid new file mode 100644 index 00000000..676575f8 --- /dev/null +++ b/scripts/fp/chat/street_lines.gd.uid @@ -0,0 +1 @@ +uid://cn6ql0l4favmu diff --git a/scripts/fp/drive/car_body.gd b/scripts/fp/drive/car_body.gd index 8a71a78f..07a868d1 100644 --- a/scripts/fp/drive/car_body.gd +++ b/scripts/fp/drive/car_body.gd @@ -3,6 +3,7 @@ extends RefCounted ## 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). +## People on foot are struck through traffic's hit_walker and barely slow the car. ## 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 @@ -33,13 +34,21 @@ const MAX_PUSH := 0.02 const MASS := 1.0 ## Hits closing faster than this are listed in hits. const IMPACT_SPEED := 1.5 * WS.M +## People: a body this wide (u) is struck when the car closes on them faster than PERSON_SPEED, +## each at most once per PERSON_COOL seconds, as hard as the closing speed over PERSON_FULL (1 +## knocks anyone down). They weigh PERSON_MASS of a sedan, so the car loses little speed. +const PERSON_R := 0.3 * WS.M +const PERSON_SPEED := 0.5 * WS.M +const PERSON_FULL := 12.0 * WS.M +const PERSON_COOL := 1.5 +const PERSON_MASS := 0.08 ## 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". +## what "wall" (anything fixed), "car", "prop" or "person". var hits: Array = [] var _c := Vector2.ZERO var _feet: float = 0.0 @@ -52,6 +61,10 @@ var _props: Object var _obs: Dictionary = {} var _cars: Array = [] var _knock: Array = [] +var _people: Array = [] +## Walker id -> _clock when the car last struck them. +var _struck: Dictionary = {} +var _clock: float = 0.0 func setup(half_w: float, length: float, p_height: float) -> void: @@ -74,6 +87,7 @@ func move( props: Object ) -> Vector3: hits.clear() + _clock += dt _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 @@ -92,11 +106,13 @@ func move( for _it in range(ITERATIONS): if not _resolve(model): break + _strike_people(model) _col = null _traffic = null _props = null _cars = [] _knock = [] + _people = [] return Vector3(_c.x, pos.y, _c.y) @@ -104,6 +120,12 @@ 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 [] + _people = [] + if _traffic != null and _traffic.has_method("walkers_near"): + _people = _traffic.call("walkers_near", pos, _reach + PERSON_R) + for id: int in _struck.keys(): + if _clock - float(_struck[id]) > PERSON_COOL: + _struck.erase(id) ## Resolves the deepest contact of the body as it stands; false when it touches nothing. @@ -231,6 +253,33 @@ func _knock_props(model: MODEL, u: Vector2, v: Vector2) -> bool: return false +## Strikes the people on their feet the body runs into (traffic's hit_walker, cause "car"), pushed +## away from the car, and takes the little impulse each gives back. +func _strike_people(model: MODEL) -> void: + if _people.is_empty(): + return + var u := OBB.side_axis(model.yaw) + var v := OBB.heading(model.yaw) + for w: Dictionary in _people: + var id := int(w["id"]) + if bool(w["down"]) or _struck.has(id): + continue + var q: Vector3 = w["pos"] + var hit := OBB.box_circle(_c, u, v, half, Vector2(q.x, q.z), PERSON_R) + if hit.is_empty(): + continue + var nrm: Vector2 = hit[0] + var p: Vector2 = hit[2] + var vn := (model.vel + _perp(p - _c) * _omega).dot(nrm) + if -vn < PERSON_SPEED: + continue + _struck[id] = _clock + var push := Vector3(-nrm.x, 0.0, -nrm.y) + var strength := clampf(-vn / PERSON_FULL, 0.0, 1.0) + _traffic.call("hit_walker", id, Vector3(p.x, _feet, p.y), push, strength, "car") + _impulse(model, nrm, p, Vector2.ZERO, Vector2.ZERO, 1.0 / PERSON_MASS, 0.0, "person") + + func _apply(model: MODEL, r: Vector2, j: Vector2) -> void: model.vel += j / MASS var dw := _cross(r, j) / inertia diff --git a/scripts/fp/drive/drive_feature.gd b/scripts/fp/drive/drive_feature.gd index 0dc2b129..ddfcf621 100644 --- a/scripts/fp/drive/drive_feature.gd +++ b/scripts/fp/drive/drive_feature.gd @@ -429,7 +429,9 @@ func _report() -> void: 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])): + # Props and people report themselves (prop_knocked, person_hit). + var own := str(h[2]) in ["prop", "person"] + if not own and (best.is_empty() or float(h[1]) > float(best[1])): best = h if best.is_empty(): return diff --git a/scripts/fp/fp_features.gd b/scripts/fp/fp_features.gd index aa5c8e3f..d8f4f5f2 100644 --- a/scripts/fp/fp_features.gd +++ b/scripts/fp/fp_features.gd @@ -20,4 +20,8 @@ const FEATURES := [ }, {"id": "crews", "slice": "integration", "path": "res://scripts/fp/crews/crews_feature.gd"}, {"id": "props", "slice": "integration", "path": "res://scripts/fp/props/props_feature.gd"}, + { + "id": "actions", "slice": "integration", + "path": "res://scripts/fp/actions/actions_feature.gd", + }, ] diff --git a/scripts/fp/game_bus.gd b/scripts/fp/game_bus.gd index 33a74322..9e63fd38 100644 --- a/scripts/fp/game_bus.gd +++ b/scripts/fp/game_bus.gd @@ -47,3 +47,10 @@ signal car_door(at: Vector3, open: bool) ## A street prop was knocked over at a point (its StreetDressing kind: "bin", "bench" ...) and ## flies off at speed (u/s). Emitted by the props feature. signal prop_knocked(at: Vector3, kind: String, speed: float) +## Someone on foot was hit at a point (their feet): cause "shove" or "punch" (the hero) or "car" +## (the car the player drives); info is traffic_feature.gd hit_walker()'s {"walker", +## "resident_id", "name", "down", "street", "strength", "witnesses", "crew"}. Emitted by traffic. +signal person_hit(at: Vector3, cause: String, info: Dictionary) +## The hero swung or gestured: action "jab", "cross", "shove" or an emote ("wave", "yes", "no", +## "shout", "dance"), at their feet. Emitted by the actions feature. +signal hero_action(action: String, at: Vector3) diff --git a/scripts/fp/player/hero_fk.gd b/scripts/fp/player/hero_fk.gd new file mode 100644 index 00000000..7346c804 --- /dev/null +++ b/scripts/fp/player/hero_fk.gd @@ -0,0 +1,70 @@ +extends RefCounted +## Forward kinematics over a body's own clip keys, without a tree or a scene: where the last bone +## of a chain sits at a phase of one clip or of two clips blended (rotations slerped, positions +## lerped). HeroRig measures heel contacts, standing heights and strike times with it. The root +## stays at rest (the mixer extracts its motion); position keys scale by the skeleton's +## motion_scale, as the mixer applies them. + +var skeleton: Skeleton3D +var _tracks: Dictionary = {} + + +func _init(sk: Skeleton3D) -> void: + skeleton = sk + + +## The bones from the root down to b. +func chain_to(b: int) -> Array[int]: + var chain: Array[int] = [] + var i := b + while i >= 0: + chain.push_front(i) + i = skeleton.get_bone_parent(i) + return chain + + +## The +Z of root (the body's scene root above the skeleton) in skeleton space. +func forward_of(root: Node3D) -> Vector3: + var b := Basis.IDENTITY + var n: Node = skeleton + while n != null and n != root: + if n is Node3D: + b = (n as Node3D).transform.basis * b + n = n.get_parent() + return (b.inverse() * Vector3.BACK).normalized() + + +## The position (skeleton metres) of the chain's last bone at phase ph of clip a0 blended toward +## a1 by w. +func end_pos(a0: Animation, a1: Animation, w: float, chain: Array, ph: float) -> Vector3: + var t0 := ph * a0.length + var t1 := ph * a1.length + var ms := skeleton.motion_scale + var xf := Transform3D.IDENTITY + for b: int in chain: + var rest := skeleton.get_bone_rest(b) + var p := rest.origin + var rot := rest.basis.get_rotation_quaternion() + if b != chain[0]: + var k0 := _track(a0, b) + var k1 := _track(a1, b) if w > 0.0 else k0 + var r0 := a0.rotation_track_interpolate(k0.x, t0) if k0.x >= 0 else rot + var r1 := a1.rotation_track_interpolate(k1.x, t1) if k1.x >= 0 else rot + rot = r0.slerp(r1, w) if w > 0.0 else r0 + var p0 := a0.position_track_interpolate(k0.y, t0) * ms if k0.y >= 0 else p + var p1 := a1.position_track_interpolate(k1.y, t1) * ms if k1.y >= 0 else p + p = p0.lerp(p1, w) + xf = xf * Transform3D(Basis(rot).scaled(rest.basis.get_scale()), p) + return xf.origin + + +## (rotation track, position track) of bone b in clip a; -1 where it has none. Cached per clip. +func _track(a: Animation, b: int) -> Vector2i: + var key := "%d|%d" % [a.get_instance_id(), b] + if _tracks.has(key): + return _tracks[key] + var path := NodePath("%GeneralSkeleton:" + skeleton.get_bone_name(b)) + var rt := a.find_track(path, Animation.TYPE_ROTATION_3D) + var r := Vector2i(rt, a.find_track(path, Animation.TYPE_POSITION_3D)) + _tracks[key] = r + return r diff --git a/scripts/fp/player/hero_fk.gd.uid b/scripts/fp/player/hero_fk.gd.uid new file mode 100644 index 00000000..69c409a1 --- /dev/null +++ b/scripts/fp/player/hero_fk.gd.uid @@ -0,0 +1 @@ +uid://uxnynukfbjjr diff --git a/scripts/fp/player/hero_rig.gd b/scripts/fp/player/hero_rig.gd index a01d41dc..ee135786 100644 --- a/scripts/fp/player/hero_rig.gd +++ b/scripts/fp/player/hero_rig.gd @@ -13,13 +13,21 @@ extends RefCounted const WS := preload("res://scripts/fp/world_scale.gd") const AP := preload("res://scripts/fp/asset_paths.gd") const SM := preload("res://scripts/fp/player/spring_math.gd") +const FK := preload("res://scripts/fp/player/hero_fk.gd") ## Locomotion loops in blend order; HC starts each on the left-heel strike. const LOCO := ["walk", "walk_fast", "run", "run_fast"] ## Upper-body one-shots (6.9 OneShot). const GESTURES := ["interact", "wave", "phone", "door_try"] +## Body actions over the gestures (the actions feature): punches, the shove and emotes on the +## upper body with quick fades; the dance on the whole body. +const ACTIONS := ["punch_jab", "punch_cross", "push", "yes", "no", "shout"] ## Every clip the tree plays. -const CLIPS := ["idle", "walk", "walk_fast", "run", "run_fast", "crouch_idle", "crouch_walk", "jump_start", "fall", "land", "talk", "sit_down", "sit", "stand_up", "interact", "wave", "phone", "door_try"] +const CLIPS := [ + "idle", "walk", "walk_fast", "run", "run_fast", "crouch_idle", "crouch_walk", "jump_start", + "fall", "land", "talk", "sit_down", "sit", "stand_up", "interact", "wave", "phone", "door_try", + "punch_jab", "punch_cross", "push", "yes", "no", "shout", "dance", +] ## Foot-slide correction: TimeScale = clamp(v / v_anim, TS_MIN, TS_MAX). const TS_MIN := 0.5 const TS_MAX := 1.5 @@ -76,6 +84,18 @@ const STEP_MIN_MOVE := 0.25 ## Gesture one-shot fades (s). const SHOT_IN := 0.25 const SHOT_OUT := 0.35 +## Action one-shot fades (a punch lands at once), the dance's, and the guard's halflife (s). +const ACT_IN := 0.06 +const ACT_OUT := 0.2 +const DANCE_IN := 0.3 +const DANCE_OUT := 0.4 +const HL_GUARD := 0.06 +## The guard (fists up) is GUARD_CLIP held at GUARD_AT seconds on the upper body. +const GUARD_CLIP := "punch_jab" +const GUARD_AT := 0.0 +## strike_time samples this many poses over a clip's opening seconds. +const STRIKE_SAMPLES := 48 +const STRIKE_WITHIN := 1.2 ## sit_down and stand_up play this much faster than authored (in and out of a car seat). const SIT_SPEED := 1.5 const SIT_CLIPS := ["sit_down", "stand_up"] @@ -107,6 +127,14 @@ const P_LAND := "parameters/sm/land/mix/blend_amount" const P_GESTURE := "parameters/gesture/transition_request" const P_SHOT := "parameters/shot/request" const P_SHOT_ACTIVE := "parameters/shot/active" +const P_ACTION := "parameters/action/transition_request" +const P_ACT := "parameters/act/request" +const P_ACT_ACTIVE := "parameters/act/active" +const P_DANCE := "parameters/dance/request" +const P_DANCE_ACTIVE := "parameters/dance/active" +const P_GUARD := "parameters/guard/blend_amount" +const P_GUARD_SEEK := "parameters/guard_at/seek_request" +const P_GUARD_HOLD := "parameters/guard_hold/scale" ## The body's scene root (faces +Z, feet at y = 0, metric) and its parts. var body: Node3D @@ -133,6 +161,8 @@ var move: float = 0.0 var contacts: Array[Dictionary] = [] ## How far the body is lowered to put the soles on the ground (skeleton metres; _update_ground). var ground_fix: float = 0.0 +## How far the guard is up (0..1). +var guard: float = 0.0 ## Head-look influence (0..1) and whether the modifier last found its target within its limits. var look_weight: float = 0.0 var look_within: bool = false @@ -142,8 +172,8 @@ var _head_bone: int = -1 var _feet: Array[int] = [] var _feet_rest: Array[float] = [] var _contact: Dictionary = {} -var _tracks: Dictionary = {} var _chains: Array = [] +var _fk: FK var _lib: AnimationLibrary var _look_goal: Vector3 = Vector3(NAN, NAN, NAN) var _look_dir: Vector3 = Vector3.ZERO @@ -154,6 +184,11 @@ var _sit: bool = false var _prev_state: String = "" var _prev_phase: float = -1.0 var _last_vy: float = 0.0 +var _guard_goal: float = 0.0 +## Seconds until the playing action (dance) fades out by itself; INF for at its own end. +var _act_left: float = INF +var _dance_left: float = INF +var _strike: Dictionary = {} ## Instantiates the body at path with the AnimationLibrary at lib_path; k is the body's speed @@ -196,6 +231,7 @@ func _setup(b: Node3D, sk: Skeleton3D, lib: AnimationLibrary, k: float, p_gender for n: String in ["LeftFoot", "RightFoot", "LeftToes", "RightToes"]: _feet.append(skeleton.find_bone(n)) _lib = lib + _fk = FK.new(skeleton) _analyse_feet() tree = AnimationTree.new() tree.name = "AnimationTree" @@ -258,6 +294,7 @@ func update(dt: float, v: float, grounded: bool, vy: float, crouched: bool, talk _go(_base(a, crouched, talking)) _last_vy = vy _set_locomotion(v, a) + _update_actions(dt, a) tree.advance(dt) _update_ground(dt) _update_contacts() @@ -295,6 +332,86 @@ func stop_gesture() -> void: tree.set(P_SHOT, AnimationNodeOneShot.ONE_SHOT_REQUEST_FADE_OUT) +## Plays a body action: one of ACTIONS on the upper body over whatever the legs do, or "dance" +## on the whole body (standing only; moving ends it). secs > 0 fades it out that long after the +## start; otherwise a clip plays to its end, a loop for one cycle and the dance until stopped. +func play_action(a: String, secs: float = 0.0) -> bool: + var left := secs if secs > 0.0 else INF + if a == "dance": + if state() != "loco" or move * float(speeds["walk"]) > TALK_STILL: + return false + tree.set(P_DANCE, AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE) + _dance_left = left + return true + if a not in ACTIONS: + return false + tree.set(P_ACTION, a) + tree.set(P_ACT, AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE) + _act_left = left + return true + + +## True while an action or the dance plays (fading out included). +func action_active() -> bool: + return bool(tree.get(P_ACT_ACTIVE)) or bool(tree.get(P_DANCE_ACTIVE)) + + +## Fades the playing action and the dance out. +func stop_action() -> void: + tree.set(P_ACT, AnimationNodeOneShot.ONE_SHOT_REQUEST_FADE_OUT) + tree.set(P_DANCE, AnimationNodeOneShot.ONE_SHOT_REQUEST_FADE_OUT) + _act_left = INF + _dance_left = INF + + +## Raises (on) or drops the guard: fists up on the upper body, eased over HL_GUARD. +func set_guard(on: bool) -> void: + _guard_goal = 1.0 if on else 0.0 + + +## When clip's fists (or palms) are furthest out in front within its first within seconds: where +## a punch or the shove lands (s). Measured once per clip on this body's keys and cached. +func strike_time(clip: String, within: float = STRIKE_WITHIN) -> float: + var key := "%s|%.3f" % [clip, within] + if _strike.has(key): + return _strike[key] + var a := _clip(clip) + if a == null: + return 0.0 + var fwd := _fk.forward_of(body) + var hands := [] + for h: String in ["LeftHand", "RightHand"]: + hands.append(_fk.chain_to(skeleton.find_bone(h))) + var span := minf(a.length, within) + var best := -INF + var at := 0.0 + for k in range(STRIKE_SAMPLES + 1): + var t := span * float(k) / float(STRIKE_SAMPLES) + for c: Array in hands: + var reach := _fk.end_pos(a, a, 0.0, c, t / a.length).dot(fwd) + if reach > best + 1e-5: + best = reach + at = t + _strike[key] = at + return at + + +## Times the playing action and the dance out, ends the dance once the body moves or leaves its +## standing locomotion, and eases the guard. +func _update_actions(dt: float, a: float) -> void: + _act_left -= dt + if _act_left <= 0.0: + _act_left = INF + tree.set(P_ACT, AnimationNodeOneShot.ONE_SHOT_REQUEST_FADE_OUT) + _dance_left -= dt + var ending := a > TALK_STILL or state() != "loco" or _dance_left <= 0.0 + if ending and bool(tree.get(P_DANCE_ACTIVE)): + _dance_left = INF + tree.set(P_DANCE, AnimationNodeOneShot.ONE_SHOT_REQUEST_FADE_OUT) + guard = _guard_goal if dt <= 0.0 else SM.damp(guard, _guard_goal, HL_GUARD, dt) + tree.set(P_GUARD, guard) + + ## How far clip has carried its root t seconds after its state began (0..1 of the whole clip's ## horizontal root travel; sit_down and stand_up play at SIT_SPEED). The tree drops root motion, ## so whoever moves the body through a clip (into a car seat) follows this curve; a clip without @@ -351,6 +468,12 @@ func snap(crouched: bool) -> void: ground_fix = 0.0 body.position.y = 0.0 tree.set("parameters/shot/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_ABORT) + tree.set(P_ACT, AnimationNodeOneShot.ONE_SHOT_REQUEST_ABORT) + tree.set(P_DANCE, AnimationNodeOneShot.ONE_SHOT_REQUEST_ABORT) + _guard_goal = 0.0 + guard = 0.0 + _act_left = INF + _dance_left = INF playback.start("crouch" if crouched else "loco", true) tree.advance(0.0) _prev_state = state() @@ -396,33 +519,72 @@ func _update_ground(dt: float) -> void: # --- Tree +## sm -> shot (gestures, upper body) -> dance (whole body) -> guard (GUARD_CLIP held, upper +## body) -> act (ACTIONS, upper body) -> output. func _root_tree() -> AnimationNodeBlendTree: var bt := AnimationNodeBlendTree.new() bt.add_node("sm", _state_machine()) - var tr := AnimationNodeTransition.new() - tr.input_count = GESTURES.size() - for i in GESTURES.size(): - tr.set_input_name(i, GESTURES[i]) - tr.xfade_time = 0.0 - bt.add_node("gesture", tr) - for i in GESTURES.size(): - var n := "g_" + str(GESTURES[i]) - bt.add_node(n, _anim(GESTURES[i])) - bt.connect_node("gesture", i, n) - var shot := AnimationNodeOneShot.new() - shot.fadein_time = SHOT_IN - shot.fadeout_time = SHOT_OUT - shot.break_loop_at_end = true - shot.filter_enabled = true - for b in _upper_bones(): - shot.set_filter_path(NodePath("%GeneralSkeleton:" + b), true) - bt.add_node("shot", shot) + _switch(bt, "gesture", GESTURES, "g_") + bt.add_node("shot", _one_shot(SHOT_IN, SHOT_OUT, true)) bt.connect_node("shot", 0, "sm") bt.connect_node("shot", 1, "gesture") - bt.connect_node("output", 0, "shot") + bt.add_node("dance", _one_shot(DANCE_IN, DANCE_OUT, false)) + bt.add_node("a_dance", _anim("dance")) + bt.connect_node("dance", 0, "shot") + bt.connect_node("dance", 1, "a_dance") + # The guard pose: GUARD_CLIP sought to GUARD_AT and held there (time scale 0). + bt.add_node("guard_pose", _anim(GUARD_CLIP)) + bt.add_node("guard_at", AnimationNodeTimeSeek.new()) + bt.add_node("guard_hold", AnimationNodeTimeScale.new()) + bt.connect_node("guard_at", 0, "guard_pose") + bt.connect_node("guard_hold", 0, "guard_at") + var guard_mix := AnimationNodeBlend2.new() + _filter_upper(guard_mix) + bt.add_node("guard", guard_mix) + bt.connect_node("guard", 0, "dance") + bt.connect_node("guard", 1, "guard_hold") + _switch(bt, "action", ACTIONS, "a_") + bt.add_node("act", _one_shot(ACT_IN, ACT_OUT, true)) + bt.connect_node("act", 0, "guard") + bt.connect_node("act", 1, "action") + bt.connect_node("output", 0, "act") return bt +## A Transition n in bt over clips (no cross-fade; each input restarts its clip), the clips as +## Animation nodes named prefix + clip. +static func _switch(bt: AnimationNodeBlendTree, n: String, clips: Array, prefix: String) -> void: + var tr := AnimationNodeTransition.new() + tr.input_count = clips.size() + for i in clips.size(): + tr.set_input_name(i, clips[i]) + tr.xfade_time = 0.0 + tr.allow_transition_to_self = true + bt.add_node(n, tr) + for i in clips.size(): + var c := prefix + str(clips[i]) + bt.add_node(c, _anim(clips[i])) + bt.connect_node(n, i, c) + + +## A one-shot with these fades; upper filters it to the upper body. A looping clip in it plays one +## cycle, except on the whole body (the dance), which loops until faded out. +func _one_shot(fade_in: float, fade_out: float, upper: bool) -> AnimationNodeOneShot: + var s := AnimationNodeOneShot.new() + s.fadein_time = fade_in + s.fadeout_time = fade_out + s.break_loop_at_end = upper + if upper: + _filter_upper(s) + return s + + +func _filter_upper(n: AnimationNode) -> void: + n.filter_enabled = true + for b in _upper_bones(): + n.set_filter_path(NodePath("%GeneralSkeleton:" + b), true) + + func _state_machine() -> AnimationNodeStateMachine: var sm := AnimationNodeStateMachine.new() sm.add_node("loco", _locomotion()) @@ -564,6 +726,9 @@ func _reset_params() -> void: tree.set(P_CBS, 0.0) tree.set(P_CTS, 1.0) tree.set(P_LAND, LAND_MIN) + tree.set(P_GUARD, guard) + tree.set(P_GUARD_SEEK, GUARD_AT) + tree.set(P_GUARD_HOLD, 0.0) time_scale = 1.0 move = 0.0 @@ -676,7 +841,7 @@ func _contacts_of(c0: String, c1: String, w: float) -> Vector2: var h := PackedFloat32Array() for k in range(CONTACT_SAMPLES): var ph := float(k) / float(CONTACT_SAMPLES) - h.append(_fk_y(a0, a1, wq, _chains[f], ph)) + h.append(_fk.end_pos(a0, a1, wq, _chains[f], ph).y) out[f] = _down_phase(h, 0.5 * float(f)) _contact[key] = out return out @@ -687,57 +852,18 @@ func _contacts_of(c0: String, c1: String, w: float) -> Vector2: func _analyse_feet() -> void: _chains.clear() for b: int in _feet: - var chain: Array[int] = [] - var i := b - while i >= 0: - chain.push_front(i) - i = skeleton.get_bone_parent(i) - _chains.append(chain) + _chains.append(_fk.chain_to(b)) var idle := _lib.get_animation("idle") var stand := [INF, INF] for k in range(IDLE_SAMPLES): var ph := float(k) / float(IDLE_SAMPLES) for f in range(4): - stand[f / 2] = minf(stand[f / 2], _fk_y(idle, idle, 0.0, _chains[f], ph)) + stand[f / 2] = minf(stand[f / 2], _fk.end_pos(idle, idle, 0.0, _chains[f], ph).y) _feet_rest.clear() for f in range(4): _feet_rest.append(stand[f / 2]) -## The y (skeleton metres) of the chain's last bone at phase ph of clip a0 blended toward a1 by w -## (rotations slerped, positions lerped). Root stays at rest (its motion is extracted); position -## keys scale by the skeleton's motion_scale, as the mixer applies them. -func _fk_y(a0: Animation, a1: Animation, w: float, chain: Array, ph: float) -> float: - var t0 := ph * a0.length - var t1 := ph * a1.length - var xf := Transform3D.IDENTITY - for b: int in chain: - var rest := skeleton.get_bone_rest(b) - var pos := rest.origin - var rot := rest.basis.get_rotation_quaternion() - if b != chain[0]: - var k0 := _track(a0, b) - var k1 := _track(a1, b) if w > 0.0 else k0 - var r0 := a0.rotation_track_interpolate(k0.x, t0) if k0.x >= 0 else rot - var r1 := a1.rotation_track_interpolate(k1.x, t1) if k1.x >= 0 else rot - rot = r0.slerp(r1, w) if w > 0.0 else r0 - var p0 := a0.position_track_interpolate(k0.y, t0) * skeleton.motion_scale if k0.y >= 0 else pos - var p1 := a1.position_track_interpolate(k1.y, t1) * skeleton.motion_scale if k1.y >= 0 else pos - pos = p0.lerp(p1, w) - xf = xf * Transform3D(Basis(rot).scaled(rest.basis.get_scale()), pos) - return xf.origin.y - - -## (rotation track, position track) of bone b in clip a; -1 where it has none. Cached per clip. -func _track(a: Animation, b: int) -> Vector2i: - var key := "%d|%d" % [a.get_instance_id(), b] - if _tracks.has(key): - return _tracks[key] - var path := NodePath("%GeneralSkeleton:" + skeleton.get_bone_name(b)) - var r := Vector2i(a.find_track(path, Animation.TYPE_ROTATION_3D), a.find_track(path, Animation.TYPE_POSITION_3D)) - _tracks[key] = r - return r - ## Where samples h (one cycle) come down to within CONTACT_EPS of their lowest value inside the ## window around nominal (the heel strike after the swing), interpolated between samples. diff --git a/scripts/fp/player/player.gd b/scripts/fp/player/player.gd index 840b4134..d5340dad 100644 --- a/scripts/fp/player/player.gd +++ b/scripts/fp/player/player.gd @@ -73,6 +73,8 @@ const FOOT_SPREAD := 0.09 * WS.M const FP_SPRINT_FOV := 4.0 const HL_FOV := 0.25 const FP_PITCH_LIMIT := deg_to_rad(WS.PITCH_LIMIT_DEG) +## A view kick (kick_view) dies away with this halflife (s). +const HL_KICK := 0.07 # --- Camera modes and the right stick const MODE_BLEND := 0.35 const STICK_YAW := deg_to_rad(270.0) @@ -167,6 +169,11 @@ var _set_up: bool = false var _traffic_t: float = 0.0 ## True while another owner (the city overview) places the camera through set_view_override(). var view_override: bool = false +## Third person: while not NAN the body turns toward this yaw instead of its travel (the actions +## feature: the guard faces where the camera looks, a blow faces its target). +var aim_yaw: float = NAN +## The view kick still applied (pitch, roll; radians). +var _kick: Vector2 = Vector2.ZERO var _override_xf: Transform3D = Transform3D() var _override_fov: float = 50.0 @@ -377,6 +384,7 @@ func update_view(delta: float) -> void: var dt := clampf(delta, 0.0, MAX_FRAME_DELTA) var sp := speed() _cam_dy = SM.damp(_cam_dy, 0.0, HL_FP_STEP, dt) + _kick *= pow(0.5, dt / HL_KICK) _bob_amp = SM.damp(_bob_amp, sqrt(clampf(sp / WS.RUN, 0.0, 1.0)) if grounded else 0.0, HL_BOB, dt) var fp_goal := _fov_setting + (FP_SPRINT_FOV if _head_bob and _running and sp > 0.8 * WS.RUN else 0.0) _fp_fov = SM.damp(_fp_fov, fp_goal, HL_FOV, dt) @@ -404,7 +412,7 @@ func update_view(delta: float) -> void: xf = _conv_xform(dt) fv = CONV_FOV elif third: - xf = rig.cam_xform + xf = rig.cam_xform * _kick_xform() fv = rig.fov else: xf = _fp_world() @@ -442,6 +450,16 @@ func update_view(delta: float) -> void: _step_snap = 0.0 +## Jolts the view by d_pitch (positive up) and d_roll (radians; positive tips left), dying away +## over HL_KICK: a blow landing. +func kick_view(d_pitch: float, d_roll: float) -> void: + _kick += Vector2(d_pitch, d_roll) + + +func _kick_xform() -> Transform3D: + return Transform3D(Basis.from_euler(Vector3(_kick.x, 0.0, _kick.y)), Vector3.ZERO) + + ## Mouse or stick look (radians): turns the view; in first person the body turns with it. func look(d_yaw: float, d_pitch: float) -> void: cam_yaw = wrapf(cam_yaw + d_yaw, -PI, PI) @@ -861,8 +879,8 @@ func _snap_y(y: float) -> void: position.y = y -## Third person: the body turns toward its travel (a spring, rate-limited; toward the wish during -## a pivot). First person: the body follows the view. +## Third person: the body turns toward aim_yaw, else its travel (a spring, rate-limited; toward +## the wish during a pivot). First person: the body follows the view. func _body_yaw_step(h: float) -> void: if camera_mode == "first": if absf(yaw - cam_yaw) > 0.0: @@ -873,7 +891,9 @@ func _body_yaw_step(h: float) -> void: var flat := Vector2(velocity.x, velocity.z) var sp := flat.length() var goal := NAN - if _pivot_t > 0.0 and _wish_dir != Vector2.ZERO: + if not is_nan(aim_yaw): + goal = aim_yaw + elif _pivot_t > 0.0 and _wish_dir != Vector2.ZERO: goal = atan2(-_wish_dir.x, -_wish_dir.y) elif sp > TURN_MIN_SPEED: goal = atan2(-flat.x, -flat.y) @@ -1079,7 +1099,7 @@ func _fp_local() -> Transform3D: if _head_bob: bv = -BOB_V * _bob_amp * cos(2.0 * _gait_phase) bl = BOB_L * _bob_amp * sin(_gait_phase) - return Transform3D(Basis.from_euler(Vector3(cam_pitch, 0.0, 0.0)), Vector3(bl, _eye_h + _cam_dy + bv, 0.0)) + return Transform3D(Basis.from_euler(Vector3(cam_pitch, 0.0, 0.0)), Vector3(bl, _eye_h + _cam_dy + bv, 0.0)) * _kick_xform() func _fp_world() -> Transform3D: diff --git a/scripts/fp/player/tps_rig.gd b/scripts/fp/player/tps_rig.gd index 56f88f0b..9579747e 100644 --- a/scripts/fp/player/tps_rig.gd +++ b/scripts/fp/player/tps_rig.gd @@ -68,6 +68,8 @@ var yaw: float = 0.0 var pitch: float = PITCH_REST ## Arm length goal before pitch scaling (settings.tps_distance_m, then the wheel). var zoom: float = DIST_DEFAULT +## Factor on that goal (the actions feature pulls the arm in while the guard is up). +var arm_scale: float = 1.0 ## Shoulder goal: 1 right, -1 left. var side: float = 1.0 var base_fov: float = 60.0 @@ -210,7 +212,8 @@ func _solve(dt: float, feet: Vector3, vel: Vector3, body_yaw: float, eye_h: floa # Lateral look-ahead: leave screen space in front of a character crossing the frame. var lead_goal := clampf(vel.dot(right) * LEAD_TIME, -LEAD_MAX, LEAD_MAX) # Shoulder, zoom, run extension. - var want := zoom * _pitch_scale() + (DIST_RUN_ADD if running and flat > 0.5 * WS.RUN else 0.0) + var run_add := DIST_RUN_ADD if running and flat > 0.5 * WS.RUN else 0.0 + var want := zoom * _pitch_scale() * arm_scale + run_add var fov_goal := base_fov + (FOV_RUN_ADD if running and flat > 0.8 * WS.RUN else 0.0) if snapping: _lead = lead_goal diff --git a/scripts/fp/traffic/fleets.gd b/scripts/fp/traffic/fleets.gd index bca33298..2d54a55c 100644 --- a/scripts/fp/traffic/fleets.gd +++ b/scripts/fp/traffic/fleets.gd @@ -16,6 +16,8 @@ const MAX_CREWS := 10 ## rest wait in `pending` (docs/fp/slices/E-traffic.md §1). const ASTAR_PER_FRAME := 4 const CURB_LAT := 0.055 +## A crew answering a call (stay > 0) gives up after this long (s) from setting off. +const CALL_MAX := 240.0 const JOBS := {"bus": "bus driver", "truck": "truck driver", "garbage_truck": "sanitation worker", "firetruck": "firefighter", "ambulance": "paramedic", "police": "police officer"} @@ -38,7 +40,8 @@ var lines: Array = [] var stop_sides: Dictionary = {} # tile -> side index of its shelter ## Why the last crew build sent nobody ("" when it sent a crew); for tests and logs. var last_dispatch_fail: String = "" -## Crew requests waiting for an A* slot: [kind, station, target]; crews waiting to route home. +## Crew requests waiting for an A* slot: [kind, station, target, stay]; crews waiting to route +## home. var pending: Array = [] var homing: Array = [] ## A* builds left this frame. @@ -328,12 +331,13 @@ func _next_leg(c: VH.Car, line: Line, k: int) -> void: (c.owner as Dictionary)["next"] = nk -## Queues a crew (CityLife.external.dispatch_crew); it leaves when an A* slot is free. -func dispatch_crew(kind: String, station: Vector2i, target: Vector2i) -> void: +## Queues a crew (CityLife.external.dispatch_crew); it leaves when an A* slot is free. A crew +## called with stay > 0 answers a call, not a sim event: it stays that many seconds at the scene. +func dispatch_crew(kind: String, station: Vector2i, target: Vector2i, stay: float = 0.0) -> void: for q: Array in pending: if str(q[0]) == kind and q[2] == target: return - pending.append([kind, station, target]) + pending.append([kind, station, target, stay]) _drain() @@ -347,10 +351,10 @@ func _drain() -> void: while astar_left > 0 and not pending.is_empty(): var q: Array = pending.pop_front() astar_left -= 1 - _send_crew(str(q[0]), q[1], q[2]) + _send_crew(str(q[0]), q[1], q[2], float(q[3])) -func _send_crew(kind: String, station: Vector2i, target: Vector2i) -> void: +func _send_crew(kind: String, station: Vector2i, target: Vector2i, stay: float) -> void: last_dispatch_fail = "" if count("crew") >= MAX_CREWS: last_dispatch_fail = "cap" @@ -381,6 +385,9 @@ func _send_crew(kind: String, station: Vector2i, target: Vector2i) -> void: c.siren = kind != "truck" _name_driver(c, kind) c.owner = {"kind": kind, "event_id": _event_at(target), "phase": "to_scene", "home": start.id, "t": 0.0} + if stay > 0.0: + c.owner["event_id"] = -1 + c.owner["stay"] = stay var s := clampf(start.length * 0.5 + c.length * 0.5, c.length, start.length) if f.is_far(Vector3(D.tx(start.tile), 0, D.ty(start.tile))) or not vh.fits(start, s, c.length): c.lane = start @@ -499,6 +506,9 @@ func _step_crew(c: VH.Car, dt: float, live: Dictionary) -> void: o["t"] = float(o.get("t", 0.0)) + dt var phase := str(o.get("phase", "")) var ev_live := live.has(int(o.get("event_id", -1))) + if o.has("stay"): + # Answering a call: there for its stay (and not for ever when the way there is long). + ev_live = float(o["stay"]) > 0.0 and float(o["t"]) < CALL_MAX match phase: "to_scene": if not ev_live and float(o["t"]) > 5.0: @@ -507,6 +517,8 @@ func _step_crew(c: VH.Car, dt: float, live: Dictionary) -> void: o["phase"] = "at_scene" c.state = "at the scene" "at_scene": + if o.has("stay"): + o["stay"] = float(o["stay"]) - dt if not ev_live: _go_home(c, o) "leaving": diff --git a/scripts/fp/traffic/incidents.gd b/scripts/fp/traffic/incidents.gd new file mode 100644 index 00000000..9b2c5102 --- /dev/null +++ b/scripts/fp/traffic/incidents.gd @@ -0,0 +1,111 @@ +extends RefCounted +## What a blow in the street means for the city (traffic_feature.gd hit_walker): a resident who +## was hit remembers it and their mood drops (a shove least, a car most, more when knocked down), +## and residents who saw it remember it too. A punch that knocks someone down, or the ASSAULT-th +## punch within ASSAULT_S, calls the police; someone a car knocks down, an ambulance (a crew from +## the nearest station that stays at the scene a while, fleets.gd), each kind at most once per +## CREW_COOL. + +const D := preload("res://scripts/sim/sim_defs.gd") +const WK := preload("res://scripts/fp/traffic/walkers.gd") +const MOOD := {"shove": -0.5, "punch": -1.0, "car": -2.0} +const DOWN_MOOD := 1.5 +const SAW_MOOD := -0.3 +const ASSAULT := 3 +const ASSAULT_S := 60.0 +const CREW_COOL := 90.0 +const STAY := {"police": 40.0, "ambulance": 30.0} +## Memory lines (residents.gd remember): what happened to them and what they saw (%s is where). +const HIT_LINES := { + "shove": "got shoved by a visitor %s", + "punch": "was punched by a visitor %s", + "down": "was knocked down by a visitor %s", + "car": "was hit by a visitor's car %s", +} +const SAW_LINES := { + "shove": "saw a visitor shove someone %s", + "punch": "saw a visitor punch someone %s", + "car": "saw a visitor's car hit someone %s", +} + +var f: Object +var _punches: Array[float] = [] +var _called: Dictionary = {} # crew kind -> traffic clock when last called + + +func setup(p_f: Object) -> void: + f = p_f + _punches.clear() + _called.clear() + + +## The person_hit info for walker w hit by cause at world point at (res: reactions.gd hit()'s +## result, seen: the walkers who saw it): {"walker", "resident_id", "name", "down", "street", +## "strength", "witnesses" (their resident ids), "crew" ("police", "ambulance" or "")}. +func report( + w: WK.Walker, at: Vector3, strength: float, cause: String, res: Dictionary, seen: Array +) -> Dictionary: + var down := bool(res.get("down", false)) + var street := _street(at) + var where := ("on " + street) if not street.is_empty() else "in the street" + var info := { + "walker": w.id, + "resident_id": w.resident_id, + "name": "", + "down": down, + "street": street, + "strength": strength, + "witnesses": [], + "crew": "", + } + var key := "down" if down and cause == "punch" else cause + var r := _resident(w.resident_id) + if not r.is_empty(): + info["name"] = str(r.get("name", "")) + f.ctx.residents.remember(r, str(HIT_LINES.get(key, HIT_LINES["shove"])) % where) + f.ctx.residents.nudge_mood(r, float(MOOD.get(cause, -0.5)) * (DOWN_MOOD if down else 1.0)) + for o: WK.Walker in seen: + var s := _resident(o.resident_id) + if s.is_empty(): + continue + f.ctx.residents.remember(s, str(SAW_LINES.get(cause, SAW_LINES["shove"])) % where) + f.ctx.residents.nudge_mood(s, SAW_MOOD) + (info["witnesses"] as Array).append(o.resident_id) + info["crew"] = _crew(cause, down, at) + return info + + +func _resident(rid: String) -> Dictionary: + var rs: Variant = f.ctx.residents + if rid.is_empty() or rs == null or not rs.has_method("remember"): + return {} + return rs.find(rid) + + +func _street(at: Vector3) -> String: + var x := roundi(at.x) + var y := roundi(at.z) + return f.street_label(D.idx(x, y)) if D.in_bounds(x, y) else "" + + +## Calls the police after an assault and an ambulance for someone a car knocked down; the kind +## called, or "". +func _crew(cause: String, down: bool, at: Vector3) -> String: + var now: float = f.clock + var kind := "" + if cause == "punch": + _punches.append(now) + while not _punches.is_empty() and now - _punches[0] > ASSAULT_S: + _punches.pop_front() + if down or _punches.size() >= ASSAULT: + kind = "police" + elif cause == "car" and down: + kind = "ambulance" + if kind.is_empty() or now - float(_called.get(kind, -INF)) < CREW_COOL: + return "" + if not f.call_crew(kind, at, float(STAY[kind])): + return "" + _called[kind] = now + if kind == "police": + _punches.clear() + return kind diff --git a/scripts/fp/traffic/incidents.gd.uid b/scripts/fp/traffic/incidents.gd.uid new file mode 100644 index 00000000..8053f57a --- /dev/null +++ b/scripts/fp/traffic/incidents.gd.uid @@ -0,0 +1 @@ +uid://b8etl6uhbwc8k diff --git a/scripts/fp/traffic/people.gd b/scripts/fp/traffic/people.gd index aaa815f1..c607cb3d 100644 --- a/scripts/fp/traffic/people.gd +++ b/scripts/fp/traffic/people.gd @@ -46,18 +46,7 @@ const PERSON_FADE := 0.4 ## Frustum test: a sphere around the body (metres above the feet, radius). const CULL_UP := 0.9 * WS.M const CULL_R := 1.3 * WS.M -const STATES: Array[StringName] = [ - &"idle", - &"idle_look", - &"idle_wait", - &"walk", - &"walk_carry", - &"walk_phone", - &"carry_idle", - &"phone", - &"talk", - &"talk_2" -] +const STATES := KIT.STATES const LOCO: Array[StringName] = [&"walk", &"walk_carry", &"walk_phone"] const STILL: Array[StringName] = [&"", &"carry_idle", &"phone"] const BOX_COLOR := Color(0.62, 0.47, 0.3) @@ -475,12 +464,15 @@ func add(w: WK.Walker, d: float, residents: Variant) -> void: _cands.append([d, w, on_screen(w.pos + w.vis_off)]) -## Picks w's clip from what it is doing and advances its time (anim_t mirrors the mixer). +## Picks w's clip from what it is doing (a reaction first) and advances its time (anim_t mirrors +## the mixer). func _animate(w: WK.Walker) -> void: var g: String = (avatars[w.av] as Avatar).gender var moving := w.vis_v > WALK_ON or (w.vis_v > WALK_OFF and w.anim_clip in LOCO) var want: StringName - if w.hold: + if w.react != &"": + want = w.react + elif w.hold: want = &"talk" elif w.state == "chat": want = w.talk_clip @@ -491,13 +483,16 @@ func _animate(w: WK.Walker) -> void: var lens: Dictionary = _len[g] if want != w.anim_clip: w.anim_clip = want - # A mixer starts the new clip from its beginning; a body-less walker picks a phase. + # A mixer starts the new clip from its beginning; a body-less walker picks a phase, and a + # reaction runs on its own clock. w.anim_t = 0.0 if w.body != null else _rng.randf() * float(lens[want]) + if w.react == want: + w.anim_t = w.react_t var rate := 1.0 - if want in LOCO: + if want in LOCO or want == &"run": rate = clampf(w.vis_v / maxf(authored_speed(w, want), 1e-6), RATE_MIN, RATE_MAX) w.anim_rate = rate - w.anim_t = fposmod(w.anim_t + _adt * rate, float(lens[want])) + w.anim_t = KIT.clip_time(want, w.anim_t + _adt * rate, float(lens[want])) ## Assigns tiers, attaches, advances and releases bodies, and fills the T3 layers. @@ -670,7 +665,7 @@ func _drive(b: Body, w: WK.Walker, tier: int, d: float) -> int: if b.since < 1.0 / hz and b.clip == w.anim_clip: return 0 if b.clip != w.anim_clip: - b.ap.play(w.anim_clip, XFADE) + b.ap.play(w.anim_clip, KIT.REACT_XFADE if w.anim_clip in KIT.BLOWS else XFADE) b.clip = w.anim_clip # speed_scale applies to the whole interval since the last update (game seconds in acc). b.ap.speed_scale = w.anim_rate diff --git a/scripts/fp/traffic/people_kit.gd b/scripts/fp/traffic/people_kit.gd index 22031db5..7b8469f4 100644 --- a/scripts/fp/traffic/people_kit.gd +++ b/scripts/fp/traffic/people_kit.gd @@ -1,6 +1,7 @@ extends RefCounted ## 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. +## the shared T0 AnimationTree, and HC's lod1 meshes as dither-fading T3 MultiMesh layers; the +## clips reactions.gd plays (blows, falls, running off) and how their time runs. ## Layered clips: the arm bones taken from another clip's pose at a fixed time. const ARM_PARTS := [ @@ -27,8 +28,55 @@ const ARM_PARTS := [ const CARRY_AT := 0.0 const PHONE_AT := 0.5 const NEEDS: Array[StringName] = [ - &"idle", &"walk", &"talk", &"talk_2", &"phone", &"carry", &"idle_look", &"idle_wait", &"sit" + &"idle", + &"walk", + &"talk", + &"talk_2", + &"phone", + &"carry", + &"idle_look", + &"idle_wait", + &"sit", + &"run", + &"knockback", + &"get_up", + &"hit_head", + &"hit_chest", + &"shout", + &"no", + &"wave", + &"yes" ] +## Every clip the T0 tree holds: HC's walking and standing clips, the layered ones, and the +## reactions (reactions.gd). +const STATES: Array[StringName] = [ + &"idle", + &"idle_look", + &"idle_wait", + &"walk", + &"walk_carry", + &"walk_phone", + &"carry_idle", + &"phone", + &"talk", + &"talk_2", + &"run", + &"knockback", + &"get_up", + &"hit_head", + &"hit_chest", + &"shout", + &"no", + &"wave", + &"yes" +] +## Reactions that play once and hold their last frame (the rest loop), the blows among them, and +## the quicker crossfade into a blow (s): it lands at once. +const ONE_SHOTS: Array[StringName] = [ + &"knockback", &"get_up", &"hit_head", &"hit_chest", &"wave", &"yes" +] +const BLOWS: Array[StringName] = [&"knockback", &"hit_head", &"hit_chest"] +const REACT_XFADE := 0.08 static var _crc_table := PackedInt64Array() const T3_SHADER := """ shader_type spatial; @@ -106,7 +154,8 @@ static func layer( ## The T0 tree, shared by every body: state machine (every state reaches every other with a -## crossfade) -> TimeSeek (handovers from T1) -> TimeScale (playback rate) -> output. +## crossfade, REACT_XFADE into a blow) -> TimeSeek (handovers from T1) -> TimeScale (playback +## rate) -> output. static func build_tree(states: Array[StringName], xfade: float) -> AnimationNodeBlendTree: var sm := AnimationNodeStateMachine.new() for n: StringName in states: @@ -117,7 +166,7 @@ static func build_tree(states: Array[StringName], xfade: float) -> AnimationNode for y: StringName in states: if x != y: var tr := AnimationNodeStateMachineTransition.new() - tr.xfade_time = xfade + tr.xfade_time = REACT_XFADE if y in BLOWS else xfade sm.add_transition(x, y, tr) var bt := AnimationNodeBlendTree.new() bt.add_node(&"sm", sm) @@ -129,6 +178,11 @@ static func build_tree(states: Array[StringName], xfade: float) -> AnimationNode return bt +## Time t into a clip of length ln: one-shots stop on their last frame, loops wrap. +static func clip_time(clip: StringName, t: float, ln: float) -> float: + return minf(t, ln) if clip in ONE_SHOTS else fposmod(t, ln) + + static func t3_shader() -> Shader: var sh := Shader.new() sh.code = T3_SHADER diff --git a/scripts/fp/traffic/reactions.gd b/scripts/fp/traffic/reactions.gd new file mode 100644 index 00000000..1fd1f5a7 --- /dev/null +++ b/scripts/fp/traffic/reactions.gd @@ -0,0 +1,410 @@ +extends RefCounted +## People hit in the street (docs/fp/ARCHITECTURE.md, Body actions): shoved or punched by the +## player, or struck by the car they drive. A light blow makes the person reel (hit_chest, +## hit_head) and slide back a little; a hard one (DOWN), the HITS_DOWN-th punch in a row or a fast +## car knocks them down (knockback), and they lie a while before they get up (get_up). Then they +## shake their head or shout at whoever did it, run off, or walk back to where they were. People +## within WITNESS_R who see it run off or shout too. While a walker stands where the blow left it +## its state is "react" (it keeps its edge and plan; the slide and the walk back move only its +## drawn offset), and a run gives it its own state back at RUN_F times its pace with the run clip. +## What follows a blow for the city (memory, mood, crews) is incidents.gd's. + +const WS := preload("res://scripts/fp/world_scale.gd") +const AP := preload("res://scripts/fp/asset_paths.gd") +const WK := preload("res://scripts/fp/traffic/walkers.gd") +## Clip lengths (s) in HC's libraries (the female clips match); setup() reads them again. +const LENGTHS := {&"knockback": 0.833, &"get_up": 1.54, &"hit_head": 0.417, &"hit_chest": 0.333} +## Clips that play once: the walker does not turn while they run (it may be lying down). +const ONCE: Array[StringName] = [&"knockback", &"get_up", &"hit_head", &"hit_chest"] +const WITNESS_R := 8.0 * WS.M +## A blow this strong (0..1) knocks a person down, and so does the HITS_DOWN-th punch within +## HITS_S seconds. +const DOWN := 0.7 +const HITS_DOWN := 3 +const HITS_S := 8.0 +const RUN_F := 2.6 +const BACK_V := 1.1 * WS.M +const BODY_R := 0.25 * WS.M +const BODY_H := 1.7 * WS.M +## Share of the people around who react, and of those the share who run off, by severity (a +## shove, a punch, a knock-down). +const SEE: Array[float] = [0.3, 0.6, 0.85] +const FLEE: Array[float] = [0.15, 0.5, 0.6] +## Who answers an emote of the hero's: [range (m), the cosine of the cone ahead, share, clip, +## seconds]. A share of 0 is the nearest person in the cone; otherwise each of those around +## answers with that chance, at most ANSWER_MAX of them. +const ANSWERS := { + "wave": [10.0, 0.75, 0.0, &"wave", 2.8], + "yes": [5.0, 0.75, 0.0, &"yes", 2.5], + "no": [5.0, 0.75, 0.0, &"no", 2.0], + "shout": [10.0, -1.0, 0.5, &"no", 2.0], + "dance": [8.0, -1.0, 0.4, &"wave", 2.8], +} +const ANSWER_MAX := 4 + + +## One person reacting: the steps still to come, [mode, clip, seconds] with mode "place" (stand +## and play the clip), "back" (walk back to the walker's own place) or "run". +class Reaction: + extends RefCounted + var w: WK.Walker = null + var steps: Array = [] + var mode: String = "" + var left: float = 0.0 # seconds left in the current step + var delay: float = 0.0 # someone who saw it takes a moment + var src := Vector3.ZERO # who did it: faced, run from + var slide := Vector3.ZERO # drawn offset the blow adds, eased out over slide_s + var slide_t: float = 0.0 + var slide_s: float = 0.0 + var was: String = "" # walker state to give back ("" until the first step starts) + var speed: float = 0.0 # pace to give back after a run + var victim: bool = false + var down: bool = false + var hits: int = 0 + var last: float = -INF + + +var wk: WK +var collision: CollisionWorld +var lengths: Dictionary = LENGTHS.duplicate() +var clock: float = 0.0 +var _active: Dictionary = {} # walker id -> Reaction +var _rng := RandomNumberGenerator.new() + + +func setup(p_wk: WK, p_collision: CollisionWorld, seed: int) -> void: + wk = p_wk + collision = p_collision + _rng.seed = seed * 9157 + 5 + _active.clear() + clock = 0.0 + if not ResourceLoader.exists(AP.CHAR_LIB_M): + return + var lib := load(AP.CHAR_LIB_M) as AnimationLibrary + for n: StringName in LENGTHS.keys(): + if lib != null and lib.has_animation(n): + lengths[n] = lib.get_animation(n).length + + +func clear() -> void: + _active.clear() + + +## w takes a blow from src (a world point) along push (xz, any length) of strength 0..1, cause +## "shove", "punch" or "car". Returns {"down": bool, "clip": StringName}, or {} when w cannot take +## one now (hidden, in a conversation, or down already). +func hit(w: WK.Walker, src: Vector3, push: Vector3, strength: float, cause: String) -> Dictionary: + if w.dead or w.state == "hidden" or w.hold: + return {} + var r: Reaction = _active.get(w.id) + if r != null and r.down: + return {} + if r == null: + r = _new(w) + elif r.mode == "run": + w.speed = r.speed + w.react = &"" + r.victim = true + r.delay = 0.0 + r.hits = r.hits + 1 if clock - r.last < HITS_S else 1 + r.last = clock + r.src = src + var d := _away(w, src, push) + r.down = strength >= DOWN or (cause == "punch" and r.hits >= HITS_DOWN) + # Facing the blow; a box in the hands is dropped. + w.yaw = atan2(-d.x, -d.z) + w.extra = 0 if w.extra == 1 else w.extra + var clip := _plan_down(r, d, strength, cause) if r.down else _plan_reel(r, d, strength, cause) + r.steps.append_array(_after(r, cause)) + r.slide_t = 0.0 + _next_step(r) + return {"down": r.down, "clip": clip} + + +## Unit xz direction the blow pushes w: push, else away from src, else backward. +static func _away(w: WK.Walker, src: Vector3, push: Vector3) -> Vector3: + var d := Vector3(push.x, 0.0, push.z) + if d.length_squared() < 1e-12: + d = Vector3(w.pos.x - src.x, 0.0, w.pos.z - src.z) + if d.length_squared() < 1e-12: + d = -Vector3(sin(w.yaw), 0.0, cos(w.yaw)) + return d.normalized() + + +## Knocked down: the fall and its slide (further for a car), a while on the ground, getting up. +func _plan_down(r: Reaction, d: Vector3, strength: float, cause: String) -> StringName: + var car := cause == "car" + var lie := lerpf(2.5, 6.0, strength) if car else _rng.randf_range(1.2, 2.4) + var m := lerpf(1.5, 4.0, strength) if car else lerpf(0.8, 1.4, strength) + r.steps = [ + ["place", &"knockback", float(lengths[&"knockback"]) + lie], + ["place", &"get_up", float(lengths[&"get_up"])], + ] + r.slide = d * (m * WS.M) + r.slide_s = 0.55 + return &"knockback" + + +## A light blow: reeling back (head for a punch, chest otherwise; the other one when the same clip +## is playing, so a second blow shows). +func _plan_reel(r: Reaction, d: Vector3, strength: float, cause: String) -> StringName: + var clip: StringName = &"hit_head" if cause == "punch" else &"hit_chest" + if r.w.react == clip: + clip = &"hit_chest" if clip == &"hit_head" else &"hit_head" + var ln := float(lengths[clip]) + r.steps = [["place", clip, ln]] + r.slide = d * (lerpf(0.25, 0.6, strength) * WS.M) + r.slide_s = ln * 0.8 + return clip + + +## What a victim does once the blow is over: shakes their head at a shove or shouts at a punch or +## a car; after a punch they run off when they can, else they walk back to their place. +func _after(r: Reaction, cause: String) -> Array: + var shout := ["place", &"no" if cause == "shove" else &"shout", _rng.randf_range(1.6, 2.6)] + if cause == "punch" and _can_run(r): + return [shout, _run_step()] + return [shout, ["back", &"", 30.0]] + + +func _run_step() -> Array: + return ["run", &"run", _rng.randf_range(4.0, 7.0)] + + +## True when r's walker can run off: it was walking somewhere (an edge or a free leg). +func _can_run(r: Reaction) -> bool: + var st := r.was if not r.was.is_empty() else r.w.state + return (st == "walk" or st == "chat") and (r.w.edge != null or r.w.on_leg) + + +## People within WITNESS_R of w who see what happened to it (severity 0 a shove, 1 a punch, 2 a +## knock-down) and are not reacting already: some run off, some shout at src (shake their head at +## a shove). Returns them. +func witness(w: WK.Walker, src: Vector3, severity: int) -> Array: + var out: Array = [] + var s := clampi(severity, 0, 2) + var r2 := WITNESS_R * WITNESS_R + for o: WK.Walker in wk.walkers: + if o == w or not _free(o): + continue + if o.pos.distance_squared_to(w.pos) > r2 or _rng.randf() > SEE[s]: + continue + var r := _new(o) + r.src = src + r.delay = _rng.randf_range(0.2, 0.7) + if _can_run(r) and _rng.randf() < FLEE[s]: + r.steps = [_run_step()] + else: + r.steps = [["place", &"no" if s == 0 else &"shout", _rng.randf_range(1.5, 3.0)]] + out.append(o) + return out + + +## People who answer the hero's emote ("wave", "yes", "no", "shout", "dance"; ANSWERS) made at +## p facing fwd (xz): after a moment they turn to p and wave, nod or shake their head. Returns +## them. +func answer_emote(p: Vector3, fwd: Vector3, emote: String) -> Array: + var spec: Array = ANSWERS.get(emote, []) + var out: Array = [] + if spec.is_empty(): + return out + var reach := float(spec[0]) * WS.M + var f2 := Vector2(fwd.x, fwd.z).normalized() + var best: WK.Walker = null + var bd := INF + for o: WK.Walker in wk.walkers: + if not _free(o): + continue + var q := o.pos + o.vis_off + var d := Vector2(q.x - p.x, q.z - p.z) + var dist := d.length() + if dist > reach or dist < 1e-6 or d.dot(f2) < float(spec[1]) * dist: + continue + if float(spec[2]) <= 0.0: + if dist < bd: + bd = dist + best = o + elif out.size() < ANSWER_MAX and _rng.randf() < float(spec[2]): + out.append(o) + if best != null: + out.append(best) + for o: WK.Walker in out: + var r := _new(o) + r.src = p + r.delay = _rng.randf_range(0.3, 0.8) + r.steps = [["place", spec[3], float(spec[4])]] + return out + + +## True when o can start a reaction: drawn near, not talking to the player, not reacting. +func _free(o: WK.Walker) -> bool: + return not (o.dead or o.far or o.state == "hidden" or o.hold or _active.has(o.id)) + + +func _new(w: WK.Walker) -> Reaction: + var r := Reaction.new() + r.w = w + r.speed = w.speed + _active[w.id] = r + return r + + +## Starts r's next step, or gives the walker its own state back when none is left. +func _next_step(r: Reaction) -> void: + var w := r.w + if r.was.is_empty(): + # A chat is over (the other one walks on by itself). + r.was = "walk" if w.state == "chat" else w.state + w.chat_with = null + if r.steps.is_empty(): + _finish(r) + return + var st: Array = r.steps.pop_front() + r.mode = str(st[0]) + r.left = float(st[2]) + if r.mode == "run": + w.state = r.was + w.react = &"run" + w.speed = r.speed * RUN_F + wk.turn_from(w, r.src) + return + w.state = "react" + w.react = st[1] + w.react_t = 0.0 + w.v = 0.0 + w.vel = Vector3.ZERO + + +func _finish(r: Reaction) -> void: + var w := r.w + _active.erase(w.id) + w.react = &"" + w.speed = r.speed + if w.state == "react": + w.state = r.was if not r.was.is_empty() else "walk" + + +func step(dt: float) -> void: + clock += dt + for r: Reaction in _active.values(): + var w := r.w + if w.dead or (r.mode in ["place", "back"] and w.state != "react"): + # Gone, or taken on by something else (a trip set off): let go. + _finish(r) + continue + if r.delay > 0.0: + r.delay -= dt + if r.delay <= 0.0: + _next_step(r) + continue + _slide(r, dt) + var over := false + match r.mode: + "place": + over = _place(r, dt) + "back": + over = _back(r, dt) + _: + r.left -= dt + over = r.left <= 0.0 or w.state != "walk" + if over: + _next_step(r) + + +## Stands and plays the step's clip (turning to face src once a one-shot is over); true when done. +func _place(r: Reaction, dt: float) -> bool: + var w := r.w + r.left -= dt + w.react_t += dt + w.vis_v = 0.0 + w.gait = move_toward(w.gait, 0.0, dt * 4.0) + if w.react not in ONCE: + var d := r.src - (w.pos + w.vis_off) + if Vector2(d.x, d.z).length_squared() > 1e-10: + w.yaw = lerp_angle(w.yaw, atan2(d.x, d.z), minf(1.0, dt * 5.0)) + return r.left <= 0.0 + + +## Walks back to the walker's own place (its drawn offset to nothing); true once there. +func _back(r: Reaction, dt: float) -> bool: + var w := r.w + r.left -= dt + var off := Vector2(w.vis_off.x, w.vis_off.z) + var d := BACK_V * dt + if off.length() <= d or r.left <= 0.0: + w.vis_off = Vector3.ZERO + w.vis_v = 0.0 + return true + var dir := -off.normalized() + _set_feet(w, w.pos + w.vis_off + Vector3(dir.x, 0.0, dir.y) * d) + w.yaw = lerp_angle(w.yaw, atan2(dir.x, dir.y), minf(1.0, dt * 8.0)) + w.vis_v = BACK_V + w.gait = move_toward(w.gait, 1.0, dt * 4.0) + w.phase += d / (0.75 * WS.M) * PI + return false + + +## Moves the drawn offset on along the blow's slide (easing out), stopped by walls and posts. +func _slide(r: Reaction, dt: float) -> void: + if r.slide_t >= r.slide_s: + return + var a := _ease(r.slide_t / r.slide_s) + r.slide_t = minf(r.slide_t + dt, r.slide_s) + var move := r.slide * (_ease(r.slide_t / r.slide_s) - a) + var p := r.w.pos + r.w.vis_off + var q := p + move + if collision != null: + q = collision.move_body(p, move, BODY_R, BODY_H, WS.STEP_HEIGHT) + _set_feet(r.w, q) + + +static func _ease(x: float) -> float: + return 1.0 - (1.0 - x) * (1.0 - x) + + +## Draws w's feet at q, on the ground there (off the curb into the road, say). +func _set_feet(w: WK.Walker, q: Vector3) -> void: + var y := w.pos.y + w.vis_off.y + if collision != null: + var g := collision.walk_height(q) + if not is_nan(g): + y = g + w.vis_off = Vector3(q.x - w.pos.x, y - w.pos.y, q.z - w.pos.z) + + +## Lane blocks for vehicles.gd: the people hit, until they walk or run on, as circles at their +## drawn feet (on a sidewalk they are clear of every lane). +func blocks() -> Array: + var out: Array = [] + for r: Reaction in _active.values(): + if r.victim and r.mode != "run" and not r.w.dead: + var q := r.w.pos + r.w.vis_off + out.append([Vector2(q.x, q.z), Vector2.ZERO, -1.0, 0.0, Vector2.ZERO]) + return out + + +## True while w is knocked down (lying or getting up). +func is_down(w: WK.Walker) -> bool: + var r: Reaction = _active.get(w.id) + return r != null and r.down and r.w.react in [&"knockback", &"get_up"] + + +func count() -> int: + return _active.size() + + +## Stages w playing clip from t seconds in, for secs (photos, tests). The caller releases its body +## so the next one starts at t. +func pose(w: WK.Walker, clip: StringName, t: float, secs: float = 60.0) -> void: + var r: Reaction = _active.get(w.id) + if r == null: + r = _new(w) + r.src = w.pos + Vector3(sin(w.yaw), 0.0, cos(w.yaw)) + r.steps = [["place", clip, secs]] + r.delay = 0.0 + r.victim = clip in [&"knockback", &"get_up"] + r.down = r.victim + _next_step(r) + w.react_t = t + w.anim_clip = clip + w.anim_t = t diff --git a/scripts/fp/traffic/reactions.gd.uid b/scripts/fp/traffic/reactions.gd.uid new file mode 100644 index 00000000..ba69425d --- /dev/null +++ b/scripts/fp/traffic/reactions.gd.uid @@ -0,0 +1 @@ +uid://yqjk5h1v6v14 diff --git a/scripts/fp/traffic/traffic_feature.gd b/scripts/fp/traffic/traffic_feature.gd index d86f7c0d..8c1ed06b 100644 --- a/scripts/fp/traffic/traffic_feature.gd +++ b/scripts/fp/traffic/traffic_feature.gd @@ -24,6 +24,8 @@ const CS := preload("res://scripts/fp/traffic/contact_shadows.gd") const OCC := preload("res://scripts/fp/traffic/occupants.gd") const SHOVES := preload("res://scripts/fp/traffic/shoves.gd") const DOORS := preload("res://scripts/fp/traffic/car_doors.gd") +const REACT := preload("res://scripts/fp/traffic/reactions.gd") +const INCIDENTS := preload("res://scripts/fp/traffic/incidents.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. @@ -36,6 +38,8 @@ const PHOTO_BACK := 1.3 const LINE_RETRY := 10.0 ## The player can take a parked car, their own, or an ambient car stopped below this speed. const CLAIM_SPEED := 0.5 * WS.M +## A crew called with no station in town sets off from a road at most this many tiles away. +const STATION_FAR := 10 var lanes: LG var peds: SW @@ -52,6 +56,9 @@ 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 +## People hit in the street (reel, fall, get up, run off) and what that means for the city. +var reactions: REACT +var incidents: INCIDENTS 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: @@ -116,6 +123,8 @@ func setup(p_ctx: FpContext) -> void: trips = TRIPS.new() fleets = FLEETS.new() shoves = SHOVES.new() + reactions = REACT.new() + incidents = INCIDENTS.new() trips.jitter_enabled = jitter_enabled render.setup(_agents_root) contact = CS.new() @@ -196,8 +205,10 @@ func on_world_rebuilt() -> void: vehicles.on_removed = _on_vehicle_removed shoves.setup(vehicles, ctx.collision) shoves.visible = on_screen - vehicles.extra_blocks = shoves.blocks + vehicles.extra_blocks = func() -> Array: return shoves.blocks() + reactions.blocks() walkers.setup(peds, lanes, junctions, vehicles, sim.city_seed) + reactions.setup(walkers, ctx.collision, sim.city_seed) + incidents.setup(self) walkers.hc = people.ok walkers.on_removed = func(w: WK.Walker) -> void: render.release_walker(w) @@ -363,6 +374,7 @@ func step(dt: float) -> void: vehicles.step(dt) shoves.step(dt) walkers.step(dt) + reactions.step(dt) trips.step(dt) fleets.step(dt) _maint_t -= dt @@ -1077,7 +1089,7 @@ func pick(origin: Vector3, dir: Vector3, max_dist: float) -> Dictionary: continue if w.pos.distance_to(origin) > max_dist + 0.1: continue - var t := _ray_box(origin, d, Transform3D(Basis(Vector3.UP, w.yaw), w.pos), Vector3(0.32 * WS.M, 0.0, 0.25 * WS.M), 1.8 * WS.M) + var t := _ray_box(origin, d, Transform3D(Basis(Vector3.UP, w.yaw), w.pos + w.vis_off), Vector3(0.32 * WS.M, 0.0, 0.25 * WS.M), 1.8 * WS.M) if t >= 0.0 and t < bt: bt = t best = _walker_target(w, t) @@ -1195,6 +1207,122 @@ func bump_car(id: int, j: Vector2, at: Vector2) -> void: shoves.bump(c, j, at) +# ------------------------------------------------------------------ people hit + +## People on foot within r of p, for the player's blows and the car body: [{"id", "pos" (the +## drawn feet), "yaw" (facing (sin, 0, cos)), "down", "resident_id"}]. +func walkers_near(p: Vector3, r: float) -> Array: + var out: Array = [] + if not _built: + return out + for w: WK.Walker in walkers.walkers: + if w.dead or w.far or w.state == "hidden": + continue + var q := w.pos + w.vis_off + if Vector2(q.x - p.x, q.z - p.z).length_squared() > r * r: + continue + var down := reactions.is_down(w) + out.append({"id": w.id, "pos": q, "yaw": w.yaw, "down": down, "resident_id": w.resident_id}) + return out + + +## Person id (walkers_near) takes a blow from src (a world point) along push, of strength 0..1, +## cause "shove", "punch" or "car": they react and the people around see it (reactions.gd), the +## city hears of it (incidents.gd) and the bus emits person_hit. Returns that signal's info, {} +## when they could not be hit now. +func hit_walker(id: int, src: Vector3, push: Vector3, strength: float, cause: String) -> Dictionary: + var w := _walker_by_id(id) if _built else null + if w == null: + return {} + var res := reactions.hit(w, src, push, clampf(strength, 0.0, 1.0), cause) + if res.is_empty(): + return {} + var sev := 2 if bool(res["down"]) else (0 if cause == "shove" else 1) + var seen := reactions.witness(w, src, sev) + var at := w.pos + w.vis_off + var info := incidents.report(w, at, strength, cause, res, seen) + if ctx.bus != null: + ctx.bus.person_hit.emit(at, cause, info) + return info + + +## The hero's emote ("wave", "yes", "no", "shout", "dance") at p facing fwd: the people who +## answer it (reactions.gd answer_emote). Returns how many do. +func answer_emote(p: Vector3, fwd: Vector3, emote: String) -> int: + return reactions.answer_emote(p, fwd, emote).size() if _built else 0 + + +## Photo and test hook: person id plays reaction clip from t seconds in (held, sim paused or not). +func pose_walker(id: int, clip: StringName, t: float) -> bool: + var w := _walker_by_id(id) if _built else null + if w == null: + return false + reactions.pose(w, clip, t) + people.release(w) + return true + + +func _walker_by_id(id: int) -> WK.Walker: + for w: WK.Walker in walkers.walkers: + if w.id == id and not w.dead: + return w + return null + + +## Calls a crew ("police" or "ambulance") to world point at, from the road beside the nearest +## police station or hospital (a road some way off when the city has none), to stay that many +## seconds once there (fleets.gd). False when no road is near at. +func call_crew(kind: String, at: Vector3, stay: float) -> bool: + if not _built: + return false + var target := _road_near(Vector2i(roundi(at.x), roundi(at.z))) + if target.x < 0: + return false + var station := _station_road(D.POLICE if kind == "police" else D.HOSPITAL, target) + if station.x < 0: + return false + fleets.dispatch_crew(kind, station, target, stay) + return true + + +## The road tile nearest c (within 2 tiles), or (-1, -1). +func _road_near(c: Vector2i) -> Vector2i: + for ring in range(3): + for dy in range(-ring, ring + 1): + for dx in range(-ring, ring + 1): + var x := c.x + dx + var y := c.y + dy + var edge := maxi(absi(dx), absi(dy)) == ring + if edge and D.in_bounds(x, y) and lanes.tiles.has(D.idx(x, y)): + return Vector2i(x, y) + return Vector2i(-1, -1) + + +## The road beside the station of sim kind nearest target, else the road tile farthest from +## target within STATION_FAR tiles (a crew from out of town); (-1, -1) when there is none. +func _station_road(kind: int, target: Vector2i) -> Vector2i: + var best := Vector2i(-1, -1) + var bd := 1 << 30 + for i: int in ctx.sim.active: + if ctx.sim.kind[i] != kind: + continue + for dv: Vector2i in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: + var n := Vector2i(D.tx(i), D.ty(i)) + dv + var d := absi(n.x - target.x) + absi(n.y - target.y) + if D.in_bounds(n.x, n.y) and lanes.tiles.has(D.idx(n.x, n.y)) and d < bd: + bd = d + best = n + if best.x >= 0: + return best + var far := -1 + for i: int in lanes.tiles.keys(): + var d := absi(D.tx(i) - target.x) + absi(D.ty(i) - target.y) + if d > far and d <= STATION_FAR: + far = d + best = Vector2i(D.tx(i), D.ty(i)) + return best + + # ------------------------------------------------------------------ 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/walkers.gd b/scripts/fp/traffic/walkers.gd index 184352d2..fcdd56f4 100644 --- a/scripts/fp/traffic/walkers.gd +++ b/scripts/fp/traffic/walkers.gd @@ -63,7 +63,7 @@ class Walker extends RefCounted: var plan: Array = [] # steps: {"edge": id, "dir": d} or {"pt": Vector3} var pt_target := Vector3.ZERO # current free-leg target var on_leg: bool = false - var state: String = "walk" # walk | wait | stand | hidden + var state: String = "walk" # walk | wait | stand | hidden | react (reactions.gd has it) var wait_t: float = 0.0 var stand_yaw: float = 0.0 var crossing: SW.PedEdge = null # zebra it is registered on @@ -108,6 +108,8 @@ class Walker extends RefCounted: var anim_rate: float = 1.0 var body: Object = null # people.gd Body while it draws this walker var tier: int = -1 # people.gd LOD tier, -1 not drawn + var react: StringName = &"" # clip a reaction plays instead of its own (reactions.gd) + var react_t: float = 0.0 # seconds into react var sw: SW @@ -261,7 +263,7 @@ func _step_one(w: Walker, dt: float) -> void: if d.length_squared() > 1e-8: w.yaw = lerp_angle(w.yaw, atan2(d.x, d.z), minf(1.0, dt * 6.0)) return - if w.state == "stand": + if w.state == "stand" or w.state == "react": w.v = 0.0 return if w.state == "chat": @@ -697,6 +699,20 @@ func _walk_leg(w: Walker, dt: float) -> void: w.phase += dt * v / (0.75 * WS.M) * PI +## Turns an ambient walker round on its edge when src lies ahead (it runs off from there); one on +## a plan, a free leg or a zebra keeps its way. +func turn_from(w: Walker, src: Vector3) -> void: + var e := w.edge + if e == null or w.on_leg or e.zebra or not w.plan.is_empty() or w.role != "ambient": + return + if _fwd(w).dot(Vector2(src.x - w.pos.x, src.z - w.pos.z)) <= 0.0: + return + w.dir = -w.dir + w.t = e.length - w.t + w.t_end = -1.0 + w.lat = -w.lat + + ## Starts w on a plan from where it stands (first step a free leg to the network or an edge). func start_plan(w: Walker, plan: Array) -> void: w.plan = plan @@ -708,8 +724,12 @@ func start_plan(w: Walker, plan: Array) -> void: ## Eases the drawn stride toward the walking speed and the fade-in toward 1 (real seconds, so a -## paused tour still settles). +## paused tour still settles). A reaction moves the drawn offset and sets the drawn speed itself. func ease_visuals(w: Walker, delta: float) -> void: + if w.fade < 1.0: + w.fade = minf(1.0, w.fade + delta / FADE_IN) + if w.state == "react": + return var want := clampf(w.v / (0.7 * WS.PED_WALK), 0.0, 1.0) w.vis_v = w.v if (w.state == "walk" and not w.hold) else 0.0 var off := _wait_offset(w) @@ -722,8 +742,6 @@ func ease_visuals(w: Walker, delta: float) -> void: w.vis_v = maxf(w.vis_v, sv) w.phase += (w.vis_off - was).length() / (0.75 * WS.M) * PI w.gait = move_toward(w.gait, want, delta * 4.0) - if w.fade < 1.0: - w.fade = minf(1.0, w.fade + delta / FADE_IN) ## Draw transform of w: the legs and arms swing in the people shader (render.gd), and the body diff --git a/scripts/fp/ui/chat_panel_row.gd b/scripts/fp/ui/chat_panel_row.gd index 91223126..f75ff977 100644 --- a/scripts/fp/ui/chat_panel_row.gd +++ b/scripts/fp/ui/chat_panel_row.gd @@ -23,10 +23,11 @@ const REPLY_H := 17.0 const CHIP_H := 15.0 const CARD_ICON := 22.0 const ROLE_TAGS := {"mayor": "MAYOR", "press": "PRESS", "council": "COUNCIL"} -## Card icon per incident type (refs.type). +## Card icon per incident type (refs.type; "street": a crew called after someone was hit). const INCIDENT_ICONS := { "fire": "fire", "blackout": "bolt", "power_line": "bolt", "sewage_burst": "drop", "water_main": "drop", "flood": "drop", "gridlock": "alert", "car_crash": "alert", "smog": "alert", "earthquake": "alert", "tornado": "alert", + "street": "alert", } static var _cache: Dictionary = {} diff --git a/scripts/fp/ui/pause_menu.gd b/scripts/fp/ui/pause_menu.gd index 5d6ea673..7164d256 100644 --- a/scripts/fp/ui/pause_menu.gd +++ b/scripts/fp/ui/pause_menu.gd @@ -55,6 +55,7 @@ const LEGEND := [ ] const KEYS := [ ["M", "Map"], ["Tab", "City overview"], ["V", "Camera view"], ["Q", "Shoulder"], ["E", "Interact"], ["F", "Get in / out of a car"], ["B", "Build phone"], + ["Click", "Shove"], ["Right-click", "Guard (click to punch)"], ["1-5", "Emotes"], ["Enter", "Chat"], ["J", "AI activity"], ["G", "Route to AI decision"], ["[ ]", "Game speed"], ["T", "Skip to morning"], ["P", "Photo mode"], ["F1", "Hide HUD"], ["F5", "Quick save"], ["F9", "Quick load"], diff --git a/sounds/fp/body_fall.wav b/sounds/fp/body_fall.wav new file mode 100644 index 00000000..15573667 Binary files /dev/null and b/sounds/fp/body_fall.wav differ diff --git a/sounds/fp/body_fall.wav.import b/sounds/fp/body_fall.wav.import new file mode 100644 index 00000000..666fde57 --- /dev/null +++ b/sounds/fp/body_fall.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://ccq576syfs62v" +path="res://.godot/imported/body_fall.wav-c167c04457acbc7a3c3f990270b0f8d0.sample" + +[deps] + +source_file="res://sounds/fp/body_fall.wav" +dest_files=["res://.godot/imported/body_fall.wav-c167c04457acbc7a3c3f990270b0f8d0.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/punch_hit.wav b/sounds/fp/punch_hit.wav new file mode 100644 index 00000000..7e96d2af Binary files /dev/null and b/sounds/fp/punch_hit.wav differ diff --git a/sounds/fp/punch_hit.wav.import b/sounds/fp/punch_hit.wav.import new file mode 100644 index 00000000..0f6a6d44 --- /dev/null +++ b/sounds/fp/punch_hit.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://buuhlpcg0dnbl" +path="res://.godot/imported/punch_hit.wav-2f794ba419988123ea72784e9bec1712.sample" + +[deps] + +source_file="res://sounds/fp/punch_hit.wav" +dest_files=["res://.godot/imported/punch_hit.wav-2f794ba419988123ea72784e9bec1712.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/punch_whoosh.wav b/sounds/fp/punch_whoosh.wav new file mode 100644 index 00000000..566b59e5 Binary files /dev/null and b/sounds/fp/punch_whoosh.wav differ diff --git a/sounds/fp/punch_whoosh.wav.import b/sounds/fp/punch_whoosh.wav.import new file mode 100644 index 00000000..659f65c1 --- /dev/null +++ b/sounds/fp/punch_whoosh.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://b8e4h5ubu300t" +path="res://.godot/imported/punch_whoosh.wav-5d06c7937d85ccb7fc978ac66ccfa1ee.sample" + +[deps] + +source_file="res://sounds/fp/punch_whoosh.wav" +dest_files=["res://.godot/imported/punch_whoosh.wav-5d06c7937d85ccb7fc978ac66ccfa1ee.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 6c448ba3..d56deae4 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, crashes, knocked props and car doors, UI cues, the engine loops and the siren. Starting an Ogg Vorbis playback costs about + horns, crashes, knocked props, car doors and blows, 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, @@ -1034,6 +1034,57 @@ def synth_car_door_close() -> np.ndarray: return norm_peak(fade_edges(out, 0.0005, 0.1), -1.0) +# ------------------------------------------------------------------ bodies + +def synth_punch_hit() -> np.ndarray: + """A fist or a shove landing on someone: the dull thump of the body, the slap of knuckles on + cloth and a rustle of clothes.""" + rng = rng_for("punch_hit") + n = int(0.4 * SR) + out = np.zeros(n) + out += 1.0 * thud(rng, n, 150.0, 95.0, 0.035) + out += 0.45 * thud(rng, n, 260.0, 180.0, 0.018) + out += 0.45 * crack(rng, n, 0.006, 300.0, 2500.0) + sl = int(0.12 * SR) + cloth = bandpass(rng.standard_normal(sl), 900.0, 6000.0) * env_ad(sl, 0.004, 0.03) + add_at(out, 0.12 * cloth / (np.max(np.abs(cloth)) + 1e-9), int(0.004 * SR), wrap=False) + out = stereo_reverb(out, rng, 0.35, 0.08, 4500.0, circular=False).mean(axis=1) + return norm_peak(fade_edges(out, 0.0005, 0.06), -1.0) + + +def synth_punch_whoosh() -> np.ndarray: + """An arm swung fast: the air it tears, a band of noise rising as the swing speeds up and + falling as it ends, with the sleeve's flutter.""" + rng = rng_for("punch_whoosh") + n = int(0.32 * SR) + t = secs(n) + src = rng.standard_normal(n) + out = np.zeros(n) + for k, (lo, hi) in enumerate([(250.0, 700.0), (450.0, 1300.0), (800.0, 2200.0), (500.0, 1500.0)]): + out += bandpass(src, lo, hi) * np.exp(-0.5 * ((t - (0.05 + 0.035 * k)) / 0.03) ** 2) + out *= 0.75 + 0.25 * np.sin(2 * np.pi * 38.0 * t + rng.uniform(0, 2 * np.pi)) + return norm_peak(fade_edges(out, 0.002, 0.05), -1.0) + + +def synth_body_fall() -> np.ndarray: + """Someone knocked down: the body meeting the pavement (a heavy, soft thud), an arm or the + head following it down and a short scuff of clothes along the ground.""" + rng = rng_for("body_fall") + n = int(1.0 * SR) + out = np.zeros(n) + out += 1.0 * thud(rng, n, 85.0, 52.0, 0.09) + out += 0.5 * thud(rng, n, 170.0, 110.0, 0.04) + out += 0.3 * lowpass(rng.standard_normal(n), 300.0) * env_ad(n, 0.003, 0.06) / 0.2 + out += 0.35 * crack(rng, n, 0.01, 250.0, 2500.0) + second = int(rng.uniform(0.12, 0.16) * SR) + add_at(out, 0.45 * thud(rng, int(0.3 * SR), 120.0, 80.0, 0.04), second, wrap=False) + add_at(out, 0.2 * crack(rng, int(0.1 * SR), 0.006, 400.0, 3000.0), second, wrap=False) + out += 0.12 * scrape(rng, n, 0.03, 0.35, 500.0, 4000.0, 5.0) + grit = crunch(rng, int(0.08 * SR), 300.0, 0.03, 1500.0, 7000.0) + add_at(out, 0.12 * grit, int(0.01 * SR), wrap=False) + out = stereo_reverb(out, rng, 0.5, 0.1, 4500.0, circular=False).mean(axis=1) + return norm_peak(fade_edges(out, 0.0005, 0.12), -1.0) + # ------------------------------------------------------------------ UI def bell_note(f: float, dur: float, index: float = 2.0, ratio: float = 1.4, decay: float = 0.4) -> np.ndarray: @@ -1079,7 +1130,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", "crash_heavy", "crash_light", "prop_clatter", "prop_clang", - "car_door_open", "car_door_close"} + "car_door_open", "car_door_close", "punch_hit", "punch_whoosh", "body_fall"} def is_wav(name: str) -> bool: @@ -1113,6 +1164,9 @@ def catalogue() -> dict[str, tuple[object, bool]]: "prop_clang": (synth_prop_clang, False), "car_door_open": (synth_car_door_open, False), "car_door_close": (synth_car_door_close, False), + "punch_hit": (synth_punch_hit, False), + "punch_whoosh": (synth_punch_whoosh, False), + "body_fall": (synth_body_fall, False), } for s in SURFACES: for k in range(1, STEP_VARIANTS + 1): diff --git a/tools/characters/build_clips.py b/tools/characters/build_clips.py index 8a825364..64004801 100644 --- a/tools/characters/build_clips.py +++ b/tools/characters/build_clips.py @@ -95,6 +95,13 @@ "no": ("UAL2", "Idle_No_Loop", True, False), "climb_1m": ("UAL2", "ClimbUp_1m", False, False), "knockback": ("UAL2", "Hit_Knockback", False, False), + "punch_jab": ("UAL1", "Punch_Jab", False, False), + "punch_cross": ("UAL1", "Punch_Cross", False, False), + "hit_head": ("UAL1", "Hit_Head", False, False), + "hit_chest": ("UAL1", "Hit_Chest", False, False), + "dance": ("UAL1", "Dance_Loop", True, False), + "get_up": ("UAL2", "LayToIdle", False, False), + "shout": ("UAL2", "Idle_Rail_Call", True, False), } UAL_GLB = {"UAL1": "UAL1/UAL1_Standard.glb", "UAL2": "UAL2/UAL2_Standard.glb"} diff --git a/tools/tests/fp_actions_test.gd b/tools/tests/fp_actions_test.gd new file mode 100644 index 00000000..75c65b0c --- /dev/null +++ b/tools/tests/fp_actions_test.gd @@ -0,0 +1,502 @@ +extends SceneTree +## Body actions (integration feature "actions"; docs/fp/ARCHITECTURE.md, Body actions): the hero +## rig's action layer (strike times, the guard, punches, the dance); the strike button shoving +## whoever stands in front, the guard turning it into a jab-cross combo whose third blow knocks +## them down, swings at nobody; emotes the people around answer; the guard squaring the body to +## the camera; what blocks the actions; presses (events, a trigger's edge) and held input; the +## player's car striking people (car_body.gd); person_hit and hero_action on the bus; teardown. +## Third person on a grown city; time runs through the feature's tick(1/60), traffic's step and +## the player's update_view. + +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 WK := preload("res://scripts/fp/traffic/walkers.gd") +const LG := preload("res://scripts/fp/traffic/lane_graph.gd") +const BODY := preload("res://scripts/fp/drive/car_body.gd") +const MODEL := preload("res://scripts/fp/drive/drive_model.gd") +const DT := 1.0 / 60.0 + +var fails: Array[String] = [] +var ctx: FpContext +var tr: FpFeature +var f: FpFeature +var rig: RefCounted +## [at, cause, info] of every person_hit, and every hero_action's action. +var hits: Array = [] +var acts: Array[String] = [] + + +func _init() -> void: + await process_frame + ctx = K.make_ctx(get_root(), {"view": true, "grow": true, "camera_mode": "third"}) + ctx.player.set_process(false) + tr = K.make_feature(ctx, "traffic") + f = K.make_feature(ctx, "actions") + if tr == null or f == null: + K.check(fails, false, "the traffic and actions features are registered") + K.finish(self, "FP_ACTIONS_TEST", fails) + return + tr.set("auto_step", false) + tr.set("ambient_enabled", false) + f.set_process(false) + _quiet() + ctx.bus.person_hit.connect( + func(at: Vector3, cause: String, info: Dictionary) -> void: hits.append([at, cause, info]) + ) + ctx.bus.hero_action.connect(func(a: String, _at: Vector3) -> void: acts.append(a)) + var p := ctx.player + p.teleport(p.position, rad_to_deg(p.yaw)) + rig = p.avatar.get("rig") + K.check(fails, rig != null, "fixture: the hero is HC's animated rig") + _check_inputs() + if rig != null: + _check_rig() + _check_shove() + _check_combo() + _check_miss() + _check_emotes() + _check_guard() + _check_blocked() + await _check_held_input() + _check_car() + _check_teardown() + K.finish(self, "FP_ACTIONS_TEST", fails) + + +# ------------------------------------------------------------------ helpers + + +## Steps the actions feature (unless feature is false: the rig on its own), traffic and the player. +func _step(secs: float, feature: bool = true) -> void: + for _k in range(ceili(secs / DT - 1e-6)): + if feature: + f.call("tick", DT) + tr.call("step", DT) + ctx.player.simulate(Vector2.ZERO, false, DT) + ctx.player.update_view(DT) + + +## Removes every walker, so only the test's own people are about. +func _quiet() -> void: + var wk: WK = tr.get("walkers") + for w: WK.Walker in wk.walkers.duplicate(): + wk.remove(w) + + +## Someone standing dist_m metres ahead of the hero (behind for a negative distance), facing them. +func _person(dist_m: float) -> WK.Walker: + var wk: WK = tr.get("walkers") + var p := ctx.player + var fwd := p.body_forward() + var w := wk.make("ambient") + w.extra = 0 + wk.stand(w, p.position + fwd * dist_m * WS.M, atan2(-fwd.x, -fwd.z)) + return w + + +func _remove(w: WK.Walker) -> void: + (tr.get("walkers") as WK).remove(w) + + +## Steps the hero up to dist_m metres from where w is drawn (a blow slides them back). +func _close_in(w: WK.Walker, dist_m: float) -> void: + var p := ctx.player + var q := w.pos + w.vis_off + var d := Vector3(q.x - p.position.x, 0.0, q.z - p.position.z).normalized() + p.position = Vector3(q.x, p.position.y, q.z) - d * dist_m * WS.M + + +## The higher hand above the feet (metres). +func _hands_up() -> float: + var y := -INF + for b: String in ["LeftHand", "RightHand"]: + y = maxf(y, (rig.call("bone_world", b) as Vector3).y) + return (y - ctx.player.position.y) / WS.M + + +func _causes() -> Array[String]: + var out: Array[String] = [] + for h: Array in hits: + out.append(str(h[1])) + return out + + +# ------------------------------------------------------------------ checks + + +func _check_inputs() -> void: + var st := f.self_test() + K.check( + fails, + bool(st.get("ok", false)), + "self_test: every input exists (%s)" % st.get("detail", "") + ) + var pads := [_pad("act_strike"), _pad("act_aim")] + K.check( + fails, pads == ["a5+", "a4+"], "the right trigger strikes, the left guards (%s)" % str(pads) + ) + var mice := [_mouse("act_strike"), _mouse("act_aim")] + var mice_ok := mice == [MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT] + K.check(fails, mice_ok, "the left mouse button strikes, the right guards (%s)" % str(mice)) + var keys: Array[int] = [] + for a: String in ["emote_wave", "emote_yes", "emote_no", "emote_shout", "emote_dance"]: + keys.append(_key(a)) + var keys_ok := keys == [KEY_1, KEY_2, KEY_3, KEY_4, KEY_5] + K.check(fails, keys_ok, "keys 1-5 wave, nod, shake the head, shout and dance (%s)" % str(keys)) + + +func _check_rig() -> void: + _step(0.5, false) + var jab := float(rig.call("strike_time", "punch_jab")) + var cross := float(rig.call("strike_time", "punch_cross")) + var times_ok := jab > 0.1 and jab < 0.35 and cross > jab and cross < 0.45 + K.check( + fails, + times_ok, + "each punch is furthest out early on (jab %.3f s, cross %.3f s)" % [jab, cross] + ) + var low := _hands_up() + rig.call("set_guard", true) + _step(0.4, false) + var high := _hands_up() + var g := float(rig.get("guard")) + var up_msg := "the guard raises the fists (%.2f, hands at %.2f m, %.2f m before)" + K.check(fails, g > 0.95 and high > low + 0.15, up_msg % [g, high, low]) + rig.call("set_guard", false) + _step(0.4, false) + K.check(fails, float(rig.get("guard")) < 0.05, "and drops them again") + K.check(fails, rig.call("play_action", "punch_jab") == true, "a jab plays") + _step(DT, false) + K.check(fails, bool(rig.call("action_active")), "on the upper body") + _step(1.2, false) + K.check(fails, not bool(rig.call("action_active")), "and ends by itself") + K.check(fails, rig.call("play_action", "bogus") == false, "an unknown action is refused") + K.check(fails, rig.call("play_action", "dance") == true, "the hero dances standing") + _step(1.0, false) + K.check(fails, bool(rig.call("action_active")), "and keeps dancing") + for _k in range(40): + rig.call("update", DT, 1.3, true, 0.0, false, false) + K.check(fails, not bool(rig.call("action_active")), "walking off ends the dance") + _step(1.0, false) + + +func _check_shove() -> void: + var w := _person(0.8) + hits.clear() + acts.clear() + var started: bool = f.call("strike") == true and str(f.get("swing")) == "shove" + K.check(fails, started, "with the guard down the strike button shoves") + K.check(fails, f.call("strike") == false, "one swing at a time") + _step(0.1) + K.check(fails, hits.is_empty(), "the shove lands once the arms are out, not at once") + _step(0.15) + K.check(fails, _causes() == ["shove"], "it lands on the person in front (%s)" % str(_causes())) + var info: Dictionary = hits[0][2] if not hits.is_empty() else {} + var reel := not bool(info.get("down", true)) and w.state == "react" + K.check( + fails, reel, "they reel but keep their feet (%s, %s)" % [str(info.get("down")), w.state] + ) + K.check( + fails, absf(float(info.get("strength", 0.0)) - 0.35) < 0.01, "a standing shove is light" + ) + var kick: Vector2 = ctx.player.get("_kick") + K.check(fails, kick.length() > 1e-4, "the view jolts as it lands") + K.check(fails, acts == ["shove"], "hero_action hears the shove (%s)" % str(acts)) + _step(0.6) + K.check(fails, str(f.get("swing")).is_empty(), "the swing ends") + _remove(w) + _step(0.5) + + +func _check_combo() -> void: + var w := _person(0.8) + hits.clear() + acts.clear() + f.call("set_guard", true) + _step(0.3) + K.check(fails, bool(f.get("guarding")), "set_guard raises the guard") + var downs: Array[bool] = [] + for _k in range(3): + _close_in(w, 0.8) + f.call("strike") + _step(0.55) + for h: Array in hits: + downs.append(bool((h[2] as Dictionary).get("down", false))) + var combo := acts == ["jab", "cross", "jab"] + K.check(fails, combo, "with the guard up the punches go jab, cross, jab (%s)" % str(acts)) + K.check(fails, _causes() == ["punch", "punch", "punch"], "each lands (%s)" % str(_causes())) + K.check( + fails, downs == [false, false, true], "the third in a row knocks them down %s" % str(downs) + ) + K.check(fails, str(w.react) == "knockback", "they fall (%s)" % str(w.react)) + _close_in(w, 0.8) + f.call("strike") + _step(0.55) + K.check(fails, hits.size() == 3, "nobody is punched lying down") + f.call("set_guard", false) + _step(0.3) + _remove(w) + _step(0.5) + + +func _check_miss() -> void: + hits.clear() + var n0 := int(f.get("hits")) + K.check(fails, f.call("strike") == true, "a swing at nobody still plays") + _step(0.7) + K.check(fails, hits.is_empty() and int(f.get("hits")) == n0, "and hits nobody") + var w := _person(-0.8) + f.call("strike") + _step(0.7) + K.check(fails, hits.is_empty(), "nor someone behind the hero") + _remove(w) + var far := _person(2.5) + f.call("strike") + _step(0.7) + K.check(fails, hits.is_empty(), "nor someone out of reach") + _remove(far) + + +func _check_emotes() -> void: + var w := _person(3.0) + acts.clear() + var waved: bool = f.call("emote", "wave") == true + K.check( + fails, + waved and int(f.get("answered")) == 1, + "the person ahead answers a wave (%d)" % int(f.get("answered")) + ) + K.check(fails, f.call("emote", "yes") == false, "emotes are a moment apart") + _step(1.1) + var dancing: bool = f.call("emote", "dance") == true + _step(DT) + K.check( + fails, + dancing and bool(rig.call("action_active")) if rig != null else dancing, + "the hero dances" + ) + K.check(fails, acts == ["wave", "dance"], "hero_action hears the emotes (%s)" % str(acts)) + _step(1.1) + K.check(fails, f.call("emote", "bogus") == false, "an unknown emote is refused") + f.call("cancel") + _step(0.5) + K.check(fails, rig == null or not bool(rig.call("action_active")), "cancel ends the dance") + _remove(w) + + +func _check_guard() -> void: + var p := ctx.player + f.call("set_guard", true) + p.cam_yaw = wrapf(p.yaw + 1.0, -PI, PI) + _step(1.0) + K.check( + fails, + is_equal_approx(p.aim_yaw, p.cam_yaw), + "the guard aims the body where the camera looks" + ) + var off := absf(angle_difference(p.yaw, p.cam_yaw)) + K.check(fails, off < 0.15, "and the body turns to it (%.2f rad off)" % off) + K.check(fails, is_equal_approx(p.rig.arm_scale, 0.8), "the camera closes in") + f.call("set_guard", false) + _step(0.1) + var back := is_nan(p.aim_yaw) and is_equal_approx(p.rig.arm_scale, 1.0) + K.check(fails, back, "dropping the guard gives the body and the camera back") + p.teleport(p.position, rad_to_deg(p.cam_yaw)) + + +func _check_blocked() -> void: + var p := ctx.player + ctx.push_modal(&"test") + var none: bool = f.call("strike") == false and f.call("emote", "wave") == false + K.check(fails, none, "no swing or emote while something modal is open") + f.call("set_guard", true) + _step(0.1) + K.check(fails, not bool(f.get("guarding")), "nor a guard") + ctx.pop_modal(&"test") + f.call("set_guard", false) + p.set_photo_mode(true) + K.check(fails, f.call("strike") == false, "nor in photo mode") + p.set_photo_mode(false) + ctx.bus.drive_changed.emit(true) + K.check(fails, f.call("strike") == false, "nor at the wheel") + ctx.bus.drive_changed.emit(false) + K.check(fails, f.call("strike") == true, "on foot again it swings") + _step(0.7) + f.call("strike") + ctx.push_modal(&"test") + _step(DT) + K.check(fails, str(f.get("swing")).is_empty(), "a modal opening ends the swing") + ctx.pop_modal(&"test") + _step(0.5) + + +func _check_held_input() -> void: + var p := ctx.player + p.input_enabled = true + _step(DT) + var n0 := int(f.get("swings")) + _send(_action("act_strike")) + K.check(fails, int(f.get("swings")) == n0 + 1, "a strike press swings") + _step(0.7) + K.check(fails, int(f.get("swings")) == n0 + 1, "once") + for v: float in [0.6, 1.0, 0.8]: + _send(_trigger(v)) + _step(0.7) + var held := int(f.get("swings")) - n0 + K.check(fails, held == 2, "a trigger held down swings once (%d)" % (held - 1)) + _send(_trigger(0.1)) + _send(_trigger(0.9)) + K.check(fails, int(f.get("swings")) == n0 + 3, "pulled again, it swings again") + _step(0.7) + ctx.push_modal(&"test") + _send(_action("act_strike")) + ctx.pop_modal(&"test") + p.input_enabled = false + _send(_action("act_strike")) + p.input_enabled = true + var taken := int(f.get("swings")) - n0 - 3 + K.check(fails, taken == 0, "no press is taken under a modal or with input off (%d)" % taken) + acts.clear() + _send(_key_event(KEY_2, true)) + K.check(fails, acts.is_empty(), "a held key's echo does nothing") + _send(_key_event(KEY_2)) + K.check(fails, acts == ["yes"], "key 2 nods (%s)" % str(acts)) + f.call("cancel") + _step(1.1) + Input.action_press("act_aim") + _step(0.1) + K.check(fails, bool(f.get("guarding")), "holding aim keeps the guard up") + Input.action_release("act_aim") + _step(0.1) + K.check(fails, not bool(f.get("guarding")), "letting go drops it") + p.input_enabled = false + await process_frame + + +## The player's car runs into someone on a road at 36 km/h, then rolls into someone else at walking +## pace. +func _check_car() -> void: + var lanes: LG = tr.get("lanes") + var tile := -1 + for i: int in lanes.tiles.keys(): + if ( + lanes.tiles.has(D.idx(D.tx(i) + 1, D.ty(i))) + and lanes.tiles.has(D.idx(D.tx(i) - 1, D.ty(i))) + ): + tile = i + break + K.check(fails, tile >= 0, "fixture: a straight east-west road") + if tile < 0: + return + var at := Vector3(D.tx(tile), 0.0, D.ty(tile)) + var g := ctx.collision.walk_height(at) + at.y = 0.0 if is_nan(g) else g + var fast := _run_car(at, 10.0) + var info: Dictionary = fast[0] + K.check(fails, not info.is_empty(), "a car at 36 km/h strikes the person in its way") + K.check( + fails, + str(info.get("cause", "")) == "car" and bool(info.get("down", false)), + "and knocks them down (%s)" % str(info) + ) + var kept := float(fast[1]) / (10.0 * WS.M) + K.check( + fails, + kept > 0.8 and kept < 1.0, + "the car loses a little speed (keeps %.0f%%)" % (kept * 100.0) + ) + K.check(fails, bool(fast[2]), "the body lists the hit as a person") + var slow := _run_car(at + Vector3(0.0, 0.0, 0.2), 0.3) + K.check(fails, (slow[0] as Dictionary).is_empty(), "a car creeping at 1 km/h strikes nobody") + + +## [the person_hit info (with "cause"), the car's speed after, whether body.hits had a person] for a +## car driving east at kmh... at mps metres a second into someone standing at at. +func _run_car(at: Vector3, mps: float) -> Array: + var wk: WK = tr.get("walkers") + var w := wk.make("ambient") + wk.stand(w, at, 0.0) + hits.clear() + var body := BODY.new() + body.setup(0.045, 0.24, 0.075) + var m := MODEL.new() + m.reset(PI * 0.5, 0.24) + m.vel = Vector2(mps * WS.M, 0.0) + var pos := at - Vector3(0.2, 0.0, 0.0) + var person := false + for _k in range(30): + pos = body.move(m, pos, m.yaw, DT, ctx.collision, tr, null) + for h: Array in body.hits: + person = person or str(h[2]) == "person" + tr.call("step", DT) + var info: Dictionary = {} + if not hits.is_empty(): + info = (hits[0][2] as Dictionary).duplicate() + info["cause"] = hits[0][1] + _remove(w) + return [info, m.vel.length(), person] + + +func _check_teardown() -> void: + f.call("set_guard", true) + _step(0.1) + f.teardown() + var p := ctx.player + var clean := ( + is_nan(p.aim_yaw) and is_equal_approx(p.rig.arm_scale, 1.0) and not bool(f.get("guarding")) + ) + K.check(fails, clean, "teardown drops the guard and gives the body and camera back") + + +# ------------------------------------------------------------------ input + + +func _send(e: InputEvent) -> void: + f.call("_unhandled_input", e) + + +func _action(a: String) -> InputEventAction: + var e := InputEventAction.new() + e.action = a + e.pressed = true + return e + + +func _trigger(v: float) -> InputEventJoypadMotion: + var e := InputEventJoypadMotion.new() + e.axis = JOY_AXIS_TRIGGER_RIGHT + e.axis_value = v + return e + + +func _key_event(k: Key, echo: bool = false) -> InputEventKey: + var e := InputEventKey.new() + e.physical_keycode = k + e.pressed = true + e.echo = echo + return e + + +func _pad(action: String) -> String: + for e: InputEvent in InputMap.action_get_events(action): + if e is InputEventJoypadMotion: + var m := e as InputEventJoypadMotion + return "a%d%s" % [m.axis, "+" if m.axis_value > 0.0 else "-"] + if e is InputEventJoypadButton: + return "b%d" % (e as InputEventJoypadButton).button_index + return "" + + +func _mouse(action: String) -> int: + for e: InputEvent in InputMap.action_get_events(action): + if e is InputEventMouseButton: + return (e as InputEventMouseButton).button_index + return -1 + + +func _key(action: String) -> int: + for e: InputEvent in InputMap.action_get_events(action): + if e is InputEventKey: + return int((e as InputEventKey).physical_keycode) + return -1 diff --git a/tools/tests/fp_actions_test.gd.uid b/tools/tests/fp_actions_test.gd.uid new file mode 100644 index 00000000..a9a609b5 --- /dev/null +++ b/tools/tests/fp_actions_test.gd.uid @@ -0,0 +1 @@ +uid://be6k4yvp6gowd diff --git a/tools/tests/fp_audio_test.gd b/tools/tests/fp_audio_test.gd index 139d9e1f..5e09d40c 100644 --- a/tools/tests/fp_audio_test.gd +++ b/tools/tests/fp_audio_test.gd @@ -3,8 +3,8 @@ extends SceneTree ## the AudioDirector's tick throttle and on-foot routing, the initial scan, emitter settings, the ## 24-player cap and stealing, manual doppler, horns, sirens, footsteps by surface (polling and the ## stepped signal), ambience beds, settings, the pause muffle, the talk duck, UI cues, crashes, -## knocked props and car doors, the frame budget and teardown. Headless runs the Dummy audio -## driver, so this asserts node state. +## knocked props, car doors, blows and swings, 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") @@ -44,6 +44,7 @@ func _init() -> void: _check_ui(ctx, f) _check_impacts(ctx, f) _check_doors(ctx, f) + _check_blows(ctx, f) _check_budget(ctx, f) var st: Dictionary = f.self_test() K.check(fails, bool(st["ok"]), "self_test: %s" % st["detail"]) @@ -676,6 +677,42 @@ func _check_doors(ctx: FpContext, f: FpFeature) -> void: _steps(f, 3.5) +## person_hit thumps at the chest (a car louder and lower), someone knocked down lands +## BODY_FALL_S later (body_fall), and hero_action whooshes for a swing (a shove softer) but not +## for an emote. +func _check_blows(ctx: FpContext, f: FpFeature) -> void: + var em := _em(f) + var at := em.listener() + Vector3(0.05, 0.0, 0.0) + var punch := {"strength": BANK.HIT_FULL, "down": false} + var run_down := {"strength": 1.0, "down": true} + var jab := _shot(f, func() -> void: ctx.bus.person_hit.emit(at, "punch", punch)) + var car := _shot(f, func() -> void: ctx.bus.person_hit.emit(at, "car", run_down)) + var thumps := _is_shot(jab, "hit", "punch_hit") and _is_shot(car, "hit", "punch_hit") + K.check(fails, thumps, "blows: a punch and a car each thump (punch_hit), both started") + var heavier := jab != null and car != null and car.pitch_base < jab.pitch_base + heavier = heavier and is_equal_approx(car.db, jab.db + BANK.HIT_CAR_DB) + K.check(fails, heavier, "blows: a car's thump is HIT_CAR_DB louder and lower") + var n := em.shots_played + _steps(f, BANK.BODY_FALL_S - 0.1) + var early := em.shots_played - n + _steps(f, 0.2) + var fall: EM.Src = null + if em.shots_played == n + 1: + fall = em.source("shot:%d" % em.shots_played) + K.check( + fails, + early == 0 and _is_shot(fall, "hit", "body_fall"), + "blows: the one knocked down lands BODY_FALL_S later (body_fall), not before" + ) + var swing := _shot(f, func() -> void: ctx.bus.hero_action.emit("jab", at)) + var push := _shot(f, func() -> void: ctx.bus.hero_action.emit("shove", at)) + n = em.shots_played + ctx.bus.hero_action.emit("wave", at) + var whoosh := _is_shot(swing, "hit", "punch_whoosh") and _is_shot(push, "hit", "punch_whoosh") + whoosh = whoosh and push.db < swing.db and em.shots_played == n + K.check(fails, whoosh, "swings: a jab and a shove whoosh (the shove softer), a wave is silent") + _steps(f, 3.5) + ## The one-shot that fire() starts (after one step), or null. func _shot(f: FpFeature, fire: Callable) -> EM.Src: var em := _em(f) diff --git a/tools/tests/fp_chat_street_test.gd b/tools/tests/fp_chat_street_test.gd new file mode 100644 index 00000000..3dbf6d85 --- /dev/null +++ b/tools/tests/fp_chat_street_test.gd @@ -0,0 +1,185 @@ +extends SceneTree +## People hit in the street, in the chat (street_lines.gd): the one hit and one who saw it speak, +## by cause; a softer blow inside the gap stays quiet but a harder one posts; a crew posts a City +## Alert card; a witness of someone with no record says "someone"; and the chat feature hears +## person_hit on the game bus. Time only moves through ChatFeed.pump(). + +const K := preload("res://tools/tests/fp_kit.gd") +const D := preload("res://scripts/sim/sim_defs.gd") +const T := preload("res://scripts/fp/chat/chat_text.gd") +const STREET := preload("res://scripts/fp/chat/street_lines.gd") +const STEP := 0.25 +const WHERE := "Test Street" + +var fails: Array[String] = [] +var ctx: FpContext +var feed: ChatFeed +var lines: STREET +var ida := "" +var idb := "" +var at := Vector3.ZERO + + +func _init() -> void: + await process_frame + ctx = K.make_ctx(get_root(), {"grow": true}) + var people: Array = ctx.residents.people + K.check(fails, people.size() >= 2, "fixture: at least two residents (%d)" % people.size()) + if people.size() < 2: + K.finish(self, "FP_CHAT_STREET_TEST", fails) + return + ida = str(people[0]["id"]) + idb = str(people[1]["id"]) + at = Vector3(D.tx(int(people[0]["home"])), 0.0, D.ty(int(people[0]["home"]))) + feed = ChatFeed.new() + get_root().add_child(feed) + feed.setup(ctx) + feed.set_process(false) + lines = STREET.new() + lines.setup(ctx, feed) + _check_punches() + _check_car() + _check_unnamed() + _check_bus() + K.finish(self, "FP_CHAT_STREET_TEST", fails) + + +func _pump(f: ChatFeed, seconds: float) -> void: + for _k in range(int(round(seconds / STEP))): + f.pump(STEP) + + +func _hit(victim: String, witness: String, down: bool, crew: String) -> Dictionary: + return { + "walker": 1, + "resident_id": victim, + "name": "", + "down": down, + "street": WHERE, + "strength": 0.5, + "witnesses": [witness] if not witness.is_empty() else [], + "crew": crew, + } + + +func _rows_since(f: ChatFeed, seq0: int) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + for m: Dictionary in f.messages: + if int(m["seq"]) > seq0: + out.append(m) + return out + + +## Rows by author whose text is one of the templates in pool, filled with who (and WHERE). +func _said(rows: Array[Dictionary], author: String, pool: Array, who: String = "") -> int: + var want: Array[String] = [] + for l: Variant in pool: + want.append(T.display(str(l).format({"street": WHERE, "who": who}), ctx.directory)) + var n := 0 + for m in rows: + if str(m["author_id"]) == author and str(m["text"]) in want: + n += 1 + return n + + +## City Alert cards with this text (kind "incident", refs.type "street"). +func _cards(rows: Array[Dictionary], text: String) -> int: + var n := 0 + for m in rows: + var street := str((m.get("refs", {}) as Dictionary).get("type", "")) == "street" + if str(m["kind"]) == "incident" and street and str(m["text"]) == text: + n += 1 + return n + + +func _first(id: String) -> String: + return T.first_name(str(ctx.residents.find(id)["name"])) + + +## A punch (a speaks, b saw it), a shove 0.5 s later (quiet: softer, inside the gap), then a +## knock-down with the police called (posts: harder). +func _check_punches() -> void: + var seq0 := feed.seq + lines.on_person_hit(at, "punch", _hit(ida, idb, false, "")) + _pump(feed, 0.5) + lines.on_person_hit(at, "shove", _hit(idb, ida, false, "")) + _pump(feed, 0.5) + lines.on_person_hit(at, "punch", _hit(ida, idb, true, "police")) + _pump(feed, 30.0) + var rows := _rows_since(feed, seq0) + var hit := _said(rows, ida, STREET.VICTIM["punch"]) == 1 + hit = hit and _said(rows, ida, STREET.VICTIM["down"]) == 1 + K.check(fails, hit, "punch: the one hit says so, then that they were knocked down") + var saw := _said(rows, idb, STREET.WITNESS["punch"], _first(ida)) == 1 + saw = saw and _said(rows, idb, STREET.WITNESS["down"], _first(ida)) == 1 + K.check(fails, saw, "punch: the one who saw it says so twice, naming them") + var quiet := _said(rows, idb, STREET.VICTIM["shove"]) + quiet += _said(rows, ida, STREET.WITNESS["shove"], _first(idb)) + K.check( + fails, quiet == 0, "punch: a shove inside the gap after a punch posts nothing (%d)" % quiet + ) + var police := _cards(rows, "Police called to %s after a visitor attacked people" % WHERE) + K.check(fails, police == 1, "punch: the police call posts one City Alert card (%d)" % police) + var card := {} + for m in rows: + if str(m["kind"]) == "incident": + card = m + var tile := D.idx(roundi(at.x), roundi(at.z)) + var placed := int((card.get("refs", {}) as Dictionary).get("tile", -1)) == tile + placed = placed and int(card.get("district", -9)) == ctx.sim.district[tile] + K.check(fails, placed, "punch: the card carries the tile and its district") + + +## Once the gap is over, a car knocks b down: b and a witness speak, an ambulance card posts. +func _check_car() -> void: + _pump(feed, 9.0) + var seq0 := feed.seq + lines.on_person_hit(at, "car", _hit(idb, ida, true, "ambulance")) + _pump(feed, 20.0) + var rows := _rows_since(feed, seq0) + var car := _said(rows, idb, STREET.VICTIM["car_down"]) == 1 + car = car and _said(rows, ida, STREET.WITNESS["car_down"], _first(idb)) == 1 + K.check(fails, car, "car: the one knocked down and a witness speak") + var amb := _cards(rows, "Ambulance called to %s: a car hit someone" % WHERE) + K.check(fails, amb == 1, "car: the ambulance posts one City Alert card (%d)" % amb) + + +## A walker with no resident record: only the witness speaks, of "someone"; with nobody named +## around, nothing posts. +func _check_unnamed() -> void: + _pump(feed, 9.0) + var seq0 := feed.seq + lines.on_person_hit(at, "shove", _hit("", ida, false, "")) + _pump(feed, 20.0) + var rows := _rows_since(feed, seq0) + var anon := _said(rows, ida, STREET.WITNESS["shove"], "someone") + K.check( + fails, anon == 1 and rows.size() == 1, "unnamed: one witness line, of someone (%d)" % anon + ) + _pump(feed, 9.0) + seq0 = feed.seq + lines.on_person_hit(at, "punch", _hit("", "", false, "")) + _pump(feed, 20.0) + var none := _rows_since(feed, seq0).size() + K.check(fails, none == 0, "unnamed: nobody named, no crew: nothing posts (%d)" % none) + + +## The chat feature hears person_hit on the bus and posts through its own feed. +func _check_bus() -> void: + var chat := K.make_feature(ctx, "chat") + K.check( + fails, chat != null and chat.get("street_lines") != null, "bus: chat feature with lines" + ) + if chat == null: + return + var f: ChatFeed = chat.get("feed") + f.set_process(false) + var seq0 := f.seq + ctx.bus.person_hit.emit(at, "punch", _hit(ida, idb, false, "")) + _pump(f, 20.0) + var rows := _rows_since(f, seq0) + var heard := _said(rows, ida, STREET.VICTIM["punch"]) == 1 + K.check(fails, heard, "bus: person_hit puts the one hit in the chat") + chat.teardown() + var n := ctx.bus.person_hit.get_connections().size() + K.check(fails, n == 0, "bus: teardown lets go of person_hit (%d left)" % n) diff --git a/tools/tests/fp_chat_street_test.gd.uid b/tools/tests/fp_chat_street_test.gd.uid new file mode 100644 index 00000000..adccf1a6 --- /dev/null +++ b/tools/tests/fp_chat_street_test.gd.uid @@ -0,0 +1 @@ +uid://g12v74tqvs5a diff --git a/tools/tests/fp_core_test.gd b/tools/tests/fp_core_test.gd index bb35caee..06f60c10 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() == 15, "all 15 registry entries load as FpFeatures with matching unique ids") + K.check(fails, ok and ids.size() == 16, "all 16 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_overview_test.gd b/tools/tests/fp_overview_test.gd index ac8debb3..5da1acb5 100644 --- a/tools/tests/fp_overview_test.gd +++ b/tools/tests/fp_overview_test.gd @@ -14,7 +14,7 @@ const ON_FOOT_PAD := [ "look_left", "look_right", "look_up", "look_down", "jump", "crouch", "sprint", "interact", "view_toggle", "shoulder_swap", "zoom_in", "zoom_out", "photo_mode", "map_toggle", "pause_menu", - "time_faster", "time_slower", "overview_toggle", + "time_faster", "time_slower", "overview_toggle", "act_strike", "act_aim", "site_next", "site_prev", "site_go", "site_waypoint", ] ## Live at the same time on the street; the overview's site actions reuse buttons while it owns @@ -22,7 +22,7 @@ const ON_FOOT_PAD := [ const ON_FOOT_UNIQUE := [ "jump", "crouch", "sprint", "interact", "view_toggle", "shoulder_swap", "zoom_in", "zoom_out", "photo_mode", "map_toggle", "pause_menu", "time_faster", "time_slower", - "overview_toggle", + "overview_toggle", "act_strike", "act_aim", ] var fails: Array[String] = []