diff --git a/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java b/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java
index 352dd0d..7e259a7 100644
--- a/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java
+++ b/src/main/java/com/mythlane/gravityflip/region/GravityFlipRegion.java
@@ -88,6 +88,11 @@ public final class GravityFlipRegion {
(r, v) -> r.visualRefreshMs = v, r -> r.visualRefreshMs).add()
.append(new KeyedCodec<>("VisualOpacity", Codec.DOUBLE),
(r, v) -> r.visualOpacity = v, r -> r.visualOpacity).add()
+ // --- Plan 03-06 : 2 optional particle-mode fields ---
+ .append(new KeyedCodec<>("VisualParticleId", Codec.STRING),
+ (r, v) -> r.visualParticleId = v, r -> r.visualParticleId).add()
+ .append(new KeyedCodec<>("VisualParticleDensity", Codec.DOUBLE),
+ (r, v) -> r.visualParticleDensity = v, r -> r.visualParticleDensity).add()
.build();
// Package-private mutable fields written directly by the codec setters.
@@ -109,6 +114,11 @@ public final class GravityFlipRegion {
int visualRefreshMs = 1000;
double visualOpacity = 0.5;
+ // Plan 03-06 : particle-mode fields — defaults chosen after Task 1 dump
+ // (Dust_Sparkles_Fine, density 1.0 particle/m on each of the 12 AABB edges).
+ String visualParticleId = "Dust_Sparkles_Fine";
+ double visualParticleDensity = 1.0;
+
public GravityFlipRegion() {}
public GravityFlipRegion(String name, Box box, boolean enabled) {
@@ -155,6 +165,14 @@ public final class GravityFlipRegion {
public void setVisualRefreshMs(int v) { this.visualRefreshMs = v; }
public void setVisualOpacity(double v) { this.visualOpacity = v; }
+ // --- Plan 03-06 getters / setters ---
+
+ public String getVisualParticleId() { return visualParticleId; }
+ public double getVisualParticleDensity() { return visualParticleDensity; }
+
+ public void setVisualParticleId(String v) { this.visualParticleId = v; }
+ public void setVisualParticleDensity(double v) { this.visualParticleDensity = v; }
+
/** Convenience accessor for tick-loop / physics consumers in Phase 02-02. */
public Box asBox() { return box; }
}
diff --git a/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java b/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java
index 41af469..37f281a 100644
--- a/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java
+++ b/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java
@@ -5,95 +5,158 @@ import com.hypixel.hytale.math.shape.Box;
import com.hypixel.hytale.math.vector.Vector3d;
import com.hypixel.hytale.math.vector.Vector3f;
import com.hypixel.hytale.protocol.DebugShape;
+import com.hypixel.hytale.protocol.Position;
+import com.hypixel.hytale.protocol.ToClientPacket;
+import com.hypixel.hytale.protocol.packets.world.SpawnParticleSystem;
+import com.hypixel.hytale.server.core.asset.type.particle.config.ParticleSystem;
import com.hypixel.hytale.server.core.modules.debug.DebugUtils;
+import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World;
import com.mythlane.gravityflip.region.GravityFlipRegion;
import com.mythlane.gravityflip.region.RegionSnapshot;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentHashMap.KeySetView;
import java.util.function.Consumer;
import java.util.function.LongSupplier;
/**
- * Plan 03-05 : service qui émet des cubes debug (DisplayDebug / ClearDebugShapes) pour
- * matérialiser chaque région de gravity-flip côté clients. Ne crée aucun scheduler ;
- * {@link #visualize(World, RegionSnapshot)} est appelé à chaque tick par
- * {@code RegionTickLoop}, avec throttling par région via {@code VisualRefreshMs}.
+ * Plan 03-05 + 03-06 : service qui émet soit des cubes {@link DebugUtils} (modes
+ * Outline/Faces/Both) soit des particules le long des 12 arêtes de l'AABB
+ * (mode Particles) pour matérialiser chaque région de gravity-flip côté clients.
+ * Ne crée aucun scheduler ; {@link #visualize(World, RegionSnapshot)} est appelé
+ * à chaque tick par {@code RegionTickLoop}, avec throttling par région via
+ * {@code VisualRefreshMs}.
*
- *
Mapping VisualMode → flags (cf. {@link DebugUtils}) :
+ *
Mapping VisualMode → render path :
*
- * - {@code "Outline"} → {@link DebugUtils#FLAG_NO_SOLID} (wireframe uniquement).
- * - {@code "Faces"} → {@link DebugUtils#FLAG_NO_WIREFRAME} (faces uniquement).
- * - {@code "Both"} → {@link DebugUtils#FLAG_NONE} (wire + solide).
+ * - {@code "Outline"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NO_SOLID}.
+ * - {@code "Faces"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NO_WIREFRAME}.
+ * - {@code "Both"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NONE}.
+ * - {@code "Particles"} → particules le long des 12 arêtes AABB (Plan 03-06).
* - {@code "None"} → skip (aucune émission).
* - autre / null → fallback {@code "Outline"}.
*
*
- * Opacity caveat : {@link DebugUtils#addCube} hardcode {@code opacity=0.8} ;
- * on passe donc par le low-level {@link DebugUtils#add(World, DebugShape, Matrix4d, Vector3f, float, float, int)}
- * qui accepte l'opacity custom. TTL = {@code refreshMs * 1.2 / 1000} (overlap 20 %
- * pour éviter le flicker entre deux émissions).
+ *
Particles render path (03-06) : on n'utilise pas
+ * {@code ParticleUtil.spawnParticleEffect} directement car sa signature exige un
+ * {@code ComponentAccessor} non trivial à résoudre depuis un
+ * {@code world.execute(...)} lambda côté plugin. À la place, on émet le packet
+ * {@link SpawnParticleSystem} directement vers {@code world.getPlayerRefs()}
+ * (exact même pattern que {@link DebugUtils#add} — cf. lignes 59-61 du
+ * décompilé). L'accessor n'est utilisé par ParticleUtil que pour résoudre les
+ * {@code PlayerRef} via {@code SpatialResource} ; puisque {@code World} expose
+ * déjà les {@code PlayerRef} directement, ce raccourci est sémantiquement
+ * équivalent pour un broadcast world-wide.
+ *
+ * Runtime id validation : avant la première émission pour un
+ * {@code VisualParticleId} donné, on vérifie que l'id existe dans
+ * {@code ParticleSystem.getAssetMap()} (pattern précédent :
+ * {@code ParticleSystemExistsValidator}). Id invalide → warning loggé une fois
+ * via {@code errorHandler}, fallback sur {@value #DEFAULT_PARTICLE_ID}.
*
*
Threading : {@code lastEmitMs} est un {@link ConcurrentHashMap} car
- * {@code RegionTickLoop} tourne sur un thread daemon séparé. Chaque appel
- * {@link DebugUtils} est wrappé dans {@code world.execute(...)} pour satisfaire
- * l'assert WorldThread (pattern identique à {@code RegionRegistry.refreshFor}).
+ * {@code RegionTickLoop} tourne sur un thread daemon séparé. Les émissions
+ * packet sont wrappées dans {@code world.execute(...)} pour respecter l'assert
+ * WorldThread (pattern identique à {@code RegionRegistry.refreshFor}).
*
*
Anti-DoS : {@code VisualRefreshMs} est clampé à un plancher pour
- * empêcher une valeur pathologique (0 ou très petite) de saturer les clients
- * avec des paquets {@code DisplayDebug}.
+ * empêcher une valeur pathologique de saturer les clients. {@code density} est
+ * clampé côté {@link ParticleEdgeEmitter} à {@code [0.1, 10.0]}.
*/
public final class RegionVisualizer {
/** Plancher anti-DoS sur {@code VisualRefreshMs} (threat T-03-05-03). */
static final int MIN_REFRESH_MS = 100;
- /** Testability: emitter injectable (prod = DebugUtils.add wrapper). */
+ /** Fallback asset-id lorsqu'une VisualParticleId invalide est détectée. */
+ public static final String DEFAULT_PARTICLE_ID = "Dust_Sparkles_Fine";
+
+ /** Testability: DebugUtils emitter injectable (prod = {@link DebugUtils#add} wrapper). */
@FunctionalInterface
public interface DebugEmitter {
void emit(World world, DebugShape shape, Matrix4d matrix,
Vector3f color, float opacity, float time, int flags);
}
+ /** Testability: particle emitter injectable (prod = direct-packet broadcast). */
+ @FunctionalInterface
+ public interface ParticleEmitter {
+ void emit(World world, String id, Vector3d pos);
+ }
+
/** Testability: executor injectable (prod = {@code world::execute}). */
@FunctionalInterface
public interface WorldExecutor {
void execute(World world, Runnable r);
}
- private static final DebugEmitter DEFAULT_EMITTER =
+ private static final DebugEmitter DEFAULT_DEBUG_EMITTER =
(world, shape, matrix, color, opacity, time, flags) ->
DebugUtils.add(world, shape, matrix, color, opacity, time, flags);
+ /**
+ * Prod particle emitter : broadcast direct de {@link SpawnParticleSystem} à
+ * {@code world.getPlayerRefs()} (sans ComponentAccessor — voir javadoc de classe).
+ */
+ private static final ParticleEmitter DEFAULT_PARTICLE_EMITTER =
+ (world, id, pos) -> {
+ SpawnParticleSystem packet = new SpawnParticleSystem(
+ id,
+ new Position(pos.x, pos.y, pos.z),
+ null, // no rotation
+ 1.0f, // default scale
+ null); // no color override
+ for (PlayerRef playerRef : world.getPlayerRefs()) {
+ playerRef.getPacketHandler().write((ToClientPacket) packet);
+ }
+ };
+
private static final WorldExecutor DEFAULT_EXECUTOR =
(world, r) -> world.execute(r);
private final Consumer errorHandler;
private final DebugEmitter emitter;
+ private final ParticleEmitter particleEmitter;
private final WorldExecutor executor;
private final LongSupplier clock;
private final Map lastEmitMs = new ConcurrentHashMap<>();
+ /** Ids déjà warned comme invalides — évite le log-spam par tick. */
+ private final KeySetView warnedInvalidIds = ConcurrentHashMap.newKeySet();
public RegionVisualizer(Consumer errorHandler) {
- this(errorHandler, DEFAULT_EMITTER, DEFAULT_EXECUTOR, System::currentTimeMillis);
+ this(errorHandler, DEFAULT_DEBUG_EMITTER, DEFAULT_PARTICLE_EMITTER,
+ DEFAULT_EXECUTOR, System::currentTimeMillis);
}
- /** Package-private ctor pour tests (emitter, executor, clock injectés). */
+ /** Package-private ctor pour tests (emitter DebugUtils + executor + clock injectés). */
RegionVisualizer(Consumer errorHandler,
DebugEmitter emitter,
WorldExecutor executor,
LongSupplier clock) {
+ this(errorHandler, emitter, DEFAULT_PARTICLE_EMITTER, executor, clock);
+ }
+
+ /** Package-private ctor pour tests (tous les emitters + executor + clock injectés). */
+ RegionVisualizer(Consumer errorHandler,
+ DebugEmitter emitter,
+ ParticleEmitter particleEmitter,
+ WorldExecutor executor,
+ LongSupplier clock) {
this.errorHandler = errorHandler == null ? t -> {} : errorHandler;
this.emitter = emitter;
+ this.particleEmitter = particleEmitter;
this.executor = executor;
this.clock = clock;
}
/**
- * Émet un cube debug pour chaque région éligible du {@code snapshot}, en respectant
- * mode / couleur / opacity / throttling. Ne propage aucune exception : tout throw
- * est routé vers l'errorHandler (le tick ne doit pas mourir).
+ * Émet la visualisation debug pour chaque région éligible du {@code snapshot},
+ * en respectant mode / couleur / opacity / throttling. Ne propage aucune
+ * exception : tout throw est routé vers l'errorHandler (le tick ne doit pas mourir).
*/
public void visualize(World world, RegionSnapshot snapshot) {
if (snapshot == null) return;
@@ -112,20 +175,27 @@ public final class RegionVisualizer {
if (r == null || !r.isEnabled()) return;
String mode = normalizeMode(r.getVisualMode());
if ("None".equals(mode)) return;
- int flags = flagsForMode(mode);
int refreshMs = Math.max(MIN_REFRESH_MS, r.getVisualRefreshMs());
long now = clock.getAsLong();
Long last = lastEmitMs.get(r.getName());
if (last != null && (now - last) < refreshMs) return;
+ lastEmitMs.put(r.getName(), now);
+ if ("Particles".equals(mode)) {
+ emitParticles(world, r);
+ } else {
+ emitDebugCube(world, r, mode, refreshMs);
+ }
+ }
+
+ private void emitDebugCube(World world, GravityFlipRegion r, String mode, int refreshMs) {
+ int flags = flagsForMode(mode);
Vector3f color = parseColor(r.getVisualColor());
Matrix4d matrix = matrixFromBox(r.getBox());
float ttlSeconds = refreshMs * 1.2f / 1000f;
float opacity = (float) clamp(r.getVisualOpacity(), 0.0, 1.0);
- lastEmitMs.put(r.getName(), now);
-
try {
executor.execute(world, () -> {
try {
@@ -139,6 +209,53 @@ public final class RegionVisualizer {
}
}
+ private void emitParticles(World world, GravityFlipRegion r) {
+ List points = ParticleEdgeEmitter.edgePoints(r.getBox(), r.getVisualParticleDensity());
+ String requested = r.getVisualParticleId();
+ String particleId = resolveParticleId(requested);
+
+ try {
+ executor.execute(world, () -> {
+ for (Vector3d p : points) {
+ try {
+ particleEmitter.emit(world, particleId, p);
+ } catch (Throwable th) {
+ errorHandler.accept(th);
+ }
+ }
+ });
+ } catch (Throwable th) {
+ errorHandler.accept(th);
+ }
+ }
+
+ /**
+ * Runtime validation de {@code VisualParticleId}. Id valide → retourne tel quel.
+ * Id inconnu → warn-once (dédup via {@link #warnedInvalidIds}) et fallback
+ * sur {@link #DEFAULT_PARTICLE_ID}. Si la validation elle-même throw (ex :
+ * AssetMap indisponible hors contexte serveur), on laisse passer l'id tel quel
+ * (fail-open — le serveur loggera l'erreur par packet si vraiment cassé).
+ */
+ String resolveParticleId(String requested) {
+ if (requested == null || requested.isEmpty()) {
+ return DEFAULT_PARTICLE_ID;
+ }
+ try {
+ if (ParticleSystem.getAssetMap().getAsset(requested) != null) {
+ return requested;
+ }
+ } catch (Throwable th) {
+ // AssetMap indisponible (tests hors serveur) — fail-open.
+ return requested;
+ }
+ if (warnedInvalidIds.add(requested)) {
+ errorHandler.accept(new IllegalArgumentException(
+ "[RegionVisualizer] Unknown VisualParticleId '" + requested
+ + "' — falling back to '" + DEFAULT_PARTICLE_ID + "'"));
+ }
+ return DEFAULT_PARTICLE_ID;
+ }
+
/** Clear immédiat de toutes les shapes debug côté clients (appelé au shutdown). */
public void clearAll(World world) {
if (world == null) return;
@@ -172,20 +289,22 @@ public final class RegionVisualizer {
}
}
- /** Normalise le mode : un mode inconnu → "Outline" ; null → "Outline". */
+ /** Normalise le mode : inconnu/null → "Outline". */
static String normalizeMode(String mode) {
- if ("Outline".equals(mode) || "Faces".equals(mode) || "Both".equals(mode) || "None".equals(mode)) {
+ if ("Outline".equals(mode) || "Faces".equals(mode) || "Both".equals(mode)
+ || "None".equals(mode) || "Particles".equals(mode)) {
return mode;
}
return "Outline";
}
- /** Mapping VisualMode → flags DebugUtils. "None" n'est jamais passé ici (skip en amont). */
+ /** Mapping VisualMode → flags DebugUtils. "None"/"Particles" n'utilisent pas les flags. */
static int flagsForMode(String mode) {
switch (normalizeMode(mode)) {
case "Faces": return DebugUtils.FLAG_NO_WIREFRAME;
case "Both": return DebugUtils.FLAG_NONE;
case "None": return DebugUtils.FLAG_NONE; // sentinel — caller skip avant.
+ case "Particles": return DebugUtils.FLAG_NONE; // unused — particles ne passent pas par DebugUtils.
case "Outline":
default: return DebugUtils.FLAG_NO_SOLID;
}
diff --git a/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java b/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java
index 0b4460b..dcde42b 100644
--- a/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java
+++ b/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java
@@ -75,10 +75,51 @@ class RegionVisualizerTest {
assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("Outline"));
assertEquals(DebugUtils.FLAG_NO_WIREFRAME, RegionVisualizer.flagsForMode("Faces"));
assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Both"));
+ assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Particles"));
// unknown → Outline
assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("xxx"));
}
+ @Test
+ void parseMode_particlesAccepted() {
+ assertEquals("Particles", RegionVisualizer.normalizeMode("Particles"));
+ }
+
+ // ---------- Particles branch ----------
+
+ @Test
+ void visualize_particlesMode_callsParticleEmitterOncePerEdgePoint() {
+ List particleCalls = new ArrayList<>();
+ RegionVisualizer.ParticleEmitter pe = (w, id, pos) -> particleCalls.add(id + "@" + pos.x + "," + pos.y + "," + pos.z);
+ RegionVisualizer viz = new RegionVisualizer(
+ t -> fail("errorHandler unexpectedly called: " + t),
+ (w, shape, m, c, o, t, f) -> fail("DebugEmitter should not fire in Particles mode"),
+ pe,
+ (w, r) -> r.run(),
+ () -> 0L);
+
+ // unit box at density=1 → 8 corner points emitted.
+ GravityFlipRegion r = region("pz", "#FFFFFF", "Particles", 1000, 0.5);
+ r.setVisualParticleId("Dust_Sparkles_Fine");
+ r.setVisualParticleDensity(1.0);
+
+ viz.visualize(null, snapshotOf(r));
+ assertEquals(8, particleCalls.size(), "unit box + density=1 → 8 corner emissions");
+ // All calls use the requested id (validation falls open in test context).
+ for (String call : particleCalls) {
+ assertTrue(call.startsWith("Dust_Sparkles_Fine@"), "unexpected call: " + call);
+ }
+ }
+
+ @Test
+ void particleDefaults_absentInConstructedRegion() {
+ // Defaults must match the codec's documented defaults (03-06).
+ GravityFlipRegion r = new GravityFlipRegion("x",
+ new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), true);
+ assertEquals("Dust_Sparkles_Fine", r.getVisualParticleId());
+ assertEquals(1.0, r.getVisualParticleDensity(), 1e-9);
+ }
+
// ---------- matrixFromBox ----------
@Test