From 4bf66a2bdfcd9ee94551ae140fac7e38848b929e Mon Sep 17 00:00:00 2001 From: Kunal Siyag Date: Thu, 29 Oct 2026 00:34:27 +0530 Subject: [PATCH 1/2] fix(fp): a hit-stop never ends before its second frame (low fps caps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of hit feel at the browser's 20-25 fps cap: the stop's clock is real time charged per rendered frame, and the frame a blow lands on charges the whole frame's span — so at 40-50 ms frames the short stops (a bite's 0.04 s, a weak animal hit's 0.03 s, at 25 fps even the weakest punch's 0.045 s) were spent before one frozen frame was drawn, and the stop vanished. Now a stop never ends before its second tick: one frame's overdraft holds it open, so every stop spans the frame it lands on plus one full frozen one. At 60 fps nothing changes (three 16 ms ticks already span two frames); at 20-25 fps every stop reads. Shake and kick decay were already frame-rate independent (exponential and linear in dt, clamped to MAX_FRAME_DELTA); the shake's 9 Hz/15.6 Hz wobble aliases past Nyquist at 20 fps, which reads as harsher jitter, not a lost effect — noted, not changed. fp_hitfeel's _real helper now ticks per frame, as production does, and a new _check_low_fps pins the floor at 20 fps and the unchanged few-frames read at 60 fps. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/api/scripts.fp.hitfeel.md | 4 ++-- docs/fp/ARCHITECTURE.md | 2 +- scripts/fp/hitfeel/hitfeel_feature.gd | 19 ++++++++++++--- tools/tests/fp_hitfeel_test.gd | 33 +++++++++++++++++++++++++-- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/docs/api/scripts.fp.hitfeel.md b/docs/api/scripts.fp.hitfeel.md index c2cfbfd..5e2d1e1 100644 --- a/docs/api/scripts.fp.hitfeel.md +++ b/docs/api/scripts.fp.hitfeel.md @@ -8,7 +8,7 @@ extends `FpFeature` -Hit feel (integration feature "hit_feel"; docs/fp/ARCHITECTURE.md, Hit feel): what a blow feels like. A blow of the hero's that lands (person_hit "punch" or "shove", and only the hero's — info "by") freezes the world for a few hundredths of a second (hit-stop: Engine.time_scale dips to STOP_SCALE for real seconds, longer for a harder blow and one that knocks them down) and shakes the camera (the player's shake). Someone the player's car knocks down, a blow on the hero (hero_hurt) and a crash in the car (car_impact) shake it too, the hardest of them with a shorter stop; a kick or a swing landing on an animal (animal_hit) stops and shakes less than one on a person, and someone slammed into a wall or a prop (hero_slammed) stops and shakes it most. The thrown body of a knock-down is traffic's (reactions.gd, under the same flag). Nothing stops in photo mode, while the game is paused or at sim speeds above 1. +Hit feel (integration feature "hit_feel"; docs/fp/ARCHITECTURE.md, Hit feel): what a blow feels like. A blow of the hero's that lands (person_hit "punch" or "shove", and only the hero's — info "by") freezes the world for a few hundredths of a second (hit-stop: Engine.time_scale dips to STOP_SCALE for real seconds, longer for a harder blow and one that knocks them down, and never less than the frame it lands on plus one full one — at a 20-25 fps cap that frame could otherwise charge the whole stop before one frozen frame is drawn) and shakes the camera (the player's shake). Someone the player's car knocks down, a blow on the hero (hero_hurt) and a crash in the car (car_impact) shake it too, the hardest of them with a shorter stop; a kick or a swing landing on an animal (animal_hit) stops and shakes less than one on a person, and someone slammed into a wall or a prop (hero_slammed) stops and shakes it most. The thrown body of a knock-down is traffic's (reactions.gd, under the same flag). Nothing stops in photo mode, while the game is paused or at sim speeds above 1. ### Constants @@ -39,7 +39,7 @@ Hit feel (integration feature "hit_feel"; docs/fp/ARCHITECTURE.md, Hit feel): wh - `func feature_id() -> String` - `func setup(p_ctx: FpContext) -> void` - `func teardown() -> void` -- `func tick(real_dt: float) -> void`: Runs the stop under way on by real seconds; gives the time scale back once it is over. +- `func tick(real_dt: float) -> void`: Runs the stop under way on by real seconds; gives the time scale back once it is over, never before the stop has spanned two frames (one frame's overdraft holds it open until then). - `func stopped() -> bool`: True while a hit-stop holds the world. - `func hit_stop(secs: float) -> void`: Freezes the world for secs real seconds (a longer one already under way stands). - `func shake(trauma: float) -> void`: Shakes the camera by trauma (0..1). diff --git a/docs/fp/ARCHITECTURE.md b/docs/fp/ARCHITECTURE.md index 253ed71..db23183 100644 --- a/docs/fp/ARCHITECTURE.md +++ b/docs/fp/ARCHITECTURE.md @@ -131,7 +131,7 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th | **Integration: health** | `scripts/fp/health/**`, `tools/tests/fp_health_test.gd` | The hero's health (flag `health`): 100 points, shown as an arc round the left of the minimap (`health_arc.gd`: it fills from the bottom up, green, amber below half and a pulsing red below a quarter, and after a hurt a pale trail eats down to the new value) and a red vignette that flashes with each hurt and stays, pulsing, below 40% (`health_screen.gd`, in the overlay under the pause menu; not in photo mode). Crashes in the car the hero drives (`car_impact`: nothing up to 7 m/s, a full bar at 32 m/s), hard landings (the player's `landed`: from 6 m/s, about a 1.8 m drop, to a full bar at 14 m/s, a 10 m drop; one rolled off counts 4 m/s slower) and blows on the hero on foot (`hero_hit` from traffic and the dogs) cost points, and 6 s after the last hurt they come back at 5 a second. A punch from someone fighting back costs 5 plus 10 times its strength, and the guard raised toward it (the actions feature's `guarding`) takes 70% of that. With `guard_chip` each punch the guard catches drains its poise (a thin sliver inside the arc), the one that empties it lands in full and drops the guard with a beat of stagger, and until the poise knits back a guard raised again catches nothing. A dog's bite (Integration: animals, strength 0.25) costs the same, 7.5, and the guard does not stop it. A blow makes the body flinch the way it came from — one in the face snaps the head back (`hit_head`), one in the back or the side folds the chest (`hit_chest`) — and kicks the view, rolling toward the side that was hit, harder for a harder blow while `hit_feel` is on (flag `hero_flinch`; off, the two flinches alternate with a fixed jolt; a body a move carries only feels it in the view), and in first person a short red arc flashes on the screen's edge toward where it came from, fading over half a second (flag `hit_direction`; `hit_wedge.gd` in the health screen). A car's blow costs 100 times its strength and from 0.25 on knocks the hero down, as does a landing that costs 35 or more unless it was rolled off: input is held and the actions stop, the body turns to face where the blow came from and falls (`play_action("knockdown")`), the push throws it on along the ground (slowing at 7 m/s², stopped by walls and posts), it lies 1.4 s and gets up, and input comes back. With nothing left the hero is knocked out: out of the car they drive, down, the screen fades to black with a caption, and in the dark they come round healed on the sidewalk outside the nearest hospital (outside City Hall when the city has none), with a toast saying where. `hero_hurt` on the bus reports every hurt (I plays the blows). Health is saved with the city (`{"hp"}`; a save at 0 loads at 1) and a load or a rebuild stands the hero up. The people who fight back (`fight_back`, E's `reactions.gd`: 40% of those punched and 30% of those shoved, not knocked down) shout, square up, run in from beyond 3 m, walk the last metres to 0.9 m and throw jabs and crosses in turn, each landing within 1.25 m in the cone ahead at its contact time, until the hero is down, 12 m away or in a car, or 20 s pass; hit mid-fight they reel and go straight back at it. Cars that cannot stop (`hit_by_cars`, E's `vehicles.gd`) run the hero down as E describes. Both need this feature, and with either flag off people only shout, run off or walk back and every car stops short of the hero. | | **Integration: parkour** | `scripts/fp/parkour/**`, `tools/tests/fp_parkour_test.gd`, `tools/tests/fp_freerun_test.gd` | Space in front of an obstacle gets the hero over it (flag `parkour`). The player's `jump_hook` hands a jump press about to leave the ground to the feature, and `moves.gd` plans a move from the `CollisionWorld` box ahead (a gap of at most 0.6 m plus 0.2 s of the speed, at most 53° off head-on): a vault over one up to 1.1 m tall and 0.9 m deep with room to land beyond (as far as the speed carries, else nearer, at most 0.6 m lower), a hop onto a ledge up to 0.7 m, or a climb onto one up to 2.1 m, each only with room to stand where it ends. A move is two tracks of keys, the distance across the face and the height of the feet, checked for room all along (a tucked body, counted from the obstacle's top up while over it). The feature carries the body along it (`FpPlayer.carry`), turning it across the obstacle. A vault or a hop plays the jump and fall clips and hands the body back in the air, coming down at 3.5 m/s at most, so the landing (its footstep and `landed`) is a soft one. A climb walks to the face and plays the rig's `climb_1m` clip (in place, stretched over 0.45 s plus 0.25 s a metre) while the body rises with it from 10% of the clip on and steps forward onto the top from 35% on; the view dips as the hands take the edge. A knock-down or a loss of control ends a move, the body dropping from the last point of it where a standing body fits (never half inside a ledge), and a teleport ends it at once. Not while crouched, talking, knocked down or in photo mode, and photo mode does not start during a move. A wall taller than a climb (flag `wall_climb`) is taken hold of instead: `wall.gd` finds its frame (the axis into the face, the axis along it and the top of the boxes stacked behind it) and a spot to hang on, the feet 0.5 m up and at most 1.55 m under the top, with wall behind the body 0.5 m either side from 0.3 m to 1.2 m above the feet and room for it in front (a column 2 m tall over dry ground), so a post or a wall under an awning is jumped at as before. The hero leaps onto it in 0.3 s, and `climb.gd` moves the body on it with the move keys: up at 0.6 m/s, down at 0.8 m/s and along at 0.6 m/s (Shift 1.6 times that), never past its ends, into a lower stretch, over higher ground or into anything in the way (an overhang stops the climb). The hands and feet hold on to the wall: one limb at a time, the one furthest behind its rest, swings to a hold ahead of it, arcing off the wall, and the rig puts each on its hold through the limbs' IK over its wall pose. At the top with room to stand the hero climbs over (the climb move, from where the body hangs; with no room it hangs on), at the foot of the wall it steps off standing, crouch lets go and Space pushes off (2 m/s back, 2.5 m/s up). A knock-down, a load or the wall going away lets go, and a teleport ends the climb. A hard landing on the move rolls (flag `landing_roll`): the player's `land_hook` hands the feature a touchdown from 6 m/s to 14 m/s down (about a 1.8 m to a 10 m drop) at 1 m/s or more along the ground, and `moves.roll` plans the roll straight on, at the speed (2.5 m/s at least, a run at most) for 0.4 s, then 2 m/s at most, 0.95 s in all. It follows the ground (a curb up or down) sampled every 0.2 m and stops short of a wall, a drop or water, as long but slower; with less than 1 m of room there is none and the hero just lands. The rig plays its `roll` clip from where the hands reach the ground, the first-person view turns over forward in 0.4 s, there is no heavy-landing slow-down and the body comes up moving on. `landed` says the landing was rolled, so the health feature counts it 4 m/s slower and never knocks the hero down, and I plays the shoulder's thud. While a move carries the body, swings, emotes, interaction and getting into a car wait. Free running (flag `free_running`): Shift held on a straight run builds a sprint (`FpPlayer.sprinting()`: 0.8 s at 90% of a run or more, heading within 25° of the wish, in first person with the wish mostly ahead; then the top speed is `WS.SPRINT`, 6.5 m/s; Shift let go, a hard turn, a crouch or a carry drops it, and in the air it holds). At a sprint the feature looks ahead every frame with no press: a box within 0.8 m that a vault or a hop gets over starts it, and an edge (the ground 0.15 m ahead falls away more than 0.5 m) with ground across the gap (at most 0.2 m higher and 2 m lower, 0.5 m above the gap's floor at least) that a running jump at the pace reaches with 0.3 m to spare and a clear arc is leapt (`moves.leap`, `FpPlayer.leap()`: an ordinary jump, the flight and landing the player's own). Space at a sprint climbs ledges up to 3 m (a run-up). Coming down at 2 m/s or more along the ground, a ledge within 0.5 m that a climb or a hop gets onto is caught. `hero_action` on the bus reports each move (`vault`, `hop`, `climb`, `roll`, `grab` taking hold of a wall, `run_up`, `catch` and `leap`), and the dogs, cats and pigeons startle at them. | | **Integration: animals** | `scripts/fp/animals/**`, `models/real/animals/**`, the animals entries of `scripts/fp/asset_paths.gd` (`DOG_SHIBA`, `DOG_HUSKY`, `CAT`), `tools/blender/make_cat.py`, `tools/tests/fp_animals_test.gd`, `tools/tests/fp_pigeons_test.gd`, `tools/tests/fp_cats_test.gd`, `tools/tests/fp_animal_hits_test.gd` | Dogs, pigeons and cats in the street (flag `animals`). Some people out on foot walk a dog on a lead: a resident whose household `lives with a dog` (unless they are off to work, working late or protesting) and 8% of the other people on foot who are not carrying a box, 40% as many at night and a quarter as many in the rain. A dog household's dog keeps to the front yard of its home (a house up to level 2 with a road in front) while nobody walks it, by day and in the dry. The resident coming out takes it along, and when they leave the street it goes back to the yard (with the home out of range, it goes with them). Dogs come out within 3 tiles of the player and go beyond 3.6, at most 10 at once, and the same resident or walker always has the same dog in a city: breed, size, sex, name and temper (friendly, barky or shy). Nothing is saved. The bodies are Quaternius' Shiba Inu and Husky (CC0, `models/real/LICENSES/quaternius-dogs.txt`): `dog_bodies.gd` loads them on background threads, scales them to 0.38 m and 0.55 m at the shoulder (±8%), keeps released ones for the next dog and puts a brown box in for a missing model. The scene's own clips play in place: Walk or Gallop at the rate the ground speed asks for, the idles, `Eating` to sniff, `Attack` to lunge, the hit reactions to flinch and `Jump_ToIdle` to hop. `dog_rig.gd`, a `SkeletonModifier3D` on each skeleton, wags the tail by the dog's mood, turns the head to what it watches and jerks it up with each bark. `dog_pack.gd` runs the dogs on game time, so pause freezes them. On the lead a dog walks at its owner's left (E's `walkers_by_id`: the feet, the facing and the left hand), stops now and then to sniff until the 1.8 m lead pulls it on, and is let off to roam within 6 m of an owner who stands still for 3 s; walking on calls it back. A friendly dog looks up at the hero and wags, and trots over to a crouching hero within 4 m (on the lead only while its owner stands). E within 2.4 m of the eyes pets a dog (`pick()` kind `"dog"`; the hero reaches out): it hops and wags, a toast says so, and a barky or shy dog turns friendly. A barky dog barks at the hero within 3 m and, in its yard, runs to the edge of it barking at people going by; a shy one backs off from a hero within 1.4 m. A swing or a shove within 3 m, a shout within 7 m, someone hit within 8 m (`person_hit`) or the hero sprinting past within 1.2 m makes a dog flinch away and bark (a shy one whines). Punching or shoving a dog's owner sets it on the hero for 12 s: it growls, barks and goes for the hero as far as its lead allows, lunging from 1.3 m with its jaws closing 0.3 s into the `Attack` clip on a hero within reach (`hero_hit` cause `"bite"`, strength 0.25, from its head, with a small push). After 2 bites it calms down, and while angry it growls instead of being petted. It only bites a hero on foot and in control with the health feature on, and only barks otherwise. A moving car whose path takes in the dog (its length plus 1.2 s of travel ahead, 0.6 m to either side; E's `agents_packed`) makes it leap aside, whining. `dog_leads.gd` draws the leads within 40 m of the camera as camera-facing ribbons from the hand to the collar in one `ImmediateMesh`, sagging with the slack. `animal_called` on the bus reports each bark, growl and whine (I plays them). Photo shot `dogs`: a Shiba Inu and a husky in the front yard of the nearest house, seen from the sidewalk. Pigeons (flag `pigeons`; `pigeons.gd` places and draws them, `pigeon_flock.gd` runs them): a flock keeps to the sidewalk in front of 30% of the built shops, every town hall and train station, 70% of the stadiums and universities and 30% of the schools (on a straight street, where there is room), and to 80% of the parks; 5 to 10 birds on a sidewalk, foraging up to 5 m either way along it, 8 to 14 in a park within 6 m. By day flocks come out within 2.2 tiles of the player and go beyond 2.8, at most 4 at once. Each bird walks about with the pigeon's head bob, pecks in bouts and stands about, and now and then a cock puffs up and turns round cooing; a calm flock within 15 m of the hero coos every 5 to 12 s. A bird walks out of the way of the hero walking at it within 2.6 m and of people going by within 1.3 m, and flutters a couple of metres off from a hero within 1 m, a passer-by within 0.45 m or a car about to run it over (1 s of travel, 1 m to the side). The whole flock goes up, the nearest birds first, with one clatter of wings at the hero sprinting within 6 m, a dog within 3 m (or running within 6 m), three birds in a car's way, the hero's swing, shove or wave within 3 m, a dance within 3.5 m, a shout within 12 m, a parkour move within 3 to 5 m, someone hit within 8 m, a bark within 6 m, a crash within 25 m or a prop knocked over within 8 m. It climbs away, wheels round over the street (round a park, or above the roofs where no ring clears the buildings) for at least 6 s, and once nothing is within 3.5 m of its spot (after 25 s anyway) glides back down and flares to land. E with the view on the ground within 4.5 m of the feet and 1.5 m of a bird (`pick()` kind `"pigeons"`, "Feed the pigeons") throws 24 crumbs up to 1.2 m ahead, with a toast: the flock hurries over, wary at only 30% of those distances, and pecks them up. Each flock is one `MultiMesh` of `pigeon_body.gd`'s bird (built in code: a blue-bar rock dove in vertex colours, each bird tinted to one of five morphs), posed by `pigeon.gdshader` from each bird's custom data (wingbeat, stride, peck and head bob). `animal_called` reports each coo, flap and take-off (size 0.13 m). Photo shot `pigeons`: the nearest flock spot, its birds feeding round crumbs. Cats (flag `cats`; `cats.gd` places them, `cat_life.gd` runs them): 30% of the houses with a front yard and no dog keep a cat in the yard and 30% of the shops a shop cat on the sidewalk by the door; by night strays come out on the sidewalk by 40% of the works yards and in 35% of the parks. In the rain they all stay in. Cats come out within 2.4 tiles of the player and go beyond 3, at most 6 at once, and the same spot always has the same cat in a city: coat (brown or silver tabby, ginger, black, tuxedo, grey, calico, tortoiseshell or white; calicos and torties are she-cats), sex, name, size (±10%), temper (friendly, aloof or skittish, by where it keeps) and whether it hunts. Nothing is saved. The body is Quaternius' Cat (CC0, `models/real/LICENSES/quaternius-cat.txt`), rebuilt by `tools/blender/make_cat.py` with posed clips (Walk, Trot, Run, Stalk, Crouch, Pounce, Sit, Loaf, Groom, Sleep, Hiss, Startle, Stretch) and coat fields for `cat_coat.gdshader` (tabby stripes, white spreading from the paws and chest, calico and tortie patches, a pink nose, green to amber eyes; each cat's colours are instance uniforms on one shared material). `cat_bodies.gd` loads and pools them as `dog_bodies.gd` does (0.25 m at the shoulder), and `cat_rig.gd` sways the tail (from a lazy sway to a lash), lowers it on a frightened or wary cat and turns the head. A cat sits, loafs, grooms and sleeps at its spot, and now and then stretches and strolls about within 3 m. A friendly cat comes over to a crouching hero within 4 m with its tail up and meows; an aloof one walks off from a hero walking at it within 1.3 m; a skittish one runs from a hero within 3 m unless they crouch still. E within 2.4 m of the eyes pets a cat (`pick()` kind `"cat"`): a friendly one purrs for 4 s, an aloof one sniffs the hand and walks off the first time, a skittish or frightened one hisses and runs, with a toast. The hero sprinting past within 2.5 m, a swing or a shove within 2.5 m, a shout within 10 m, a parkour move within 2.5 to 4 m, someone hit within 6 m, a crash within 20 m or a prop knocked over within 6 m makes a cat jump and run 5 to 9 m. A dog within 4 m makes it arch and hiss (and the dog barks back); one within 1.6 m, running at it or still there after the hiss sends it running. A car about to run it down (its length plus a second of travel, 1 m to the side) makes it bolt. By day a hunter chatters at a pigeon on the ground within 9 m, stalks it low and slow, crouches with its tail twitching and pounces from 1 m (the flock goes up: to the pigeons a cat is a threat only within 0.8 m or running), then sits down to groom. `animal_called` reports each meow, purr, hiss and chatter (size 0.25 m). Photo shot `cats`: a ginger, a tuxedo and a calico sitting, loafing and grooming in the front yard of the nearest house. Animal hits (flag `animal_hits`): a kick or a weapon swing of the hero's with no person in reach lands on the nearest dog, cat or pigeon in the swing's cone whose side is within its reach less the hero's girth (`strike()`, called by Integration: body actions; a fist goes over them, and a pigeon more than 0.6 m above the hero's feet is out of reach). A struck dog yelps (`animal_called` `yelp`), is knocked away at 2 m/s plus 3 m/s times the blow's strength and flinches for 0.35 s; a barky or angry dog struck softer than 0.7 (a bat) then goes for the hero as if its owner had been hit, any other runs off whining for 3 s and is shy of the hero from then on. A struck cat yowls (`yowl`), is knocked away at 1.5 m/s plus 3 m/s times the strength (sliding to a stop at 14 m/s²) and bolts, frightened three times as long and skittish from then on. A struck pigeon is knocked up and away at 2.5 m/s plus 4 m/s times the strength in a puff of 14 feathers (`CPUParticles3D`), and its flock goes up. A toast names the dog or cat, and `animal_hit` on the bus reports the blow (I plays a lighter thump, Integration: hit feel a shorter stop and shake). | -| **Integration: hit feel** | `scripts/fp/hitfeel/**`, the throw in `scripts/fp/traffic/reactions.gd`, the shake in `scripts/fp/player/player.gd`, `tools/tests/fp_hitfeel_test.gd` | How blows feel (flag `hit_feel`). A blow of the hero's that lands (`person_hit` "punch" or "shove") is a hit-stop: `Engine.time_scale` drops to 0.05 for 0.045 to 0.09 real seconds by the blow's strength, 0.03 s more for a knock-down (at most 0.15 s; a longer stop under way stands; the stop's clock is real time, so it always ends), and the camera shakes (`FpPlayer.shake`: trauma 0..1 that drains at 1.6 a second; the view turns by up to 1.7°, 1.4° and 2.3° in pitch, yaw and roll times trauma squared, on smooth noise, in first and third person and through a view override such as the drive camera). Someone the player's car hits shakes it by the blow's strength (and stops it when they go down), a blow on the hero (`hero_hurt`) shakes it by the health lost and stops it for a punch, a bite or a car, and a crash (`car_impact`) shakes it from 2 m/s up, full at 18 m/s. A kick or a swing landing on an animal (`animal_hit`) stops it for 0.03 to 0.06 s and shakes it with trauma 0.08 to 0.25 by the blow's strength. Nothing stops in photo mode, while paused or at sim speeds above 1; teardown gives the time scale back. Someone knocked down is thrown (`reactions.gd`, same flag): their drawn feet go up 0.12 to 0.3 m by the blow's strength (0.3 to 0.9 m for a car) over 0.32 s and back down, bounce a fifth as high over 0.18 s, then lie where the slide leaves them. Someone flung (a brawl throw or slam, a carjack) flies instead: up 0.3 m per metre of the fling (0.9 m for a 3 m throw) on a ballistic arc (gravity 9.8 m/s², so about 0.86 s in the air), drawn on at a steady pace through the air and skidding the last 0.5 m along the ground over 0.35 s, the body tipped back about its hips by up to 0.7 rad at the top of the arc (the walker's `vis_tilt`, `walkers.gd` `body_xf`). | +| **Integration: hit feel** | `scripts/fp/hitfeel/**`, the throw in `scripts/fp/traffic/reactions.gd`, the shake in `scripts/fp/player/player.gd`, `tools/tests/fp_hitfeel_test.gd` | How blows feel (flag `hit_feel`). A blow of the hero's that lands (`person_hit` "punch" or "shove") is a hit-stop: `Engine.time_scale` drops to 0.05 for 0.045 to 0.09 real seconds by the blow's strength, 0.03 s more for a knock-down (at most 0.15 s; a longer stop under way stands; the stop's clock is real time, so it always ends; the frame a stop lands on is free, so it always holds at least one full frame past it — it still reads at the browser's 20-25 fps cap), and the camera shakes (`FpPlayer.shake`: trauma 0..1 that drains at 1.6 a second; the view turns by up to 1.7°, 1.4° and 2.3° in pitch, yaw and roll times trauma squared, on smooth noise, in first and third person and through a view override such as the drive camera). Someone the player's car hits shakes it by the blow's strength (and stops it when they go down), a blow on the hero (`hero_hurt`) shakes it by the health lost and stops it for a punch, a bite or a car, and a crash (`car_impact`) shakes it from 2 m/s up, full at 18 m/s. A kick or a swing landing on an animal (`animal_hit`) stops it for 0.03 to 0.06 s and shakes it with trauma 0.08 to 0.25 by the blow's strength. Nothing stops in photo mode, while paused or at sim speeds above 1; teardown gives the time scale back. Someone knocked down is thrown (`reactions.gd`, same flag): their drawn feet go up 0.12 to 0.3 m by the blow's strength (0.3 to 0.9 m for a car) over 0.32 s and back down, bounce a fifth as high over 0.18 s, then lie where the slide leaves them. Someone flung (a brawl throw or slam, a carjack) flies instead: up 0.3 m per metre of the fling (0.9 m for a 3 m throw) on a ballistic arc (gravity 9.8 m/s², so about 0.86 s in the air), drawn on at a steady pace through the air and skidding the last 0.5 m along the ground over 0.35 s, the body tipped back about its hips by up to 0.7 rad at the top of the arc (the walker's `vis_tilt`, `walkers.gd` `body_xf`). | | **Integration: brawl** | `scripts/fp/brawl/**`, the hold in `scripts/fp/traffic/reactions.gd` (`grab`, `hold_to`, `release`, the `"held"` mode), the counter and the throw in `scripts/fp/actions/actions_feature.gd`, the player's `speed_cap`, `tools/tests/fp_brawl_test.gd`, `tools/tests/fp_slams_test.gd` | Grapples (flag `brawl`), counters (flag `counters`) and environmental attacks (flag `slams`). H (or the back mouse button) takes hold of the person on their feet within 1.1 m ahead of the camera: `reactions.gd` puts them in a `"held"` step (hit_chest stopped 0.12 s in through the walker's `react_rate` 0), and every frame the feature moves their feet to 0.6 m ahead of the hero (`hold_walker`, stopped by walls), the hero dragging them at 1.1 m/s at most with the fists up and the body squared to the camera. A blow on someone held makes them reel and hang on (`_keep_held`) unless it knocks them down; the actions feature aims every swing at them, and R becomes a `"throw"` (the hero's `throw` heave clip, letting go at 0.3 s, strength 0.8, knocked down and flung 3 m along the camera through `hit_walker`'s `fling`; a flung body that meets a wall or a solid prop mid-flight bounces off the face and slumps where it comes to rest (flag `throw_impact`), the impact marked on the throw's one `person_hit` as info `{"impact": {"at", "in"}}`). H lets go, and after 6 s they break free; either way they then react as to a shove (shout, fight back or walk off). Counters: a fighter starting a punch within reach calls `on_wind_up` and traffic emits `hero_threatened`, which shows a red `!` above the attacker's head that grows through the wind-up and fades once the punch has landed, while the punch's own clip holds back through the first half of the run to it and then snaps in, its timing untouched (flag `counter_cues`); at contact `on_hero_parry` asks the actions feature's `parry`, which counters when the guard went up at most 0.4 s before (once per raise, the fist ahead): the punch misses (`punches_parried`) and the hero throws a `"counter"` hook (0.8) that knocks the fighter down. Slams: before R becomes a throw the actions feature asks the brawl feature's `slam_spot` along the camera; with something solid within 0.8 m beyond the body of the person held (a knockable street prop standing from `knockables_near`, else the first of 5 cm steps where `body_blocked` finds a wall, a post, a parked car or a prop that holds; open street, a drop or water is none) R becomes a `"slam"` (the same heave, strength 1.0). At contact `slam` hands a prop the person's share of 3 m/s (`knock`; a bin, cone, wheelie bin or carton goes over, heavier props hold) and emits `hero_slammed`; they go down 1 m on over a prop that went over, else 0.4 m off the wall to the side with room (right first) and back toward the hero, so the body never goes through it. Hit feel stops the world 0.13 s and shakes the camera, and the audio feature plays a low, loud thump at the contact. | | **Integration: road grades** | the grade parts of `scripts/sim/sim_defs.gd` (`ROAD_GRADES`), `city_sim.gd` (`_act_road`, `road_density_cap`), `scripts/ai/road_plans.gd` (`road_step`, `plan_paving`), `road_topology.gd` (`grade`, `paved`), the track quad in `city_view.gd` (`_track`, `track_material`) and the track mode of `shaders/ground_detail.gdshader`, `tools/tests/fp_road_grades_test.gd` | Gravel roads, dirt tracks and grass trails (flag `road_grades`). A ROAD tile's level is its grade (0 paved, so old saves load paved; bridges are always paved). On foot an unpaved tile is one quad at `ASPHALT_TOP` drawn by the ground shader in track mode: the carriageway's shape from the tile's mask (as `RoadTopology.is_carriageway`), grey-beige gravel with a pebble speckle, brown earth with wheel ruts and grass tufts between the lanes, or a worn two-rut path per lane in trampled grass, with a worn patch where traffic turns at a dead end; water stands in the ruts in the rain, and in orbit only the track is drawn. No curb, sidewalk, lamp, bus stop, furniture, sign or signal: the whole tile is `ASPHALT_TOP` (`surface_height`, the walkers' graph), `surface_at` names the carriageway `gravel`, `dirt` or `trail` (grass beside it; footsteps play dirt and grass), the player's car grips less there (`drive_model.gd`). Lanes are limited to the grade's speed (0.7, 0.5, 0.35), a crossroads has no lights and each arm gives way to the one on its right, and nobody parks there. The ambient filler makes three times as many of its spawns there cyclists (at most 60%) and only 35% of the cars it would have put there. The phone's road tool takes the grade on the wheel; the minimap draws each grade in its own colour. | | **Integration: melee weapons** | `scripts/fp/melee/**`, the weapon swing in `scripts/fp/actions/actions_feature.gd`, `tools/tests/fp_melee_test.gd` | Weapons the hero picks up and swings (flag `melee`). `weapon_kit.gd` builds a baseball bat (0.84 m), a steel pipe (0.75 m) and a bottle (0.3 m) in code from lathe profiles, so there are no model files. Bats lie in 50% of the parks and by 15% of the homes, pipes on the sidewalk by 35% of the industrial lots and bottles by 25% of the shops (on a straight street, with 0.5 m of room; the same lots every time for a city). Those within 45 m of the hero are drawn lying on the ground, and one taken lies there again once the hero has been more than 60 m away. E with the look on one within 1.8 m of the feet picks it up (`pick()` kind `"weapon"`; with one in hand the two swap), Z drops it at the feet, and getting into a car or being knocked down (`hero_hurt` with `down`) drops it. Only the weapon in hand is saved. In hand it follows the rig's `RightHand` bone. The actions feature asks for it (`held()`) at each press of the strike button and swings it: a bat (strength 0.62, 0.5 m past a fist) makes people reel and its third blow in a row puts them down, a pipe (0.75, 0.45 m) and a bottle (0.8, 0.1 m) knock them down at once. A blow that lands on a person, an animal or a street prop goes back to the feature (`landed()`): `weapon_hit` on the bus reports it, and a bottle breaks on the first one in a spray of glass (`CPUParticles3D`), leaving the fists. A swing a guarded fighter turns aside never lands — with the `weapon_disarm` flag it knocks the weapon from the hero's hand instead, to lie a metre off and be picked up again. I plays `bat_hit`, `pipe_hit` or `bottle_smash` for it, and dogs, cats and pigeons startle at a swing as at a punch. | diff --git a/scripts/fp/hitfeel/hitfeel_feature.gd b/scripts/fp/hitfeel/hitfeel_feature.gd index c463223..c8f87c4 100644 --- a/scripts/fp/hitfeel/hitfeel_feature.gd +++ b/scripts/fp/hitfeel/hitfeel_feature.gd @@ -3,7 +3,9 @@ extends FpFeature ## like. A blow of the hero's that lands (person_hit "punch" or "shove", and only the hero's — ## info "by") freezes the world for a few ## hundredths of a second (hit-stop: Engine.time_scale dips to STOP_SCALE for real seconds, longer -## for a harder blow and one that knocks them down) and shakes the camera (the player's shake). +## for a harder blow and one that knocks them down, and never less than the frame it lands on plus +## one full one — at a 20-25 fps cap that frame could otherwise charge the whole stop before one +## frozen frame is drawn) and shakes the camera (the player's shake). ## Someone the player's car knocks down, a blow on the hero (hero_hurt) and a crash in the car ## (car_impact) shake it too, the hardest of them with a shorter stop; a kick or a swing landing on ## an animal (animal_hit) stops and shakes less than one on a person, and someone slammed into a @@ -45,6 +47,10 @@ var shakes: int = 0 ## Real seconds the stop under way has left, and the time scale to give back. var _stop_left: float = 0.0 var _base_scale: float = 1.0 +## Rendered frames (ticks) the stop under way has spanned: a stop never ends before its second, +## so however short it is — or however coarse the frames (a 20-25 fps cap, a hitch) — at least one +## whole frozen frame is drawn past the one it lands on. +var _stop_ticks: int = 0 var _last_us: int = 0 @@ -76,13 +82,18 @@ func _process(_delta: float) -> void: tick(real) -## Runs the stop under way on by real seconds; gives the time scale back once it is over. +## Runs the stop under way on by real seconds; gives the time scale back once it is over, never +## before the stop has spanned two frames (one frame's overdraft holds it open until then). func tick(real_dt: float) -> void: if _stop_left <= 0.0: return + _stop_ticks += 1 _stop_left -= real_dt if _stop_left <= 0.0: - _end_stop() + if _stop_ticks >= 2: + _end_stop() + else: + _stop_left = 1e-4 # the second frame's hold; the next tick ends it ## True while a hit-stop holds the world. @@ -98,6 +109,7 @@ func hit_stop(secs: float) -> void: return if _stop_left <= 0.0: _base_scale = Engine.time_scale + _stop_ticks = 0 _stop_left = maxf(_stop_left, minf(secs, STOP_MAX)) Engine.time_scale = STOP_SCALE stops += 1 @@ -115,6 +127,7 @@ func _end_stop() -> void: if is_equal_approx(Engine.time_scale, STOP_SCALE): Engine.time_scale = _base_scale _stop_left = 0.0 + _stop_ticks = 0 func _open() -> bool: diff --git a/tools/tests/fp_hitfeel_test.gd b/tools/tests/fp_hitfeel_test.gd index 29d853f..14cc9f3 100644 --- a/tools/tests/fp_hitfeel_test.gd +++ b/tools/tests/fp_hitfeel_test.gd @@ -46,6 +46,7 @@ func _init() -> void: var p := ctx.player p.teleport(p.position, rad_to_deg(p.yaw)) _check_punch() + _check_low_fps() _check_strength() _check_attacker_marker() _check_arc_spec() @@ -72,9 +73,10 @@ func _step(secs: float) -> void: ctx.player.update_view(DT) -## Runs the hit-stop clock on by real seconds. +## Runs the hit-stop clock on by real seconds, one frame's tick at a time (tick is per frame). func _real(secs: float) -> void: - hf.call("tick", secs) + for _k in range(ceili(secs / DT - 1e-6)): + hf.call("tick", DT) func _quiet() -> void: @@ -136,6 +138,33 @@ func _check_punch() -> void: _settle() +## At a 20-25 fps cap (or a hitch) the frame a blow lands on could charge the whole stop before +## one frozen frame is drawn; so no stop ends before its second frame — every stop holds at least +## one full frame past the one it lands on — and then runs out in real seconds as ever. +func _check_low_fps() -> void: + hf.call("hit_stop", 0.04) # the shortest stops: a bite, a weak animal hit + K.check(fails, bool(hf.call("stopped")), "low fps: the stop starts") + hf.call("tick", 0.05) # the 20 fps frame it landed on: free + K.check( + fails, + bool(hf.call("stopped")) and is_equal_approx(Engine.time_scale, HF.STOP_SCALE), + "low fps: still frozen after the landing frame" + ) + hf.call("tick", 0.05) # the next frame charges in full: 0.04 < 0.05, so the stop ends + K.check( + fails, + not bool(hf.call("stopped")) and is_equal_approx(Engine.time_scale, 1.0), + "low fps: and time runs again a frame later" + ) + # At 60 fps the same stop still reads as a few frames, as before. + hf.call("hit_stop", 0.045) + var frames := 1 # the landing frame, free + while bool(hf.call("stopped")) and frames < 12: + hf.call("tick", DT) + frames += 1 + K.check(fails, frames >= 3 and frames <= 6, "60 fps: it holds a few frames (%d)" % frames) + + ## A blow that is not the hero's (person_hit info "by" a walker's id) never stops or shakes the ## hero's world; the same blow from the hero does. Dicts with no "by" read as the hero's. func _check_attacker_marker() -> void: From 97081f4300c5c909d13d80dc6ded04536790bae1 Mon Sep 17 00:00:00 2001 From: Kunal Siyag Date: Thu, 29 Oct 2026 00:46:26 +0530 Subject: [PATCH 2/2] fix(fp): a frame that outlasts the longest stop ends it (hit-stop floor) fp_animal_hits drives the stop clock with one coarse tick(1.0) after a blow; the two-frame floor held that stop open and the scale never came back. A single frame longer than STOP_MAX has already displayed the frozen world for longer than any stop asks (a real hitch does the same), so it ends the stop on its own. The floor still covers the case it was for: frame-sized ticks at a 20-25 fps cap. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- scripts/fp/hitfeel/hitfeel_feature.gd | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/fp/hitfeel/hitfeel_feature.gd b/scripts/fp/hitfeel/hitfeel_feature.gd index c8f87c4..91af86b 100644 --- a/scripts/fp/hitfeel/hitfeel_feature.gd +++ b/scripts/fp/hitfeel/hitfeel_feature.gd @@ -48,8 +48,9 @@ var shakes: int = 0 var _stop_left: float = 0.0 var _base_scale: float = 1.0 ## Rendered frames (ticks) the stop under way has spanned: a stop never ends before its second, -## so however short it is — or however coarse the frames (a 20-25 fps cap, a hitch) — at least one -## whole frozen frame is drawn past the one it lands on. +## so however short it is — or however coarse the frames (a 20-25 fps cap) — at least one whole +## frozen frame is drawn. A single frame that outlasts the longest stop ends it as ever: the +## frozen frame was up longer than any stop asks (a real hitch). var _stop_ticks: int = 0 var _last_us: int = 0 @@ -90,7 +91,7 @@ func tick(real_dt: float) -> void: _stop_ticks += 1 _stop_left -= real_dt if _stop_left <= 0.0: - if _stop_ticks >= 2: + if _stop_ticks >= 2 or real_dt >= STOP_MAX: _end_stop() else: _stop_left = 1e-4 # the second frame's hold; the next tick ends it