feat(03-01): add GravityApplier with native PhysicsValues.invertedGravity toggle (Task 1)
- Service GravityApplier qui toggle PhysicsValues.invertedGravity via CommandBuffer.replaceComponent - Thread-safe tracker ConcurrentHashMap.newKeySet<UUID> pour dedup entre ticks - Second pass O(N) pour restaurer la gravité à la sortie de région (trade-off v1 documenté) - Pure diff static helper + hooks package-private pour tests unitaires sans runtime Hytale - 6 tests verts (diff + tracker semantics)
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package com.mythlane.gravityflip.physics;
|
||||
|
||||
import com.hypixel.hytale.component.ComponentType;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
|
||||
import com.hypixel.hytale.server.core.modules.physics.component.PhysicsValues;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import com.mythlane.gravityflip.region.GravityFlipRegion;
|
||||
import com.mythlane.gravityflip.region.RegionSnapshot;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Tick-driven service that toggles the native {@code PhysicsValues.invertedGravity} flag on every
|
||||
* entity present in an enabled {@link GravityFlipRegion}. Mutations are queued via
|
||||
* {@code CommandBuffer.replaceComponent(...)} inside a {@code Store.forEachEntityParallel(...)}
|
||||
* lambda — the ECS engine commits them on the main thread after the parallel pass.
|
||||
*
|
||||
* <p>Dedup: a thread-safe {@link Set}{@code <UUID>} tracks entities flipped at the previous tick.
|
||||
* Entities that left an active region are restored to {@code invertedGravity=false} in a second
|
||||
* pass (trade-off documented in the plan — second O(N) scan accepted v1).
|
||||
*/
|
||||
public final class GravityApplier {
|
||||
// Lazy ComponentType holders — pattern identique à RegionRegistry.transform()
|
||||
// (évite static-init Hytale PluginBase pendant les tests).
|
||||
private static volatile ComponentType<EntityStore, PhysicsValues> physicsType;
|
||||
private static volatile ComponentType<EntityStore, UUIDComponent> uuidType;
|
||||
private static volatile ComponentType<EntityStore, TransformComponent> transformType;
|
||||
|
||||
private static ComponentType<EntityStore, PhysicsValues> physicsType() {
|
||||
ComponentType<EntityStore, PhysicsValues> t = physicsType;
|
||||
if (t == null) {
|
||||
synchronized (GravityApplier.class) {
|
||||
t = physicsType;
|
||||
if (t == null) { t = PhysicsValues.getComponentType(); physicsType = t; }
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
private static ComponentType<EntityStore, UUIDComponent> uuidType() {
|
||||
ComponentType<EntityStore, UUIDComponent> t = uuidType;
|
||||
if (t == null) {
|
||||
synchronized (GravityApplier.class) {
|
||||
t = uuidType;
|
||||
if (t == null) { t = UUIDComponent.getComponentType(); uuidType = t; }
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
private static ComponentType<EntityStore, TransformComponent> transformType() {
|
||||
ComponentType<EntityStore, TransformComponent> t = transformType;
|
||||
if (t == null) {
|
||||
synchronized (GravityApplier.class) {
|
||||
t = transformType;
|
||||
if (t == null) { t = TransformComponent.getComponentType(); transformType = t; }
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// THREADING: écrit/lu depuis les workers ECS via forEachEntityParallel → ConcurrentHashMap.newKeySet obligatoire.
|
||||
private final Set<UUID> previouslyInverted = ConcurrentHashMap.newKeySet();
|
||||
private final Consumer<Throwable> errorHandler;
|
||||
|
||||
public GravityApplier(Consumer<Throwable> errorHandler) {
|
||||
this.errorHandler = errorHandler == null ? t -> {} : errorHandler;
|
||||
}
|
||||
|
||||
/** Tick entry point. NO-OP si world ou snapshot est null. */
|
||||
public void apply(World world, RegionSnapshot snapshot) {
|
||||
if (world == null || snapshot == null) return;
|
||||
Collection<GravityFlipRegion> enabledRegions = snapshot.byRegion().keySet();
|
||||
// Stratégie figée (cf. WARNING 1 résolu) : on accepte la duplication du containsPosition
|
||||
// (cf. must_haves trade-off) plutôt que de coupler à un nouveau contrat RegionSnapshot
|
||||
// qui exposerait des UUIDs préformés. Coût : 2 pass O(N) sur le store par tick.
|
||||
|
||||
try {
|
||||
Store<EntityStore> store = world.getEntityStore().getStore();
|
||||
ComponentType<EntityStore, PhysicsValues> PHYST = physicsType();
|
||||
ComponentType<EntityStore, UUIDComponent> UUIDT = uuidType();
|
||||
ComponentType<EntityStore, TransformComponent> TT = transformType();
|
||||
|
||||
// PASS 1 — pour chaque entité avec PhysicsValues : si dans une région activée, queue le flip ON
|
||||
// via cmdBuf.replaceComponent ET enregistre l'UUID dans currentlyInRegion (thread-safe).
|
||||
// THREADING: lambda sur worker ECS → currentlyInRegion = ConcurrentHashMap.newKeySet ; toutes les
|
||||
// mutations PhysicsValues passent par cmdBuf (jamais replaceValues direct).
|
||||
Set<UUID> currentlyInRegion = ConcurrentHashMap.newKeySet();
|
||||
store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> {
|
||||
TransformComponent t;
|
||||
UUIDComponent uc;
|
||||
PhysicsValues v;
|
||||
try {
|
||||
t = chunk.getComponent(index, TT);
|
||||
uc = chunk.getComponent(index, UUIDT);
|
||||
v = chunk.getComponent(index, PHYST);
|
||||
} catch (Throwable ignored) { return; }
|
||||
if (t == null || uc == null || v == null) return;
|
||||
|
||||
com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition();
|
||||
double x = pos.x, y = pos.y, z = pos.z;
|
||||
|
||||
boolean inAnyRegion = false;
|
||||
for (GravityFlipRegion r : enabledRegions) {
|
||||
if (r.asBox().containsPosition(x, y, z)) { inAnyRegion = true; break; }
|
||||
}
|
||||
if (!inAnyRegion) return;
|
||||
|
||||
UUID u = uc.getUuid();
|
||||
currentlyInRegion.add(u);
|
||||
if (!v.isInvertedGravity()) {
|
||||
Ref<EntityStore> ref = chunk.getReferenceTo(index);
|
||||
cmdBuf.replaceComponent(ref, PHYST,
|
||||
new PhysicsValues(v.getMass(), v.getDragCoefficient(), true));
|
||||
}
|
||||
});
|
||||
|
||||
// PASS 2 — restore : pour chaque UUID dans previouslyInverted \ currentlyInRegion,
|
||||
// re-localiser l'entité (second scan) et queue le flip OFF via cmdBuf.
|
||||
// Trade-off perf accepté v1 — voir SUMMARY follow-up.
|
||||
Set<UUID> toRestore = ConcurrentHashMap.newKeySet();
|
||||
toRestore.addAll(previouslyInverted);
|
||||
toRestore.removeAll(currentlyInRegion);
|
||||
if (!toRestore.isEmpty()) {
|
||||
store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> {
|
||||
UUIDComponent uc;
|
||||
PhysicsValues v;
|
||||
try {
|
||||
uc = chunk.getComponent(index, UUIDT);
|
||||
v = chunk.getComponent(index, PHYST);
|
||||
} catch (Throwable ignored) { return; }
|
||||
if (uc == null || v == null) return;
|
||||
if (toRestore.contains(uc.getUuid()) && v.isInvertedGravity()) {
|
||||
Ref<EntityStore> ref = chunk.getReferenceTo(index);
|
||||
cmdBuf.replaceComponent(ref, PHYST,
|
||||
new PhysicsValues(v.getMass(), v.getDragCoefficient(), false));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update tracker — ces ops sont sur le tick thread après la fin du pass parallel.
|
||||
// ConcurrentHashMap.newKeySet supporte clear()/addAll() concurrents safely.
|
||||
previouslyInverted.clear();
|
||||
previouslyInverted.addAll(currentlyInRegion);
|
||||
} catch (Throwable th) {
|
||||
errorHandler.accept(th);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure data-diff utilitaire pour tests unitaires (pas de runtime Hytale). */
|
||||
public static DiffResult diff(Set<UUID> previous, Set<UUID> current) {
|
||||
Set<UUID> toFlip = new HashSet<>(current);
|
||||
toFlip.removeAll(previous);
|
||||
Set<UUID> toRestore = new HashSet<>(previous);
|
||||
toRestore.removeAll(current);
|
||||
return new DiffResult(toFlip, toRestore);
|
||||
}
|
||||
|
||||
public static final class DiffResult {
|
||||
public final Set<UUID> toFlip;
|
||||
public final Set<UUID> toRestore;
|
||||
DiffResult(Set<UUID> f, Set<UUID> r) { this.toFlip = f; this.toRestore = r; }
|
||||
}
|
||||
|
||||
// ---- Test hooks (package-private) ----
|
||||
|
||||
/** Vue immuable du tracker — pour tests unitaires sémantiques (résolution WARNING 2). */
|
||||
Set<UUID> previouslyInvertedView() {
|
||||
return Collections.unmodifiableSet(previouslyInverted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force une valeur du tracker hors runtime ECS — pour tester la sémantique sans dépendre de World/Store.
|
||||
* Package-private : NE PAS appeler depuis le code de production.
|
||||
*/
|
||||
void __updateTrackerForTest(Set<UUID> newState) {
|
||||
previouslyInverted.clear();
|
||||
previouslyInverted.addAll(newState);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user