@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
|
@@ -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);
|
|
@@ -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;
|
|
@@ -60,7 +64,7 @@ export function createGameContext(options) {
|
|
|
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: {
|
|
@@ -107,6 +107,16 @@ export interface GameContextOptions<TAssetRef extends ModelAssetRef = ModelAsset
|
|
|
107
107
|
};
|
|
108
108
|
now?: () => number;
|
|
109
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;
|
|
110
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). */
|
|
111
121
|
save?: RuntimeSaveOptions;
|
|
112
122
|
/**
|
|
@@ -414,6 +424,14 @@ export interface GameContext {
|
|
|
414
424
|
raycastAll(input: SceneRaycastInput): readonly SceneRaycastHit[];
|
|
415
425
|
};
|
|
416
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;
|
|
417
435
|
game: {
|
|
418
436
|
commands: GameContextCommands;
|
|
419
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
|
};
|
|
@@ -12,7 +12,7 @@ export interface RuntimeSaveTarget {
|
|
|
12
12
|
hydrate(snapshot: WorldSnapshot): void;
|
|
13
13
|
subscribe(listener: () => void): () => void;
|
|
14
14
|
}
|
|
15
|
-
/** `"autosave"` writes a fresh capture on a
|
|
15
|
+
/** `"autosave"` writes a fresh capture on a trailing timer while the world changes (at most once per `autosaveMs`, so a never-idle world still persists); `"manual"` writes only on an explicit `save()`/`checkpoint()` — the save-point / quest-trigger model. */
|
|
16
16
|
export type RuntimeSaveMode = "autosave" | "manual";
|
|
17
17
|
/** How {@link createRuntimeSave} is wired — the live world `target`, the `backend` it persists through, and optional mode/slot/versioning/cadence knobs. */
|
|
18
18
|
export interface RuntimeSaveConfig {
|
|
@@ -29,7 +29,7 @@ export interface RuntimeSaveConfig {
|
|
|
29
29
|
/** Save-format version; bump + pass {@link migrate} on a breaking world-shape change. */
|
|
30
30
|
version?: number;
|
|
31
31
|
migrate?: (data: unknown, fromVersion: number) => WorldSnapshot;
|
|
32
|
-
/**
|
|
32
|
+
/** Trailing autosave interval (ms) for `autosave` mode — the world persists at most once per interval and always within one interval of its first unsaved change. Default `3000`. */
|
|
33
33
|
autosaveMs?: number;
|
|
34
34
|
now?: () => number;
|
|
35
35
|
timers?: SaveTimers;
|
|
@@ -41,7 +41,7 @@ export type RuntimeSaveOptions = Omit<RuntimeSaveConfig, "target">;
|
|
|
41
41
|
* Whole-world save/load bound to a live world and a pluggable backend. `save()`
|
|
42
42
|
* captures `target.snapshot()` and writes it; `load()` reads it back and
|
|
43
43
|
* `target.hydrate()`s the whole world. In `autosave` mode it also writes on a
|
|
44
|
-
*
|
|
44
|
+
* trailing timer while the world keeps changing. Named slots, versioned migration, and offline↔cloud
|
|
45
45
|
* (backend swap) all come for free from the underlying save store.
|
|
46
46
|
*/
|
|
47
47
|
export interface RuntimeSave {
|
|
@@ -51,10 +51,19 @@ export function createRuntimeSave(config) {
|
|
|
51
51
|
store.set(config.target.snapshot());
|
|
52
52
|
return store.save();
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Arm a trailing autosave. A living world (AI, physics, the day/night clock)
|
|
56
|
+
* changes every frame, so a debounce that *reset* its timer on every change
|
|
57
|
+
* would never elapse and the game would never persist — the timer would be
|
|
58
|
+
* pushed forward faster than it could fire. Instead the first change since the
|
|
59
|
+
* last write arms a single timer and later changes ride that same timer; when
|
|
60
|
+
* it fires it captures the latest snapshot. The result is at most one write per
|
|
61
|
+
* `autosaveMs`, and always within `autosaveMs` of the first unsaved change,
|
|
62
|
+
* even while the world never stops moving.
|
|
63
|
+
*/
|
|
54
64
|
function scheduleAutosave() {
|
|
55
|
-
if (disposed || restoring)
|
|
65
|
+
if (disposed || restoring || pendingTimer !== null)
|
|
56
66
|
return;
|
|
57
|
-
clearTimer();
|
|
58
67
|
pendingTimer = timers.set(() => {
|
|
59
68
|
pendingTimer = null;
|
|
60
69
|
void persist();
|
|
@@ -92,11 +92,11 @@ export function classifyAssetResponse(probe) {
|
|
|
92
92
|
});
|
|
93
93
|
if (status !== undefined && (status === 0 || status >= 400)) {
|
|
94
94
|
const suffix = statusText === undefined || statusText.length === 0 ? "" : ` ${statusText}`;
|
|
95
|
-
return finish("missing", `Asset ${who} did not load: HTTP ${status}${suffix}. The
|
|
95
|
+
return finish("missing", `Asset ${who} did not load: HTTP ${status}${suffix}. The bytes are not being served at that path — run \`assets pull <source>\`, which provisions the pack into the dev server's served models dir (\`apps/dev/public/models\` in the jgengine monorepo) so no manual copy is needed; or check the model path.`);
|
|
96
96
|
}
|
|
97
97
|
const htmlContentType = contentType !== undefined && contentType.toLowerCase().includes("text/html");
|
|
98
98
|
if (signature === "html" || (htmlContentType && signature !== "glb" && signature !== "gltf-json")) {
|
|
99
|
-
return finish("html", `Asset ${who} resolved to an HTML page, not a model — the dev server returned its fallback document because the file is missing.
|
|
99
|
+
return finish("html", `Asset ${who} resolved to an HTML page, not a model — the dev server returned its fallback document because the file is missing. Run \`assets pull <source>\` to provision the pack into the served models dir (\`apps/dev/public/models\` in the jgengine monorepo); the bytes must exist there, no manual copy needed.`);
|
|
100
100
|
}
|
|
101
101
|
if (signature === "glb" || signature === "gltf-json")
|
|
102
102
|
return finish("ok", `Asset ${who} loaded (${signature}).`);
|
package/dist/scene/autoTarget.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveRng } from "../random/resolveRng.js";
|
|
1
2
|
function metric(policy, fromId, toId, deps) {
|
|
2
3
|
switch (policy) {
|
|
3
4
|
case "nearest":
|
|
@@ -21,7 +22,7 @@ export function selectAutoTarget(policy, fromId, deps) {
|
|
|
21
22
|
if (candidates.length === 0)
|
|
22
23
|
return null;
|
|
23
24
|
if (policy === "random") {
|
|
24
|
-
const rng = deps.rng
|
|
25
|
+
const rng = resolveRng(deps.rng, "autoTarget");
|
|
25
26
|
return candidates[Math.min(candidates.length - 1, Math.floor(rng() * candidates.length))];
|
|
26
27
|
}
|
|
27
28
|
const higher = prefersHigher(policy);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveRng } from "../random/resolveRng.js";
|
|
1
2
|
function clamp01(value) {
|
|
2
3
|
return Math.min(1, Math.max(0, value));
|
|
3
4
|
}
|
|
@@ -6,6 +7,6 @@ export function captureChance(input) {
|
|
|
6
7
|
const hpFactor = 1 - clamp01(input.hpFraction) * 0.6;
|
|
7
8
|
return clamp01((input.catchPower * hpFactor) / Math.max(difficulty, 0.0001));
|
|
8
9
|
}
|
|
9
|
-
export function rollCapture(input, rng
|
|
10
|
-
return rng() < captureChance(input);
|
|
10
|
+
export function rollCapture(input, rng) {
|
|
11
|
+
return resolveRng(rng, "rollCapture")() < captureChance(input);
|
|
11
12
|
}
|
|
@@ -22,6 +22,8 @@ export interface SceneEntity<TMeta = unknown> {
|
|
|
22
22
|
role: EntityRole;
|
|
23
23
|
movement: EntityMovement;
|
|
24
24
|
behaviors: readonly BehaviorDescriptor[];
|
|
25
|
+
/** Render-only visibility: `true` skips the entity's visual entirely (seated vehicle riders, cutscene actors, stealth cloaks) while the entity keeps simulating — position, stats, AI targeting, and queries are untouched. Absent/`false` renders normally. */
|
|
26
|
+
hidden?: boolean;
|
|
25
27
|
/** Game-defined per-instance data set at `spawn`/`update` and carried to `renderEntity`/queries — narrow with {@link entityMetaOf} (or a type guard) on read (#286.1); never encode state in the id/name string. */
|
|
26
28
|
meta: TMeta;
|
|
27
29
|
}
|
|
@@ -44,6 +46,7 @@ export interface SpawnOptions<TMeta = unknown> {
|
|
|
44
46
|
role?: EntityRole;
|
|
45
47
|
movement?: EntityMovement;
|
|
46
48
|
behaviors?: readonly BehaviorDescriptor[];
|
|
49
|
+
hidden?: boolean;
|
|
47
50
|
meta?: TMeta;
|
|
48
51
|
/** When `id` is already spawned: `"throw"` (default), `"replace"` (fresh spawn over it — remount-safe world setup, #284.10), or `"keep"` (leave it untouched and return the id). */
|
|
49
52
|
onExisting?: "throw" | "replace" | "keep";
|
|
@@ -68,7 +71,7 @@ export interface PoseConstraintFrame {
|
|
|
68
71
|
}
|
|
69
72
|
/** Return a replacement position to constrain the step, or nothing to accept it. */
|
|
70
73
|
export type PoseConstraint = (frame: PoseConstraintFrame) => readonly [number, number, number] | undefined | void;
|
|
71
|
-
export type EntityUpdatePatch<TMeta = unknown> = Partial<Pick<SceneEntity<TMeta>, "name" | "rotationY" | "rotationX" | "rotationZ" | "role" | "movement" | "behaviors" | "meta">> & {
|
|
74
|
+
export type EntityUpdatePatch<TMeta = unknown> = Partial<Pick<SceneEntity<TMeta>, "name" | "rotationY" | "rotationX" | "rotationZ" | "role" | "movement" | "behaviors" | "hidden" | "meta">> & {
|
|
72
75
|
/** Accepts the same friendly shapes as `spawn`/`setPose` (#286.13). Raw patch semantics — velocity is not derived; use `setPose` with `dt` for that. */
|
|
73
76
|
position?: SpawnPositionInput;
|
|
74
77
|
};
|
|
@@ -91,6 +91,7 @@ export function createEntityStore() {
|
|
|
91
91
|
role: options.role ?? "prop",
|
|
92
92
|
movement: options.movement ?? {},
|
|
93
93
|
behaviors: options.behaviors ?? [],
|
|
94
|
+
...(options.hidden === undefined ? {} : { hidden: options.hidden }),
|
|
94
95
|
meta: options.meta,
|
|
95
96
|
});
|
|
96
97
|
spawnPoses.set(id, { position, rotationY });
|
|
@@ -124,6 +125,8 @@ export function createEntityStore() {
|
|
|
124
125
|
current.movement = patch.movement;
|
|
125
126
|
if (patch.behaviors !== undefined)
|
|
126
127
|
current.behaviors = patch.behaviors;
|
|
128
|
+
if (patch.hidden !== undefined)
|
|
129
|
+
current.hidden = patch.hidden;
|
|
127
130
|
if (patch.meta !== undefined)
|
|
128
131
|
current.meta = patch.meta;
|
|
129
132
|
store.set(id, current);
|