From beba72911558f338de906a81600488a7f2c65b03 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Thu, 23 Apr 2026 15:44:42 +0200 Subject: [PATCH] feat(03-06): add Particles visual mode with per-region id/density (Task 3) --- .../gravityflip/region/GravityFlipRegion.java | 18 ++ .../gravityflip/viz/RegionVisualizer.java | 179 +++++++++++++++--- .../gravityflip/viz/RegionVisualizerTest.java | 41 ++++ 3 files changed, 208 insertions(+), 30 deletions(-) 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 : *

* - *

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