Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
}
}

Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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);
}
}
43 changes: 35 additions & 8 deletions modules/core/src/main/java/com/jvn/core/engine/Engine.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -211,7 +212,13 @@ public <T extends AutoCloseable> 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;
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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++;
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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
// ──────────────────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
26 changes: 16 additions & 10 deletions modules/core/src/main/java/com/jvn/core/graphics/Camera2D.java
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,10 @@ public Map<String, Double> 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();
}

Expand All @@ -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();
}

Expand Down Expand Up @@ -230,8 +230,8 @@ public static Collection<AnimatablePropertyRegistry.Definition<Camera2D>> 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();
}

Expand All @@ -248,8 +248,8 @@ public void setTarget(double x, double y) {
* full camera frame stays inside the configured world bounds.</p>
*/
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();
}

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
36 changes: 34 additions & 2 deletions modules/core/src/main/java/com/jvn/core/math/Geometry2D.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
}
46 changes: 42 additions & 4 deletions modules/core/src/main/java/com/jvn/core/math/Vec2.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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 + ")"; }
}
Loading
Loading