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;