feat(03-05): ajoute RegionVisualizer (parsing hex/mode, throttling, low-level DebugUtils.add) (Task 2)
- RegionVisualizer emet DisplayDebug cube par region via DebugUtils.add(World, DebugShape.Cube, Matrix4d, color, opacity, time, flags) (low-level => opacity custom respectee, vs addCube qui hardcode 0.8). - Mapping modes: Outline=FLAG_NO_SOLID, Faces=FLAG_NO_WIREFRAME, Both=FLAG_NONE, None=skip, unknown=fallback Outline. - parseColor(#RRGGBB) avec fallback COLOR_CYAN sur input invalide. - Throttling par region via ConcurrentHashMap<name, lastEmitMs> + clock injecte. - Clamp defensive: VisualRefreshMs plancher 100ms (anti-DoS T-03-05-03), VisualOpacity clamp [0,1] (T-03-05-04). - TTL = refreshMs * 1.2 / 1000 (overlap 20% anti-flicker). - Toute emission wrappee dans world.execute(...) via WorldExecutor injectable. - Tests: parseColor valid/invalid, normalizeMode, flagsForMode, matrixFromBox non-cubique, throttling deterministe, skip None/disabled, clamp opacity/refresh.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
package com.mythlane.gravityflip.viz;
|
||||
|
||||
import com.hypixel.hytale.math.matrix.Matrix4d;
|
||||
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.server.core.modules.debug.DebugUtils;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.mythlane.gravityflip.region.GravityFlipRegion;
|
||||
import com.mythlane.gravityflip.region.RegionSnapshot;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
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}.
|
||||
*
|
||||
* <p><b>Mapping VisualMode → flags</b> (cf. {@link DebugUtils}) :
|
||||
* <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 "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>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}).
|
||||
*
|
||||
* <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}.
|
||||
*/
|
||||
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). */
|
||||
@FunctionalInterface
|
||||
public interface DebugEmitter {
|
||||
void emit(World world, DebugShape shape, Matrix4d matrix,
|
||||
Vector3f color, float opacity, float time, int flags);
|
||||
}
|
||||
|
||||
/** Testability: executor injectable (prod = {@code world::execute}). */
|
||||
@FunctionalInterface
|
||||
public interface WorldExecutor {
|
||||
void execute(World world, Runnable r);
|
||||
}
|
||||
|
||||
private static final DebugEmitter DEFAULT_EMITTER =
|
||||
(world, shape, matrix, color, opacity, time, flags) ->
|
||||
DebugUtils.add(world, shape, matrix, color, opacity, time, flags);
|
||||
|
||||
private static final WorldExecutor DEFAULT_EXECUTOR =
|
||||
(world, r) -> world.execute(r);
|
||||
|
||||
private final Consumer<Throwable> errorHandler;
|
||||
private final DebugEmitter emitter;
|
||||
private final WorldExecutor executor;
|
||||
private final LongSupplier clock;
|
||||
private final Map<String, Long> lastEmitMs = new ConcurrentHashMap<>();
|
||||
|
||||
public RegionVisualizer(Consumer<Throwable> errorHandler) {
|
||||
this(errorHandler, DEFAULT_EMITTER, DEFAULT_EXECUTOR, System::currentTimeMillis);
|
||||
}
|
||||
|
||||
/** Package-private ctor pour tests (emitter, executor, clock injectés). */
|
||||
RegionVisualizer(Consumer<Throwable> errorHandler,
|
||||
DebugEmitter emitter,
|
||||
WorldExecutor executor,
|
||||
LongSupplier clock) {
|
||||
this.errorHandler = errorHandler == null ? t -> {} : errorHandler;
|
||||
this.emitter = emitter;
|
||||
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).
|
||||
*/
|
||||
public void visualize(World world, RegionSnapshot snapshot) {
|
||||
if (snapshot == null) return;
|
||||
try {
|
||||
Map<GravityFlipRegion, ?> byRegion = snapshot.byRegion();
|
||||
if (byRegion == null) return;
|
||||
for (GravityFlipRegion r : byRegion.keySet()) {
|
||||
emitOne(world, r);
|
||||
}
|
||||
} catch (Throwable th) {
|
||||
errorHandler.accept(th);
|
||||
}
|
||||
}
|
||||
|
||||
private void emitOne(World world, GravityFlipRegion r) {
|
||||
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;
|
||||
|
||||
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 {
|
||||
emitter.emit(world, DebugShape.Cube, matrix, color, opacity, ttlSeconds, flags);
|
||||
} catch (Throwable th) {
|
||||
errorHandler.accept(th);
|
||||
}
|
||||
});
|
||||
} catch (Throwable th) {
|
||||
errorHandler.accept(th);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear immédiat de toutes les shapes debug côté clients (appelé au shutdown). */
|
||||
public void clearAll(World world) {
|
||||
if (world == null) return;
|
||||
try {
|
||||
executor.execute(world, () -> {
|
||||
try {
|
||||
DebugUtils.clear(world);
|
||||
} catch (Throwable th) {
|
||||
errorHandler.accept(th);
|
||||
}
|
||||
});
|
||||
} catch (Throwable th) {
|
||||
errorHandler.accept(th);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers (package-private pour tests) ----------
|
||||
|
||||
/** Parse {@code #RRGGBB} → Vector3f(r/255, g/255, b/255) ; fallback COLOR_CYAN sur toute erreur. */
|
||||
static Vector3f parseColor(String hex) {
|
||||
if (hex == null || hex.length() != 7 || hex.charAt(0) != '#') {
|
||||
return new Vector3f(DebugUtils.COLOR_CYAN);
|
||||
}
|
||||
try {
|
||||
int r = Integer.parseInt(hex.substring(1, 3), 16);
|
||||
int g = Integer.parseInt(hex.substring(3, 5), 16);
|
||||
int b = Integer.parseInt(hex.substring(5, 7), 16);
|
||||
return new Vector3f(r / 255.0f, g / 255.0f, b / 255.0f);
|
||||
} catch (NumberFormatException e) {
|
||||
return new Vector3f(DebugUtils.COLOR_CYAN);
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalise le mode : un mode inconnu → "Outline" ; null → "Outline". */
|
||||
static String normalizeMode(String mode) {
|
||||
if ("Outline".equals(mode) || "Faces".equals(mode) || "Both".equals(mode) || "None".equals(mode)) {
|
||||
return mode;
|
||||
}
|
||||
return "Outline";
|
||||
}
|
||||
|
||||
/** Mapping VisualMode → flags DebugUtils. "None" n'est jamais passé ici (skip en amont). */
|
||||
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 "Outline":
|
||||
default: return DebugUtils.FLAG_NO_SOLID;
|
||||
}
|
||||
}
|
||||
|
||||
/** Identity.translate(center).scale(sizeX, sizeY, sizeZ) pour une Box non-cubique. */
|
||||
static Matrix4d matrixFromBox(Box box) {
|
||||
Vector3d min = box.min;
|
||||
Vector3d max = box.max;
|
||||
double cx = (min.x + max.x) * 0.5;
|
||||
double cy = (min.y + max.y) * 0.5;
|
||||
double cz = (min.z + max.z) * 0.5;
|
||||
double sx = max.x - min.x;
|
||||
double sy = max.y - min.y;
|
||||
double sz = max.z - min.z;
|
||||
Matrix4d m = new Matrix4d();
|
||||
m.identity();
|
||||
m.translate(cx, cy, cz);
|
||||
m.scale(sx, sy, sz);
|
||||
return m;
|
||||
}
|
||||
|
||||
static double clamp(double v, double lo, double hi) {
|
||||
return v < lo ? lo : (v > hi ? hi : v);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user