merge(04-01): pure ParticleTrail + VolumeCurve + JUnit (Wave 1)

This commit is contained in:
2026-04-27 13:11:00 +02:00
6 changed files with 248 additions and 0 deletions
@@ -0,0 +1,56 @@
package com.mythlane.chainlightning.chain;
import java.util.ArrayList;
import java.util.List;
/**
* Pure-Java sampler for the electric trail between two endpoints.
* <p>
* Produces evenly-spaced interpolated points strictly between {@code from} and
* {@code to}; both endpoints are excluded by construction (the parametric
* interpolation parameter {@code t} never reaches 0 or 1).
* <p>
* Density is expressed in particles per block. The sample count for a given
* segment is {@code ceil(distance * density)}.
* <p>
* This class has ZERO Hytale imports — it lives behind the Phase 2 sealed
* frontier so it can be unit-tested without runtime dependencies.
*/
public final class ParticleTrail {
/** Default trail density in particles per block. */
public static final double TRAIL_DENSITY = 4.0;
private ParticleTrail() {
// utility class — no instances
}
/**
* Sample interpolated points strictly between {@code from} and {@code to}.
* Endpoints are NOT included in the returned list. The list is freshly
* allocated on each call.
*
* @param from start endpoint (excluded from output)
* @param to end endpoint (excluded from output)
* @param density particles per block
* @return newly-allocated list of interpolated points; empty if the
* endpoints coincide or {@code density} produces a non-positive
* count.
*/
public static List<Vec3> sample(Vec3 from, Vec3 to, double density) {
double dist = from.distance(to);
if (dist <= 0) {
return List.of();
}
int count = (int) Math.ceil(dist * density);
if (count <= 0) {
return List.of();
}
List<Vec3> out = new ArrayList<>(count);
for (int i = 1; i <= count; i++) {
double t = (double) i / (count + 1);
out.add(from.lerp(to, t));
}
return out;
}
}
@@ -22,4 +22,13 @@ public record Vec3(double x, double y, double z) {
public double distance(Vec3 other) { public double distance(Vec3 other) {
return Math.sqrt(distanceSquared(other)); return Math.sqrt(distanceSquared(other));
} }
/** Linear interpolation: t=0 returns this, t=1 returns other. */
public Vec3 lerp(Vec3 other, double t) {
return new Vec3(
this.x + (other.x - this.x) * t,
this.y + (other.y - this.y) * t,
this.z + (other.z - this.z) * t
);
}
} }
@@ -0,0 +1,40 @@
package com.mythlane.chainlightning.chain;
/**
* Pure-Java volume curve for the chain-lightning sound emission.
* <p>
* Each successive hop in the chain emits its sound at a progressively lower
* volume, per VFX-02. The curve is hard-coded to the spec values
* {@code [1.0, 0.8, 0.6, 0.5, 0.4]} indexed by hop number.
* <p>
* This class has ZERO Hytale imports — Phase 2 sealed-frontier rule preserved.
*/
public final class VolumeCurve {
/** Spec curve from VFX-02. Order and values are exact. */
private static final float[] CURVE = {1.0f, 0.8f, 0.6f, 0.5f, 0.4f};
private VolumeCurve() {
// utility class — no instances
}
/**
* Volume for a given hop index in the chain.
* <ul>
* <li>Indices 0..4 return the spec values {@code 1.0, 0.8, 0.6, 0.5, 0.4}.</li>
* <li>Indices &ge; 5 clamp to the last value {@code 0.4f}.</li>
* <li>Negative indices throw {@link IllegalArgumentException}.</li>
* </ul>
*
* @param hopIndex zero-based hop index
* @return volume scalar in the range (0, 1]
* @throws IllegalArgumentException if {@code hopIndex < 0}
*/
public static float volumeFor(int hopIndex) {
if (hopIndex < 0) {
throw new IllegalArgumentException("hopIndex must be >= 0, got " + hopIndex);
}
int clamped = Math.min(hopIndex, CURVE.length - 1);
return CURVE[clamped];
}
}
@@ -0,0 +1,82 @@
package com.mythlane.chainlightning.chain;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class ParticleTrailTest {
private static final double EPS = 1e-9;
@Test
void zeroDistance() {
Vec3 p = new Vec3(2, 3, 4);
List<Vec3> out = ParticleTrail.sample(p, p, 4.0);
assertTrue(out.isEmpty());
}
@Test
void subBlockDistance() {
// distance 0.5 along X with density 4 → ceil(2.0) = 2
Vec3 from = new Vec3(0, 0, 0);
Vec3 to = new Vec3(0.5, 0, 0);
List<Vec3> out = ParticleTrail.sample(from, to, 4.0);
assertEquals(2, out.size());
// Both points strictly between endpoints
for (Vec3 v : out) {
assertTrue(from.distanceSquared(v) > EPS, "sample equals from");
assertTrue(to.distanceSquared(v) > EPS, "sample equals to");
}
}
@Test
void integerDistanceCount() {
Vec3 from = new Vec3(0, 0, 0);
Vec3 to = new Vec3(5, 0, 0);
List<Vec3> out = ParticleTrail.sample(from, to, 4.0);
assertEquals(20, out.size());
}
@Test
void fractionalDistanceCount() {
Vec3 from = new Vec3(0, 0, 0);
Vec3 to = new Vec3(3.7, 0, 0);
List<Vec3> out = ParticleTrail.sample(from, to, 4.0);
// ceil(3.7 * 4) = ceil(14.8) = 15
assertEquals(15, out.size());
}
@Test
void endpointsExcluded() {
Vec3 from = new Vec3(1, 2, 3);
Vec3 to = new Vec3(7, 8, 9);
List<Vec3> out = ParticleTrail.sample(from, to, 4.0);
assertTrue(out.size() > 0);
for (Vec3 v : out) {
assertTrue(from.distanceSquared(v) > EPS, "sample equals from endpoint");
assertTrue(to.distanceSquared(v) > EPS, "sample equals to endpoint");
}
}
@Test
void uniformSpacing() {
Vec3 from = new Vec3(0, 0, 0);
Vec3 to = new Vec3(5, 0, 0);
List<Vec3> out = ParticleTrail.sample(from, to, 4.0);
assertEquals(20, out.size());
double total = from.distance(to);
double expectedSpacing = total / (out.size() + 1);
// from -> first
assertEquals(expectedSpacing, from.distance(out.get(0)), EPS);
// last -> to
assertEquals(expectedSpacing, out.get(out.size() - 1).distance(to), EPS);
// consecutive
for (int i = 1; i < out.size(); i++) {
assertEquals(expectedSpacing, out.get(i - 1).distance(out.get(i)), EPS);
}
}
}
@@ -41,4 +41,34 @@ final class Vec3Test {
assertEquals(12.0, a.distanceSquared(b), 1e-9); assertEquals(12.0, a.distanceSquared(b), 1e-9);
assertTrue(a.distance(b) > 3.4 && a.distance(b) < 3.5); assertTrue(a.distance(b) > 3.4 && a.distance(b) < 3.5);
} }
@Test
void lerpAtZeroReturnsFrom() {
Vec3 from = Vec3.ZERO;
Vec3 to = new Vec3(10, 10, 10);
Vec3 result = from.lerp(to, 0.0);
assertEquals(from.x(), result.x(), 1e-9);
assertEquals(from.y(), result.y(), 1e-9);
assertEquals(from.z(), result.z(), 1e-9);
}
@Test
void lerpAtOneReturnsTo() {
Vec3 from = Vec3.ZERO;
Vec3 to = new Vec3(10, 10, 10);
Vec3 result = from.lerp(to, 1.0);
assertEquals(to.x(), result.x(), 1e-9);
assertEquals(to.y(), result.y(), 1e-9);
assertEquals(to.z(), result.z(), 1e-9);
}
@Test
void lerpAtHalfReturnsMidpoint() {
Vec3 from = new Vec3(0, 0, 0);
Vec3 to = new Vec3(10, 0, 0);
Vec3 result = from.lerp(to, 0.5);
assertEquals(5.0, result.x(), 1e-9);
assertEquals(0.0, result.y(), 1e-9);
assertEquals(0.0, result.z(), 1e-9);
}
} }
@@ -0,0 +1,31 @@
package com.mythlane.chainlightning.chain;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
final class VolumeCurveTest {
@Test
void specValues() {
assertEquals(1.0f, VolumeCurve.volumeFor(0), 1e-6);
assertEquals(0.8f, VolumeCurve.volumeFor(1), 1e-6);
assertEquals(0.6f, VolumeCurve.volumeFor(2), 1e-6);
assertEquals(0.5f, VolumeCurve.volumeFor(3), 1e-6);
assertEquals(0.4f, VolumeCurve.volumeFor(4), 1e-6);
}
@Test
void indexClamp() {
assertEquals(0.4f, VolumeCurve.volumeFor(5), 1e-6);
assertEquals(0.4f, VolumeCurve.volumeFor(99), 1e-6);
assertEquals(0.4f, VolumeCurve.volumeFor(Integer.MAX_VALUE), 1e-6);
}
@Test
void negativeIndexThrows() {
assertThrows(IllegalArgumentException.class, () -> VolumeCurve.volumeFor(-1));
assertThrows(IllegalArgumentException.class, () -> VolumeCurve.volumeFor(Integer.MIN_VALUE));
}
}