feat(phase-4): VFX via EntityEffect bridge — chain hits glow blue
Working POC visual : chaque cible touchée par la chaîne reçoit l'EntityEffect
`Chain_Hit_Effect` (Server/Entity/Effects/) appliqué via
`EffectControllerComponent.addEffect`. L'effet contient EntityTopTint/BottomTint
bleu + un Splash particle scale 4 tinted, duration 0.6s.
## Pourquoi pas ParticleUtil.spawnParticleEffect
Tentatives extensives via le path standalone SpawnParticleSystem packet (3-arg
auto-broadcast et 7-arg explicit playerRef) ont échoué — particles invisibles
côté client malgré delivery confirmée serveur. Pattern Java→custom particle
non supporté en l'état Hytale 2026.03.26-89796e57b (asset sync plugin custom
pas wired). Vanilla "Splash" via le path standalone : 0/5 visible.
## EntityEffect = path ECS replication
`EffectControllerComponent.addEffect` ajoute un `ActiveEntityEffect` au target,
propagé aux clients via la sync ECS automatique (path Cleric-Rod / canonique).
Le client lookup l'EntityEffect par index dans son map (broadcast au connect)
et applique ApplicationEffects (tints + particles inline) sur le modèle de
l'entity. C'est ce path qui rend.
## Limitations POC
- VFX-02 (atténuation sonore) non livré : SoundEvent custom subit le même
problème de sync que ParticleSystem custom. Path standalone SoundUtil
également cassé pour assets plugin. EntityEffect a un WorldSoundEventId
field qu'on pourrait remplir avec un vanilla, deferred.
- VolumeCurve.java + ParticleTrail.java conservés (33 tests JUnit verts) mais
inutilisés runtime — la courbe de volume était hop-indexed et l'EntityEffect
est uniforme. Garder pour usage futur si Hytale fix le sync.
- TRAIL_DENSITY abaissé 4.0 → 1.0 (réduction du spam packet pendant les
itérations diagnostic, pas critique vu qu'inutilisé).
- Phase 1 items en snake_case toujours warns au boot — pas bloquant, fix
cosmétique reporté.
## Files
NEW : `src/main/resources/Server/Entity/Effects/Chain_Hit_Effect.json`
NEW : `src/main/java/com/mythlane/chainlightning/sceptre/HytaleVfxEmitter.java`
(réécrit pour utiliser EffectControllerComponent.addEffect)
MOD : `ChainLightningSceptreInteraction.java` étape 7.5 — passe playerRef
MOD : `ParticleTrail.TRAIL_DENSITY` 4.0 → 1.0
DEL : `Server/Particles/Chain_Spark.{particlesystem,particlespawner}` +
`Common/Particles/Chain_Spark.png` (dropped — inutilisés par EntityEffect
qui référence vanilla Splash)
.gitignore : ignore *.zip + note (asset source temporaires)
Tests : 41/41 verts (29 baseline + 12 Phase 4 pure-Java).
Build : `./gradlew shadowJar` clean.
UAT 17:05 : confirmé visuel — mobs glow bleu sur hit chain.
This commit is contained in:
+11
@@ -136,6 +136,17 @@ public final class ChainLightningSceptreInteraction extends SimpleInstantInterac
|
||||
ChainDamageApplier.apply(hits, playerRef, commandBuffer, commandBuffer);
|
||||
LOGGER.info("[ChainLightning][7/9] damage application returned");
|
||||
|
||||
// --- Étape 7.5 : émettre VFX/SFX (best-effort) ---
|
||||
// CONTEXT failure-mode decision : damage déjà appliqué — si l'emit échoue, log + continue
|
||||
// vers le cooldown. Pas de propagation : le tick serveur ne doit pas crash sur un bug VFX.
|
||||
try {
|
||||
LOGGER.info(String.format("[ChainLightning][7.5/9] vfx emit START hits=%d", hits.size()));
|
||||
HytaleVfxEmitter.playChainEffects(hits, playerRef, commandBuffer);
|
||||
LOGGER.info("[ChainLightning][7.5/9] vfx emit DONE");
|
||||
} catch (Throwable t) {
|
||||
LOGGER.log(Level.WARNING, "[ChainLightning][7.5/9] vfx emit failed (damage already applied)", t);
|
||||
}
|
||||
|
||||
// --- Étape 8 : démarrer le cooldown APRÈS succès ---
|
||||
cooldown.deductCharge();
|
||||
LOGGER.info(String.format("[ChainLightning][8/9] cooldown deducted (next available in %.1fs)", COOLDOWN_TIME));
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.mythlane.chainlightning.sceptre;
|
||||
|
||||
import com.hypixel.hytale.component.CommandBuffer;
|
||||
import com.hypixel.hytale.component.ComponentAccessor;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.server.core.asset.type.entityeffect.config.EntityEffect;
|
||||
import com.hypixel.hytale.server.core.entity.effect.EffectControllerComponent;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import com.mythlane.chainlightning.chain.ChainHit;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Phase 4 — Adapter VFX/SFX qui applique un EntityEffect custom à chaque cible touchée.
|
||||
*
|
||||
* <p><b>Pivot 14:52 (POC EntityEffect bridge) :</b> les tentatives précédentes via
|
||||
* {@code ParticleUtil.spawnParticleEffect("Splash", ...)} ne rendaient pas les particles côté
|
||||
* client (asset sync plugin custom + budget client → 0/5 visible). On utilise maintenant le
|
||||
* pattern canonique Cleric-Rod : on déclare un {@code EntityEffect} JSON (Server/Entity/Effects/
|
||||
* Chain_Hit_Effect.json) qui contient des particles vanilla inline + EntityTopTint/BottomTint,
|
||||
* et on applique cet effect à chaque target via {@link EffectControllerComponent#addEffect}.
|
||||
*
|
||||
* <p>Le rendu passe par la réplication ECS (le ECS state du target propage l'effet aux clients
|
||||
* automatiquement), pas par un packet SpawnParticleSystem standalone. C'est le path éprouvé.
|
||||
*
|
||||
* <p><b>Fallback :</b> si le lookup de l'EntityEffect échoue (asset pas chargé), on log et skip.
|
||||
* Pas de propagation : damage déjà appliqué côté caller, l'emit failure ne doit pas crash.
|
||||
*
|
||||
* <p><b>Hop-index ignoré pour POC :</b> l'EntityEffect est uniforme sur les 5 cibles. La courbe
|
||||
* de volume (VFX-02) reste implémentée dans {@code VolumeCurve} mais n'est pas utilisée ici --
|
||||
* une variante future pourrait définir 5 EntityEffects {@code Chain_Hit_Effect_0..4} avec des
|
||||
* intensités décroissantes, ou patcher l'EntityEffect existant runtime (non supporté par l'API).
|
||||
*/
|
||||
public final class HytaleVfxEmitter {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(HytaleVfxEmitter.class.getName());
|
||||
|
||||
/** Asset id du EntityEffect appliqué à chaque target touchée par la chaîne. */
|
||||
private static final String EFFECT_ID = "Chain_Hit_Effect";
|
||||
|
||||
private HytaleVfxEmitter() {}
|
||||
|
||||
/**
|
||||
* Applique l'EntityEffect {@code Chain_Hit_Effect} à chaque hit de la chaîne.
|
||||
*
|
||||
* @param hits liste résolue par ChainResolver, ordre BFS
|
||||
* @param playerRef ref du caster (présent pour symétrie d'API future, non utilisé ici --
|
||||
* l'EntityEffect rend automatiquement à tous les viewers en range)
|
||||
* @param commandBuffer le CommandBuffer du tick courant (sert pour {@code getComponent} et
|
||||
* pour propager les changes d'état ECS au tick end)
|
||||
*/
|
||||
public static void playChainEffects(@Nonnull List<ChainHit> hits,
|
||||
@Nonnull Ref<EntityStore> playerRef,
|
||||
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
|
||||
if (hits.isEmpty()) return;
|
||||
|
||||
EntityEffect entityEffect = EntityEffect.getAssetMap().getAsset(EFFECT_ID);
|
||||
if (entityEffect == null) {
|
||||
LOGGER.warning(String.format(
|
||||
"[ChainLightning][Vfx] EntityEffect '%s' not registered -- VFX skipped (damage already applied)",
|
||||
EFFECT_ID));
|
||||
return;
|
||||
}
|
||||
|
||||
ComponentAccessor<EntityStore> accessor = commandBuffer;
|
||||
int applied = 0;
|
||||
int skipped = 0;
|
||||
for (int i = 0; i < hits.size(); i++) {
|
||||
Ref<EntityStore> targetRef = ((HytaleEntityAdapter) hits.get(i).target()).ref();
|
||||
if (targetRef == null || !targetRef.isValid()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
EffectControllerComponent ecc = accessor.getComponent(targetRef, EffectControllerComponent.getComponentType());
|
||||
if (ecc == null) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
boolean ok = ecc.addEffect(targetRef, entityEffect, accessor);
|
||||
if (ok) {
|
||||
applied++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.info(String.format(
|
||||
"[ChainLightning][Vfx] EntityEffect '%s' applied to %d/%d targets (skipped=%d, caster=%s)",
|
||||
EFFECT_ID, applied, hits.size(), skipped, playerRef));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user