@jgengine/core 0.13.0 → 0.15.0-next.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 +529 -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/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 +21 -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/context/scene.d.ts +2 -0
- package/dist/runtime/context/scene.js +48 -1
- package/dist/runtime/gameContext.js +8 -2
- package/dist/runtime/gameContextTypes.d.ts +39 -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/builtinSceneKinds.d.ts +6 -1
- package/dist/scene/builtinSceneKinds.js +10 -3
- package/dist/scene/captureCheck.js +3 -2
- package/dist/scene/colliders.d.ts +20 -0
- package/dist/scene/colliders.js +78 -0
- package/dist/scene/collisionMesh.d.ts +8 -0
- package/dist/scene/collisionMesh.js +48 -0
- 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 +37 -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/hudScale.d.ts +16 -1
- package/dist/ui/hudScale.js +22 -1
- 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 +9 -46
- 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/grassKind.js +4 -1
- 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/scatterCoverage.d.ts +98 -0
- package/dist/world/scatterCoverage.js +101 -0
- package/dist/world/scatterRegion.js +6 -0
- 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 +18 -2
- package/dist/world.js +18 -2
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +7 -3
- 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 +408 -12
- package/skills/jgengine-world/capabilities.md +127 -2
- package/skills/jgengine-world/reference.md +15 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { steerYaw } from "../movement/steering.js";
|
|
2
2
|
import { DEFAULT_GRIP_CURVE, sampleGripCurve } from "./vehicleBody.js";
|
|
3
|
-
export function createKinematicVehicle(
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
export function createKinematicVehicle(initialTuning, options = {}) {
|
|
4
|
+
// Mutable so `retune` can swap the whole block without disturbing the integrator's state; every
|
|
5
|
+
// read below is per-tick rather than captured at construction for the same reason.
|
|
6
|
+
let tuning = initialTuning;
|
|
7
7
|
const surfaceFriction = options.surfaceFriction ?? (() => 1);
|
|
8
8
|
const dragAt = options.dragAt ?? (() => 0);
|
|
9
9
|
const clampMove = options.clampMove;
|
|
@@ -21,11 +21,17 @@ export function createKinematicVehicle(tuning, options = {}) {
|
|
|
21
21
|
let previousForwardSpeed = 0;
|
|
22
22
|
let bodyPitch = 0;
|
|
23
23
|
let bodyRoll = 0;
|
|
24
|
+
let airOffset = 0;
|
|
25
|
+
let verticalVelocity = 0;
|
|
24
26
|
function forward() {
|
|
25
27
|
return [Math.sin(heading), Math.cos(heading)];
|
|
26
28
|
}
|
|
27
29
|
return {
|
|
28
30
|
tick(dt, axis, modifiers) {
|
|
31
|
+
const grip = tuning.grip ?? DEFAULT_GRIP_CURVE;
|
|
32
|
+
const chassis = tuning.chassis;
|
|
33
|
+
const rollingResistance = tuning.rollingResistance ?? 0;
|
|
34
|
+
const hopGravity = tuning.hopGravity ?? 0;
|
|
29
35
|
const topSpeed = tuning.topSpeed * (modifiers?.topSpeedScale ?? 1);
|
|
30
36
|
const engineAccel = tuning.engineAccel * (modifiers?.accelScale ?? 1);
|
|
31
37
|
const turnRate = tuning.turnRate * (modifiers?.turnRateScale ?? 1);
|
|
@@ -50,6 +56,20 @@ export function createKinematicVehicle(tuning, options = {}) {
|
|
|
50
56
|
yawRate += (targetYaw - yawRate) * yawResponse;
|
|
51
57
|
heading += yawRate * dt;
|
|
52
58
|
}
|
|
59
|
+
// Handbrake rear-lock oversteer: pure grip bleed leaves a bicycle car going mostly straight
|
|
60
|
+
// while sliding. Extra yaw from steer×handbrake is what makes drifts initiate and hold
|
|
61
|
+
// (GTA/NFS-style street feel) without needing a dual-axle tire model.
|
|
62
|
+
if (axis.handbrake > 0.05 && Math.abs(speed) > HANDBRAKE_OVERSTEER_MIN_SPEED && Math.abs(axis.steer) > 0.02) {
|
|
63
|
+
const speedFactor = Math.min(1, Math.abs(speed) / HANDBRAKE_OVERSTEER_REF_SPEED);
|
|
64
|
+
const oversteerYaw = -Math.sign(speed === 0 ? 1 : speed) *
|
|
65
|
+
axis.steer *
|
|
66
|
+
axis.handbrake *
|
|
67
|
+
HANDBRAKE_OVERSTEER_YAW *
|
|
68
|
+
speedFactor;
|
|
69
|
+
// Integrate immediately so this tick's heading (and next-frame velocity basis) rotate with the slide.
|
|
70
|
+
heading += oversteerYaw * dt;
|
|
71
|
+
yawRate += oversteerYaw * 0.65;
|
|
72
|
+
}
|
|
53
73
|
const [fx, fz] = forward();
|
|
54
74
|
let accel = 0;
|
|
55
75
|
let tractionLimited = false;
|
|
@@ -81,6 +101,15 @@ export function createKinematicVehicle(tuning, options = {}) {
|
|
|
81
101
|
const normalizedRpm = (rpm - powertrain.idleRpm) / Math.max(1, powertrain.redlineRpm - powertrain.idleRpm);
|
|
82
102
|
const torque = sampleGripCurve(powertrain.torqueCurve, normalizedRpm);
|
|
83
103
|
torqueFactor = torque * (shiftRemaining > 0 ? 0.32 : 1);
|
|
104
|
+
// Launch clutch floor: at crawl speeds the idle end of a torque curve under-reads what a
|
|
105
|
+
// clutched street car actually puts down. Fade the floor out by {@link LAUNCH_TORQUE_FADE_SPEED}
|
|
106
|
+
// so mid-range power still comes from the curve.
|
|
107
|
+
if (axis.throttle > 0) {
|
|
108
|
+
const launchBlend = 1 - Math.min(1, Math.abs(speed) / LAUNCH_TORQUE_FADE_SPEED);
|
|
109
|
+
if (launchBlend > 0) {
|
|
110
|
+
torqueFactor = Math.max(torqueFactor, LAUNCH_TORQUE_FLOOR * launchBlend + torqueFactor * (1 - launchBlend));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
84
113
|
}
|
|
85
114
|
if (axis.throttle > 0 && speed < topSpeed) {
|
|
86
115
|
accel += chassis === undefined
|
|
@@ -90,16 +119,20 @@ export function createKinematicVehicle(tuning, options = {}) {
|
|
|
90
119
|
if (axis.brake > 0) {
|
|
91
120
|
if (speed > 0.2) {
|
|
92
121
|
const abs = tuning.dynamics?.abs ?? 0;
|
|
93
|
-
const brakeScale = Math.max(0.15, 1 - abs * Math.max(0, Math.abs(speed) / Math.max(1, topSpeed) - 0.72))
|
|
94
|
-
|
|
122
|
+
const brakeScale = Math.max(0.15, 1 - abs * Math.max(0, Math.abs(speed) / Math.max(1, topSpeed) - 0.72)) *
|
|
123
|
+
(modifiers?.brakeScale ?? 1);
|
|
124
|
+
absActive = brakeScale < 0.999 * (modifiers?.brakeScale ?? 1);
|
|
95
125
|
accel -= chassis === undefined
|
|
96
126
|
? axis.brake * tuning.brakeAccel * brakeScale
|
|
97
127
|
: (axis.brake * chassis.brakeForce * brakeScale) / chassis.massKg;
|
|
98
128
|
}
|
|
99
129
|
else if (speed > -tuning.reverseSpeed) {
|
|
130
|
+
// Reverse is intentionally softer than forward — full engineForce made performance cars
|
|
131
|
+
// hit reverse top in ~2 s and felt like a second highway gear.
|
|
132
|
+
const reverseScale = chassis?.reverseForceScale ?? tuning.reverseForceScale ?? DEFAULT_REVERSE_FORCE_SCALE;
|
|
100
133
|
accel -= chassis === undefined
|
|
101
|
-
? axis.brake * engineAccel
|
|
102
|
-
: (axis.brake * chassis.engineForce * (modifiers?.accelScale ?? 1)) / chassis.massKg;
|
|
134
|
+
? axis.brake * engineAccel * reverseScale
|
|
135
|
+
: (axis.brake * chassis.engineForce * reverseScale * (modifiers?.accelScale ?? 1)) / chassis.massKg;
|
|
103
136
|
}
|
|
104
137
|
}
|
|
105
138
|
const traction = tuning.dynamics?.tractionControl ?? 0;
|
|
@@ -121,11 +154,27 @@ export function createKinematicVehicle(tuning, options = {}) {
|
|
|
121
154
|
// never push the car backwards.
|
|
122
155
|
if (chassis !== undefined && dt > 0 && Math.abs(speed) > 1e-4) {
|
|
123
156
|
let resistDecel = ROLLING_RESISTANCE_COEFFICIENT * GRAVITY;
|
|
124
|
-
|
|
157
|
+
// Engine braking is a coast/lift-off load only. Applying it while the brake axis is driving
|
|
158
|
+
// reverse (speed ≤ 0.2 + brake) would fight reverse accel and leave cars crawling backwards.
|
|
159
|
+
const reverseDriving = axis.brake > 0 && speed <= 0.2;
|
|
160
|
+
if (axis.throttle <= 0 && !reverseDriving && tuning.powertrain !== undefined) {
|
|
125
161
|
resistDecel += ENGINE_BRAKE_COEFFICIENT * GRAVITY * gearLeverage;
|
|
126
162
|
}
|
|
127
163
|
accel -= Math.sign(speed) * Math.min(resistDecel, Math.abs(speed) / dt);
|
|
128
164
|
}
|
|
165
|
+
// Arcade coast road-load: a flat deceleration while genuinely coasting — no throttle and no
|
|
166
|
+
// brake — capped at |speed|/dt so it can never drag the vehicle backwards through zero. The
|
|
167
|
+
// `chassis` path above derives the same feel from mass and gearing, so the two are mutually
|
|
168
|
+
// exclusive by construction rather than by convention.
|
|
169
|
+
const coastDeceleration = tuning.coastDeceleration ?? 0;
|
|
170
|
+
if (chassis === undefined &&
|
|
171
|
+
coastDeceleration > 0 &&
|
|
172
|
+
dt > 0 &&
|
|
173
|
+
axis.throttle <= 0 &&
|
|
174
|
+
axis.brake <= 0 &&
|
|
175
|
+
Math.abs(speed) > 1e-4) {
|
|
176
|
+
accel -= Math.sign(speed) * Math.min(coastDeceleration, Math.abs(speed) / dt);
|
|
177
|
+
}
|
|
129
178
|
vx += fx * accel * dt;
|
|
130
179
|
vz += fz * accel * dt;
|
|
131
180
|
let forwardSpeed = vx * fx + vz * fz;
|
|
@@ -136,7 +185,8 @@ export function createKinematicVehicle(tuning, options = {}) {
|
|
|
136
185
|
const downforce = 1 + (tuning.dynamics?.downforce ?? 0) * Math.min(1, Math.abs(forwardSpeed) / Math.max(1, topSpeed));
|
|
137
186
|
const gripValue = sampleGripCurve(grip, slip) * surface * handbrakeFactor * downforce;
|
|
138
187
|
const keep = Math.max(0, 1 - gripValue * tuning.gripStrength * dt);
|
|
139
|
-
|
|
188
|
+
// ESC fights intentional slides — ease it off while the handbrake is held so drift stays steerable.
|
|
189
|
+
const stability = (tuning.dynamics?.stabilityControl ?? 0) * (1 - 0.85 * axis.handbrake);
|
|
140
190
|
let keptLateral = lateralSpeed * keep * Math.max(0, 1 - stability * Math.abs(axis.steer) * dt);
|
|
141
191
|
if (chassis !== undefined) {
|
|
142
192
|
// Friction budget (#1051): total tire force F = μeff * m * g, with μeff = tireGrip * gripValue
|
|
@@ -212,6 +262,17 @@ export function createKinematicVehicle(tuning, options = {}) {
|
|
|
212
262
|
const targetRoll = clampSigned(-yawRate * forwardSpeed * (tuning.dynamics?.bodyRollFactor ?? 0) * comScale, tuning.dynamics?.maxBodyRoll ?? 0.16);
|
|
213
263
|
bodyPitch += (targetPitch - bodyPitch) * attitudeResponse;
|
|
214
264
|
bodyRoll += (targetRoll - bodyRoll) * attitudeResponse;
|
|
265
|
+
// Hop/air state is a pure vertical offset above the ground plane, deliberately independent of
|
|
266
|
+
// the planar sim: a hopping vehicle still steers, still slides, and still follows the terrain
|
|
267
|
+
// underneath it. Without `hopGravity` there is no state to integrate and `airOffset` stays 0.
|
|
268
|
+
if (hopGravity > 0 && (airOffset > 0 || verticalVelocity > 0)) {
|
|
269
|
+
verticalVelocity -= hopGravity * dt;
|
|
270
|
+
airOffset += verticalVelocity * dt;
|
|
271
|
+
if (airOffset <= 0) {
|
|
272
|
+
airOffset = 0;
|
|
273
|
+
verticalVelocity = 0;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
215
276
|
return {
|
|
216
277
|
position: [x, y, z],
|
|
217
278
|
heading,
|
|
@@ -229,6 +290,8 @@ export function createKinematicVehicle(tuning, options = {}) {
|
|
|
229
290
|
wheelspin,
|
|
230
291
|
bodyPitch,
|
|
231
292
|
bodyRoll,
|
|
293
|
+
airOffset,
|
|
294
|
+
airborne: airOffset > 0,
|
|
232
295
|
};
|
|
233
296
|
},
|
|
234
297
|
pose: () => ({ position: [x, y, z], heading }),
|
|
@@ -237,6 +300,15 @@ export function createKinematicVehicle(tuning, options = {}) {
|
|
|
237
300
|
vx *= factor;
|
|
238
301
|
vz *= factor;
|
|
239
302
|
},
|
|
303
|
+
hop(impulse) {
|
|
304
|
+
if ((tuning.hopGravity ?? 0) <= 0 || airOffset > 0 || verticalVelocity > 0)
|
|
305
|
+
return false;
|
|
306
|
+
verticalVelocity = impulse;
|
|
307
|
+
return true;
|
|
308
|
+
},
|
|
309
|
+
retune(next) {
|
|
310
|
+
tuning = next;
|
|
311
|
+
},
|
|
240
312
|
resetTo(position, nextHeading) {
|
|
241
313
|
x = position[0];
|
|
242
314
|
y = position[1];
|
|
@@ -252,11 +324,28 @@ export function createKinematicVehicle(tuning, options = {}) {
|
|
|
252
324
|
previousForwardSpeed = 0;
|
|
253
325
|
bodyPitch = 0;
|
|
254
326
|
bodyRoll = 0;
|
|
327
|
+
airOffset = 0;
|
|
328
|
+
verticalVelocity = 0;
|
|
255
329
|
},
|
|
256
330
|
};
|
|
257
331
|
}
|
|
258
332
|
/** Gravitational acceleration, m/s², for the chassis friction budget and weight-transfer ratio (#1051). */
|
|
259
333
|
const GRAVITY = 9.81;
|
|
334
|
+
/** Default reverse drive force as a fraction of forward `engineForce` / `engineAccel`. */
|
|
335
|
+
export const DEFAULT_REVERSE_FORCE_SCALE = 0.48;
|
|
336
|
+
/**
|
|
337
|
+
* Minimum torque-curve factor under throttle near standstill (launch clutch). Fades out by
|
|
338
|
+
* {@link LAUNCH_TORQUE_FADE_SPEED} so it only fills the weak idle end of the curve.
|
|
339
|
+
*/
|
|
340
|
+
const LAUNCH_TORQUE_FLOOR = 0.9;
|
|
341
|
+
/** Forward speed (world units/s) by which the launch torque floor has fully faded. */
|
|
342
|
+
const LAUNCH_TORQUE_FADE_SPEED = 9;
|
|
343
|
+
/** Minimum |forward speed| before handbrake oversteer yaw kicks in. */
|
|
344
|
+
const HANDBRAKE_OVERSTEER_MIN_SPEED = 2.5;
|
|
345
|
+
/** Speed at which handbrake oversteer reaches full strength. */
|
|
346
|
+
const HANDBRAKE_OVERSTEER_REF_SPEED = 12;
|
|
347
|
+
/** Peak extra yaw rate (rad/s) from full steer + full handbrake at {@link HANDBRAKE_OVERSTEER_REF_SPEED}. */
|
|
348
|
+
const HANDBRAKE_OVERSTEER_YAW = 3.1;
|
|
260
349
|
/**
|
|
261
350
|
* Tyre rolling-resistance coefficient μ_rr (#1051). The always-present coast force is `μ_rr · m · g`
|
|
262
351
|
* (the road-load floor) — a little high vs. a real ~0.013 tyre so a lifted-off car settles to a stop
|
|
@@ -29,6 +29,7 @@ export function createVehicleObstacleClamp(options) {
|
|
|
29
29
|
// Grow-only scratch of Y-normalised solids, reused across ticks so a sustained crash allocates nothing
|
|
30
30
|
// after warm-up. Per-clamp (closure-local) so concurrent cars never share it.
|
|
31
31
|
const scratch = [];
|
|
32
|
+
const offsetScratch = [];
|
|
32
33
|
let pending = null;
|
|
33
34
|
function vehicleView(source) {
|
|
34
35
|
const n = source.length;
|
|
@@ -43,6 +44,24 @@ export function createVehicleObstacleClamp(options) {
|
|
|
43
44
|
entry.position[1] = CAR_BODY_MID_Y;
|
|
44
45
|
entry.position[2] = from.position[2];
|
|
45
46
|
entry.halfExtents = from.halfExtents;
|
|
47
|
+
// Keep the collider's XZ offset (a fitted body centred away from the placement point) but zero
|
|
48
|
+
// its Y: with the body pinned to CAR_BODY_MID_Y a vertical offset would push the box out of the
|
|
49
|
+
// stepper's pedestrian-span gate and the car would drive through a tall building's collider.
|
|
50
|
+
const fromOffset = from.offset;
|
|
51
|
+
if (fromOffset !== undefined && (fromOffset[0] !== 0 || fromOffset[2] !== 0)) {
|
|
52
|
+
let flat = offsetScratch[i];
|
|
53
|
+
if (flat === undefined) {
|
|
54
|
+
flat = [0, 0, 0];
|
|
55
|
+
offsetScratch[i] = flat;
|
|
56
|
+
}
|
|
57
|
+
flat[0] = fromOffset[0];
|
|
58
|
+
flat[2] = fromOffset[2];
|
|
59
|
+
entry.offset = flat;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
entry.offset = undefined;
|
|
63
|
+
}
|
|
64
|
+
entry.boxes = from.boxes;
|
|
46
65
|
}
|
|
47
66
|
if (scratch.length > n)
|
|
48
67
|
scratch.length = n;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves an optional injected `[0,1)` generator, falling back to `Math.random` — and says so, once
|
|
3
|
+
* per call site.
|
|
4
|
+
*
|
|
5
|
+
* Every primitive that rolls dice takes an `rng`, but the default was silent: a caller who forgot to
|
|
6
|
+
* pass `ctx.rng` got a simulation that cannot replay, lockstep, roll back, or be pinned by a test,
|
|
7
|
+
* with nothing on screen or in the log to say why. The fallback stays (a portable helper called
|
|
8
|
+
* outside a game context is a real use), but it is now loud instead of invisible.
|
|
9
|
+
*
|
|
10
|
+
* @capability resolve-rng resolve an optional injected RNG, warning once per site when it falls back to `Math.random`
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveRng(provided: (() => number) | undefined, site: string): () => number;
|
|
13
|
+
/** Clears the warned-site memo. Tests only — the warning is once-per-site for the life of the process. */
|
|
14
|
+
export declare function resetRngFallbackWarnings(): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isProductionEnvironment } from "../devtools/saveEndpoint.js";
|
|
2
|
+
const warned = new Set();
|
|
3
|
+
/** Narrow structural view of the ambient console — core never pulls in the DOM lib. */
|
|
4
|
+
function consoleWarn(message) {
|
|
5
|
+
const host = globalThis;
|
|
6
|
+
host.console?.warn?.(message);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Resolves an optional injected `[0,1)` generator, falling back to `Math.random` — and says so, once
|
|
10
|
+
* per call site.
|
|
11
|
+
*
|
|
12
|
+
* Every primitive that rolls dice takes an `rng`, but the default was silent: a caller who forgot to
|
|
13
|
+
* pass `ctx.rng` got a simulation that cannot replay, lockstep, roll back, or be pinned by a test,
|
|
14
|
+
* with nothing on screen or in the log to say why. The fallback stays (a portable helper called
|
|
15
|
+
* outside a game context is a real use), but it is now loud instead of invisible.
|
|
16
|
+
*
|
|
17
|
+
* @capability resolve-rng resolve an optional injected RNG, warning once per site when it falls back to `Math.random`
|
|
18
|
+
*/
|
|
19
|
+
export function resolveRng(provided, site) {
|
|
20
|
+
if (provided !== undefined)
|
|
21
|
+
return provided;
|
|
22
|
+
if (!warned.has(site) && !isProductionEnvironment()) {
|
|
23
|
+
warned.add(site);
|
|
24
|
+
consoleWarn(`[jgengine:rng] ${site} fell back to Math.random — this run cannot replay, lockstep, roll back, or be pinned by a test. Pass \`ctx.rng\` (the per-world seeded stream), or \`seededRng(seed)\` outside a game context.`);
|
|
25
|
+
}
|
|
26
|
+
return Math.random;
|
|
27
|
+
}
|
|
28
|
+
/** Clears the warned-site memo. Tests only — the warning is once-per-site for the life of the process. */
|
|
29
|
+
export function resetRngFallbackWarnings() {
|
|
30
|
+
warned.clear();
|
|
31
|
+
}
|
|
@@ -47,7 +47,13 @@ export interface DofConfig {
|
|
|
47
47
|
maxBlur?: number;
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
50
|
+
* Edge antialiasing for the post chain. SMAA runs in linear-sRGB before the output
|
|
51
|
+
* tone-map and cleans alpha-tested foliage/particles that MSAA samples alone leave crawling.
|
|
52
|
+
* `"msaa"` keeps only the render-target multisample resolve; `false` disables AA stages.
|
|
53
|
+
*/
|
|
54
|
+
export type PostAaMode = "smaa" | "msaa" | false;
|
|
55
|
+
/**
|
|
56
|
+
* Declarative post-processing chain (RenderPass → AO → Bloom → SMAA → tone-map output →
|
|
51
57
|
* Grade). Present on a game means the shell mounts an `EffectComposer` and owns
|
|
52
58
|
* the render; absent means the renderer draws directly (unchanged). Each stage is
|
|
53
59
|
* a config object, `false` to skip, or omitted for its default. Pure data — no
|
|
@@ -60,6 +66,11 @@ export interface PostProcessingConfig {
|
|
|
60
66
|
toneMapping?: ToneMappingMode;
|
|
61
67
|
/** Exposure multiplier applied before tone mapping. Default 1. */
|
|
62
68
|
exposure?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Antialiasing. Default `"smaa"` (plus a light MSAA resolve on the beauty target).
|
|
71
|
+
* Set `"msaa"` to skip the SMAA pass, or `false` for neither.
|
|
72
|
+
*/
|
|
73
|
+
aa?: PostAaMode;
|
|
63
74
|
bloom?: BloomConfig | false;
|
|
64
75
|
/** Ambient occlusion. Heavier than the other stages — omit or `false` on low-end targets. Default off (omitted). */
|
|
65
76
|
ao?: AoConfig | false;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChaseCameraConfig, CinematicCameraConfig } from "../game/playableGame.js";
|
|
2
|
-
/** Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11). */
|
|
3
|
-
export type ChaseCameraTuning = Partial<Pick<ChaseCameraConfig, "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank">>;
|
|
2
|
+
/** Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11), or a whole driving-feel overlay (speed→FOV, lead, bank, speed shake, drift-lag) applied only while a vehicle is piloted (#1299). */
|
|
3
|
+
export type ChaseCameraTuning = Partial<Pick<ChaseCameraConfig, "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank" | "shakePerSpeed" | "velocityYaw" | "yawResponse">>;
|
|
4
4
|
export interface CameraDirector {
|
|
5
5
|
follow(entityId: string | null): void;
|
|
6
6
|
/** `undefined` means no runtime override — the shell falls back to the static `playable.camera.followEntityId`. `null` means explicitly follow nothing. */
|
|
@@ -34,6 +34,13 @@ export function createCombatSubsystem(d) {
|
|
|
34
34
|
statPools: createEntityStatsApi((instanceId) => statsByInstance.get(instanceId)),
|
|
35
35
|
getStat: weapon.getStat,
|
|
36
36
|
spatial: combatSpatial,
|
|
37
|
+
resolveSlainIdentity(instanceId) {
|
|
38
|
+
const entity = entities.get(instanceId);
|
|
39
|
+
if (entity === null)
|
|
40
|
+
return null;
|
|
41
|
+
// `entity.name` is the spawn kind/catalog id, matching the `entity.died` event.
|
|
42
|
+
return { catalogId: entity.name, name: entity.name };
|
|
43
|
+
},
|
|
37
44
|
onLethal(instanceId, lethalCtx) {
|
|
38
45
|
// Capture identity + onDeath *before* resolveDeath despawns the entity.
|
|
39
46
|
const dyingEntity = entities.get(instanceId);
|
|
@@ -117,6 +124,7 @@ export function createCombatSubsystem(d) {
|
|
|
117
124
|
at: [report.at[0], report.at[1], report.at[2]],
|
|
118
125
|
effect: report.effect,
|
|
119
126
|
hit: report.hit,
|
|
127
|
+
ballistic: report.ballistic,
|
|
120
128
|
});
|
|
121
129
|
},
|
|
122
130
|
}), ["fireProjectile", "settleProjectile"], signalNotify);
|
|
@@ -5,6 +5,7 @@ import { type EntityStatsApi, type StatValueMap } from "../../scene/entityStats.
|
|
|
5
5
|
import { type EntityPosition, type EntityStore, type SceneEntity, type SpawnOptions } from "../../scene/entityStore.js";
|
|
6
6
|
import { type Forms } from "../../scene/form.js";
|
|
7
7
|
import { type EntityColliderSet, type MeasuredBounds } from "../../scene/colliders.js";
|
|
8
|
+
import { type CollisionMeshSource } from "../../scene/collisionMesh.js";
|
|
8
9
|
import { type ObjectStore } from "../../scene/objectStore.js";
|
|
9
10
|
import { type PaintLayer } from "../../scene/paintLayer.js";
|
|
10
11
|
import { type SceneRaycastApi } from "../../scene/sceneRaycast.js";
|
|
@@ -42,6 +43,7 @@ export interface SceneSubsystem {
|
|
|
42
43
|
entityCollidersOf: (instanceId: string) => EntityColliderSet | null;
|
|
43
44
|
objectCollidersOf: (instanceId: string) => EntityColliderSet | null;
|
|
44
45
|
reportEntityBounds: (kind: string, bounds: MeasuredBounds | null) => boolean;
|
|
46
|
+
reportEntityCollisionMesh: (kind: string, mesh: CollisionMeshSource | null) => boolean;
|
|
45
47
|
entityVisualScaleOf: (instanceId: string) => number;
|
|
46
48
|
forms: Forms;
|
|
47
49
|
paintLayer: PaintLayer;
|
|
@@ -2,7 +2,8 @@ import { createBodyBind } from "../../scene/bodyBind.js";
|
|
|
2
2
|
import { createEntityStatsApi, seedStatValues, } from "../../scene/entityStats.js";
|
|
3
3
|
import { createEntityStore, } from "../../scene/entityStore.js";
|
|
4
4
|
import { createForms } from "../../scene/form.js";
|
|
5
|
-
import { fittedEntityColliders, fittedObjectColliders, measuredEntityColliders, measuredObjectColliders, scaledEntityColliders, scaledObjectColliders, } from "../../scene/colliders.js";
|
|
5
|
+
import { fittedEntityColliders, fittedObjectColliders, hasMeshColliderShape, measuredEntityColliders, measuredObjectColliders, meshEntityColliders, meshObjectColliders, scaledEntityColliders, scaledObjectColliders, } from "../../scene/colliders.js";
|
|
6
|
+
import { prepareCollisionMeshSource } from "../../scene/collisionMesh.js";
|
|
6
7
|
import { raycastObjects, raycastObjectsAll } from "../../scene/objectQuery.js";
|
|
7
8
|
import { createObjectStore, objectVisualScale } from "../../scene/objectStore.js";
|
|
8
9
|
import { createPaintLayer } from "../../scene/paintLayer.js";
|
|
@@ -98,6 +99,38 @@ export function createSceneSubsystem(d) {
|
|
|
98
99
|
signalNotify();
|
|
99
100
|
return true;
|
|
100
101
|
}
|
|
102
|
+
const meshEntityByKind = new Map();
|
|
103
|
+
const meshObjectByCatalogId = new Map();
|
|
104
|
+
function reportEntityCollisionMesh(kind, mesh) {
|
|
105
|
+
if (mesh === null) {
|
|
106
|
+
const existed = meshEntityByKind.delete(kind);
|
|
107
|
+
if (existed)
|
|
108
|
+
signalNotify();
|
|
109
|
+
return existed;
|
|
110
|
+
}
|
|
111
|
+
const prepared = prepareCollisionMeshSource(mesh);
|
|
112
|
+
const set = prepared === null ? null : meshEntityColliders(prepared);
|
|
113
|
+
if (set === null)
|
|
114
|
+
return false;
|
|
115
|
+
meshEntityByKind.set(kind, set);
|
|
116
|
+
signalNotify();
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
function reportObjectCollisionMesh(catalogId, mesh) {
|
|
120
|
+
if (mesh === null) {
|
|
121
|
+
const existed = meshObjectByCatalogId.delete(catalogId);
|
|
122
|
+
if (existed)
|
|
123
|
+
signalNotify();
|
|
124
|
+
return existed;
|
|
125
|
+
}
|
|
126
|
+
const prepared = prepareCollisionMeshSource(mesh);
|
|
127
|
+
const set = prepared === null ? null : meshObjectColliders(prepared);
|
|
128
|
+
if (set === null)
|
|
129
|
+
return false;
|
|
130
|
+
meshObjectByCatalogId.set(catalogId, set);
|
|
131
|
+
signalNotify();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
101
134
|
function reportObjectBounds(catalogId, bounds) {
|
|
102
135
|
if (bounds === null) {
|
|
103
136
|
const existed = measuredObjectByCatalogId.delete(catalogId);
|
|
@@ -122,6 +155,13 @@ export function createSceneSubsystem(d) {
|
|
|
122
155
|
const kind = entities.get(instanceId)?.name;
|
|
123
156
|
if (kind !== undefined) {
|
|
124
157
|
const fitted = fittedEntitySetFor(kind);
|
|
158
|
+
// Index-derived mesh shapes win (identical on a headless host); otherwise a runtime-reported
|
|
159
|
+
// collision mesh upgrades the fitted/measured bounding box to the rendered triangles.
|
|
160
|
+
if (fitted !== null && hasMeshColliderShape(fitted))
|
|
161
|
+
return fitted;
|
|
162
|
+
const runtimeMesh = meshEntityByKind.get(kind);
|
|
163
|
+
if (runtimeMesh !== undefined)
|
|
164
|
+
return runtimeMesh;
|
|
125
165
|
if (fitted !== null)
|
|
126
166
|
return fitted;
|
|
127
167
|
const measured = measuredEntityByKind.get(kind);
|
|
@@ -149,6 +189,11 @@ export function createSceneSubsystem(d) {
|
|
|
149
189
|
if (object === null)
|
|
150
190
|
return null;
|
|
151
191
|
const fitted = fittedObjectSetFor(object.catalogId);
|
|
192
|
+
if (fitted !== null && hasMeshColliderShape(fitted))
|
|
193
|
+
return fitted;
|
|
194
|
+
const runtimeMesh = meshObjectByCatalogId.get(object.catalogId);
|
|
195
|
+
if (runtimeMesh !== undefined)
|
|
196
|
+
return runtimeMesh;
|
|
152
197
|
if (fitted !== null)
|
|
153
198
|
return fitted;
|
|
154
199
|
const measured = measuredObjectByCatalogId.get(object.catalogId);
|
|
@@ -291,6 +336,7 @@ export function createSceneSubsystem(d) {
|
|
|
291
336
|
},
|
|
292
337
|
collidersOf: objectCollidersOf,
|
|
293
338
|
reportBounds: reportObjectBounds,
|
|
339
|
+
reportCollisionMesh: reportObjectCollisionMesh,
|
|
294
340
|
selection: objectSelection,
|
|
295
341
|
};
|
|
296
342
|
return {
|
|
@@ -310,6 +356,7 @@ export function createSceneSubsystem(d) {
|
|
|
310
356
|
entityCollidersOf,
|
|
311
357
|
objectCollidersOf,
|
|
312
358
|
reportEntityBounds,
|
|
359
|
+
reportEntityCollisionMesh,
|
|
313
360
|
entityVisualScaleOf,
|
|
314
361
|
forms,
|
|
315
362
|
paintLayer,
|
|
@@ -19,6 +19,7 @@ import { createRuntimeSave } from "./runtimeSave.js";
|
|
|
19
19
|
import { isOffline } from "./adapter.js";
|
|
20
20
|
import { localSaveBackend, memorySaveBackend } from "../game/saveStore.js";
|
|
21
21
|
import { createSimClock } from "../time/simClock.js";
|
|
22
|
+
import { seededRng } from "../random/rng.js";
|
|
22
23
|
import { createCameraDirector } from "./cameraDirector.js";
|
|
23
24
|
import { createInputSnapshot } from "./inputSnapshot.js";
|
|
24
25
|
import { baselineDescriptors } from "./descriptors/baseline.js";
|
|
@@ -32,6 +33,9 @@ export function createGameContext(options) {
|
|
|
32
33
|
const { definition, content, player } = options;
|
|
33
34
|
const now = options.now ?? Date.now;
|
|
34
35
|
const occluder = options.occluder;
|
|
36
|
+
// Per-world deterministic stream — never Math.random on simulation paths (#1545).
|
|
37
|
+
const rng = options.rng ??
|
|
38
|
+
seededRng(options.seed ?? `jg:${definition.name.trim().length > 0 ? definition.name : "game"}`);
|
|
35
39
|
const signal = createChangeSignal();
|
|
36
40
|
let actingUserId = null;
|
|
37
41
|
const activeUserId = () => actingUserId ?? player.userId;
|
|
@@ -56,11 +60,11 @@ export function createGameContext(options) {
|
|
|
56
60
|
...(occluder !== undefined ? { occluder } : {}),
|
|
57
61
|
...(options.models !== undefined ? { models: options.models } : {}),
|
|
58
62
|
});
|
|
59
|
-
const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
|
|
63
|
+
const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, reportEntityCollisionMesh, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
|
|
60
64
|
// --- Shared game services (weapon, feed, features bag, commands, item use) ---
|
|
61
65
|
const weapon = createWeaponStats((itemId) => content.itemById?.(itemId));
|
|
62
66
|
const feed = createGameFeed(definition.feed);
|
|
63
|
-
const lootRegistry = createLootRegistry();
|
|
67
|
+
const lootRegistry = createLootRegistry({ rng });
|
|
64
68
|
/**
|
|
65
69
|
* Single feature-activation path (G9): `definition.features` is already the final map after
|
|
66
70
|
* `defineGame` OR-merged explicit boolean sugar (`features: { quest: true }`) with system-implied
|
|
@@ -322,6 +326,7 @@ export function createGameContext(options) {
|
|
|
322
326
|
}
|
|
323
327
|
// --- Assemble public GameContext ---
|
|
324
328
|
const ctx = {
|
|
329
|
+
rng,
|
|
325
330
|
scene: {
|
|
326
331
|
object: sceneObjects,
|
|
327
332
|
entity: {
|
|
@@ -369,6 +374,7 @@ export function createGameContext(options) {
|
|
|
369
374
|
},
|
|
370
375
|
collidersOf: entityCollidersOf,
|
|
371
376
|
reportBounds: reportEntityBounds,
|
|
377
|
+
reportCollisionMesh: reportEntityCollisionMesh,
|
|
372
378
|
visualScaleOf: entityVisualScaleOf,
|
|
373
379
|
form: forms,
|
|
374
380
|
paint: paintLayer,
|
|
@@ -35,6 +35,7 @@ import type { EntityStatsApi, StatCatalog } from "../scene/entityStats.js";
|
|
|
35
35
|
import type { EntityBlackboard, EntityPose, EntityPosition, EntityStore, SceneEntity, SpawnOptions, SpawnPose } from "../scene/entityStore.js";
|
|
36
36
|
import type { Forms } from "../scene/form.js";
|
|
37
37
|
import type { EntityColliderSet, MeasuredBounds, ModelBodySource } from "../scene/colliders.js";
|
|
38
|
+
import type { CollisionMeshSource } from "../scene/collisionMesh.js";
|
|
38
39
|
import type { ObjectRaycastHit, ObjectRaycastInput } from "../scene/objectQuery.js";
|
|
39
40
|
import type { ObjectStore } from "../scene/objectStore.js";
|
|
40
41
|
import type { Roster } from "../scene/roster.js";
|
|
@@ -106,6 +107,16 @@ export interface GameContextOptions<TAssetRef extends ModelAssetRef = ModelAsset
|
|
|
106
107
|
};
|
|
107
108
|
now?: () => number;
|
|
108
109
|
occluder?: (from: EntityPosition, to: EntityPosition) => boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Seed for {@link GameContext.rng}. Defaults to the game name so two contexts of the same
|
|
112
|
+
* definition share a stream unless a host passes a unique seed per world/session.
|
|
113
|
+
*/
|
|
114
|
+
seed?: string | number;
|
|
115
|
+
/**
|
|
116
|
+
* Injected `[0,1)` generator for this world. Defaults to a {@link seededRng} from {@link seed}.
|
|
117
|
+
* Never use `Math.random` on simulation paths — pass `ctx.rng` into pure helpers instead.
|
|
118
|
+
*/
|
|
119
|
+
rng?: () => number;
|
|
109
120
|
/** Bind `ctx.game.save` to a pluggable backend (offline/single-player whole-world save). The shell resolves this from `defineGame({ save })`; multiplayer leaves it off (the host persists). */
|
|
110
121
|
save?: RuntimeSaveOptions;
|
|
111
122
|
/**
|
|
@@ -145,6 +156,15 @@ export interface SceneObjectContext extends ObjectStore {
|
|
|
145
156
|
* @capability measured-colliders hitboxes and blocking bodies wrap what the renderer actually mounted — custom render props and dims-less models stop falling back to fixed-size boxes.
|
|
146
157
|
*/
|
|
147
158
|
reportBounds(catalogId: string, bounds: MeasuredBounds | null): boolean;
|
|
159
|
+
/**
|
|
160
|
+
* Report the entity-local triangle soup the renderer actually mounted for `catalogId` — stored per
|
|
161
|
+
* catalog id as a blocking physical body whose raycasts hit the real surface (a BVH over the
|
|
162
|
+
* triangles) while movement obstruction keeps the conservative AABB. Consulted right after
|
|
163
|
+
* authored `colliders` / index collision meshes and before the fitted-box, measured-bounds, and
|
|
164
|
+
* visual-scale fallbacks. The shell reports rendered models automatically. Pass `null` to clear.
|
|
165
|
+
* Returns `false` when no valid triangle survives.
|
|
166
|
+
*/
|
|
167
|
+
reportCollisionMesh(catalogId: string, mesh: CollisionMeshSource | null): boolean;
|
|
148
168
|
/**
|
|
149
169
|
* Per-instance selection/highlight state for placed objects — the reactive counterpart to
|
|
150
170
|
* `worldHealthBars`/`nameplates` (entities), so a build-mode or RTS selection ring reads
|
|
@@ -275,6 +295,17 @@ export interface SceneEntityContext {
|
|
|
275
295
|
* @capability measured-hitboxes an entity rendered with a custom mesh takes hits in a box matching its rendered bounds, not the fixed humanoid rectangle.
|
|
276
296
|
*/
|
|
277
297
|
reportBounds(kind: string, bounds: MeasuredBounds | null): boolean;
|
|
298
|
+
/**
|
|
299
|
+
* Report the entity-local triangle soup the renderer actually mounted for `kind` — stored per kind
|
|
300
|
+
* as a damage hitbox that raycasts the model's own triangles (BVH), so shots aimed between a
|
|
301
|
+
* character's feet or past its shoulder miss instead of hitting a bounding box. Consulted right
|
|
302
|
+
* after authored `colliders` / index collision meshes and before the fitted-box and
|
|
303
|
+
* measured-bounds fallbacks. The shell reports rendered models automatically; authoritative hosts
|
|
304
|
+
* that never render should author `colliders` or index collision meshes for identical results.
|
|
305
|
+
* Pass `null` to clear. Returns `false` when no valid triangle survives.
|
|
306
|
+
* @capability conforming-hitboxes shots aimed between a character's feet or past its shoulder MISS — damage raycasts hit the rendered model's triangles, not its bounding box.
|
|
307
|
+
*/
|
|
308
|
+
reportCollisionMesh(kind: string, mesh: CollisionMeshSource | null): boolean;
|
|
278
309
|
/** Uniform visual scale from the entity's catalog entry (1 when unscaled). */
|
|
279
310
|
visualScaleOf(instanceId: string): number;
|
|
280
311
|
form: Forms;
|
|
@@ -393,6 +424,14 @@ export interface GameContext {
|
|
|
393
424
|
raycastAll(input: SceneRaycastInput): readonly SceneRaycastHit[];
|
|
394
425
|
};
|
|
395
426
|
world: GameContextWorld;
|
|
427
|
+
/**
|
|
428
|
+
* Deterministic `[0,1)` stream for this world — loot, AI, combat rolls, and any sim path that
|
|
429
|
+
* needs randomness. Same seed → same sequence. Never fall back to `Math.random` in systems;
|
|
430
|
+
* pass this into pure helpers (`rollCheck`, `autoTarget`, loot tables, …).
|
|
431
|
+
*
|
|
432
|
+
* @capability game-context-rng per-world seeded randomness for replay, lockstep, and multi-world hosts
|
|
433
|
+
*/
|
|
434
|
+
rng: () => number;
|
|
396
435
|
game: {
|
|
397
436
|
commands: GameContextCommands;
|
|
398
437
|
events: GameEvents;
|
|
@@ -4,12 +4,16 @@ import { type AxisBinding, type AxisRange } from "../input/axisInput.js";
|
|
|
4
4
|
export interface InputFrame {
|
|
5
5
|
held: readonly string[];
|
|
6
6
|
pointer: PointerAxisState | null;
|
|
7
|
+
/** Analog per-action magnitudes (0..1) for actions driven by a continuous source (virtual joystick, gamepad stick); absent/null means every held action is fully pressed. */
|
|
8
|
+
analog?: Readonly<Record<string, number>> | null;
|
|
7
9
|
}
|
|
8
10
|
export interface InputSnapshot {
|
|
9
11
|
/** Replaces the held-action set for this frame, rolling the previous held set forward for edge detection (#671). Called by the shell before `onTick` each frame; does not bump `ctx.version()` or notify `ctx.subscribe` listeners — per-frame publishes would storm subscribers. */
|
|
10
12
|
publish(held: readonly string[]): void;
|
|
11
13
|
/** Replaces the normalized pointer-position state for this frame (#293). Same no-notify contract as `publish`. */
|
|
12
14
|
publishPointer(state: PointerAxisState | null): void;
|
|
15
|
+
/** Replaces the analog per-action magnitudes for this frame (#1370) — the virtual joystick's continuous vector, split into 0..1 values per movement action. `null` clears back to digital-only. Same no-notify contract as `publish`. */
|
|
16
|
+
publishAnalog(values: Readonly<Record<string, number>> | null): void;
|
|
13
17
|
isDown(action: string): boolean;
|
|
14
18
|
/** The held-action set for this frame — an owned, frozen array; the array {@link publish} was called with is never aliased or returned. */
|
|
15
19
|
held(): readonly string[];
|
|
@@ -17,13 +21,18 @@ export interface InputSnapshot {
|
|
|
17
21
|
justPressed(action: string): boolean;
|
|
18
22
|
/** True only on the frame `action` transitions from down to up (#671); mirrors {@link justPressed}. */
|
|
19
23
|
justReleased(action: string): boolean;
|
|
24
|
+
/** This frame's magnitude for `action`: its published analog value when one exists, else 1 while held / 0 while up. What `axis()` samples per code, so an analog stick steers proportionally where a key is all-or-nothing. */
|
|
25
|
+
value(action: string): number;
|
|
26
|
+
/** The published analog map for this frame, `null` when input is digital-only — an owned, frozen copy. */
|
|
27
|
+
analog(): Readonly<Record<string, number>> | null;
|
|
20
28
|
/** Pointer position over the play surface, `[-1, 1]` per axis with `+y` down, published by the shell each frame; `null` until the first pointer move. Frozen — an owned copy of what {@link publishPointer} was called with. */
|
|
21
29
|
pointer(): Readonly<PointerAxisState> | null;
|
|
22
30
|
/**
|
|
23
31
|
* Instantaneous analog axis sample against the held-action set and current pointer (#533.7) — bind
|
|
24
32
|
* throttle/steer/handbrake (or any axes) to *action names*, not raw key codes, since the held set is
|
|
25
33
|
* semantic actions. Feed the result into an `AxisChannel` for smoothing; unlisted ranges default to
|
|
26
|
-
* bipolar `[-1, 1]`, so pass `{ min: 0, max: 1 }` for one-directional pedals.
|
|
34
|
+
* bipolar `[-1, 1]`, so pass `{ min: 0, max: 1 }` for one-directional pedals. Actions with a published
|
|
35
|
+
* analog magnitude contribute that value instead of 0/1, so a virtual joystick steers proportionally.
|
|
27
36
|
*/
|
|
28
37
|
axis<TAxes extends string>(bindings: Record<TAxes, AxisBinding>, ranges?: Partial<Record<TAxes, AxisRange>>): Record<TAxes, number>;
|
|
29
38
|
}
|
|
@@ -4,6 +4,13 @@ export function createInputSnapshot() {
|
|
|
4
4
|
let previousHeldSet = new Set();
|
|
5
5
|
let heldList = [];
|
|
6
6
|
let pointerState = null;
|
|
7
|
+
let analogValues = null;
|
|
8
|
+
const value = (action) => {
|
|
9
|
+
const analog = analogValues?.[action];
|
|
10
|
+
if (analog !== undefined)
|
|
11
|
+
return analog;
|
|
12
|
+
return heldSet.has(action) ? 1 : 0;
|
|
13
|
+
};
|
|
7
14
|
return {
|
|
8
15
|
publish(held) {
|
|
9
16
|
previousHeldSet = heldSet;
|
|
@@ -13,10 +20,15 @@ export function createInputSnapshot() {
|
|
|
13
20
|
publishPointer(state) {
|
|
14
21
|
pointerState = state === null ? null : Object.freeze({ ...state });
|
|
15
22
|
},
|
|
23
|
+
publishAnalog(values) {
|
|
24
|
+
analogValues = values === null ? null : Object.freeze({ ...values });
|
|
25
|
+
},
|
|
16
26
|
isDown: (action) => heldSet.has(action),
|
|
17
27
|
held: () => heldList,
|
|
18
28
|
pointer: () => pointerState,
|
|
19
|
-
|
|
29
|
+
value,
|
|
30
|
+
analog: () => analogValues,
|
|
31
|
+
axis: (bindings, ranges) => sampleAxisBindings(bindings, (action) => heldSet.has(action), pointerState, ranges, value),
|
|
20
32
|
justPressed: (action) => heldSet.has(action) && !previousHeldSet.has(action),
|
|
21
33
|
justReleased: (action) => !heldSet.has(action) && previousHeldSet.has(action),
|
|
22
34
|
};
|