feat(03-06): add Particles visual mode with per-region id/density (Task 3)
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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}.
|
||||
*
|
||||
* <p><b>Mapping VisualMode → flags</b> (cf. {@link DebugUtils}) :
|
||||
* <p><b>Mapping VisualMode → render path</b> :
|
||||
* <ul>
|
||||
* <li>{@code "Outline"} → {@link DebugUtils#FLAG_NO_SOLID} (wireframe uniquement).</li>
|
||||
* <li>{@code "Faces"} → {@link DebugUtils#FLAG_NO_WIREFRAME} (faces uniquement).</li>
|
||||
* <li>{@code "Both"} → {@link DebugUtils#FLAG_NONE} (wire + solide).</li>
|
||||
* <li>{@code "Outline"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NO_SOLID}.</li>
|
||||
* <li>{@code "Faces"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NO_WIREFRAME}.</li>
|
||||
* <li>{@code "Both"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NONE}.</li>
|
||||
* <li>{@code "Particles"} → particules le long des 12 arêtes AABB (Plan 03-06).</li>
|
||||
* <li>{@code "None"} → skip (aucune émission).</li>
|
||||
* <li>autre / null → fallback {@code "Outline"}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Opacity caveat</b> : {@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).
|
||||
* <p><b>Particles render path (03-06)</b> : on n'utilise pas
|
||||
* {@code ParticleUtil.spawnParticleEffect} directement car sa signature exige un
|
||||
* {@code ComponentAccessor<EntityStore>} 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.
|
||||
*
|
||||
* <p><b>Runtime id validation</b> : 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}.
|
||||
*
|
||||
* <p><b>Threading</b> : {@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}).
|
||||
*
|
||||
* <p><b>Anti-DoS</b> : {@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<Throwable> errorHandler;
|
||||
private final DebugEmitter emitter;
|
||||
private final ParticleEmitter particleEmitter;
|
||||
private final WorldExecutor executor;
|
||||
private final LongSupplier clock;
|
||||
private final Map<String, Long> lastEmitMs = new ConcurrentHashMap<>();
|
||||
/** Ids déjà warned comme invalides — évite le log-spam par tick. */
|
||||
private final KeySetView<String, Boolean> warnedInvalidIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public RegionVisualizer(Consumer<Throwable> 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<Throwable> 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<Throwable> 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<Vector3d> 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;
|
||||
}
|
||||
|
||||
@@ -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<String> 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
|
||||
|
||||
Reference in New Issue
Block a user