@jgengine/core 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +500 -1
- package/dist/ai/difficulty.d.ts +154 -0
- package/dist/ai/difficulty.js +194 -0
- package/dist/ai/driver.d.ts +107 -0
- package/dist/ai/driver.js +175 -0
- package/dist/ai/flock.js +13 -23
- package/dist/ai/laneSelect.js +2 -1
- package/dist/ai/mobBrain.js +2 -1
- package/dist/ai/waveRunner.d.ts +81 -0
- package/dist/ai/waveRunner.js +104 -0
- package/dist/audio/musicState.d.ts +47 -0
- package/dist/audio/musicState.js +38 -0
- package/dist/audio/stepCadence.d.ts +47 -0
- package/dist/audio/stepCadence.js +29 -0
- package/dist/audio/synth.d.ts +12 -0
- package/dist/authoring.d.ts +6 -4
- package/dist/authoring.js +6 -4
- package/dist/combat/castRunner.d.ts +13 -1
- package/dist/combat/castRunner.js +5 -1
- package/dist/combat/comboMeter.d.ts +140 -0
- package/dist/combat/comboMeter.js +215 -0
- package/dist/combat/comboPoints.d.ts +11 -0
- package/dist/combat/comboPoints.js +11 -2
- package/dist/combat/damageResolution.d.ts +1 -1
- package/dist/combat/defensiveWindow.d.ts +5 -0
- package/dist/combat/effects.d.ts +24 -0
- package/dist/combat/effects.js +6 -1
- package/dist/combat/projectiles.d.ts +6 -0
- package/dist/combat/projectiles.js +2 -2
- package/dist/combat/regenShield.d.ts +40 -2
- package/dist/combat/regenShield.js +66 -18
- package/dist/combat/resourcePool.d.ts +19 -0
- package/dist/combat/resourcePool.js +20 -2
- package/dist/combat/shotOrigin.d.ts +5 -0
- package/dist/combat/statusApplication.d.ts +1 -1
- package/dist/combat/statusApplication.js +2 -1
- package/dist/combat/statusEffectView.d.ts +56 -0
- package/dist/combat/statusEffectView.js +52 -0
- package/dist/combat.d.ts +4 -2
- package/dist/combat.js +2 -0
- package/dist/devtools/fallbackSeams.d.ts +3 -2
- package/dist/devtools/fallbackSeams.js +1 -1
- package/dist/devtools/textureErrors.d.ts +40 -0
- package/dist/devtools/textureErrors.js +68 -0
- package/dist/economy/shopStock.d.ts +110 -0
- package/dist/economy/shopStock.js +157 -0
- package/dist/economy/wallet.d.ts +6 -0
- package/dist/economy/wallet.js +1 -0
- package/dist/game/achievements.d.ts +80 -0
- package/dist/game/achievements.js +124 -0
- package/dist/game/cameraConfig.d.ts +7 -0
- package/dist/game/codex.d.ts +60 -0
- package/dist/game/codex.js +89 -0
- package/dist/game/credits.d.ts +36 -0
- package/dist/game/credits.js +0 -0
- package/dist/game/defineGame.d.ts +28 -3
- package/dist/game/defineGame.js +5 -0
- package/dist/game/dialogueGraph.d.ts +116 -0
- package/dist/game/dialogueGraph.js +103 -0
- package/dist/game/eventTicker.d.ts +95 -0
- package/dist/game/eventTicker.js +91 -0
- package/dist/game/events.d.ts +24 -5
- package/dist/game/events.js +18 -0
- package/dist/game/gamePhase.d.ts +1 -1
- package/dist/game/gamePhase.js +1 -1
- package/dist/game/leaderboardRank.d.ts +71 -0
- package/dist/game/leaderboardRank.js +76 -0
- package/dist/game/lootTable.d.ts +10 -1
- package/dist/game/lootTable.js +4 -2
- package/dist/game/notifications.d.ts +57 -0
- package/dist/game/notifications.js +107 -0
- package/dist/game/playableGame.d.ts +39 -2
- package/dist/game/quest.d.ts +26 -0
- package/dist/game/quest.js +31 -0
- package/dist/game/runDraft.js +2 -1
- package/dist/game/saveSlots.d.ts +102 -0
- package/dist/game/saveSlots.js +136 -0
- package/dist/game/talentTreeView.d.ts +106 -0
- package/dist/game/talentTreeView.js +114 -0
- package/dist/game/worldItem.js +2 -1
- package/dist/gameplay.d.ts +15 -4
- package/dist/gameplay.js +13 -2
- package/dist/i18n/i18n.d.ts +51 -0
- package/dist/i18n/i18n.js +82 -0
- package/dist/input/axisInput.d.ts +1 -1
- package/dist/input/axisInput.js +18 -2
- package/dist/input/rebindSession.d.ts +141 -0
- package/dist/input/rebindSession.js +235 -0
- package/dist/input/touchControlsMode.d.ts +16 -0
- package/dist/input/touchControlsMode.js +23 -0
- package/dist/input/touchScheme.d.ts +30 -1
- package/dist/input/touchScheme.js +31 -1
- package/dist/interaction/promptRegistry.d.ts +78 -0
- package/dist/interaction/promptRegistry.js +130 -0
- package/dist/inventory/storageTier.js +3 -2
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +145 -1
- package/dist/movement/movementModel.d.ts +26 -3
- package/dist/movement/movementModel.js +121 -15
- package/dist/movement/playerMovement.js +161 -25
- package/dist/physics/drivableVehicle.js +3 -1
- package/dist/physics/kinematicVehicle.d.ts +56 -1
- package/dist/physics/kinematicVehicle.js +99 -10
- package/dist/physics/vehicleObstacles.js +19 -0
- package/dist/random/resolveRng.d.ts +14 -0
- package/dist/random/resolveRng.js +31 -0
- package/dist/render/postProcessing.d.ts +12 -1
- package/dist/runtime/cameraDirector.d.ts +2 -2
- package/dist/runtime/context/combat.js +8 -0
- package/dist/runtime/gameContext.js +6 -1
- package/dist/runtime/gameContextTypes.d.ts +18 -0
- package/dist/runtime/inputSnapshot.d.ts +10 -1
- package/dist/runtime/inputSnapshot.js +13 -1
- package/dist/runtime/runtimeSave.d.ts +3 -3
- package/dist/runtime/runtimeSave.js +11 -2
- package/dist/scene/assetDiagnostics.js +2 -2
- package/dist/scene/autoTarget.js +2 -1
- package/dist/scene/captureCheck.js +3 -2
- package/dist/scene/entityStore.d.ts +4 -1
- package/dist/scene/entityStore.js +3 -0
- package/dist/scene/objectStore.d.ts +10 -0
- package/dist/scene/objectStore.js +29 -6
- package/dist/scene/sceneKinds.d.ts +18 -0
- package/dist/scene/sceneKinds.js +19 -0
- package/dist/scene/sequenceDirector.d.ts +110 -0
- package/dist/scene/sequenceDirector.js +146 -0
- package/dist/session/roles.js +3 -2
- package/dist/settings/settingsModel.d.ts +1 -0
- package/dist/settings/settingsModel.js +1 -0
- package/dist/stats/rollCheck.js +4 -2
- package/dist/time/dayNightCycle.d.ts +114 -0
- package/dist/time/dayNightCycle.js +208 -0
- package/dist/time/timerSet.d.ts +132 -0
- package/dist/time/timerSet.js +243 -0
- package/dist/ui/accessibility.d.ts +51 -0
- package/dist/ui/accessibility.js +83 -0
- package/dist/ui/coachMarks.d.ts +99 -0
- package/dist/ui/coachMarks.js +126 -0
- package/dist/ui/floatingText.d.ts +114 -0
- package/dist/ui/floatingText.js +203 -0
- package/dist/ui/modalStack.d.ts +143 -0
- package/dist/ui/modalStack.js +160 -0
- package/dist/ui/objectiveBanner.d.ts +123 -0
- package/dist/ui/objectiveBanner.js +162 -0
- package/dist/ui/photoMode.d.ts +32 -0
- package/dist/ui/photoMode.js +51 -0
- package/dist/ui/radialMenu.d.ts +47 -0
- package/dist/ui/radialMenu.js +74 -0
- package/dist/ui/screenMarkers.d.ts +128 -0
- package/dist/ui/screenMarkers.js +118 -0
- package/dist/ui.d.ts +8 -0
- package/dist/ui.js +8 -0
- package/dist/vfx/cameraShake.d.ts +97 -0
- package/dist/vfx/cameraShake.js +136 -0
- package/dist/vfx/damageDirection.d.ts +102 -0
- package/dist/vfx/damageDirection.js +188 -0
- package/dist/vfx/particles.d.ts +103 -0
- package/dist/vfx/particles.js +256 -0
- package/dist/vfx/screenEffects.d.ts +149 -0
- package/dist/vfx/screenEffects.js +191 -0
- package/dist/world/authoredObjects.d.ts +18 -0
- package/dist/world/authoredObjects.js +21 -0
- package/dist/world/authoredSpawn.js +19 -0
- package/dist/world/buildingLots.d.ts +54 -11
- package/dist/world/buildingLots.js +116 -30
- package/dist/world/cityBlocks.d.ts +45 -0
- package/dist/world/cityBlocks.js +159 -0
- package/dist/world/cityContent.d.ts +46 -2
- package/dist/world/cityContent.js +160 -2
- package/dist/world/cityGenerator.d.ts +261 -23
- package/dist/world/cityGenerator.js +643 -33
- package/dist/world/cityGeometry.d.ts +20 -0
- package/dist/world/cityGeometry.js +91 -0
- package/dist/world/cityKind.js +1 -44
- package/dist/world/fastTravel.d.ts +64 -0
- package/dist/world/fastTravel.js +108 -0
- package/dist/world/features.d.ts +16 -0
- package/dist/world/features.js +2 -0
- package/dist/world/grounded.d.ts +11 -0
- package/dist/world/grounded.js +12 -0
- package/dist/world/mapAnnotations.d.ts +75 -0
- package/dist/world/mapAnnotations.js +139 -0
- package/dist/world/markers.js +1 -0
- package/dist/world/resourceNode.d.ts +1 -1
- package/dist/world/resourceNode.js +2 -1
- package/dist/world/roads.d.ts +294 -5
- package/dist/world/roads.js +1262 -21
- package/dist/world/sharedAnnotations.d.ts +84 -0
- package/dist/world/sharedAnnotations.js +76 -0
- package/dist/world/spawnOverride.d.ts +37 -0
- package/dist/world/spawnOverride.js +50 -0
- package/dist/world/streetGenerator.d.ts +60 -11
- package/dist/world/streetGenerator.js +1786 -129
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world/waypoints.d.ts +63 -0
- package/dist/world/waypoints.js +137 -0
- package/dist/world.d.ts +17 -2
- package/dist/world.js +17 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +3 -1
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-combat/api.md +40 -4
- package/skills/jgengine-combat/capabilities.md +12 -0
- package/skills/jgengine-combat/reference.md +2 -0
- package/skills/jgengine-gameplay/api.md +212 -11
- package/skills/jgengine-gameplay/capabilities.md +66 -1
- package/skills/jgengine-world/api.md +397 -11
- package/skills/jgengine-world/capabilities.md +123 -2
- package/skills/jgengine-world/reference.md +15 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Difficulty is decision *quality*, owned in one place: a serializable {@link DifficultyProfile}
|
|
3
|
+
* describing how fast, how accurately, and how far ahead an agent decides, plus appliers at the
|
|
4
|
+
* universal decision seams every opponent shares — reacting to new information, choosing among
|
|
5
|
+
* scored options, executing with error, spending abilities, and planning ahead. The same profile
|
|
6
|
+
* tunes an RTS grunt, a street enemy, a boss's special-ability discipline, or a turn-based
|
|
7
|
+
* lookahead player; the game supplies the domain (candidates, scores, moves) and this module
|
|
8
|
+
* degrades or sharpens the choice. Three canonical tiers ({@link DIFFICULTY_TIERS}) cover
|
|
9
|
+
* "kinda dumb / smart / super smart"; per-game overrides stay data.
|
|
10
|
+
*
|
|
11
|
+
* Everything is deterministic under an injected rng, work is bounded by the profile, and state
|
|
12
|
+
* (the reaction gate) is one small serializable object. Compose with the existing substrates
|
|
13
|
+
* rather than replacing them: scale `MobBrainConfig.aggroRadius` by {@link DifficultyProfile.perceptionScale},
|
|
14
|
+
* run `acquireTarget` candidates through {@link pickScored}, gate `advancePursuit` target swaps
|
|
15
|
+
* behind {@link advanceReactionGate}, and score ability windows into {@link shouldUseAbility}.
|
|
16
|
+
*/
|
|
17
|
+
/** The three canonical decision-quality tiers. */
|
|
18
|
+
export type DifficultyTier = "easy" | "standard" | "expert";
|
|
19
|
+
/**
|
|
20
|
+
* Serializable decision-quality data. All fields are plain numbers so a profile can live in a
|
|
21
|
+
* save, an encounter definition, or a per-entity spawn record.
|
|
22
|
+
*/
|
|
23
|
+
export interface DifficultyProfile {
|
|
24
|
+
/** Seconds before newly observed information takes effect (see {@link advanceReactionGate}). */
|
|
25
|
+
reactionSeconds: number;
|
|
26
|
+
/**
|
|
27
|
+
* `0..1` chance of a degraded pick in {@link pickScored}, and the amplitude of perceived-value
|
|
28
|
+
* noise in {@link shouldUseAbility} and {@link planLookahead}. `0` is optimal play.
|
|
29
|
+
*/
|
|
30
|
+
decisionNoise: number;
|
|
31
|
+
/** `0..1` amplitude of execution error returned by {@link executionError} (aim, lead, timing). */
|
|
32
|
+
executionJitter: number;
|
|
33
|
+
/**
|
|
34
|
+
* `0..1` opportunity threshold for spending abilities: low values fire specials on weak
|
|
35
|
+
* moments, high values hold them for strong windows (see {@link shouldUseAbility}).
|
|
36
|
+
*/
|
|
37
|
+
abilityDiscipline: number;
|
|
38
|
+
/** Multiplier games apply to sight/aggro/awareness radii (e.g. `aggroRadius * perceptionScale`). */
|
|
39
|
+
perceptionScale: number;
|
|
40
|
+
/** Lookahead plies for {@link planLookahead}; `1` is greedy. */
|
|
41
|
+
planDepth: number;
|
|
42
|
+
/** Candidate moves considered per ply in {@link planLookahead} (branching bound). */
|
|
43
|
+
planWidth: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The canonical easy/standard/expert profiles. Frozen — use {@link difficultyProfile} to derive
|
|
47
|
+
* a tweaked copy instead of mutating.
|
|
48
|
+
*
|
|
49
|
+
* @capability difficulty-tiers canonical easy/standard/expert decision-quality profiles for any opponent
|
|
50
|
+
*/
|
|
51
|
+
export declare const DIFFICULTY_TIERS: Readonly<Record<DifficultyTier, DifficultyProfile>>;
|
|
52
|
+
/**
|
|
53
|
+
* A fresh profile from a canonical tier with optional per-game overrides — e.g. an "easy" boss
|
|
54
|
+
* that still reacts fast: `difficultyProfile("easy", { reactionSeconds: 0.2 })`.
|
|
55
|
+
*
|
|
56
|
+
* @capability difficulty-profile derive a per-game decision-quality profile from a canonical tier
|
|
57
|
+
*/
|
|
58
|
+
export declare function difficultyProfile(tier: DifficultyTier, overrides?: Partial<DifficultyProfile>): DifficultyProfile;
|
|
59
|
+
/**
|
|
60
|
+
* Serializable reaction-gate state: the value the agent is currently acting on plus the newer
|
|
61
|
+
* value it has noticed but not yet committed to. `T` should be serializable (ids, positions as
|
|
62
|
+
* tuples) if the gate lives in saved state.
|
|
63
|
+
*/
|
|
64
|
+
export interface ReactionGateState<T> {
|
|
65
|
+
/** The value currently in effect — what the agent acts on this step. */
|
|
66
|
+
committed: T;
|
|
67
|
+
/** The newer observed value waiting out the reaction delay, or the committed value when idle. */
|
|
68
|
+
pending: T;
|
|
69
|
+
/** Seconds the pending value has been observed. */
|
|
70
|
+
pendingSeconds: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A fresh gate already committed to `initial`.
|
|
74
|
+
*
|
|
75
|
+
* @capability difficulty-reaction-gate-state fresh serializable reaction-gate state for one observed value
|
|
76
|
+
*/
|
|
77
|
+
export declare function createReactionGate<T>(initial: T): ReactionGateState<T>;
|
|
78
|
+
/**
|
|
79
|
+
* Advance one reaction step and return the value to act on. `desired` is what a perfect agent
|
|
80
|
+
* would act on this frame (current best target, latest known player position); the gate holds the
|
|
81
|
+
* previously committed value until `desired` has been stable for `profile.reactionSeconds`,
|
|
82
|
+
* restarting the delay whenever `desired` changes again (compared with `Object.is`). Feed the
|
|
83
|
+
* returned value — not `desired` — into pursuit/aim/ability logic. Mutates `state` in place;
|
|
84
|
+
* allocation-free.
|
|
85
|
+
*
|
|
86
|
+
* @capability difficulty-reaction-gate delay acting on new information by the profile's reaction time
|
|
87
|
+
*/
|
|
88
|
+
export declare function advanceReactionGate<T>(state: ReactionGateState<T>, dt: number, desired: T, profile: DifficultyProfile): T;
|
|
89
|
+
/** One candidate with a caller-computed score; higher is better. */
|
|
90
|
+
export interface ScoredOption<T> {
|
|
91
|
+
option: T;
|
|
92
|
+
score: number;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Choose from a scored option list at the profile's quality: with probability
|
|
96
|
+
* `profile.decisionNoise` the pick is uniform over all options (the "wrong target / bad move"
|
|
97
|
+
* mistake), otherwise it is the best score (first wins ties). Works over any decision the game
|
|
98
|
+
* can score — acquisition candidates, ability choices, retreat routes, chess moves. Returns
|
|
99
|
+
* `null` for an empty list. Allocation-free scan; one rng draw plus one more on a noisy pick.
|
|
100
|
+
*
|
|
101
|
+
* @capability difficulty-pick noisy argmax over scored options; expert picks best, easy blunders
|
|
102
|
+
*/
|
|
103
|
+
export declare function pickScored<T>(options: readonly ScoredOption<T>[], profile: DifficultyProfile, rng: () => number): T | null;
|
|
104
|
+
/**
|
|
105
|
+
* Symmetric execution error in `[-scale * executionJitter, +scale * executionJitter]` — add it to
|
|
106
|
+
* a yaw, a lead time, a throw power, or a release timestamp. `scale` maps the unitless jitter
|
|
107
|
+
* onto the caller's units (e.g. pass max miss radians).
|
|
108
|
+
*
|
|
109
|
+
* @capability difficulty-execution-error symmetric aim/lead/timing error scaled by the profile
|
|
110
|
+
*/
|
|
111
|
+
export declare function executionError(profile: DifficultyProfile, rng: () => number, scale?: number): number;
|
|
112
|
+
/**
|
|
113
|
+
* Should the agent spend an ability now? `opportunity` is the caller-scored quality of this
|
|
114
|
+
* moment in `0..1` (targets clumped for the AoE, player mid-animation, burn phase). The perceived
|
|
115
|
+
* opportunity is fuzzed by `decisionNoise` and compared against `abilityDiscipline`: an easy
|
|
116
|
+
* profile (low discipline, high noise) wastes cooldowns on weak moments and sometimes sits on a
|
|
117
|
+
* perfect window; an expert profile fires exactly on strong windows. One rng draw when noisy.
|
|
118
|
+
*
|
|
119
|
+
* @capability difficulty-ability-gate spend-or-hold ability decision from a scored opportunity window
|
|
120
|
+
*/
|
|
121
|
+
export declare function shouldUseAbility(opportunity: number, profile: DifficultyProfile, rng: () => number): boolean;
|
|
122
|
+
/**
|
|
123
|
+
* The game-owned decision domain {@link planLookahead} searches. States and moves are caller
|
|
124
|
+
* types; the planner never inspects them.
|
|
125
|
+
*/
|
|
126
|
+
export interface LookaheadDomain<S, M> {
|
|
127
|
+
/**
|
|
128
|
+
* Candidate moves from `state` with immediate scores (higher better for the player to move).
|
|
129
|
+
* The planner explores only the best `planWidth` of these per ply.
|
|
130
|
+
*/
|
|
131
|
+
moves(state: S): readonly ScoredOption<M>[];
|
|
132
|
+
/** The successor state after the player to move plays `move`. Must not mutate `state`. */
|
|
133
|
+
apply(state: S, move: M): S;
|
|
134
|
+
/** Static evaluation of `state` from the perspective of the player to move in it. */
|
|
135
|
+
evaluate(state: S): number;
|
|
136
|
+
/**
|
|
137
|
+
* `true` (default) alternates perspective each ply — adversarial turn games (chess-like,
|
|
138
|
+
* duels). `false` keeps one perspective — planning the agent's own action sequence
|
|
139
|
+
* (rotations, build orders, multi-step routes).
|
|
140
|
+
*/
|
|
141
|
+
adversarial?: boolean;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Pick the agent's next move by bounded lookahead: a depth-limited negamax (or single-perspective
|
|
145
|
+
* sequence search when `adversarial: false`) over the caller's {@link LookaheadDomain}, exploring
|
|
146
|
+
* at most `profile.planWidth` moves per ply to `profile.planDepth` plies, with the final root
|
|
147
|
+
* choice run through {@link pickScored} so `decisionNoise` still injects tier-appropriate
|
|
148
|
+
* blunders. Depth `1` is greedy — an easy profile literally cannot see the fork coming; an
|
|
149
|
+
* expert profile finds multi-ply tactics. Work is bounded by `planWidth ** planDepth` domain
|
|
150
|
+
* calls; run it at decision cadence (turns, ability windows, order re-evaluation), not per frame.
|
|
151
|
+
*
|
|
152
|
+
* @capability difficulty-lookahead depth/width-bounded deterministic planner for turn-based or sequential decisions
|
|
153
|
+
*/
|
|
154
|
+
export declare function planLookahead<S, M>(root: S, domain: LookaheadDomain<S, M>, profile: DifficultyProfile, rng: () => number): M | null;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Difficulty is decision *quality*, owned in one place: a serializable {@link DifficultyProfile}
|
|
3
|
+
* describing how fast, how accurately, and how far ahead an agent decides, plus appliers at the
|
|
4
|
+
* universal decision seams every opponent shares — reacting to new information, choosing among
|
|
5
|
+
* scored options, executing with error, spending abilities, and planning ahead. The same profile
|
|
6
|
+
* tunes an RTS grunt, a street enemy, a boss's special-ability discipline, or a turn-based
|
|
7
|
+
* lookahead player; the game supplies the domain (candidates, scores, moves) and this module
|
|
8
|
+
* degrades or sharpens the choice. Three canonical tiers ({@link DIFFICULTY_TIERS}) cover
|
|
9
|
+
* "kinda dumb / smart / super smart"; per-game overrides stay data.
|
|
10
|
+
*
|
|
11
|
+
* Everything is deterministic under an injected rng, work is bounded by the profile, and state
|
|
12
|
+
* (the reaction gate) is one small serializable object. Compose with the existing substrates
|
|
13
|
+
* rather than replacing them: scale `MobBrainConfig.aggroRadius` by {@link DifficultyProfile.perceptionScale},
|
|
14
|
+
* run `acquireTarget` candidates through {@link pickScored}, gate `advancePursuit` target swaps
|
|
15
|
+
* behind {@link advanceReactionGate}, and score ability windows into {@link shouldUseAbility}.
|
|
16
|
+
*/
|
|
17
|
+
const TIERS = {
|
|
18
|
+
easy: {
|
|
19
|
+
reactionSeconds: 0.9,
|
|
20
|
+
decisionNoise: 0.35,
|
|
21
|
+
executionJitter: 0.3,
|
|
22
|
+
abilityDiscipline: 0.2,
|
|
23
|
+
perceptionScale: 0.7,
|
|
24
|
+
planDepth: 1,
|
|
25
|
+
planWidth: 2,
|
|
26
|
+
},
|
|
27
|
+
standard: {
|
|
28
|
+
reactionSeconds: 0.35,
|
|
29
|
+
decisionNoise: 0.12,
|
|
30
|
+
executionJitter: 0.12,
|
|
31
|
+
abilityDiscipline: 0.55,
|
|
32
|
+
perceptionScale: 1,
|
|
33
|
+
planDepth: 2,
|
|
34
|
+
planWidth: 4,
|
|
35
|
+
},
|
|
36
|
+
expert: {
|
|
37
|
+
reactionSeconds: 0.08,
|
|
38
|
+
decisionNoise: 0,
|
|
39
|
+
executionJitter: 0.02,
|
|
40
|
+
abilityDiscipline: 0.85,
|
|
41
|
+
perceptionScale: 1.3,
|
|
42
|
+
planDepth: 4,
|
|
43
|
+
planWidth: 8,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
for (const tier of Object.values(TIERS))
|
|
47
|
+
Object.freeze(tier);
|
|
48
|
+
/**
|
|
49
|
+
* The canonical easy/standard/expert profiles. Frozen — use {@link difficultyProfile} to derive
|
|
50
|
+
* a tweaked copy instead of mutating.
|
|
51
|
+
*
|
|
52
|
+
* @capability difficulty-tiers canonical easy/standard/expert decision-quality profiles for any opponent
|
|
53
|
+
*/
|
|
54
|
+
export const DIFFICULTY_TIERS = Object.freeze(TIERS);
|
|
55
|
+
/**
|
|
56
|
+
* A fresh profile from a canonical tier with optional per-game overrides — e.g. an "easy" boss
|
|
57
|
+
* that still reacts fast: `difficultyProfile("easy", { reactionSeconds: 0.2 })`.
|
|
58
|
+
*
|
|
59
|
+
* @capability difficulty-profile derive a per-game decision-quality profile from a canonical tier
|
|
60
|
+
*/
|
|
61
|
+
export function difficultyProfile(tier, overrides) {
|
|
62
|
+
return { ...DIFFICULTY_TIERS[tier], ...overrides };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A fresh gate already committed to `initial`.
|
|
66
|
+
*
|
|
67
|
+
* @capability difficulty-reaction-gate-state fresh serializable reaction-gate state for one observed value
|
|
68
|
+
*/
|
|
69
|
+
export function createReactionGate(initial) {
|
|
70
|
+
return { committed: initial, pending: initial, pendingSeconds: 0 };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Advance one reaction step and return the value to act on. `desired` is what a perfect agent
|
|
74
|
+
* would act on this frame (current best target, latest known player position); the gate holds the
|
|
75
|
+
* previously committed value until `desired` has been stable for `profile.reactionSeconds`,
|
|
76
|
+
* restarting the delay whenever `desired` changes again (compared with `Object.is`). Feed the
|
|
77
|
+
* returned value — not `desired` — into pursuit/aim/ability logic. Mutates `state` in place;
|
|
78
|
+
* allocation-free.
|
|
79
|
+
*
|
|
80
|
+
* @capability difficulty-reaction-gate delay acting on new information by the profile's reaction time
|
|
81
|
+
*/
|
|
82
|
+
export function advanceReactionGate(state, dt, desired, profile) {
|
|
83
|
+
if (Object.is(desired, state.committed)) {
|
|
84
|
+
state.pending = desired;
|
|
85
|
+
state.pendingSeconds = 0;
|
|
86
|
+
return state.committed;
|
|
87
|
+
}
|
|
88
|
+
if (!Object.is(desired, state.pending)) {
|
|
89
|
+
state.pending = desired;
|
|
90
|
+
state.pendingSeconds = 0;
|
|
91
|
+
}
|
|
92
|
+
state.pendingSeconds += dt;
|
|
93
|
+
if (state.pendingSeconds >= profile.reactionSeconds) {
|
|
94
|
+
state.committed = state.pending;
|
|
95
|
+
state.pendingSeconds = 0;
|
|
96
|
+
}
|
|
97
|
+
return state.committed;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Choose from a scored option list at the profile's quality: with probability
|
|
101
|
+
* `profile.decisionNoise` the pick is uniform over all options (the "wrong target / bad move"
|
|
102
|
+
* mistake), otherwise it is the best score (first wins ties). Works over any decision the game
|
|
103
|
+
* can score — acquisition candidates, ability choices, retreat routes, chess moves. Returns
|
|
104
|
+
* `null` for an empty list. Allocation-free scan; one rng draw plus one more on a noisy pick.
|
|
105
|
+
*
|
|
106
|
+
* @capability difficulty-pick noisy argmax over scored options; expert picks best, easy blunders
|
|
107
|
+
*/
|
|
108
|
+
export function pickScored(options, profile, rng) {
|
|
109
|
+
if (options.length === 0)
|
|
110
|
+
return null;
|
|
111
|
+
if (profile.decisionNoise > 0 && rng() < profile.decisionNoise) {
|
|
112
|
+
const index = Math.min(options.length - 1, Math.floor(rng() * options.length));
|
|
113
|
+
return options[index].option;
|
|
114
|
+
}
|
|
115
|
+
let best = options[0];
|
|
116
|
+
for (let i = 1; i < options.length; i++) {
|
|
117
|
+
const candidate = options[i];
|
|
118
|
+
if (candidate.score > best.score)
|
|
119
|
+
best = candidate;
|
|
120
|
+
}
|
|
121
|
+
return best.option;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Symmetric execution error in `[-scale * executionJitter, +scale * executionJitter]` — add it to
|
|
125
|
+
* a yaw, a lead time, a throw power, or a release timestamp. `scale` maps the unitless jitter
|
|
126
|
+
* onto the caller's units (e.g. pass max miss radians).
|
|
127
|
+
*
|
|
128
|
+
* @capability difficulty-execution-error symmetric aim/lead/timing error scaled by the profile
|
|
129
|
+
*/
|
|
130
|
+
export function executionError(profile, rng, scale = 1) {
|
|
131
|
+
if (profile.executionJitter <= 0)
|
|
132
|
+
return 0;
|
|
133
|
+
return (rng() * 2 - 1) * profile.executionJitter * scale;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Should the agent spend an ability now? `opportunity` is the caller-scored quality of this
|
|
137
|
+
* moment in `0..1` (targets clumped for the AoE, player mid-animation, burn phase). The perceived
|
|
138
|
+
* opportunity is fuzzed by `decisionNoise` and compared against `abilityDiscipline`: an easy
|
|
139
|
+
* profile (low discipline, high noise) wastes cooldowns on weak moments and sometimes sits on a
|
|
140
|
+
* perfect window; an expert profile fires exactly on strong windows. One rng draw when noisy.
|
|
141
|
+
*
|
|
142
|
+
* @capability difficulty-ability-gate spend-or-hold ability decision from a scored opportunity window
|
|
143
|
+
*/
|
|
144
|
+
export function shouldUseAbility(opportunity, profile, rng) {
|
|
145
|
+
const perceived = profile.decisionNoise > 0 ? opportunity + (rng() * 2 - 1) * profile.decisionNoise : opportunity;
|
|
146
|
+
return perceived >= profile.abilityDiscipline;
|
|
147
|
+
}
|
|
148
|
+
function rankedFrontier(domain, state, width) {
|
|
149
|
+
const moves = domain.moves(state);
|
|
150
|
+
if (moves.length <= width)
|
|
151
|
+
return moves;
|
|
152
|
+
return [...moves].sort((a, b) => b.score - a.score).slice(0, width);
|
|
153
|
+
}
|
|
154
|
+
function lookaheadValue(domain, state, depth, width) {
|
|
155
|
+
if (depth <= 0)
|
|
156
|
+
return domain.evaluate(state);
|
|
157
|
+
const frontier = rankedFrontier(domain, state, width);
|
|
158
|
+
if (frontier.length === 0)
|
|
159
|
+
return domain.evaluate(state);
|
|
160
|
+
const negate = domain.adversarial !== false;
|
|
161
|
+
let best = Number.NEGATIVE_INFINITY;
|
|
162
|
+
for (const candidate of frontier) {
|
|
163
|
+
const child = lookaheadValue(domain, domain.apply(state, candidate.option), depth - 1, width);
|
|
164
|
+
const value = negate ? -child : child;
|
|
165
|
+
if (value > best)
|
|
166
|
+
best = value;
|
|
167
|
+
}
|
|
168
|
+
return best;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Pick the agent's next move by bounded lookahead: a depth-limited negamax (or single-perspective
|
|
172
|
+
* sequence search when `adversarial: false`) over the caller's {@link LookaheadDomain}, exploring
|
|
173
|
+
* at most `profile.planWidth` moves per ply to `profile.planDepth` plies, with the final root
|
|
174
|
+
* choice run through {@link pickScored} so `decisionNoise` still injects tier-appropriate
|
|
175
|
+
* blunders. Depth `1` is greedy — an easy profile literally cannot see the fork coming; an
|
|
176
|
+
* expert profile finds multi-ply tactics. Work is bounded by `planWidth ** planDepth` domain
|
|
177
|
+
* calls; run it at decision cadence (turns, ability windows, order re-evaluation), not per frame.
|
|
178
|
+
*
|
|
179
|
+
* @capability difficulty-lookahead depth/width-bounded deterministic planner for turn-based or sequential decisions
|
|
180
|
+
*/
|
|
181
|
+
export function planLookahead(root, domain, profile, rng) {
|
|
182
|
+
const depth = Math.max(1, Math.floor(profile.planDepth));
|
|
183
|
+
const width = Math.max(1, Math.floor(profile.planWidth));
|
|
184
|
+
const negate = domain.adversarial !== false;
|
|
185
|
+
const frontier = rankedFrontier(domain, root, width);
|
|
186
|
+
if (frontier.length === 0)
|
|
187
|
+
return null;
|
|
188
|
+
const valued = [];
|
|
189
|
+
for (const candidate of frontier) {
|
|
190
|
+
const child = lookaheadValue(domain, domain.apply(root, candidate.option), depth - 1, width);
|
|
191
|
+
valued.push({ option: candidate.option, score: negate ? -child : child });
|
|
192
|
+
}
|
|
193
|
+
return pickScored(valued, profile, rng);
|
|
194
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { AxisInput } from "../input/axisInput.js";
|
|
2
|
+
import { type DifficultyProfile } from "./difficulty.js";
|
|
3
|
+
/**
|
|
4
|
+
* A difficulty-aware driver brain: pose + goal in, the shared {@link AxisInput}
|
|
5
|
+
* (`throttle`/`brake`/`steer`/`handbrake`) out, ready for `tickDrivableVehicle` — the chase/traffic
|
|
6
|
+
* steering loop every game with AI cars hand-rolls as `steer = error * k, throttle = 1`. A
|
|
7
|
+
* {@link DifficultyProfile} decides how *well* it drives: reaction time trails the target, corners
|
|
8
|
+
* shed speed instead of orbiting at full throttle, obstacles are noticed late on low tiers, the
|
|
9
|
+
* wheel wobbles by `executionJitter`, and a stuck car backs out with counter-steer after grinding
|
|
10
|
+
* for a tier-scaled while. State is one small serializable object; randomness is injected; every
|
|
11
|
+
* step is allocation-light scalar math. The caller owns the sim, collision, and all spatial
|
|
12
|
+
* queries — this module never scans the world.
|
|
13
|
+
*/
|
|
14
|
+
/** The driver's own vehicle this step: world position, yaw heading, signed forward speed. */
|
|
15
|
+
export interface DriverPose {
|
|
16
|
+
x: number;
|
|
17
|
+
z: number;
|
|
18
|
+
/** Yaw with the same convention as the kinematic vehicle sim: `atan2(dx, dz)`. */
|
|
19
|
+
heading: number;
|
|
20
|
+
/** Signed forward speed in world units/second (negative while reversing). */
|
|
21
|
+
speed: number;
|
|
22
|
+
}
|
|
23
|
+
/** Where the driver is trying to go this step. */
|
|
24
|
+
export interface DriverGoal {
|
|
25
|
+
x: number;
|
|
26
|
+
z: number;
|
|
27
|
+
/** Brake to a halt inside `arriveRadius` instead of driving through; default `false`. */
|
|
28
|
+
stopAtGoal?: boolean;
|
|
29
|
+
/** Cap on desired speed for this goal (a speed limit, a formation speed); default unlimited. */
|
|
30
|
+
speedLimit?: number;
|
|
31
|
+
}
|
|
32
|
+
/** Vehicle-feel numbers the game supplies; everything has a sensible default except `maxSpeed`. */
|
|
33
|
+
export interface DriverTuning {
|
|
34
|
+
/** Top desired speed on a straight, world units/second. */
|
|
35
|
+
maxSpeed: number;
|
|
36
|
+
/** Steering-output gain per radian of heading error; default `2`. */
|
|
37
|
+
steerGain?: number;
|
|
38
|
+
/** Fraction of speed shed at a right-angle heading error (`0` never slows for corners); default `0.75`. */
|
|
39
|
+
cornerSlowdown?: number;
|
|
40
|
+
/** Distance at which the goal counts as reached; default `4`. */
|
|
41
|
+
arriveRadius?: number;
|
|
42
|
+
/** Meters of clearance the driver tries to keep to an obstacle ahead; default `6`. */
|
|
43
|
+
brakeMargin?: number;
|
|
44
|
+
/** Seconds between steering-jitter resamples, so low tiers wobble instead of noise-shake; default `0.35`. */
|
|
45
|
+
jitterIntervalSeconds?: number;
|
|
46
|
+
/** Heading-error radians one full unit of `executionJitter` adds; default `0.35`. */
|
|
47
|
+
jitterScaleRadians?: number;
|
|
48
|
+
/** Below this |speed| while throttling the car counts as stuck; default `0.8`. */
|
|
49
|
+
stuckSpeed?: number;
|
|
50
|
+
/** Seconds of grinding (plus the profile's `reactionSeconds`) before reversing out; default `1.5`. */
|
|
51
|
+
stuckSeconds?: number;
|
|
52
|
+
/** How long a reverse-out lasts; default `1.1`. */
|
|
53
|
+
reverseSeconds?: number;
|
|
54
|
+
}
|
|
55
|
+
/** Serializable per-driver state advanced in place by {@link driveStep}. */
|
|
56
|
+
export interface DriverState {
|
|
57
|
+
/** The reaction-committed target actually steered toward (the goal as of up to `reactionSeconds` ago). */
|
|
58
|
+
targetX: number;
|
|
59
|
+
targetZ: number;
|
|
60
|
+
/** Seconds until the next target resample. */
|
|
61
|
+
sampleCooldown: number;
|
|
62
|
+
/** Current steering-wobble offset (radians of heading error) and time until its next resample. */
|
|
63
|
+
jitter: number;
|
|
64
|
+
jitterCooldown: number;
|
|
65
|
+
/** Seconds spent throttling without moving. */
|
|
66
|
+
grindSeconds: number;
|
|
67
|
+
/** Seconds of reverse-out remaining; `> 0` means currently backing up. */
|
|
68
|
+
reverseRemaining: number;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fresh driver state committed to an initial target (usually the spawn-time goal or the spawn
|
|
72
|
+
* position itself).
|
|
73
|
+
*
|
|
74
|
+
* @capability ai-driver-state fresh serializable state for one difficulty-aware AI driver
|
|
75
|
+
*/
|
|
76
|
+
export declare function createDriverState(targetX?: number, targetZ?: number): DriverState;
|
|
77
|
+
/** {@link driveStep}'s result: the axis sample to feed the vehicle sim, plus arrival. */
|
|
78
|
+
export interface DriverStep extends AxisInput {
|
|
79
|
+
/** The vehicle is inside `arriveRadius` of the *actual* goal this step. */
|
|
80
|
+
arrived: boolean;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Advance one driving step, mutating `state`, and return the {@link AxisInput} to feed the vehicle
|
|
84
|
+
* sim. `obstacleAhead` is the caller-measured distance to the nearest blocker in the driving
|
|
85
|
+
* corridor this step (`null`/omitted when clear) — the driver perceives it late by
|
|
86
|
+
* `speed * reactionSeconds`, so easy tiers brake at the last moment and expert tiers with proper
|
|
87
|
+
* margin. The steering target is a delay-line over the goal — resampled every `reactionSeconds`,
|
|
88
|
+
* so it trails a moving goal by up to that long, which is what makes an easy chaser visibly cut
|
|
89
|
+
* toward where the target *was*. When throttle produces no motion for a tier-scaled while the
|
|
90
|
+
* driver reverses out with counter-steer (brake past standstill — the shared sim's reverse).
|
|
91
|
+
*
|
|
92
|
+
* @capability ai-driver difficulty-aware chase/route driving step producing throttle/brake/steer for the vehicle sim
|
|
93
|
+
*/
|
|
94
|
+
export declare function driveStep(state: DriverState, dt: number, pose: DriverPose, goal: DriverGoal, profile: DifficultyProfile, tuning: DriverTuning, rng: () => number, obstacleAhead?: number | null): DriverStep;
|
|
95
|
+
/** A 2D polyline point (`[x, z]`), structurally compatible with authored road path centerlines. */
|
|
96
|
+
export type DrivePathPoint = readonly [number, number];
|
|
97
|
+
/**
|
|
98
|
+
* The pure-pursuit target for street/route following: the point `lookahead` meters further along
|
|
99
|
+
* the polyline from wherever the vehicle currently projects onto it. Feed the result to
|
|
100
|
+
* {@link driveStep} as the goal each tick and the driver follows the road; `loop` wraps a circuit
|
|
101
|
+
* (traffic block, race lap) instead of pinning at the final point. Returns `null` for a
|
|
102
|
+
* degenerate path. Give sharper tiers a longer `lookahead` to cut corners cleanly and duller
|
|
103
|
+
* tiers a short one so they steer segment-to-segment.
|
|
104
|
+
*
|
|
105
|
+
* @capability ai-driver-path-target pure-pursuit lookahead target on a road/route polyline for street following
|
|
106
|
+
*/
|
|
107
|
+
export declare function pathTargetAhead(path: readonly DrivePathPoint[], x: number, z: number, lookahead: number, loop?: boolean): DrivePathPoint | null;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { executionError } from "./difficulty.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fresh driver state committed to an initial target (usually the spawn-time goal or the spawn
|
|
4
|
+
* position itself).
|
|
5
|
+
*
|
|
6
|
+
* @capability ai-driver-state fresh serializable state for one difficulty-aware AI driver
|
|
7
|
+
*/
|
|
8
|
+
export function createDriverState(targetX = 0, targetZ = 0) {
|
|
9
|
+
return {
|
|
10
|
+
targetX,
|
|
11
|
+
targetZ,
|
|
12
|
+
sampleCooldown: 0,
|
|
13
|
+
jitter: 0,
|
|
14
|
+
jitterCooldown: 0,
|
|
15
|
+
grindSeconds: 0,
|
|
16
|
+
reverseRemaining: 0,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const wrapAngle = (angle) => {
|
|
20
|
+
let a = angle;
|
|
21
|
+
while (a > Math.PI)
|
|
22
|
+
a -= Math.PI * 2;
|
|
23
|
+
while (a < -Math.PI)
|
|
24
|
+
a += Math.PI * 2;
|
|
25
|
+
return a;
|
|
26
|
+
};
|
|
27
|
+
const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value);
|
|
28
|
+
/**
|
|
29
|
+
* Advance one driving step, mutating `state`, and return the {@link AxisInput} to feed the vehicle
|
|
30
|
+
* sim. `obstacleAhead` is the caller-measured distance to the nearest blocker in the driving
|
|
31
|
+
* corridor this step (`null`/omitted when clear) — the driver perceives it late by
|
|
32
|
+
* `speed * reactionSeconds`, so easy tiers brake at the last moment and expert tiers with proper
|
|
33
|
+
* margin. The steering target is a delay-line over the goal — resampled every `reactionSeconds`,
|
|
34
|
+
* so it trails a moving goal by up to that long, which is what makes an easy chaser visibly cut
|
|
35
|
+
* toward where the target *was*. When throttle produces no motion for a tier-scaled while the
|
|
36
|
+
* driver reverses out with counter-steer (brake past standstill — the shared sim's reverse).
|
|
37
|
+
*
|
|
38
|
+
* @capability ai-driver difficulty-aware chase/route driving step producing throttle/brake/steer for the vehicle sim
|
|
39
|
+
*/
|
|
40
|
+
export function driveStep(state, dt, pose, goal, profile, tuning, rng, obstacleAhead = null) {
|
|
41
|
+
const arriveRadius = tuning.arriveRadius ?? 4;
|
|
42
|
+
// Delay-line target: resample the live goal every reactionSeconds, so the steering target is
|
|
43
|
+
// the goal as of up to that long ago and a moving goal is chased where it *was*.
|
|
44
|
+
state.sampleCooldown -= dt;
|
|
45
|
+
if (state.sampleCooldown <= 0) {
|
|
46
|
+
state.targetX = goal.x;
|
|
47
|
+
state.targetZ = goal.z;
|
|
48
|
+
state.sampleCooldown = profile.reactionSeconds;
|
|
49
|
+
}
|
|
50
|
+
const goalDistance = Math.hypot(goal.x - pose.x, goal.z - pose.z);
|
|
51
|
+
const arrived = goalDistance <= arriveRadius;
|
|
52
|
+
if (arrived && goal.stopAtGoal === true) {
|
|
53
|
+
state.grindSeconds = 0;
|
|
54
|
+
state.reverseRemaining = 0;
|
|
55
|
+
return { throttle: 0, brake: clamp01(Math.abs(pose.speed)), steer: 0, handbrake: 0, arrived };
|
|
56
|
+
}
|
|
57
|
+
const dx = state.targetX - pose.x;
|
|
58
|
+
const dz = state.targetZ - pose.z;
|
|
59
|
+
const headingError = wrapAngle(Math.atan2(dx, dz) - pose.heading);
|
|
60
|
+
// Steering wobble: resample on an interval so it reads as a loose wheel, not per-frame noise.
|
|
61
|
+
state.jitterCooldown -= dt;
|
|
62
|
+
if (state.jitterCooldown <= 0) {
|
|
63
|
+
state.jitter = executionError(profile, rng, tuning.jitterScaleRadians ?? 0.35);
|
|
64
|
+
state.jitterCooldown = tuning.jitterIntervalSeconds ?? 0.35;
|
|
65
|
+
}
|
|
66
|
+
// Reverse-out recovery: back up with counter-steer, nose swinging toward the target.
|
|
67
|
+
if (state.reverseRemaining > 0) {
|
|
68
|
+
state.reverseRemaining -= dt;
|
|
69
|
+
const steer = headingError >= 0 ? -1 : 1;
|
|
70
|
+
return { throttle: 0, brake: 1, steer, handbrake: 0, arrived };
|
|
71
|
+
}
|
|
72
|
+
const steerGain = tuning.steerGain ?? 2;
|
|
73
|
+
const steer = Math.max(-1, Math.min(1, (headingError + state.jitter) * steerGain));
|
|
74
|
+
// Corner-aware desired speed: shed speed into large heading errors instead of orbiting.
|
|
75
|
+
const cornerSlowdown = tuning.cornerSlowdown ?? 0.75;
|
|
76
|
+
const cornerFactor = 1 - cornerSlowdown * Math.min(1, Math.abs(headingError) / (Math.PI / 2));
|
|
77
|
+
let desiredSpeed = Math.min(tuning.maxSpeed, goal.speedLimit ?? Number.POSITIVE_INFINITY);
|
|
78
|
+
desiredSpeed *= cornerFactor;
|
|
79
|
+
if (goal.stopAtGoal === true) {
|
|
80
|
+
// Ease in over the last stretch so the stop lands inside the radius instead of overshooting.
|
|
81
|
+
desiredSpeed = Math.min(desiredSpeed, Math.max(2, goalDistance - arriveRadius));
|
|
82
|
+
}
|
|
83
|
+
// Obstacle response on perceived (reaction-late) distance.
|
|
84
|
+
let hardBrake = false;
|
|
85
|
+
if (obstacleAhead !== null && pose.speed > 0) {
|
|
86
|
+
const brakeMargin = tuning.brakeMargin ?? 6;
|
|
87
|
+
const perceived = obstacleAhead - pose.speed * profile.reactionSeconds;
|
|
88
|
+
if (perceived <= brakeMargin)
|
|
89
|
+
hardBrake = true;
|
|
90
|
+
else if (perceived <= brakeMargin + pose.speed * 0.9)
|
|
91
|
+
desiredSpeed = Math.min(desiredSpeed, pose.speed * 0.5);
|
|
92
|
+
}
|
|
93
|
+
let throttle = 0;
|
|
94
|
+
let brake = 0;
|
|
95
|
+
if (hardBrake) {
|
|
96
|
+
brake = 1;
|
|
97
|
+
}
|
|
98
|
+
else if (pose.speed > desiredSpeed + 1) {
|
|
99
|
+
brake = clamp01((pose.speed - desiredSpeed) / Math.max(4, desiredSpeed));
|
|
100
|
+
}
|
|
101
|
+
else if (pose.speed < desiredSpeed) {
|
|
102
|
+
throttle = clamp01((desiredSpeed - pose.speed) / 3);
|
|
103
|
+
}
|
|
104
|
+
// Stuck detection: throttling but not moving (wall, prop, another car).
|
|
105
|
+
const stuckSpeed = tuning.stuckSpeed ?? 0.8;
|
|
106
|
+
if (throttle > 0.3 && Math.abs(pose.speed) < stuckSpeed) {
|
|
107
|
+
state.grindSeconds += dt;
|
|
108
|
+
if (state.grindSeconds >= (tuning.stuckSeconds ?? 1.5) + profile.reactionSeconds) {
|
|
109
|
+
state.grindSeconds = 0;
|
|
110
|
+
state.reverseRemaining = tuning.reverseSeconds ?? 1.1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
state.grindSeconds = 0;
|
|
115
|
+
}
|
|
116
|
+
return { throttle, brake, steer, handbrake: 0, arrived };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The pure-pursuit target for street/route following: the point `lookahead` meters further along
|
|
120
|
+
* the polyline from wherever the vehicle currently projects onto it. Feed the result to
|
|
121
|
+
* {@link driveStep} as the goal each tick and the driver follows the road; `loop` wraps a circuit
|
|
122
|
+
* (traffic block, race lap) instead of pinning at the final point. Returns `null` for a
|
|
123
|
+
* degenerate path. Give sharper tiers a longer `lookahead` to cut corners cleanly and duller
|
|
124
|
+
* tiers a short one so they steer segment-to-segment.
|
|
125
|
+
*
|
|
126
|
+
* @capability ai-driver-path-target pure-pursuit lookahead target on a road/route polyline for street following
|
|
127
|
+
*/
|
|
128
|
+
export function pathTargetAhead(path, x, z, lookahead, loop = false) {
|
|
129
|
+
if (path.length < 2)
|
|
130
|
+
return null;
|
|
131
|
+
let bestDistSq = Number.POSITIVE_INFINITY;
|
|
132
|
+
let bestSegment = 0;
|
|
133
|
+
let bestT = 0;
|
|
134
|
+
for (let i = 0; i < path.length - 1; i += 1) {
|
|
135
|
+
const a = path[i];
|
|
136
|
+
const b = path[i + 1];
|
|
137
|
+
const abx = b[0] - a[0];
|
|
138
|
+
const abz = b[1] - a[1];
|
|
139
|
+
const lengthSq = abx * abx + abz * abz;
|
|
140
|
+
const t = lengthSq === 0 ? 0 : Math.max(0, Math.min(1, ((x - a[0]) * abx + (z - a[1]) * abz) / lengthSq));
|
|
141
|
+
const px = a[0] + abx * t;
|
|
142
|
+
const pz = a[1] + abz * t;
|
|
143
|
+
const distSq = (x - px) * (x - px) + (z - pz) * (z - pz);
|
|
144
|
+
if (distSq < bestDistSq) {
|
|
145
|
+
bestDistSq = distSq;
|
|
146
|
+
bestSegment = i;
|
|
147
|
+
bestT = t;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
let remaining = lookahead;
|
|
151
|
+
let segment = bestSegment;
|
|
152
|
+
let t = bestT;
|
|
153
|
+
for (let hops = 0; hops <= path.length * 2; hops += 1) {
|
|
154
|
+
const a = path[segment];
|
|
155
|
+
const b = path[segment + 1];
|
|
156
|
+
const segLength = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
157
|
+
const left = segLength * (1 - t);
|
|
158
|
+
if (remaining <= left || segLength === 0) {
|
|
159
|
+
const nt = segLength === 0 ? t : t + remaining / segLength;
|
|
160
|
+
return [a[0] + (b[0] - a[0]) * nt, a[1] + (b[1] - a[1]) * nt];
|
|
161
|
+
}
|
|
162
|
+
remaining -= left;
|
|
163
|
+
t = 0;
|
|
164
|
+
if (segment + 1 < path.length - 1) {
|
|
165
|
+
segment += 1;
|
|
166
|
+
}
|
|
167
|
+
else if (loop) {
|
|
168
|
+
segment = 0;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
return [b[0], b[1]];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return [path[path.length - 1][0], path[path.length - 1][1]];
|
|
175
|
+
}
|