feat(03-06): add Particles visual mode with per-region id/density (Task 3)

This commit is contained in:
2026-04-23 15:44:42 +02:00
parent ee1d1b9bdb
commit beba729115
3 changed files with 208 additions and 30 deletions
@@ -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;
}