diff --git a/modules/core/src/main/java/com/jvn/core/animation/TimelineRunner.java b/modules/core/src/main/java/com/jvn/core/animation/TimelineRunner.java
index 9f1367db..09678a17 100644
--- a/modules/core/src/main/java/com/jvn/core/animation/TimelineRunner.java
+++ b/modules/core/src/main/java/com/jvn/core/animation/TimelineRunner.java
@@ -36,6 +36,8 @@ public class TimelineRunner {
/** Epsilon for floating-point time comparisons. */
private static final double EPS = 1e-6;
+ /** Prevent a resumed looping timeline from replaying unbounded stale cues in one frame. */
+ private static final long MAX_CUE_CYCLES_PER_UPDATE = 256;
/** The timeline data being played. */
private final TimelineData timeline;
@@ -97,7 +99,8 @@ public void setOnFinished(Runnable onFinished) {
public void update(long deltaMs) {
if (finished) return;
- double duration = Math.max(0.0, timeline.getDurationMs());
+ double rawDuration = timeline.getDurationMs();
+ double duration = Double.isFinite(rawDuration) ? Math.max(0.0, rawDuration) : 0.0;
double safeDelta = Math.max(0L, deltaMs);
if (safeDelta <= 0.0) {
applyFrame(elapsedMs);
@@ -136,6 +139,7 @@ public void update(long deltaMs) {
* @param timeMs the absolute playback time in milliseconds
*/
public void applyFrame(double timeMs) {
+ if (!Double.isFinite(timeMs)) timeMs = 0.0;
for (TimelineData.Track track : timeline.getTracks()) {
if (track.hasKeyframes(TimelineData.Property.CAMERA_X)) {
scene.setCameraX(track.getValueAt(TimelineData.Property.CAMERA_X, timeMs));
@@ -247,11 +251,13 @@ private void triggerAudioInterval(double startAbs, double endAbs, double duratio
long startCycle = (long) Math.floor(startAbs / duration);
long endCycle = (long) Math.floor(endAbs / duration);
+ startCycle = boundedCueStartCycle(startCycle, endCycle);
for (long cycle = startCycle; cycle <= endCycle; cycle++) {
double cycleBase = cycle * duration;
double localStart = (cycle == startCycle) ? (startAbs - cycleBase) : 0.0;
double localEnd = (cycle == endCycle) ? (endAbs - cycleBase) : duration;
triggerAudioWindow(localStart, localEnd, localStart <= EPS);
+ if (cycle == endCycle) break;
}
}
@@ -266,11 +272,13 @@ private void triggerEventInterval(double startAbs, double endAbs, double duratio
long startCycle = (long) Math.floor(startAbs / duration);
long endCycle = (long) Math.floor(endAbs / duration);
+ startCycle = boundedCueStartCycle(startCycle, endCycle);
for (long cycle = startCycle; cycle <= endCycle; cycle++) {
double cycleBase = cycle * duration;
double localStart = (cycle == startCycle) ? (startAbs - cycleBase) : 0.0;
double localEnd = (cycle == endCycle) ? (endAbs - cycleBase) : duration;
triggerEventWindow(localStart, localEnd, localStart <= EPS);
+ if (cycle == endCycle) break;
}
}
@@ -358,4 +366,12 @@ private void markFinished() {
callback.run();
}
}
+
+ private static long boundedCueStartCycle(long startCycle, long endCycle) {
+ if (endCycle < startCycle) return startCycle;
+ long earliestRetained = endCycle > Long.MIN_VALUE + MAX_CUE_CYCLES_PER_UPDATE
+ ? endCycle - MAX_CUE_CYCLES_PER_UPDATE + 1
+ : Long.MIN_VALUE;
+ return Math.max(startCycle, earliestRetained);
+ }
}
diff --git a/modules/core/src/main/java/com/jvn/core/engine/Engine.java b/modules/core/src/main/java/com/jvn/core/engine/Engine.java
index ffc47d1f..8bb5722b 100644
--- a/modules/core/src/main/java/com/jvn/core/engine/Engine.java
+++ b/modules/core/src/main/java/com/jvn/core/engine/Engine.java
@@ -182,6 +182,7 @@ public void start() {
if (resourcesClosed) {
throw new IllegalStateException("A stopped engine cannot be restarted after its resources are closed");
}
+ resetFrameTiming();
this.started = true;
}
@@ -211,7 +212,13 @@ public T own(T resource) {
closeResource(resource);
return resource;
}
- boolean alreadyOwned = ownedResources.stream().anyMatch(existing -> existing == resource);
+ boolean alreadyOwned = false;
+ for (int i = 0; i < ownedResources.size(); i++) {
+ if (ownedResources.get(i) == resource) {
+ alreadyOwned = true;
+ break;
+ }
+ }
if (!alreadyOwned) ownedResources.add(resource);
return resource;
}
@@ -251,6 +258,7 @@ public boolean isStarted() {
* Listeners still receive pre/post update callbacks.
*/
public void setPaused(boolean paused) {
+ if (this.paused && !paused) resetFrameTiming();
this.paused = paused;
}
@@ -301,15 +309,14 @@ public void update(long deltaMs) {
return;
}
- Scene current = sceneManager.peek();
-
// --- Fixed update phase ---
if (fixedUpdateMs > 0) {
- accumulatorMs += effective;
+ accumulatorMs = saturatingAdd(accumulatorMs, effective);
int steps = 0;
while (accumulatorMs >= fixedUpdateMs && steps < maxFixedSteps) {
- if (current != null) {
- current.fixedUpdate(fixedUpdateMs);
+ Scene fixedScene = sceneManager.peek();
+ if (fixedScene != null) {
+ fixedScene.fixedUpdate(fixedUpdateMs);
}
accumulatorMs -= fixedUpdateMs;
steps++;
@@ -328,12 +335,16 @@ public void update(long deltaMs) {
// --- Variable update phase ---
tweens.update(effective);
+ Scene current = sceneManager.peek();
if (current != null) {
current.update(effective);
}
// --- Late update phase ---
- if (current != null) {
+ // A scene that transitioned during update has already received onExit;
+ // do not call into it again, and do not late-update its replacement
+ // before that replacement has received a regular update.
+ if (current != null && sceneManager.peek() == current) {
current.lateUpdate(effective);
}
} finally {
@@ -480,7 +491,12 @@ public void setDeltaSmoothing(double alpha) {
* @param maxSteps maximum fixed steps per frame (min 1) to prevent spiral-of-death
*/
public void setFixedUpdateStepMs(long stepMs, int maxSteps) {
- this.fixedUpdateMs = stepMs <= 0 ? 0 : stepMs;
+ long resolvedStep = stepMs <= 0 ? 0 : stepMs;
+ if (this.fixedUpdateMs != resolvedStep) {
+ accumulatorMs = 0;
+ interpolationAlpha = 0.0;
+ }
+ this.fixedUpdateMs = resolvedStep;
this.maxFixedSteps = Math.max(1, maxSteps);
}
@@ -583,6 +599,17 @@ private long applyTimeScale(long deltaMs) {
return Math.round(deltaMs * timeScale);
}
+ private void resetFrameTiming() {
+ smoothedDeltaMs = -1.0;
+ accumulatorMs = 0;
+ interpolationAlpha = 0.0;
+ }
+
+ private static long saturatingAdd(long a, long b) {
+ if (b <= 0) return a;
+ return a > Long.MAX_VALUE - b ? Long.MAX_VALUE : a + b;
+ }
+
// ──────────────────────────────────────────────────────────────────────────
// Interop / menu wiring — set by the runtime layer
// ──────────────────────────────────────────────────────────────────────────
diff --git a/modules/core/src/main/java/com/jvn/core/engine/FrameStats.java b/modules/core/src/main/java/com/jvn/core/engine/FrameStats.java
index 868f2ee1..3ad1ef67 100644
--- a/modules/core/src/main/java/com/jvn/core/engine/FrameStats.java
+++ b/modules/core/src/main/java/com/jvn/core/engine/FrameStats.java
@@ -14,7 +14,8 @@ public class FrameStats {
private int head = 0;
private int count = 0;
private long totalFrames = 0;
- private long sumMs = 0;
+ /** Double avoids overflow when a diagnostics caller records extreme long deltas. */
+ private double sumMs = 0;
private long minSampleMs = 0;
private long maxSampleMs = 0;
@@ -82,7 +83,7 @@ private void recomputeMinMax() {
private void recomputeDerived() {
if (count == 0) return;
- avgMs = (double) sumMs / count;
+ avgMs = sumMs / count;
minMs = minSampleMs;
maxMs = maxSampleMs;
// avgMs is guaranteed >= 0 because every recorded sample is >= 0, but we
diff --git a/modules/core/src/main/java/com/jvn/core/graphics/Camera2D.java b/modules/core/src/main/java/com/jvn/core/graphics/Camera2D.java
index 855bbf48..c810208f 100644
--- a/modules/core/src/main/java/com/jvn/core/graphics/Camera2D.java
+++ b/modules/core/src/main/java/com/jvn/core/graphics/Camera2D.java
@@ -140,10 +140,10 @@ public Map getCustomPropertiesView() {
* @param y world Y
*/
public void setPosition(double x, double y) {
- this.x = x;
- this.y = y;
- this.targetX = x;
- this.targetY = y;
+ this.x = sanitizeFinite(x, this.x);
+ this.y = sanitizeFinite(y, this.y);
+ this.targetX = this.x;
+ this.targetY = this.y;
clampToBounds();
}
@@ -154,7 +154,7 @@ public void setPosition(double x, double y) {
* @param z desired zoom factor
*/
public void setZoom(double z) {
- this.zoom = z <= 0 ? 0.0001 : z;
+ this.zoom = !Double.isFinite(z) || z <= 0 ? 0.0001 : z;
clampToBounds();
}
@@ -230,8 +230,8 @@ public static Collection> animat
* @param y target world Y
*/
public void setTarget(double x, double y) {
- this.targetX = x;
- this.targetY = y;
+ this.targetX = sanitizeFinite(x, targetX);
+ this.targetY = sanitizeFinite(y, targetY);
clampToBounds();
}
@@ -248,8 +248,8 @@ public void setTarget(double x, double y) {
* full camera frame stays inside the configured world bounds.
*/
public void setViewportSize(double width, double height) {
- this.viewportWidth = width > 0 ? width : 0.0;
- this.viewportHeight = height > 0 ? height : 0.0;
+ this.viewportWidth = Double.isFinite(width) && width > 0 ? width : 0.0;
+ this.viewportHeight = Double.isFinite(height) && height > 0 ? height : 0.0;
clampToBounds();
}
@@ -324,7 +324,9 @@ public void setTargetCenter(double centerX, double centerY, double viewportWidth
*
* @param ms time constant in milliseconds; 0 = instant snap, 200 = gentle follow
*/
- public void setSmoothingMs(double ms) { this.smoothingMs = ms < 0 ? 0 : ms; }
+ public void setSmoothingMs(double ms) {
+ this.smoothingMs = Double.isFinite(ms) && ms > 0 ? ms : 0.0;
+ }
// ──────────────────────────────────────────────────────────────────────────
// Bounds
@@ -341,6 +343,10 @@ public void setTargetCenter(double centerX, double centerY, double viewportWidth
* @param bottom the other vertical edge
*/
public void setBounds(double left, double top, double right, double bottom) {
+ left = sanitizeFinite(left, 0.0);
+ top = sanitizeFinite(top, 0.0);
+ right = sanitizeFinite(right, left);
+ bottom = sanitizeFinite(bottom, top);
this.boundLeft = Math.min(left, right);
this.boundTop = Math.min(top, bottom);
this.boundRight = Math.max(left, right);
diff --git a/modules/core/src/main/java/com/jvn/core/math/Geometry2D.java b/modules/core/src/main/java/com/jvn/core/math/Geometry2D.java
index 02467bd0..d18a59ad 100644
--- a/modules/core/src/main/java/com/jvn/core/math/Geometry2D.java
+++ b/modules/core/src/main/java/com/jvn/core/math/Geometry2D.java
@@ -51,8 +51,14 @@ public static boolean intersects(Circle circle, Capsule2 capsule) {
public static boolean intersects(Capsule2 capsule, Rect rect) {
if (capsule == null || rect == null) return false;
- Rect expanded = new Rect(rect.x - capsule.r, rect.y - capsule.r, rect.w + capsule.r * 2.0, rect.h + capsule.r * 2.0);
- return raycastSegmentAABB(capsule.x1, capsule.y1, capsule.x2, capsule.y2, expanded) != null
+ double radius = Double.isFinite(capsule.r) ? Math.max(0.0, capsule.r) : 0.0;
+ double left = rect.left() - radius;
+ double top = rect.top() - radius;
+ double right = rect.right() + radius;
+ double bottom = rect.bottom() + radius;
+ return segmentIntersectsAabb(
+ capsule.x1, capsule.y1, capsule.x2, capsule.y2,
+ left, top, right, bottom)
|| rect.contains(capsule.x1, capsule.y1)
|| rect.contains(capsule.x2, capsule.y2);
}
@@ -159,4 +165,30 @@ public static double[] raycastRayAABB(double ox, double oy, double dx, double dy
}
return null;
}
+
+ private static boolean segmentIntersectsAabb(
+ double sx, double sy, double ex, double ey,
+ double left, double top, double right, double bottom) {
+ double dx = ex - sx;
+ double dy = ey - sy;
+ double tmin = 0.0;
+ double tmax = 1.0;
+ if (dx != 0.0) {
+ double tx1 = (left - sx) / dx;
+ double tx2 = (right - sx) / dx;
+ tmin = Math.max(tmin, Math.min(tx1, tx2));
+ tmax = Math.min(tmax, Math.max(tx1, tx2));
+ } else if (sx < left || sx > right) {
+ return false;
+ }
+ if (dy != 0.0) {
+ double ty1 = (top - sy) / dy;
+ double ty2 = (bottom - sy) / dy;
+ tmin = Math.max(tmin, Math.min(ty1, ty2));
+ tmax = Math.min(tmax, Math.max(ty1, ty2));
+ } else if (sy < top || sy > bottom) {
+ return false;
+ }
+ return tmax >= tmin && tmin >= 0.0 && tmin <= 1.0;
+ }
}
diff --git a/modules/core/src/main/java/com/jvn/core/math/Vec2.java b/modules/core/src/main/java/com/jvn/core/math/Vec2.java
index 8e83649a..5e54979d 100644
--- a/modules/core/src/main/java/com/jvn/core/math/Vec2.java
+++ b/modules/core/src/main/java/com/jvn/core/math/Vec2.java
@@ -97,7 +97,26 @@ public class Vec2 {
*
* @return this vector (mutated), now with length ≈ 1.0 (or unchanged if zero)
*/
- public Vec2 normalize() { double len = length(); if (len != 0) { x /= len; y /= len; } return this; }
+ public Vec2 normalize() {
+ double len = length();
+ if (Double.isFinite(len) && len > 0.0) {
+ x /= len;
+ y /= len;
+ } else if (!Double.isFinite(len)) {
+ zero();
+ }
+ return this;
+ }
+
+ /** @return whether both components are finite real numbers */
+ public boolean isFinite() { return Double.isFinite(x) && Double.isFinite(y); }
+
+ /** Replace non-finite components with zero. */
+ public Vec2 sanitize() {
+ if (!Double.isFinite(x)) x = 0.0;
+ if (!Double.isFinite(y)) y = 0.0;
+ return this;
+ }
/**
* Compute the dot product of two vectors.
@@ -210,8 +229,9 @@ public Vec2 reflect(Vec2 normal) {
* If the vector is already shorter, it is left unchanged. Zero vectors are safe.
*/
public Vec2 clampLength(double maxLength) {
+ if (!isFinite()) return zero();
double lenSq = lengthSq();
- double max = Math.max(0.0, maxLength);
+ double max = Double.isFinite(maxLength) ? Math.max(0.0, maxLength) : 0.0;
if (lenSq > max * max && lenSq > 0.0) {
double scale = max / Math.sqrt(lenSq);
this.x *= scale;
@@ -220,12 +240,30 @@ public Vec2 clampLength(double maxLength) {
return this;
}
- /** Non-allocating static add: returns a new {@code Vec2} holding {@code a + b}. */
+ /** Static add returning a new {@code Vec2} holding {@code a + b}. */
public static Vec2 add(Vec2 a, Vec2 b) { return new Vec2(a.x + b.x, a.y + b.y); }
- /** Non-allocating static subtract: returns a new {@code Vec2} holding {@code a - b}. */
+ /** Static subtract returning a new {@code Vec2} holding {@code a - b}. */
public static Vec2 sub(Vec2 a, Vec2 b) { return new Vec2(a.x - b.x, a.y - b.y); }
+ /** Allocation-free add into a caller-owned output vector. */
+ public static Vec2 add(Vec2 a, Vec2 b, Vec2 out) {
+ Vec2 target = out == null ? new Vec2() : out;
+ return target.set(a.x + b.x, a.y + b.y);
+ }
+
+ /** Allocation-free subtract into a caller-owned output vector. */
+ public static Vec2 sub(Vec2 a, Vec2 b, Vec2 out) {
+ Vec2 target = out == null ? new Vec2() : out;
+ return target.set(a.x - b.x, a.y - b.y);
+ }
+
+ /** Allocation-free interpolation into a caller-owned output vector. */
+ public static Vec2 lerp(Vec2 a, Vec2 b, double t, Vec2 out) {
+ Vec2 target = out == null ? new Vec2() : out;
+ return target.set(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
+ }
+
@Override
public String toString() { return "Vec2(" + x + ", " + y + ")"; }
}
diff --git a/modules/core/src/main/java/com/jvn/core/physics/PhysicsWorld2D.java b/modules/core/src/main/java/com/jvn/core/physics/PhysicsWorld2D.java
index 265bb8f5..05426ba9 100644
--- a/modules/core/src/main/java/com/jvn/core/physics/PhysicsWorld2D.java
+++ b/modules/core/src/main/java/com/jvn/core/physics/PhysicsWorld2D.java
@@ -5,7 +5,6 @@
import java.util.HashSet;
import java.util.List;
import java.util.Map;
-import java.util.Set;
import com.jvn.core.math.Rect;
@@ -38,6 +37,7 @@
* @see Collision2D
*/
public class PhysicsWorld2D {
+ private static final long MAX_CELLS_PER_BODY = 4096;
// ──────────────────────────────────────────────────────────────────────────
// World state
@@ -84,6 +84,17 @@ public class PhysicsWorld2D {
/** Unique broadphase collision pairs for the current step. */
private final List broadphasePairs = new ArrayList<>();
+ private int broadphasePairCount;
+
+ /** Reused broadphase containers to avoid rebuilding garbage every sub-step. */
+ private final List> broadphaseBucketPool = new ArrayList<>();
+ private final HashSet broadphaseSeenPairs = new HashSet<>();
+ private final Bounds scratchBounds = new Bounds();
+ private final CollisionInfo scratchCollision = new CollisionInfo();
+
+ /** Mutations requested by collision callbacks are published after the simulation step. */
+ private final List pendingMutations = new ArrayList<>();
+ private boolean stepping;
// ──────────────────────────────────────────────────────────────────────────
// Inner types
@@ -133,16 +144,21 @@ public interface CollisionListener {
// ──────────────────────────────────────────────────────────────────────────
/** Set world gravity in pixels / s². */
- public void setGravity(double gx, double gy) { this.gravityX = gx; this.gravityY = gy; }
+ public void setGravity(double gx, double gy) {
+ this.gravityX = finiteOrZero(gx);
+ this.gravityY = finiteOrZero(gy);
+ }
/** Set the optional world boundary rectangle ({@code null} = unbounded). */
public void setBounds(Rect bounds) { this.bounds = bounds; }
/** Add an immovable rectangular collider (e.g. a tile). */
- public void addStaticRect(Rect r) { if (r != null) staticRects.add(r); }
+ public void addStaticRect(Rect r) {
+ if (r != null) mutate(() -> staticRects.add(r));
+ }
/** Remove all static rectangular colliders. */
- public void clearStaticRects() { staticRects.clear(); }
+ public void clearStaticRects() { mutate(staticRects::clear); }
/** Set the sensor overlap callback. */
public void setSensorListener(PhysicsSensorListener l) { this.sensorListener = l; }
@@ -155,16 +171,22 @@ public interface CollisionListener {
// ──────────────────────────────────────────────────────────────────────────
/** Add a body to the simulation. */
- public void addBody(RigidBody2D b) { if (b != null) bodies.add(b); }
+ public void addBody(RigidBody2D b) {
+ if (b != null) mutate(() -> bodies.add(b));
+ }
/** Remove a body from the simulation. */
- public void removeBody(RigidBody2D b) { bodies.remove(b); }
+ public void removeBody(RigidBody2D b) {
+ if (b != null) mutate(() -> bodies.remove(b));
+ }
/** @return all registered bodies (mutable list) */
public List getBodies() { return bodies; }
/** Set the maximum single-step duration in ms. 0 = no limit. */
- public void setMaxStepMs(double ms) { this.maxStepMs = ms <= 0 ? 0 : ms; }
+ public void setMaxStepMs(double ms) {
+ this.maxStepMs = Double.isFinite(ms) && ms > 0 ? ms : 0;
+ }
/** @return the maximum step duration in ms */
public double getMaxStepMs() { return maxStepMs; }
@@ -176,7 +198,9 @@ public interface CollisionListener {
* @param maxSubSteps the maximum number of sub-steps per frame
*/
public void setFixedTimeStepMs(double stepMs, int maxSubSteps) {
- this.fixedTimeStepMs = stepMs <= 0 ? 0 : stepMs;
+ double resolvedStep = Double.isFinite(stepMs) && stepMs > 0 ? stepMs : 0;
+ if (Double.compare(fixedTimeStepMs, resolvedStep) != 0) accumulatorMs = 0;
+ this.fixedTimeStepMs = resolvedStep;
this.maxSubSteps = Math.max(1, maxSubSteps);
}
@@ -235,24 +259,33 @@ public RaycastHit raycast(double x1, double y1, double x2, double y2) {
* @param deltaMs frame delta in milliseconds
*/
public void step(double deltaMs) {
- if (deltaMs < 0) return;
+ if (!Double.isFinite(deltaMs) || deltaMs <= 0) return;
+ if (stepping) throw new IllegalStateException("PhysicsWorld2D.step cannot be called recursively");
double stepMs = deltaMs;
if (maxStepMs > 0 && stepMs > maxStepMs) stepMs = maxStepMs;
- if (fixedTimeStepMs > 0) {
- accumulatorMs += stepMs;
- int steps = 0;
- while (accumulatorMs >= fixedTimeStepMs && steps < maxSubSteps) {
- stepOnce(fixedTimeStepMs);
- accumulatorMs -= fixedTimeStepMs;
- steps++;
- }
- // Avoid unbounded accumulation if frame time explodes
- if (steps == maxSubSteps && accumulatorMs > fixedTimeStepMs) {
- accumulatorMs = fixedTimeStepMs;
+ stepping = true;
+ try {
+ if (fixedTimeStepMs > 0) {
+ accumulatorMs = Math.min(
+ accumulatorMs + stepMs,
+ fixedTimeStepMs * (maxSubSteps + 1.0));
+ int steps = 0;
+ while (accumulatorMs >= fixedTimeStepMs && steps < maxSubSteps) {
+ stepOnce(fixedTimeStepMs);
+ accumulatorMs -= fixedTimeStepMs;
+ steps++;
+ }
+ // Avoid unbounded accumulation if frame time explodes.
+ if (steps == maxSubSteps && accumulatorMs > fixedTimeStepMs) {
+ accumulatorMs = fixedTimeStepMs;
+ }
+ } else {
+ stepOnce(stepMs);
}
- } else {
- stepOnce(stepMs);
+ } finally {
+ stepping = false;
+ drainPendingMutations();
}
}
@@ -261,7 +294,12 @@ private void stepOnce(double stepMs) {
double dt = stepMs / 1000.0;
if (dt <= 0) return;
- for (RigidBody2D b : bodies) {
+ sanitizeRect(bounds);
+ for (int i = 0; i < staticRects.size(); i++) sanitizeRect(staticRects.get(i));
+ int bodyCount = bodies.size();
+ for (int i = 0; i < bodyCount; i++) {
+ RigidBody2D b = bodies.get(i);
+ b.sanitizeState();
if (b.isStatic()) continue;
b.setVelocity(b.getVx() + gravityX * dt, b.getVy() + gravityY * dt);
if (b.getLinearDamping() > 0) {
@@ -275,7 +313,9 @@ private void stepOnce(double stepMs) {
resolveStaticColliders(b);
}
- for (int[] pair : gatherPairs()) {
+ gatherPairs();
+ for (int pairIndex = 0; pairIndex < broadphasePairCount; pairIndex++) {
+ int[] pair = broadphasePairs.get(pairIndex);
RigidBody2D a = bodies.get(pair[0]);
RigidBody2D b = bodies.get(pair[1]);
CollisionInfo info = findCollision(a, b);
@@ -289,23 +329,41 @@ private void stepOnce(double stepMs) {
}
/** Populate broadphase grid and gather unique overlapping body pairs. */
- private List gatherPairs() {
- broadphasePairs.clear();
+ private void gatherPairs() {
+ broadphasePairCount = 0;
+ broadphaseSeenPairs.clear();
+ for (List bucket : broadphaseCells.values()) {
+ bucket.clear();
+ broadphaseBucketPool.add(bucket);
+ }
broadphaseCells.clear();
for (int i = 0; i < bodies.size(); i++) {
- Bounds bb = computeBounds(bodies.get(i));
+ Bounds bb = computeBounds(bodies.get(i), scratchBounds);
int minCx = (int) Math.floor(bb.minX / broadphaseCellSize);
int maxCx = (int) Math.floor(bb.maxX / broadphaseCellSize);
int minCy = (int) Math.floor(bb.minY / broadphaseCellSize);
int maxCy = (int) Math.floor(bb.maxY / broadphaseCellSize);
- for (int cx = minCx; cx <= maxCx; cx++) {
- for (int cy = minCy; cy <= maxCy; cy++) {
+ long spanX = (long) maxCx - minCx + 1L;
+ long spanY = (long) maxCy - minCy + 1L;
+ if (spanX <= 0 || spanY <= 0 || spanX > MAX_CELLS_PER_BODY
+ || spanY > MAX_CELLS_PER_BODY || spanX * spanY > MAX_CELLS_PER_BODY) {
+ buildAllPairs();
+ return;
+ }
+ for (long cellX = minCx; cellX <= maxCx; cellX++) {
+ int cx = (int) cellX;
+ for (long cellY = minCy; cellY <= maxCy; cellY++) {
+ int cy = (int) cellY;
long key = hashCell(cx, cy);
- broadphaseCells.computeIfAbsent(key, k -> new ArrayList<>()).add(i);
+ List bucket = broadphaseCells.get(key);
+ if (bucket == null) {
+ bucket = acquireBroadphaseBucket();
+ broadphaseCells.put(key, bucket);
+ }
+ bucket.add(i);
}
}
}
- Set seen = new HashSet<>();
for (List bucket : broadphaseCells.values()) {
int n = bucket.size();
if (n < 2) continue;
@@ -314,16 +372,14 @@ private List gatherPairs() {
int a = bucket.get(i);
int b = bucket.get(j);
long key = pairKey(a, b);
- if (seen.add(key)) broadphasePairs.add(new int[] {a, b});
+ if (broadphaseSeenPairs.add(key)) addBroadphasePair(a, b);
}
}
}
- return broadphasePairs;
}
/** Compute the axis-aligned bounding box of a body for broadphase insertion. */
- private Bounds computeBounds(RigidBody2D b) {
- Bounds out = new Bounds();
+ private Bounds computeBounds(RigidBody2D b, Bounds out) {
if (b.getShapeType() == RigidBody2D.ShapeType.CIRCLE) {
var c = b.getCircle();
out.minX = c.x - c.r;
@@ -340,6 +396,32 @@ private Bounds computeBounds(RigidBody2D b) {
return out;
}
+ private List acquireBroadphaseBucket() {
+ int last = broadphaseBucketPool.size() - 1;
+ return last >= 0 ? broadphaseBucketPool.remove(last) : new ArrayList<>();
+ }
+
+ private void addBroadphasePair(int a, int b) {
+ int[] pair;
+ if (broadphasePairCount < broadphasePairs.size()) {
+ pair = broadphasePairs.get(broadphasePairCount);
+ } else {
+ pair = new int[2];
+ broadphasePairs.add(pair);
+ }
+ pair[0] = a;
+ pair[1] = b;
+ broadphasePairCount++;
+ }
+
+ private void buildAllPairs() {
+ broadphasePairCount = 0;
+ int count = bodies.size();
+ for (int a = 0; a < count; a++) {
+ for (int b = a + 1; b < count; b++) addBroadphasePair(a, b);
+ }
+ }
+
/** Narrow-phase collision detection; returns contact info or {@code null}. */
private CollisionInfo findCollision(RigidBody2D a, RigidBody2D b) {
if (a.getShapeType() == RigidBody2D.ShapeType.CIRCLE && b.getShapeType() == RigidBody2D.ShapeType.CIRCLE) {
@@ -369,7 +451,7 @@ private CollisionInfo collideCircleCircle(RigidBody2D a, RigidBody2D b) {
double rsum = ca.r + cb.r;
if (dist2 >= rsum * rsum) return null;
double dist = Math.sqrt(Math.max(1e-9, dist2));
- CollisionInfo info = new CollisionInfo();
+ CollisionInfo info = scratchCollision;
if (dist > 1e-6) {
info.nx = dx / dist;
info.ny = dy / dist;
@@ -394,7 +476,7 @@ private CollisionInfo collideAabbAabb(RigidBody2D a, RigidBody2D b) {
double minOverlapX = Math.min(overlapX1, overlapX2);
double minOverlapY = Math.min(overlapY1, overlapY2);
- CollisionInfo info = new CollisionInfo();
+ CollisionInfo info = scratchCollision;
if (minOverlapX < minOverlapY) {
double dir = (overlapX1 < overlapX2) ? 1 : -1; // normal from a to b
info.nx = dir;
@@ -420,7 +502,7 @@ private CollisionInfo collideCircleAabb(RigidBody2D circleBody, RigidBody2D boxB
double dist2 = dx * dx + dy * dy;
double radius = c.r;
if (dist2 > radius * radius) return null;
- CollisionInfo info = new CollisionInfo();
+ CollisionInfo info = scratchCollision;
if (dist2 > 1e-9) {
double dist = Math.sqrt(dist2);
double nx = dx / dist; // normal from circle (a) toward box (b)
@@ -530,6 +612,31 @@ private double clamp(double v, double min, double max) {
return v < min ? min : Math.min(v, max);
}
+ private static double finiteOrZero(double value) {
+ return Double.isFinite(value) ? value : 0.0;
+ }
+
+ private static void sanitizeRect(Rect rect) {
+ if (rect == null) return;
+ rect.x = finiteOrZero(rect.x);
+ rect.y = finiteOrZero(rect.y);
+ rect.w = Double.isFinite(rect.w) ? Math.abs(rect.w) : 0.0;
+ rect.h = Double.isFinite(rect.h) ? Math.abs(rect.h) : 0.0;
+ }
+
+ private void mutate(Runnable mutation) {
+ if (stepping) pendingMutations.add(mutation);
+ else mutation.run();
+ }
+
+ private void drainPendingMutations() {
+ if (pendingMutations.isEmpty()) return;
+ for (int i = 0; i < pendingMutations.size(); i++) {
+ pendingMutations.get(i).run();
+ }
+ pendingMutations.clear();
+ }
+
/** Clamp a body inside the world bounds and reflect velocity on contact. */
private void resolveWorldBounds(RigidBody2D b) {
if (bounds == null) return;
diff --git a/modules/core/src/main/java/com/jvn/core/physics/RigidBody2D.java b/modules/core/src/main/java/com/jvn/core/physics/RigidBody2D.java
index b4a55e05..e313ad48 100644
--- a/modules/core/src/main/java/com/jvn/core/physics/RigidBody2D.java
+++ b/modules/core/src/main/java/com/jvn/core/physics/RigidBody2D.java
@@ -88,7 +88,10 @@ public enum ShapeType { CIRCLE, AABB }
public static RigidBody2D box(double x, double y, double w, double h) {
RigidBody2D b = new RigidBody2D();
b.shapeType = ShapeType.AABB;
- b.aabb.x = x; b.aabb.y = y; b.aabb.w = w; b.aabb.h = h;
+ b.aabb.x = finiteOrZero(x);
+ b.aabb.y = finiteOrZero(y);
+ b.aabb.w = finiteMagnitude(w);
+ b.aabb.h = finiteMagnitude(h);
return b;
}
@@ -103,7 +106,9 @@ public static RigidBody2D box(double x, double y, double w, double h) {
public static RigidBody2D circle(double x, double y, double r) {
RigidBody2D b = new RigidBody2D();
b.shapeType = ShapeType.CIRCLE;
- b.circle.x = x; b.circle.y = y; b.circle.r = r;
+ b.circle.x = finiteOrZero(x);
+ b.circle.y = finiteOrZero(y);
+ b.circle.r = finiteMagnitude(r);
return b;
}
@@ -131,7 +136,16 @@ public static RigidBody2D circle(double x, double y, double r) {
public double getY() { return shapeType == ShapeType.AABB ? aabb.y : circle.y; }
/** Set the body's position, updating the active shape. */
- public void setPosition(double x, double y) { if (shapeType == ShapeType.AABB) { aabb.x = x; aabb.y = y; } else { circle.x = x; circle.y = y; } }
+ public void setPosition(double x, double y) {
+ if (!Double.isFinite(x) || !Double.isFinite(y)) return;
+ if (shapeType == ShapeType.AABB) {
+ aabb.x = x;
+ aabb.y = y;
+ } else {
+ circle.x = x;
+ circle.y = y;
+ }
+ }
/** @return the horizontal velocity */
public double getVx() { return vx; }
@@ -140,7 +154,10 @@ public static RigidBody2D circle(double x, double y, double r) {
public double getVy() { return vy; }
/** Set the body's velocity. */
- public void setVelocity(double vx, double vy) { this.vx = vx; this.vy = vy; }
+ public void setVelocity(double vx, double vy) {
+ this.vx = finiteOrZero(vx);
+ this.vy = finiteOrZero(vy);
+ }
// ──────────────────────────────────────────────────────────────────────────
// Physical property accessors
@@ -150,7 +167,9 @@ public static RigidBody2D circle(double x, double y, double r) {
public double getMass() { return mass; }
/** Set the mass. Values ≤ 0 are clamped to 1. */
- public void setMass(double mass) { this.mass = mass <= 0 ? 1.0 : mass; }
+ public void setMass(double mass) {
+ this.mass = Double.isFinite(mass) && mass > 0 ? mass : 1.0;
+ }
/** @return {@code true} if the body is immovable */
public boolean isStatic() { return isStatic; }
@@ -162,7 +181,11 @@ public static RigidBody2D circle(double x, double y, double r) {
public double getRestitution() { return restitution; }
/** Set the restitution, clamped to [0, 1]. */
- public void setRestitution(double restitution) { this.restitution = Math.max(0, Math.min(1, restitution)); }
+ public void setRestitution(double restitution) {
+ this.restitution = Double.isFinite(restitution)
+ ? Math.max(0, Math.min(1, restitution))
+ : 0.0;
+ }
/** @return {@code true} if this body is a sensor (overlap-only) */
public boolean isSensor() { return sensor; }
@@ -189,4 +212,33 @@ public void setFriction(double friction) {
if (friction > 1) friction = 1;
this.friction = friction;
}
+
+ /**
+ * Repair mutable shape fields before simulation. Shape objects are exposed
+ * for compatibility, so callers can otherwise inject NaN or negative extents
+ * without going through the guarded setters.
+ */
+ void sanitizeState() {
+ vx = finiteOrZero(vx);
+ vy = finiteOrZero(vy);
+ mass = Double.isFinite(mass) && mass > 0 ? mass : 1.0;
+ if (shapeType == ShapeType.CIRCLE) {
+ circle.x = finiteOrZero(circle.x);
+ circle.y = finiteOrZero(circle.y);
+ circle.r = finiteMagnitude(circle.r);
+ } else {
+ aabb.x = finiteOrZero(aabb.x);
+ aabb.y = finiteOrZero(aabb.y);
+ aabb.w = finiteMagnitude(aabb.w);
+ aabb.h = finiteMagnitude(aabb.h);
+ }
+ }
+
+ private static double finiteOrZero(double value) {
+ return Double.isFinite(value) ? value : 0.0;
+ }
+
+ private static double finiteMagnitude(double value) {
+ return Double.isFinite(value) ? Math.abs(value) : 0.0;
+ }
}
diff --git a/modules/core/src/main/java/com/jvn/core/scene2d/ParticleEmitter2D.java b/modules/core/src/main/java/com/jvn/core/scene2d/ParticleEmitter2D.java
index 3e1f8c61..2e4a6927 100644
--- a/modules/core/src/main/java/com/jvn/core/scene2d/ParticleEmitter2D.java
+++ b/modules/core/src/main/java/com/jvn/core/scene2d/ParticleEmitter2D.java
@@ -1,7 +1,6 @@
package com.jvn.core.scene2d;
import java.util.ArrayList;
-import java.util.Iterator;
import java.util.List;
import java.util.Random;
@@ -27,6 +26,9 @@
* @see Blitter2D#setBlendMode(String)
*/
public class ParticleEmitter2D extends Entity2D {
+ /** Bound direct emitter updates even when it is driven outside Engine's clamped loop. */
+ private static final double MAX_UPDATE_SECONDS = 0.25;
+ private static final double MIN_LIFE_SECONDS = 0.001;
public enum RenderMode {
CIRCLE,
STREAK
@@ -175,11 +177,16 @@ public ParticleEmitter2D() {}
// ──────────────────────────────────────────────────────────────────────────
/** @param rate particles per second */
- public void setEmissionRate(double rate) { this.emissionRate = rate; }
+ public void setEmissionRate(double rate) {
+ this.emissionRate = Double.isFinite(rate) ? Math.max(0.0, rate) : 0.0;
+ }
/** @return particles per second */
public double getEmissionRate() { return emissionRate; }
/** @param max maximum concurrent alive particles */
- public void setMaxParticles(int max) { this.maxParticles = max; }
+ public void setMaxParticles(int max) {
+ this.maxParticles = Math.max(0, max);
+ trimToMaxParticles();
+ }
/** @return maximum concurrent alive particles */
public int getMaxParticles() { return maxParticles; }
/** @param emit whether continuous emission is active */
@@ -188,7 +195,12 @@ public ParticleEmitter2D() {}
public boolean isEmitting() { return emitting; }
/** Set the min/max particle lifetime range (seconds). */
- public void setLifeRange(double min, double max) { this.minLife = min; this.maxLife = max; }
+ public void setLifeRange(double min, double max) {
+ min = positiveFinite(min, MIN_LIFE_SECONDS);
+ max = positiveFinite(max, min);
+ this.minLife = Math.min(min, max);
+ this.maxLife = Math.max(min, max);
+ }
/** @return minimum lifetime (seconds) */
public double getMinLife() { return minLife; }
/** @return maximum lifetime (seconds) */
@@ -196,7 +208,11 @@ public ParticleEmitter2D() {}
/** Set the min/max initial size and the end-of-life size scale factor. */
public void setSizeRange(double min, double max, double endScale) {
- this.minSize = min; this.maxSize = max; this.endSizeScale = endScale;
+ min = nonNegativeFinite(min, 0.0);
+ max = nonNegativeFinite(max, min);
+ this.minSize = Math.min(min, max);
+ this.maxSize = Math.max(min, max);
+ this.endSizeScale = nonNegativeFinite(endScale, 0.0);
}
/** @return minimum initial size */
public double getMinSize() { return minSize; }
@@ -206,14 +222,24 @@ public void setSizeRange(double min, double max, double endScale) {
public double getEndSizeScale() { return endSizeScale; }
/** Set the min/max initial speed range (world units/sec). */
- public void setSpeedRange(double min, double max) { this.minSpeed = min; this.maxSpeed = max; }
+ public void setSpeedRange(double min, double max) {
+ min = finiteOrZero(min);
+ max = finiteOrZero(max);
+ this.minSpeed = Math.min(min, max);
+ this.maxSpeed = Math.max(min, max);
+ }
/** @return minimum initial speed */
public double getMinSpeed() { return minSpeed; }
/** @return maximum initial speed */
public double getMaxSpeed() { return maxSpeed; }
/** Set the emission angle range in degrees. */
- public void setAngleRange(double min, double max) { this.minAngle = min; this.maxAngle = max; }
+ public void setAngleRange(double min, double max) {
+ min = finiteOrZero(min);
+ max = finiteOrZero(max);
+ this.minAngle = Math.min(min, max);
+ this.maxAngle = Math.max(min, max);
+ }
/** @return minimum emission angle (degrees) */
public double getMinAngle() { return minAngle; }
/** @return maximum emission angle (degrees) */
@@ -225,6 +251,10 @@ public void setSizeRange(double min, double max, double endScale) {
* behaviour.
*/
public void setSpawnArea(double minX, double maxX, double minY, double maxY) {
+ minX = finiteOrZero(minX);
+ maxX = finiteOrZero(maxX);
+ minY = finiteOrZero(minY);
+ maxY = finiteOrZero(maxY);
this.minSpawnX = Math.min(minX, maxX);
this.maxSpawnX = Math.max(minX, maxX);
this.minSpawnY = Math.min(minY, maxY);
@@ -242,7 +272,7 @@ public void setSpawnArea(double minX, double maxX, double minY, double maxY) {
public double getMaxSpawnY() { return maxSpawnY; }
/** @param gy vertical gravity acceleration (positive = downward) */
- public void setGravity(double gy) { this.gravityY = gy; }
+ public void setGravity(double gy) { this.gravityY = finiteOrZero(gy); }
/** @return vertical gravity */
public double getGravityY() { return gravityY; }
@@ -253,13 +283,16 @@ public void setSpawnArea(double minX, double maxX, double minY, double maxY) {
*
* @param wx horizontal acceleration
*/
- public void setWindX(double wx) { this.windX = wx; }
+ public void setWindX(double wx) { this.windX = finiteOrZero(wx); }
/** @return horizontal wind acceleration (world units / sec²) */
public double getWindX() { return windX; }
/** Set the start colour (RGBA, [0.0, 1.0]). */
public void setStartColor(double r, double g, double b, double a) {
- this.startR = r; this.startG = g; this.startB = b; this.startA = a;
+ this.startR = clamp01(r);
+ this.startG = clamp01(g);
+ this.startB = clamp01(b);
+ this.startA = clamp01(a);
}
/** @return start red */
public double getStartR() { return startR; }
@@ -272,7 +305,10 @@ public void setStartColor(double r, double g, double b, double a) {
/** Set the end colour (RGBA, [0.0, 1.0]). */
public void setEndColor(double r, double g, double b, double a) {
- this.endR = r; this.endG = g; this.endB = b; this.endA = a;
+ this.endR = clamp01(r);
+ this.endG = clamp01(g);
+ this.endB = clamp01(b);
+ this.endA = clamp01(a);
}
/** @return end red */
public double getEndR() { return endR; }
@@ -323,7 +359,9 @@ public void setTextures(List paths) {
/** @return particle renderer used when no texture is set */
public RenderMode getRenderMode() { return renderMode; }
/** @param scale multiplier applied to velocity magnitude for streak length */
- public void setStreakLengthScale(double scale) { this.streakLengthScale = Math.max(0.0, scale); }
+ public void setStreakLengthScale(double scale) {
+ this.streakLengthScale = nonNegativeFinite(scale, 0.0);
+ }
/** @return velocity multiplier used for streak length */
public double getStreakLengthScale() { return streakLengthScale; }
/** @param add whether to use additive blend mode */
@@ -396,15 +434,20 @@ private void emit() {
*/
@Override
public void update(long deltaMs) {
- double dt = deltaMs / 1000.0;
+ if (deltaMs <= 0) return;
+ double dt = Math.min(deltaMs / 1000.0, MAX_UPDATE_SECONDS);
// Continuous emission: accumulate fractional particles and spawn whole ones
if (emitting) {
emissionAccum += emissionRate * dt;
- while (emissionAccum >= 1.0 && particles.size() < maxParticles) {
+ int capacity = Math.max(0, maxParticles - particles.size());
+ int emitCount = (int) Math.min(capacity, Math.floor(emissionAccum));
+ for (int i = 0; i < emitCount; i++) {
emit();
- emissionAccum -= 1.0;
}
+ emissionAccum -= emitCount;
+ // Do not bank an enormous burst while the emitter is at capacity.
+ if (capacity == emitCount && emissionAccum >= 1.0) emissionAccum %= 1.0;
}
// Update particles (using swap-and-pop to avoid O(N) array shifts)
@@ -448,53 +491,79 @@ public void update(long deltaMs) {
*/
@Override
public void render(Blitter2D b) {
- if (particles.isEmpty()) return;
+ if (b == null || particles.isEmpty()) return;
b.push();
- if (useAdditive) b.setBlendMode("additive");
-
- for (Particle p : particles) {
- b.push();
- b.translate(p.x, p.y);
- b.setGlobalAlpha(p.a);
-
- String tex = p.texturePath != null ? p.texturePath : texture;
- if (tex != null) {
- // Textured quads honour per-particle rotation so authored sprites can spin.
- b.rotateDeg(p.rotation);
- double hs = p.size / 2;
- b.drawImage(tex, -hs, -hs, p.size, p.size);
- } else if (renderMode == RenderMode.STREAK) {
- // Streaks self-orient along their velocity vector; applying the random
- // per-particle rotation here would scramble that alignment, which is
- // why rain previously rendered as a starburst instead of vertical lines.
- double speed = Math.hypot(p.vx, p.vy);
- double len = Math.max(p.size * 3.0, speed * streakLengthScale);
- double ux = speed > 1e-6 ? p.vx / speed : 0.0;
- double uy = speed > 1e-6 ? p.vy / speed : 1.0;
- b.setStroke(p.r, p.g, p.b, 1.0);
- b.setStrokeWidth(Math.max(0.75, p.size));
- b.setStrokeCap("round");
- b.drawLine(-ux * len, -uy * len, ux * len * 0.18, uy * len * 0.18);
- } else {
- // Filled circles are rotation-invariant — skip the rotate to avoid
- // pointless transform churn.
- b.setFill(p.r, p.g, p.b, p.a);
- b.fillCircle(0, 0, p.size / 2);
+ try {
+ if (useAdditive) b.setBlendMode("additive");
+ for (Particle p : particles) {
+ b.push();
+ try {
+ b.translate(p.x, p.y);
+ b.setGlobalAlpha(p.a);
+ renderParticle(b, p);
+ } finally {
+ b.pop();
+ }
}
-
+ } finally {
+ if (useAdditive) b.setBlendMode("normal");
b.pop();
}
-
- if (useAdditive) b.setBlendMode("normal");
- b.pop();
+ }
+
+ private void renderParticle(Blitter2D b, Particle p) {
+ String tex = p.texturePath != null ? p.texturePath : texture;
+ if (tex != null) {
+ b.rotateDeg(p.rotation);
+ double hs = p.size / 2;
+ b.drawImage(tex, -hs, -hs, p.size, p.size);
+ } else if (renderMode == RenderMode.STREAK) {
+ double speed = Math.hypot(p.vx, p.vy);
+ double len = Math.max(p.size * 3.0, speed * streakLengthScale);
+ double ux = speed > 1e-6 ? p.vx / speed : 0.0;
+ double uy = speed > 1e-6 ? p.vy / speed : 1.0;
+ b.setStroke(p.r, p.g, p.b, 1.0);
+ b.setStrokeWidth(Math.max(0.75, p.size));
+ b.setStrokeCap("round");
+ b.drawLine(-ux * len, -uy * len, ux * len * 0.18, uy * len * 0.18);
+ } else {
+ b.setFill(p.r, p.g, p.b, p.a);
+ b.fillCircle(0, 0, p.size / 2);
+ }
}
/** @return the number of currently alive particles */
public int getParticleCount() { return particles.size(); }
/** Remove all alive particles immediately. */
- public void clear() { particles.clear(); }
+ public void clear() {
+ pool.addAll(particles);
+ particles.clear();
+ emissionAccum = 0;
+ }
+
+ private void trimToMaxParticles() {
+ while (particles.size() > maxParticles) {
+ pool.add(particles.remove(particles.size() - 1));
+ }
+ }
+
+ private static double finiteOrZero(double value) {
+ return Double.isFinite(value) ? value : 0.0;
+ }
+
+ private static double nonNegativeFinite(double value, double fallback) {
+ return Double.isFinite(value) ? Math.max(0.0, value) : fallback;
+ }
+
+ private static double positiveFinite(double value, double fallback) {
+ return Double.isFinite(value) && value > 0.0 ? value : fallback;
+ }
+
+ private static double clamp01(double value) {
+ return Double.isFinite(value) ? Math.max(0.0, Math.min(1.0, value)) : 0.0;
+ }
private double randomRange(double min, double max) {
if (Math.abs(max - min) < 1e-9) return min;
diff --git a/modules/core/src/main/java/com/jvn/core/scene2d/Scene2DBase.java b/modules/core/src/main/java/com/jvn/core/scene2d/Scene2DBase.java
index fe845e1c..3e28296f 100644
--- a/modules/core/src/main/java/com/jvn/core/scene2d/Scene2DBase.java
+++ b/modules/core/src/main/java/com/jvn/core/scene2d/Scene2DBase.java
@@ -48,9 +48,13 @@ public class Scene2DBase implements Scene2D {
* New variables from the new method defined in Entity2D.java
* */
- private static final Comparator Z_COMPARATOR = (a,b) ->
- Double.compare(a.getZ(), b.getZ());
- private final double[] scratchColorMatrix = new double[20];
+ private static final Comparator Z_COMPARATOR = (a,b) ->
+ Double.compare(a.getZ(), b.getZ());
+ private final double[] scratchColorMatrix = new double[20];
+
+ /** Entity mutations requested from update/render callbacks, applied after traversal. */
+ private final List pendingChildMutations = new ArrayList<>();
+ private int traversalDepth;
// ──────────────────────────────────────────────────────────────────────────
// Camera & input wiring
@@ -77,17 +81,23 @@ public class Scene2DBase implements Scene2D {
*
* @param e the entity to add; {@code null} is silently ignored
*/
- public void add(Entity2D e) { if (e != null) children.add(e); }
+ public void add(Entity2D e) {
+ if (e == null) return;
+ mutateChildren(() -> children.add(e));
+ }
/**
* Remove an entity from this scene. No-op if not present.
*
* @param e the entity to remove
*/
- public void remove(Entity2D e) { children.remove(e); }
+ public void remove(Entity2D e) {
+ if (e == null) return;
+ mutateChildren(() -> children.remove(e));
+ }
/** Remove all entities from this scene. */
- public void clear() { children.clear(); }
+ public void clear() { mutateChildren(children::clear); }
/** @return the live (mutable) list of child entities */
public java.util.List getChildren() { return children; }
@@ -107,9 +117,15 @@ public class Scene2DBase implements Scene2D {
*/
@Override
public void update(long deltaMs) {
- if (camera != null) camera.update(deltaMs);
- for (int i = 0; i < children.size(); i++) {
- children.get(i).update(deltaMs);
+ beginTraversal();
+ try {
+ if (camera != null) camera.update(Math.max(0L, deltaMs));
+ int childCount = children.size();
+ for (int i = 0; i < childCount; i++) {
+ children.get(i).update(Math.max(0L, deltaMs));
+ }
+ } finally {
+ endTraversal();
}
}
@@ -127,74 +143,108 @@ public void update(long deltaMs) {
*/
@Override
public void render(Blitter2D b, double width, double height) {
- // Sort by depth so entities with lower Z are drawn first (background → foreground)
- boolean zDirty = false;
- for (int i = 1; i < children.size(); i++) {
- if (children.get(i - 1).getZ() > children.get(i).getZ()) {
- zDirty = true;
- break;
+ if (b == null) return;
+ beginTraversal();
+ try {
+ // Sort only when order actually changed; most frames remain allocation-free.
+ boolean zDirty = false;
+ for (int i = 1; i < children.size(); i++) {
+ if (children.get(i - 1).getZ() > children.get(i).getZ()) {
+ zDirty = true;
+ break;
+ }
}
- }
- if (zDirty) {
- // children.sort(Comparator.comparingDouble(Entity2D::getZ));
- children.sort(Z_COMPARATOR);
- }
- b.push();
- if (camera != null) {
- camera.setViewportSize(width, height);
- b.translate(-camera.getX(), -camera.getY());
- b.scale(camera.getZoom(), camera.getZoom());
- }
- for (int i = 0; i < children.size(); i++) {
- Entity2D e = children.get(i);
- if (!e.isVisible()) continue;
+ if (zDirty) children.sort(Z_COMPARATOR);
+
b.push();
- // Apply parallax: offset the entity position relative to camera movement.
- // A parallaxX of 0.0 makes the entity fixed to the screen (HUD-like).
- if (camera != null) {
- double ox = camera.getX() * (1.0 - e.getParallaxX());
- double oy = camera.getY() * (1.0 - e.getParallaxY());
- if (ox != 0 || oy != 0) b.translate(ox, oy);
- }
- b.translate(e.getX(), e.getY());
- if (e.getRotationDeg() != 0) b.rotateDeg(e.getRotationDeg());
- if (e.getScaleX() != 1.0 || e.getScaleY() != 1.0) b.scale(e.getScaleX(), e.getScaleY());
- if (e.hasSupplementalTransform()) {
- b.transform(
- e.getMatrixMxx(),
- e.getMatrixMyx(),
- e.getMatrixMxy(),
- e.getMatrixMyy(),
- e.getMatrixTx(),
- e.getMatrixTy());
- }
- double brightness = e.getBrightness();
- if (e.hasNonIdentityColorMatrix() || Math.abs(brightness - 1.0) > 1e-9) {
- e.getColorMatrix(scratchColorMatrix);
- if (Math.abs(brightness - 1.0) > 1e-9) {
- applyBrightness(scratchColorMatrix, brightness);
+ try {
+ if (camera != null) {
+ camera.setViewportSize(width, height);
+ b.translate(-camera.getX(), -camera.getY());
+ b.scale(camera.getZoom(), camera.getZoom());
}
- b.setColorMatrix(scratchColorMatrix);
- } else {
- b.clearColorMatrix();
- }
- double blurRadius = e.getBlurRadius();
- if (camera != null && camera.hasDepthOfField()) {
- double depthDistance = Math.abs(e.getZ() - camera.getFocusDepth());
- double dofBlur = Math.min(
- camera.getDepthOfFieldMaxBlur(),
- depthDistance * camera.getDepthOfFieldStrength());
- blurRadius += Math.max(0.0, dofBlur);
+ int childCount = children.size();
+ for (int i = 0; i < childCount; i++) {
+ Entity2D e = children.get(i);
+ if (!e.isVisible()) continue;
+ b.push();
+ try {
+ renderEntity(b, e);
+ } finally {
+ // Never leak transforms, colour matrices, blur, or blend state when
+ // an entity renderer fails.
+ b.pop();
+ }
+ }
+ } finally {
+ b.pop();
}
- if (blurRadius > 1e-9) {
- b.setBlurRadius(blurRadius);
- } else {
- b.setBlurRadius(0.0);
+ } finally {
+ endTraversal();
+ }
+ }
+
+ private void renderEntity(Blitter2D b, Entity2D e) {
+ // Apply parallax: offset the entity position relative to camera movement.
+ // A parallaxX of 0.0 makes the entity fixed to the screen (HUD-like).
+ if (camera != null) {
+ double ox = camera.getX() * (1.0 - e.getParallaxX());
+ double oy = camera.getY() * (1.0 - e.getParallaxY());
+ if (ox != 0 || oy != 0) b.translate(ox, oy);
+ }
+ b.translate(e.getX(), e.getY());
+ if (e.getRotationDeg() != 0) b.rotateDeg(e.getRotationDeg());
+ if (e.getScaleX() != 1.0 || e.getScaleY() != 1.0) b.scale(e.getScaleX(), e.getScaleY());
+ if (e.hasSupplementalTransform()) {
+ b.transform(
+ e.getMatrixMxx(),
+ e.getMatrixMyx(),
+ e.getMatrixMxy(),
+ e.getMatrixMyy(),
+ e.getMatrixTx(),
+ e.getMatrixTy());
+ }
+ double brightness = e.getBrightness();
+ if (e.hasNonIdentityColorMatrix() || Math.abs(brightness - 1.0) > 1e-9) {
+ e.getColorMatrix(scratchColorMatrix);
+ if (Math.abs(brightness - 1.0) > 1e-9) {
+ applyBrightness(scratchColorMatrix, brightness);
}
- e.render(b);
- b.pop();
+ b.setColorMatrix(scratchColorMatrix);
+ } else {
+ b.clearColorMatrix();
+ }
+ double blurRadius = e.getBlurRadius();
+ if (camera != null && camera.hasDepthOfField()) {
+ double depthDistance = Math.abs(e.getZ() - camera.getFocusDepth());
+ double dofBlur = Math.min(
+ camera.getDepthOfFieldMaxBlur(),
+ depthDistance * camera.getDepthOfFieldStrength());
+ blurRadius += Math.max(0.0, dofBlur);
+ }
+ b.setBlurRadius(blurRadius > 1e-9 ? blurRadius : 0.0);
+ e.render(b);
+ }
+
+ private void beginTraversal() {
+ traversalDepth++;
+ }
+
+ private void endTraversal() {
+ traversalDepth--;
+ if (traversalDepth != 0 || pendingChildMutations.isEmpty()) return;
+ for (int i = 0; i < pendingChildMutations.size(); i++) {
+ pendingChildMutations.get(i).run();
+ }
+ pendingChildMutations.clear();
+ }
+
+ private void mutateChildren(Runnable mutation) {
+ if (traversalDepth > 0) {
+ pendingChildMutations.add(mutation);
+ } else {
+ mutation.run();
}
- b.pop();
}
private static void applyBrightness(double[] colorMatrix, double brightness) {
diff --git a/modules/core/src/main/java/com/jvn/core/tween/Tween.java b/modules/core/src/main/java/com/jvn/core/tween/Tween.java
index 5e629577..2e019858 100644
--- a/modules/core/src/main/java/com/jvn/core/tween/Tween.java
+++ b/modules/core/src/main/java/com/jvn/core/tween/Tween.java
@@ -42,7 +42,9 @@ public class Tween {
private long delayRemainingMs;
private long loopElapsedMs;
- private int loopsCompleted;
+ private long loopsCompleted;
+ /** Current yoyo direction, tracked separately so loop-count saturation cannot corrupt parity. */
+ private boolean reversePhase;
private boolean finished;
private double currentValue;
@@ -73,13 +75,14 @@ private Tween(
this.durationMs = Math.max(1, durationMs);
this.delayMs = Math.max(0, delayMs);
this.easing = easing != null ? easing : (t -> t);
- this.loops = loops;
+ this.loops = loops == LOOP_INFINITE ? LOOP_INFINITE : Math.max(1, loops);
this.yoyo = yoyo;
this.onUpdate = onUpdate;
this.onComplete = onComplete;
this.delayRemainingMs = this.delayMs;
this.loopElapsedMs = 0;
this.loopsCompleted = 0;
+ this.reversePhase = false;
this.finished = false;
this.currentValue = start;
}
@@ -102,55 +105,80 @@ public double update(long deltaMs) {
if (remaining <= 0) return currentValue;
}
- // Phase 2: advance time within loops.
- loopElapsedMs += remaining;
- while (true) {
- if (loopElapsedMs < durationMs) {
- double t = loopElapsedMs / (double) durationMs;
- currentValue = valueAt(t);
- if (onUpdate != null) onUpdate.accept(currentValue);
- return currentValue;
- }
- // Current loop finished.
- loopElapsedMs -= durationMs;
- loopsCompleted++;
- if (loops != LOOP_INFINITE && loopsCompleted >= loops) {
+ // Phase 2: advance in O(1), even after a debugger pause or a very large
+ // clock jump. The previous per-loop while-loop could monopolise a frame
+ // for effectively unbounded time when an infinite tween caught up.
+ long completedNow = remaining / durationMs;
+ long remainder = remaining % durationMs;
+ long untilBoundary = durationMs - loopElapsedMs;
+ if (remainder >= untilBoundary) {
+ completedNow = saturatingAdd(completedNow, 1);
+ loopElapsedMs = remainder - untilBoundary;
+ } else {
+ loopElapsedMs += remainder;
+ }
+
+ if (loops != LOOP_INFINITE) {
+ long loopsRemaining = loops - loopsCompleted;
+ if (completedNow >= loopsRemaining) {
+ loopsCompleted = loops;
+ reversePhase = yoyo && (loops & 1) == 1;
+ loopElapsedMs = 0;
currentValue = terminalValue();
- if (onUpdate != null) onUpdate.accept(currentValue);
+ // Publish terminal state before invoking user code. A throwing callback
+ // must not leave a logically completed tween alive forever.
finished = true;
+ if (onUpdate != null) onUpdate.accept(currentValue);
if (onComplete != null) onComplete.run();
return currentValue;
}
- // Otherwise the next loop consumes the carry-over.
}
+
+ if ((completedNow & 1L) != 0L) reversePhase = !reversePhase;
+ loopsCompleted = saturatingAdd(loopsCompleted, completedNow);
+ double t = loopElapsedMs / (double) durationMs;
+ currentValue = valueAt(t);
+ if (onUpdate != null) onUpdate.accept(currentValue);
+ return currentValue;
+ }
+
+ private static long saturatingAdd(long a, long b) {
+ if (b > Long.MAX_VALUE - a) return Long.MAX_VALUE;
+ return a + b;
+ }
+
+ private int completedLoopCount() {
+ return loopsCompleted >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) loopsCompleted;
+ }
+
+ private boolean terminalLoopIsForward() {
+ if (!yoyo) return true;
+ if (loops != LOOP_INFINITE) return (loops & 1) == 1;
+ return !reversePhase;
+ }
+
+ private double terminalValue() {
+ return terminalLoopIsForward() ? end : start;
}
private double valueAt(double tNormalized) {
double phase = tNormalized;
- if (yoyo && (loopsCompleted & 1) == 1) {
- // Odd loop: play backwards.
+ if (yoyo && reversePhase) {
phase = 1.0 - tNormalized;
}
double k = easing.applyAsDouble(phase);
return start + (end - start) * k;
}
- /** The value held after the final loop completes (accounts for yoyo parity). */
- private double terminalValue() {
- if (!yoyo) return end;
- // With yoyo, the final resting point alternates: even completed loops end at start,
- // odd completed loops end at end.
- return (loopsCompleted & 1) == 1 ? end : start;
- }
-
public boolean isFinished() { return finished; }
public double currentValue() { return currentValue; }
- public int loopsCompleted() { return loopsCompleted; }
+ public int loopsCompleted() { return completedLoopCount(); }
public void reset() {
delayRemainingMs = delayMs;
loopElapsedMs = 0;
loopsCompleted = 0;
+ reversePhase = false;
finished = false;
currentValue = start;
}
diff --git a/modules/core/src/main/java/com/jvn/core/tween/TweenRunner.java b/modules/core/src/main/java/com/jvn/core/tween/TweenRunner.java
index 8b698373..c410faa8 100644
--- a/modules/core/src/main/java/com/jvn/core/tween/TweenRunner.java
+++ b/modules/core/src/main/java/com/jvn/core/tween/TweenRunner.java
@@ -18,28 +18,41 @@ public void add(TweenTask task) {
public int activeCount() { return tasks.size(); }
public void update(long deltaMs) {
- // Index-based loop tolerates the underlying list being mutated below
- // (we never let add() touch `tasks` during update).
+ if (updating) {
+ throw new IllegalStateException("TweenRunner.update cannot be called recursively");
+ }
+ long safeDeltaMs = Math.max(0L, deltaMs);
+ RuntimeException firstFailure = null;
+ int writeIdx = 0;
updating = true;
try {
- int writeIdx = 0;
for (int readIdx = 0; readIdx < tasks.size(); readIdx++) {
TweenTask t = tasks.get(readIdx);
- t.update(deltaMs);
- if (!t.isFinished()) {
+ boolean keep = false;
+ try {
+ t.update(safeDeltaMs);
+ keep = !t.isFinished();
+ } catch (RuntimeException ex) {
+ // Remove a poisoned task, but finish compacting the runner so one
+ // callback cannot corrupt every tween scheduled after it.
+ if (firstFailure == null) firstFailure = ex;
+ }
+ if (keep) {
if (writeIdx != readIdx) tasks.set(writeIdx, t);
writeIdx++;
}
}
- // Trim any tail that's now beyond the kept tasks (in O(removed) not O(n*removed)).
- while (tasks.size() > writeIdx) tasks.remove(tasks.size() - 1);
} finally {
+ // Always restore a valid compact list and publish deferred additions,
+ // including when a task throws.
+ while (tasks.size() > writeIdx) tasks.remove(tasks.size() - 1);
updating = false;
+ if (!pendingAdds.isEmpty()) {
+ tasks.addAll(pendingAdds);
+ pendingAdds.clear();
+ }
}
- if (!pendingAdds.isEmpty()) {
- tasks.addAll(pendingAdds);
- pendingAdds.clear();
- }
+ if (firstFailure != null) throw firstFailure;
}
public static abstract class TweenTask {
diff --git a/modules/core/src/test/java/com/jvn/core/animation/TimelineRunnerTest.java b/modules/core/src/test/java/com/jvn/core/animation/TimelineRunnerTest.java
index 83447d19..561f6810 100644
--- a/modules/core/src/test/java/com/jvn/core/animation/TimelineRunnerTest.java
+++ b/modules/core/src/test/java/com/jvn/core/animation/TimelineRunnerTest.java
@@ -150,4 +150,20 @@ void appliesBrightnessCustomPropertyToEntity() {
assertEquals(0.75, scene.hero.getBrightness(), 0.001);
assertEquals(0.75, scene.hero.readCustomProperty("effect.brightness"), 0.001);
}
+
+ @Test
+ void loopingCueCatchupIsBoundedAfterVeryLargeTimeJump() {
+ TimelineData data = new TimelineData("bounded", 1);
+ data.setLooping(true);
+ data.addAudioCue(new TimelineData.AudioCue(0, "tick.wav", "sound", 1, false, 0));
+ RecordingSceneAccessor scene = new RecordingSceneAccessor();
+ TimelineRunner runner = new TimelineRunner(data, scene);
+
+ runner.update(Long.MAX_VALUE);
+
+ assertTrue(scene.audioPlayCount <= 256,
+ "catch-up must not replay an unbounded number of stale cues");
+ assertTrue(scene.audioPlayCount > 0);
+ assertTrue(Double.isFinite(runner.getElapsedMs()));
+ }
}
diff --git a/modules/core/src/test/java/com/jvn/core/engine/FrameStatsTest.java b/modules/core/src/test/java/com/jvn/core/engine/FrameStatsTest.java
index 9f7cbdb0..5fe67fd9 100644
--- a/modules/core/src/test/java/com/jvn/core/engine/FrameStatsTest.java
+++ b/modules/core/src/test/java/com/jvn/core/engine/FrameStatsTest.java
@@ -75,4 +75,14 @@ public void rollingWindowDropsOutgoingMinAndMax() {
assertEquals(30.0, stats.getMaxMs(), 1e-9);
assertEquals((20.0 + 30.0 + 10.0) / 3.0, stats.getAvgMs(), 1e-9);
}
+
+ @Test
+ public void extremeSamplesDoNotOverflowTheRollingSum() {
+ FrameStats stats = new FrameStats(4);
+ for (int i = 0; i < 4; i++) stats.record(Long.MAX_VALUE);
+
+ assertTrue(Double.isFinite(stats.getAvgMs()));
+ assertTrue(stats.getAvgMs() > 0);
+ assertEquals((double) Long.MAX_VALUE, stats.getAvgMs(), 1.0);
+ }
}
diff --git a/modules/core/src/test/java/com/jvn/core/graphics/Camera2DTest.java b/modules/core/src/test/java/com/jvn/core/graphics/Camera2DTest.java
index b55335f8..9747bf19 100644
--- a/modules/core/src/test/java/com/jvn/core/graphics/Camera2DTest.java
+++ b/modules/core/src/test/java/com/jvn/core/graphics/Camera2DTest.java
@@ -87,4 +87,18 @@ void smoothingUsesFrameRateIndependentExponentialDecay() {
assertEquals(63.212055882855765, camera.getX(), 1e-9);
assertEquals(31.606027941427882, camera.getY(), 1e-9);
}
+
+ @Test
+ void nonFiniteConfigurationFallsBackToStableValues() {
+ Camera2D camera = new Camera2D();
+ camera.setPosition(10, 20);
+ camera.setPosition(Double.NaN, Double.POSITIVE_INFINITY);
+ camera.setZoom(Double.NaN);
+ camera.setSmoothingMs(Double.POSITIVE_INFINITY);
+ camera.update(Long.MAX_VALUE);
+
+ assertEquals(10.0, camera.getX(), 1e-9);
+ assertEquals(20.0, camera.getY(), 1e-9);
+ assertEquals(0.0001, camera.getZoom(), 1e-12);
+ }
}
diff --git a/modules/core/src/test/java/com/jvn/core/math/Vec2Test.java b/modules/core/src/test/java/com/jvn/core/math/Vec2Test.java
index 944ac4cd..31916f58 100644
--- a/modules/core/src/test/java/com/jvn/core/math/Vec2Test.java
+++ b/modules/core/src/test/java/com/jvn/core/math/Vec2Test.java
@@ -146,6 +146,23 @@ public void staticAddAndSubReturnNewVectors() {
assertVec(b, 3, 5);
}
+ @Test
+ public void outputOverloadsReuseCallerOwnedStorage() {
+ Vec2 out = new Vec2();
+ assertSame(out, Vec2.add(new Vec2(1, 2), new Vec2(3, 4), out));
+ assertVec(out, 4, 6);
+ assertSame(out, Vec2.lerp(new Vec2(0, 0), new Vec2(10, 20), 0.25, out));
+ assertVec(out, 2.5, 5);
+ }
+
+ @Test
+ public void nonFiniteVectorsSanitizeBeforeNormalization() {
+ Vec2 v = new Vec2(Double.NaN, Double.POSITIVE_INFINITY);
+ v.normalize();
+ assertVec(v, 0, 0);
+ assertTrue(v.isFinite());
+ }
+
@Test
public void toStringIncludesComponents() {
String s = new Vec2(1.5, -2.25).toString();
diff --git a/modules/core/src/test/java/com/jvn/core/physics/PhysicsWorld2DTest.java b/modules/core/src/test/java/com/jvn/core/physics/PhysicsWorld2DTest.java
index d6816792..2c0fe717 100644
--- a/modules/core/src/test/java/com/jvn/core/physics/PhysicsWorld2DTest.java
+++ b/modules/core/src/test/java/com/jvn/core/physics/PhysicsWorld2DTest.java
@@ -19,4 +19,56 @@ public void clampsDeltaAndAppliesDamping() {
assertEquals(0.0, body.getY(), 1e-6);
assertEquals(90.0, body.getVx(), 1e-3);
}
+
+ @Test
+ public void nonFiniteInputsCannotPoisonTheSimulation() {
+ PhysicsWorld2D world = new PhysicsWorld2D();
+ world.setGravity(Double.NaN, Double.POSITIVE_INFINITY);
+ world.setMaxStepMs(Double.NaN);
+ world.setFixedTimeStepMs(Double.NaN, 4);
+ RigidBody2D body = RigidBody2D.box(Double.NaN, 2, -4, Double.POSITIVE_INFINITY);
+ body.setVelocity(Double.NaN, Double.NEGATIVE_INFINITY);
+ body.setMass(Double.NaN);
+ world.addBody(body);
+
+ world.step(Double.NaN);
+ world.step(16);
+
+ assertTrue(Double.isFinite(body.getX()));
+ assertTrue(Double.isFinite(body.getY()));
+ assertTrue(Double.isFinite(body.getVx()));
+ assertTrue(Double.isFinite(body.getVy()));
+ assertTrue(body.getAabb().w >= 0 && body.getAabb().h >= 0);
+ }
+
+ @Test
+ public void collisionCallbacksCanSafelyMutateBodies() {
+ PhysicsWorld2D world = new PhysicsWorld2D();
+ RigidBody2D a = RigidBody2D.circle(0, 0, 2);
+ RigidBody2D b = RigidBody2D.circle(1, 0, 2);
+ RigidBody2D replacement = RigidBody2D.box(20, 20, 1, 1);
+ world.addBody(a);
+ world.addBody(b);
+ world.setCollisionListener(new PhysicsWorld2D.CollisionListener() {
+ @Override public void onBodiesCollide(RigidBody2D left, RigidBody2D right, double nx, double ny) {
+ world.removeBody(b);
+ world.addBody(replacement);
+ }
+ @Override public void onBoundsCollide(RigidBody2D body, String side) {}
+ @Override public void onStaticCollide(RigidBody2D body, com.jvn.core.math.Rect tile, double nx, double ny) {}
+ });
+
+ assertDoesNotThrow(() -> world.step(16));
+ assertEquals(2, world.getBodies().size());
+ assertFalse(world.getBodies().contains(b));
+ assertTrue(world.getBodies().contains(replacement));
+ }
+
+ @Test
+ public void oversizedBroadphaseShapeFallsBackWithoutWalkingBillionsOfCells() {
+ PhysicsWorld2D world = new PhysicsWorld2D();
+ world.addBody(RigidBody2D.box(0, 0, 1e15, 1e15));
+ world.addBody(RigidBody2D.circle(10, 10, 1));
+ assertDoesNotThrow(() -> world.step(16));
+ }
}
diff --git a/modules/core/src/test/java/com/jvn/core/scene2d/ParticleEmitter2DStabilityTest.java b/modules/core/src/test/java/com/jvn/core/scene2d/ParticleEmitter2DStabilityTest.java
new file mode 100644
index 00000000..f3fc1640
--- /dev/null
+++ b/modules/core/src/test/java/com/jvn/core/scene2d/ParticleEmitter2DStabilityTest.java
@@ -0,0 +1,36 @@
+package com.jvn.core.scene2d;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class ParticleEmitter2DStabilityTest {
+
+ @Test
+ void hugeDeltasAndRatesRemainBoundedByParticleCap() {
+ ParticleEmitter2D emitter = new ParticleEmitter2D();
+ emitter.setMaxParticles(12);
+ emitter.setEmissionRate(Double.MAX_VALUE);
+
+ emitter.update(Long.MAX_VALUE);
+
+ assertEquals(12, emitter.getParticleCount());
+ }
+
+ @Test
+ void invalidRangesAreNormalizedAndClearReusesStableState() {
+ ParticleEmitter2D emitter = new ParticleEmitter2D();
+ emitter.setLifeRange(Double.NaN, -5);
+ emitter.setSizeRange(10, -2, Double.NaN);
+ emitter.setMaxParticles(-1);
+
+ assertTrue(emitter.getMinLife() > 0);
+ assertTrue(emitter.getMaxLife() >= emitter.getMinLife());
+ assertTrue(emitter.getMinSize() >= 0);
+ assertTrue(emitter.getMaxSize() >= emitter.getMinSize());
+ assertEquals(0, emitter.getMaxParticles());
+ emitter.clear();
+ assertEquals(0, emitter.getParticleCount());
+ }
+}
diff --git a/modules/core/src/test/java/com/jvn/core/scene2d/Scene2DBaseStabilityTest.java b/modules/core/src/test/java/com/jvn/core/scene2d/Scene2DBaseStabilityTest.java
new file mode 100644
index 00000000..eaf2d563
--- /dev/null
+++ b/modules/core/src/test/java/com/jvn/core/scene2d/Scene2DBaseStabilityTest.java
@@ -0,0 +1,57 @@
+package com.jvn.core.scene2d;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+class Scene2DBaseStabilityTest {
+
+ @Test
+ void mutationsDuringUpdateApplyAfterStableTraversal() {
+ Scene2DBase scene = new Scene2DBase();
+ AtomicInteger firstUpdates = new AtomicInteger();
+ AtomicInteger secondUpdates = new AtomicInteger();
+ AtomicInteger addedUpdates = new AtomicInteger();
+ Entity2D added = new Entity2D() {
+ @Override public void update(long deltaMs) { addedUpdates.incrementAndGet(); }
+ };
+ Entity2D first = new Entity2D() {
+ @Override public void update(long deltaMs) {
+ firstUpdates.incrementAndGet();
+ scene.remove(this);
+ scene.add(added);
+ }
+ };
+ Entity2D second = new Entity2D() {
+ @Override public void update(long deltaMs) { secondUpdates.incrementAndGet(); }
+ };
+ scene.add(first);
+ scene.add(second);
+
+ scene.update(16);
+
+ assertEquals(1, firstUpdates.get());
+ assertEquals(1, secondUpdates.get(), "removing an earlier child must not skip its neighbour");
+ assertEquals(0, addedUpdates.get(), "new children start on the next traversal");
+ assertEquals(2, scene.getChildren().size());
+ assertSame(second, scene.getChildren().get(0));
+ assertSame(added, scene.getChildren().get(1));
+
+ scene.update(16);
+ assertEquals(1, addedUpdates.get());
+ }
+
+ @Test
+ void negativeDeltasAreSanitizedForEntities() {
+ Scene2DBase scene = new Scene2DBase();
+ long[] received = {-1};
+ scene.add(new Entity2D() {
+ @Override public void update(long deltaMs) { received[0] = deltaMs; }
+ });
+ scene.update(-10);
+ assertEquals(0, received[0]);
+ }
+}
diff --git a/modules/core/src/test/java/com/jvn/core/tween/TweenRunnerTest.java b/modules/core/src/test/java/com/jvn/core/tween/TweenRunnerTest.java
index a81c1115..54da8b74 100644
--- a/modules/core/src/test/java/com/jvn/core/tween/TweenRunnerTest.java
+++ b/modules/core/src/test/java/com/jvn/core/tween/TweenRunnerTest.java
@@ -6,6 +6,7 @@
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -93,4 +94,26 @@ public void allFinishingOnSameFrameDoesNotLoseOrders() {
assertEquals(10, updateOrder.size(), "every queued task must update exactly once");
assertEquals(0, runner.activeCount());
}
+
+ @Test
+ public void throwingTaskDoesNotCorruptCompactionOrDeferredAdds() {
+ TweenRunner runner = new TweenRunner();
+ Counting follower = new Counting("follower", 1);
+ runner.add(new TweenRunner.TweenTask() {
+ @Override public void update(long deltaMs) {
+ runner.add(follower);
+ throw new IllegalStateException("boom");
+ }
+ @Override public boolean isFinished() { return false; }
+ });
+ Counting healthy = new Counting("healthy", 1);
+ runner.add(healthy);
+
+ assertThrows(IllegalStateException.class, () -> runner.update(16));
+
+ assertTrue(healthy.isFinished(), "later tasks should still receive their update");
+ assertEquals(1, runner.activeCount(), "only the deferred follower should remain");
+ runner.update(16);
+ assertEquals(0, runner.activeCount());
+ }
}
diff --git a/modules/core/src/test/java/com/jvn/core/tween/TweenTest.java b/modules/core/src/test/java/com/jvn/core/tween/TweenTest.java
index 18cbc7db..c8d4641b 100644
--- a/modules/core/src/test/java/com/jvn/core/tween/TweenTest.java
+++ b/modules/core/src/test/java/com/jvn/core/tween/TweenTest.java
@@ -155,4 +155,24 @@ public void resetRestoresInitialState() {
assertEquals(10.0, t.currentValue(), EPS);
assertTrue(t.isFinished());
}
+
+ @Test
+ public void hugeInfiniteLoopDeltaAdvancesWithoutIteratingEveryLoop() {
+ Tween t = Tween.from(0).to(10).duration(10).loopForever().yoyo().build();
+
+ double value = t.update(Long.MAX_VALUE);
+
+ assertFalse(t.isFinished());
+ assertTrue(Double.isFinite(value));
+ assertEquals(Integer.MAX_VALUE, t.loopsCompleted(),
+ "the public loop count saturates instead of overflowing");
+ }
+
+ @Test
+ public void invalidLoopCountsGracefullyPlayOnce() {
+ Tween t = Tween.from(0).to(10).duration(10).loops(0).build();
+ t.update(10);
+ assertTrue(t.isFinished());
+ assertEquals(1, t.loopsCompleted());
+ }
}
diff --git a/modules/editor/src/main/java/com/jvn/editor/ui/WhatsNewDialog.java b/modules/editor/src/main/java/com/jvn/editor/ui/WhatsNewDialog.java
index 6fdaf421..f8b093f8 100644
--- a/modules/editor/src/main/java/com/jvn/editor/ui/WhatsNewDialog.java
+++ b/modules/editor/src/main/java/com/jvn/editor/ui/WhatsNewDialog.java
@@ -6,7 +6,6 @@
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
-import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.stage.Window;
@@ -21,29 +20,6 @@ public static void show(Window owner, WhatsNewCatalog.Release release) {
VBox content = new VBox(12);
content.getStyleClass().add("whats-new-content");
- HBox hero = new HBox(14);
- hero.setAlignment(Pos.CENTER_LEFT);
- hero.getStyleClass().add("whats-new-hero");
-
- Region icon = AeroIcon.of(AeroIcon.Kind.WHATS_NEW, 48);
- VBox heading = new VBox(3);
- HBox badges = new HBox(7);
- badges.setAlignment(Pos.CENTER_LEFT);
- Label newBadge = new Label("NEW RELEASE");
- newBadge.getStyleClass().add("whats-new-badge");
- Label versionBadge = new Label(release.versionLabel());
- versionBadge.getStyleClass().add("whats-new-version");
- badges.getChildren().addAll(newBadge, versionBadge);
-
- Label summary = new Label(release.summary());
- summary.getStyleClass().add("whats-new-summary");
- summary.setWrapText(true);
- summary.setMaxWidth(420);
- heading.getChildren().addAll(badges, summary);
- HBox.setHgrow(heading, Priority.ALWAYS);
- hero.getChildren().addAll(icon, heading);
- content.getChildren().add(hero);
-
for (WhatsNewCatalog.Section section : release.sections()) {
content.getChildren().add(sectionCard(section));
}
diff --git a/modules/editor/src/main/resources/com/jvn/editor/editor-light.css b/modules/editor/src/main/resources/com/jvn/editor/editor-light.css
index 177efaa3..7ae2ee0a 100644
--- a/modules/editor/src/main/resources/com/jvn/editor/editor-light.css
+++ b/modules/editor/src/main/resources/com/jvn/editor/editor-light.css
@@ -3224,39 +3224,6 @@
-fx-padding: 2 8 4 0;
}
-.whats-new-hero {
- -fx-background-color:
- radial-gradient(center 12% 20%, radius 85%, rgba(57, 132, 188, 0.16), transparent),
- linear-gradient(to bottom, #edf5fb, #e2ebf2);
- -fx-background-radius: 11;
- -fx-border-color: #afc7d9;
- -fx-border-radius: 11;
- -fx-padding: 14;
-}
-
-.whats-new-badge {
- -fx-background-color: linear-gradient(to bottom, #4e9bcf, #337ba9);
- -fx-background-radius: 8;
- -fx-border-color: #23648f;
- -fx-border-radius: 8;
- -fx-text-fill: #ffffff;
- -fx-font-size: 9px;
- -fx-font-weight: 900;
- -fx-padding: 3 7 3 7;
-}
-
-.whats-new-version {
- -fx-text-fill: #245f87;
- -fx-font-size: 11px;
- -fx-font-weight: 900;
-}
-
-.whats-new-summary {
- -fx-text-fill: #1c3445;
- -fx-font-size: 12px;
- -fx-font-weight: 700;
-}
-
.whats-new-section {
-fx-background-color: linear-gradient(to bottom, #f4f5f6, #eceeef);
-fx-background-radius: 10;
diff --git a/modules/editor/src/main/resources/com/jvn/editor/editor.css b/modules/editor/src/main/resources/com/jvn/editor/editor.css
index 6ec780ca..78b91ad1 100644
--- a/modules/editor/src/main/resources/com/jvn/editor/editor.css
+++ b/modules/editor/src/main/resources/com/jvn/editor/editor.css
@@ -3309,39 +3309,6 @@ The editor.css file defines the visual styling for the editor module, including
-fx-padding: 2 8 4 0;
}
-.whats-new-hero {
- -fx-background-color:
- radial-gradient(center 12% 20%, radius 85%, rgba(76, 153, 214, 0.18), transparent),
- linear-gradient(to bottom, #1b222a, #15191e);
- -fx-background-radius: 11;
- -fx-border-color: #344454;
- -fx-border-radius: 11;
- -fx-padding: 14;
-}
-
-.whats-new-badge {
- -fx-background-color: linear-gradient(to bottom, #347caf, #215878);
- -fx-background-radius: 8;
- -fx-border-color: #6db5e6;
- -fx-border-radius: 8;
- -fx-text-fill: #edf8ff;
- -fx-font-size: 9px;
- -fx-font-weight: 900;
- -fx-padding: 3 7 3 7;
-}
-
-.whats-new-version {
- -fx-text-fill: #a9d8f7;
- -fx-font-size: 11px;
- -fx-font-weight: 900;
-}
-
-.whats-new-summary {
- -fx-text-fill: #e6ebf0;
- -fx-font-size: 12px;
- -fx-font-weight: 700;
-}
-
.whats-new-section {
-fx-background-color: linear-gradient(to bottom, #181a1d, #131416);
-fx-background-radius: 10;