feat(mod): P1 modules (AutoSprint, TotemCounter, EnchantGlint, ArmorColor, PlaceAnimation), CustomModels, KineticLyrics fix
- AutoSprint, TotemCounter, EnchantGlint (styles, texture repaint, scroll speed/scale mixin), ArmorColor (durability colouring, targets, pulse), PlaceAnimation (block slides in from the hand; chunk-mesh hiding incl. Sodium, moving-block render), aligned with evo behaviour - CustomModels: Chicken, Rabbit and Verity (Blockbench) models for chosen players, bundled assets, render-state carrier + submit redirect - KineticLyrics: fix track key wiped by reset() (lyrics never applied, lrclib hammered every tick); new LyricsFinder with ranked lrclib search, unsynced timing estimate, NetEase fallback and retry on busy services; tests for the pure parts - fix mode()/modeSetting() misuse for new modules; TODO updated
23
mod/TODO.md
|
|
@ -58,8 +58,27 @@
|
||||||
Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`.
|
Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`.
|
||||||
- [ ] Knife (Melee Skins): 33 скина, клипы draw/inspect/melee_1..3/run, first/third person, звуки, шейдер holy (отчёт раздел 3.7,
|
- [ ] Knife (Melee Skins): 33 скина, клипы draw/inspect/melee_1..3/run, first/third person, звуки, шейдер holy (отчёт раздел 3.7,
|
||||||
порядок работ из 8 шагов). Ресурсы: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/`.
|
порядок работ из 8 шагов). Ресурсы: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/`.
|
||||||
- [ ] Быстрые модули P1: AutoSprint (есть `SprintControlEvent`), TotemCounter, EnchantGlint, PlaceAnimation, GodRays
|
- [x] Быстрые модули P1 (2026-10-02, сверены с `ref/evo/mod/jar-src-named/evovis/module/{misc,render}`, тесты зелёные, вживую не смотрели):
|
||||||
(референс `shaders/core/godrays`), ArmorColor.
|
AutoSprint (`player/`, как у evo держит спринт; вход через `SprintControlEvent`, решает `SprintController.canStartSprinting`),
|
||||||
|
TotemCounter (`visuals/totems/`, свои тотемы справа от хотбара; счёт попов чужих у нас уже в `util/pvp/opponents/TotemPopSnapshot`,
|
||||||
|
без отдельной клавиши сброса как у evo), EnchantGlint (`visuals/items/glint/`: стили Rainbow/Solid/Gradient/Hidden, сила, скорость,
|
||||||
|
масштаб, «предметы/броня»; у evo свои RenderType и 11 стилей, у нас перерисовка ванильных текстур блеска `GlintTexture` +
|
||||||
|
`GlintTextureMixin`; нет Pulse/Fire/Galaxy/Hologram/Glitch/Sparkle/Aurora, Coverage), ArmorColor (`visuals/items/armor/`: цвет по
|
||||||
|
прочности Gradient/Steps, цели Players/Friends/Self/Mobs, Only Below, Low Pulse; миксин в `EquipmentLayerRendererMixin`, флаг цели
|
||||||
|
в `LivingEntityRenderState`), PlaceAnimation (`visuals/placeanimation/`: блок вылетает из руки, наклон/масштаб, как у evo: скрытие из
|
||||||
|
меша `SectionCompilerPlaceMixin` + Sodium `SodiumPlace*Mixin`, отрисовка движущимся блоком `LevelRendererPlaceMixin`, блок-сущности
|
||||||
|
`BlockEntityPlaceMixin`; формулы кривых и сторона руки восстановлены по идее evo, направление наклона и сдвига по вертикали подобраны
|
||||||
|
на глаз).
|
||||||
|
Проверить в игре: миксины брони/блеска/PlaceAnimation (при ошибке прислать лог), вид наклона и сдвига блока, перекраску блеска и её
|
||||||
|
возврат при выключении, Sodium (скрытый блок не оставляет дыр у соседей), TotemCounter при GUI Scale 2/3/4.
|
||||||
|
- [x] KineticLyrics починен (2026-10-02): `LyricPlayback.loadTrack` вызывал `reset()` после записи ключа трека, ключ стирался, загруженный
|
||||||
|
текст отбрасывался, а запрос уходил заново каждый тик (lrclib отвечал 503). Теперь `lrc/source/LyricsFinder`: lrclib (точный запрос с
|
||||||
|
длительностью, ранжированные поиски `lrc/match/LyricMatch`, обычный текст получает оценочные тайминги `PlainLyricTimer`), запасной
|
||||||
|
NetEase, повтор при занятом сервисе (до 6 раз). Нет из evo: YouTube Music и распознавание Whisper. Проверить вживую на Spotify.
|
||||||
|
- [x] CustomModels (2026-10-02, из `CustomModels-2026-10-02-120830.zip`): `visuals/models/` + `mesh/` (Chicken, Rabbit, Verity из .bbmodel),
|
||||||
|
ассеты в `assets/lovisual/models/`, миксины `render/models/`. Только у вас на экране, цели: себя/друзья/все. Нужна проверка вживую
|
||||||
|
(поза рук, рука от первого лица, Verity под Iris/Sodium). Исходный проект зарезан до этого модуля, остальные его части не нужны.
|
||||||
|
- [ ] GodRays (референс `shaders/core/godrays`).
|
||||||
- [ ] Дальше P1-P2 — перенос модулей из evo поштучно (референс: `docs/EVO_REPORT.md` §1.2;
|
- [ ] Дальше P1-P2 — перенос модулей из evo поштучно (референс: `docs/EVO_REPORT.md` §1.2;
|
||||||
лицензия evo проприетарная — их ассеты/код в репо не кладём, только идеи):
|
лицензия evo проприетарная — их ассеты/код в репо не кладём, только идеи):
|
||||||
- [ ] **Blizzard** (render, evo ~1290 LOC, M/L) — высотная метель: ветровые снежинки с горизонтальным
|
- [ ] **Blizzard** (render, evo ~1290 LOC, M/L) — высотная метель: ветровые снежинки с горизонтальным
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.player;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||||
|
import dev.loki.lovisual.events.impl.input.SprintControlEvent;
|
||||||
|
import dev.loki.lovisual.events.meta.EventHandler;
|
||||||
|
import dev.loki.lovisual.features.module.core.Module;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
import dev.loki.lovisual.util.combat.rotation.SprintController;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.player.LocalPlayer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the sprint key for you while you move. Only the input is pressed, so vanilla
|
||||||
|
* rules (hunger, sneaking, blindness) and the shared {@link SprintController} still decide.
|
||||||
|
*/
|
||||||
|
@ModuleInfo(
|
||||||
|
id = "autosprint",
|
||||||
|
displayName = "Auto Sprint",
|
||||||
|
aliases = {"autosprint", "sprint"},
|
||||||
|
category = ModuleCategory.PLAYER
|
||||||
|
)
|
||||||
|
public class AutoSprint extends Module {
|
||||||
|
private final BooleanValue allDirections = bool("autoSprintAllDirections", "all_directions", false);
|
||||||
|
private final BooleanValue whileUsing = bool("autoSprintWhileUsing", "while_using", false);
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
private void onSprintControl(SprintControlEvent event) {
|
||||||
|
if (event.getSource() != SprintControlEvent.Source.INPUT || event.shouldSprint()) return;
|
||||||
|
LocalPlayer player = Minecraft.getInstance().player;
|
||||||
|
if (player == null) return;
|
||||||
|
if (!wantsSprint(event.getForward(), event.getSideways(), allDirections.get(),
|
||||||
|
player.isShiftKeyDown(), player.isUsingItem(), whileUsing.get())) return;
|
||||||
|
if (SprintController.INSTANCE.canStartSprinting(player)) event.setSprint(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure decision: does the held-sprint input apply for this movement? */
|
||||||
|
static boolean wantsSprint(float forward, float sideways, boolean allDirections,
|
||||||
|
boolean sneaking, boolean usingItem, boolean whileUsing) {
|
||||||
|
if (sneaking) return false;
|
||||||
|
if (usingItem && !whileUsing) return false;
|
||||||
|
return allDirections ? forward != 0.0f || sideways != 0.0f : forward > 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.items.armor;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.config.values.color.RGBColorValue;
|
||||||
|
import dev.loki.lovisual.config.values.mode.ModeValue;
|
||||||
|
import dev.loki.lovisual.config.values.primitive.BooleanMapValue;
|
||||||
|
import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||||
|
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||||
|
import dev.loki.lovisual.features.module.core.Module;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
import dev.loki.lovisual.features.relations.category.CategoryService;
|
||||||
|
import net.minecraft.client.player.LocalPlayer;
|
||||||
|
import net.minecraft.util.Util;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colours worn armor by its remaining durability (full, warning, broken). Which entities qualify is
|
||||||
|
* decided once per frame at render-state extraction; the tint itself is applied in EquipmentLayerRendererMixin.
|
||||||
|
*/
|
||||||
|
@ModuleInfo(
|
||||||
|
id = "armorcolor",
|
||||||
|
displayName = "Armor Color",
|
||||||
|
aliases = {"armorcolor"},
|
||||||
|
category = ModuleCategory.VISUALS
|
||||||
|
)
|
||||||
|
public class ArmorColor extends Module {
|
||||||
|
private static final String PLAYERS = "players";
|
||||||
|
private static final String FRIENDS = "friends";
|
||||||
|
private static final String SELF = "self";
|
||||||
|
private static final String MOBS = "mobs";
|
||||||
|
|
||||||
|
private final BooleanMapValue targets = group("armorColorTargets", "targets", defaultTargets());
|
||||||
|
private final ModeValue colorMode =
|
||||||
|
modeSetting("armorColorMode", "color_mode", ArmorTint.GRADIENT, ArmorTint.GRADIENT, ArmorTint.STEPS);
|
||||||
|
private final RGBColorValue fullColor = colorNoAlpha("armorColorFull", "full_color", "#4CD964");
|
||||||
|
private final RGBColorValue warnColor = colorNoAlpha("armorColorWarn", "warn_color", "#FFC107");
|
||||||
|
private final RGBColorValue brokenColor = colorNoAlpha("armorColorBroken", "broken_color", "#FF3B30");
|
||||||
|
private final NumberValue<Float> strength = num("armorColorStrength", "strength", 1.0f, 0.1f, 1.0f);
|
||||||
|
private final NumberValue<Float> onlyBelow = num("armorColorOnlyBelow", "only_below", 100.0f, 5.0f, 100.0f);
|
||||||
|
private final BooleanValue pulse = bool("armorColorPulse", "pulse", true);
|
||||||
|
|
||||||
|
private static Map<String, Boolean> defaultTargets() {
|
||||||
|
Map<String, Boolean> map = new LinkedHashMap<>();
|
||||||
|
map.put(PLAYERS, true);
|
||||||
|
map.put(FRIENDS, true);
|
||||||
|
map.put(SELF, true);
|
||||||
|
map.put(MOBS, false);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this entity's armor should be coloured; evaluated at render-state extraction. */
|
||||||
|
public boolean covers(LivingEntity entity) {
|
||||||
|
if (!isEnabled()) return false;
|
||||||
|
if (entity instanceof LocalPlayer) return targets.get(SELF);
|
||||||
|
if (entity instanceof Player player) {
|
||||||
|
return targets.get(CategoryService.isFriend(player) ? FRIENDS : PLAYERS);
|
||||||
|
}
|
||||||
|
return targets.get(MOBS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the tint for one armor piece, {@link ArmorTint#NO_TINT} to keep vanilla colour. */
|
||||||
|
public int tintFor(ItemStack stack, Object renderState) {
|
||||||
|
if (!isEnabled() || stack == null || stack.isEmpty() || !stack.isDamageableItem()) return ArmorTint.NO_TINT;
|
||||||
|
if (!(renderState instanceof ArmorColorState state) || !state.lovisual$isArmorColored()) return ArmorTint.NO_TINT;
|
||||||
|
|
||||||
|
float fraction = ArmorTint.fraction(stack.getDamageValue(), stack.getMaxDamage());
|
||||||
|
if (fraction < 0.0f || fraction * 100.0f > onlyBelow.get()) return ArmorTint.NO_TINT;
|
||||||
|
|
||||||
|
int rgb = ArmorTint.ramp(fraction, colorMode.get(),
|
||||||
|
fullColor.getArgb() & 0xFFFFFF, warnColor.getArgb() & 0xFFFFFF, brokenColor.getArgb() & 0xFFFFFF);
|
||||||
|
if (pulse.get() && fraction <= ArmorTint.CRITICAL_FRACTION) rgb = ArmorTint.pulse(rgb, Util.getMillis());
|
||||||
|
return ArmorTint.tint(rgb, strength.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.items.armor;
|
||||||
|
|
||||||
|
/** Implemented by LivingEntityRenderState: whether ArmorColor applies to this entity (decided at extraction). */
|
||||||
|
public interface ArmorColorState {
|
||||||
|
boolean lovisual$isArmorColored();
|
||||||
|
|
||||||
|
void lovisual$setArmorColored(boolean colored);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.items.armor;
|
||||||
|
|
||||||
|
import net.minecraft.util.Mth;
|
||||||
|
|
||||||
|
/** Pure maths for durability-based armor colouring (RGB/ARGB ints). */
|
||||||
|
public final class ArmorTint {
|
||||||
|
public static final String GRADIENT = "Gradient";
|
||||||
|
public static final String STEPS = "Steps";
|
||||||
|
/** Returned when the armor must keep its own colour. */
|
||||||
|
public static final int NO_TINT = 0;
|
||||||
|
|
||||||
|
static final float WARN_FRACTION = 0.5f;
|
||||||
|
static final float CRITICAL_FRACTION = 0.25f;
|
||||||
|
private static final double PULSE_PERIOD_MS = 190.0;
|
||||||
|
private static final float PULSE_DEPTH = 0.4f;
|
||||||
|
private static final int WHITE = 0xFFFFFF;
|
||||||
|
|
||||||
|
private ArmorTint() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remaining durability 0..1, or -1 for an item without durability. */
|
||||||
|
public static float fraction(int damage, int maxDamage) {
|
||||||
|
if (maxDamage <= 0) return -1.0f;
|
||||||
|
return Mth.clamp(1.0f - (float) damage / maxDamage, 0.0f, 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full above half, warning down to a quarter, broken below; Gradient blends, Steps snaps. */
|
||||||
|
public static int ramp(float fraction, String mode, int full, int warn, int broken) {
|
||||||
|
float f = Mth.clamp(fraction, 0.0f, 1.0f);
|
||||||
|
if (STEPS.equals(mode)) {
|
||||||
|
if (f > WARN_FRACTION) return full;
|
||||||
|
return f > CRITICAL_FRACTION ? warn : broken;
|
||||||
|
}
|
||||||
|
return f > WARN_FRACTION
|
||||||
|
? lerpRgb(warn, full, (f - WARN_FRACTION) / WARN_FRACTION)
|
||||||
|
: lerpRgb(broken, warn, f / WARN_FRACTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darkens the colour rhythmically, used once the armor is nearly broken. */
|
||||||
|
public static int pulse(int rgb, long millis) {
|
||||||
|
float wave = (float) ((Math.sin(millis / PULSE_PERIOD_MS) + 1.0) * 0.5);
|
||||||
|
float k = 1.0f - PULSE_DEPTH * wave;
|
||||||
|
return Math.round((rgb >> 16 & 0xFF) * k) << 16 | Math.round((rgb >> 8 & 0xFF) * k) << 8 | Math.round((rgb & 0xFF) * k);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final armor tint: white blended towards {@code rgb} by {@code strength}, opaque. */
|
||||||
|
public static int tint(int rgb, float strength) {
|
||||||
|
return 0xFF000000 | lerpRgb(WHITE, rgb, strength);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multiplies the layer's own colour (dye) by the tint; {@link #NO_TINT} leaves it alone. */
|
||||||
|
public static int apply(int layerColor, int tint) {
|
||||||
|
if (tint == NO_TINT) return layerColor;
|
||||||
|
int r = (layerColor >> 16 & 0xFF) * (tint >> 16 & 0xFF) / 255;
|
||||||
|
int g = (layerColor >> 8 & 0xFF) * (tint >> 8 & 0xFF) / 255;
|
||||||
|
int b = (layerColor & 0xFF) * (tint & 0xFF) / 255;
|
||||||
|
return (layerColor & 0xFF000000) | r << 16 | g << 8 | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int lerpRgb(int from, int to, float t) {
|
||||||
|
float k = Mth.clamp(t, 0.0f, 1.0f);
|
||||||
|
int r = Math.round(Mth.lerp(k, from >> 16 & 0xFF, to >> 16 & 0xFF));
|
||||||
|
int g = Math.round(Mth.lerp(k, from >> 8 & 0xFF, to >> 8 & 0xFF));
|
||||||
|
int b = Math.round(Mth.lerp(k, from & 0xFF, to & 0xFF));
|
||||||
|
return r << 16 | g << 8 | b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.items.glint;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.config.values.color.RGBColorValue;
|
||||||
|
import dev.loki.lovisual.config.values.mode.ModeValue;
|
||||||
|
import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||||
|
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||||
|
import dev.loki.lovisual.features.module.core.Module;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enchantment glint on items and armor: style (Rainbow, Solid, Gradient, Hidden), colours, strength, scroll
|
||||||
|
* speed and stripe scale. Speed and scale go through GlintTextureMixin, the look by repainting the textures.
|
||||||
|
*/
|
||||||
|
@ModuleInfo(
|
||||||
|
id = "enchantglint",
|
||||||
|
displayName = "Enchant Glint",
|
||||||
|
aliases = {"enchantglint", "glint"},
|
||||||
|
category = ModuleCategory.VISUALS
|
||||||
|
)
|
||||||
|
public class EnchantGlint extends Module {
|
||||||
|
private final ModeValue style = modeSetting("enchantGlintStyle", "style", GlintTint.RAINBOW,
|
||||||
|
GlintTint.RAINBOW, GlintTint.SOLID, GlintTint.GRADIENT, GlintTint.HIDDEN);
|
||||||
|
private final RGBColorValue color =
|
||||||
|
visibleWhen(colorNoAlpha("enchantGlintColor", "color", "#FF4FD8"), this::usesPrimary);
|
||||||
|
private final RGBColorValue secondColor =
|
||||||
|
visibleWhen(colorNoAlpha("enchantGlintColor2", "color2", "#4F9BFF"), this::usesSecondary);
|
||||||
|
private final NumberValue<Float> strength = num("enchantGlintStrength", "strength", 1.5f, 0.2f, 3.0f);
|
||||||
|
private final NumberValue<Float> speed = num("enchantGlintSpeed", "speed", 1.0f, 0.1f, 4.0f);
|
||||||
|
private final NumberValue<Float> scale = num("enchantGlintScale", "scale", 1.0f, 0.2f, 4.0f);
|
||||||
|
private final BooleanValue items = bool("enchantGlintItems", "items", true);
|
||||||
|
private final BooleanValue armor = bool("enchantGlintArmor", "armor", true);
|
||||||
|
|
||||||
|
private final GlintTexture texture = new GlintTexture();
|
||||||
|
|
||||||
|
private boolean usesPrimary() {
|
||||||
|
return GlintTint.SOLID.equals(style.get()) || GlintTint.GRADIENT.equals(style.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean usesSecondary() {
|
||||||
|
return GlintTint.GRADIENT.equals(style.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTick() {
|
||||||
|
GlintTexture.Paint paint = new GlintTexture.Paint(style.get(),
|
||||||
|
color.getArgb() & 0xFFFFFF, secondColor.getArgb() & 0xFFFFFF, strength.get());
|
||||||
|
texture.sync(items.get() ? paint : null, armor.get() ? paint : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisable() {
|
||||||
|
texture.sync(null, null);
|
||||||
|
texture.forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
public float speedFactor() {
|
||||||
|
return isEnabled() ? speed.get() : 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float scaleFactor() {
|
||||||
|
return isEnabled() ? scale.get() : 1.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.items.glint;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.platform.NativeImage;
|
||||||
|
import com.mojang.blaze3d.systems.RenderSystem;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.renderer.feature.ItemFeatureRenderer;
|
||||||
|
import net.minecraft.client.renderer.texture.AbstractTexture;
|
||||||
|
import net.minecraft.client.renderer.texture.ReloadableTexture;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.server.packs.resources.Resource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.IdentityHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites the two vanilla glint textures in place with a restyled copy of the pack's original image.
|
||||||
|
* Remembers which texture object it painted so a resource reload (which re-uploads the originals) is detected.
|
||||||
|
*/
|
||||||
|
final class GlintTexture {
|
||||||
|
/** What to paint onto a glint texture; a null paint means "leave it vanilla". */
|
||||||
|
record Paint(String style, int primary, int secondary, float strength) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Map<AbstractTexture, Paint> painted = new IdentityHashMap<>();
|
||||||
|
|
||||||
|
/** Applies {@code items} to the item/entity glint and {@code armor} to the armor glint. */
|
||||||
|
void sync(Paint items, Paint armor) {
|
||||||
|
sync(ItemFeatureRenderer.ENCHANTED_GLINT_ITEM, items);
|
||||||
|
sync(ItemFeatureRenderer.ENCHANTED_GLINT_ARMOR, armor);
|
||||||
|
}
|
||||||
|
|
||||||
|
void forget() {
|
||||||
|
painted.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sync(Identifier id, Paint paint) {
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
AbstractTexture texture = mc.getTextureManager().getTexture(id);
|
||||||
|
if (!(texture instanceof ReloadableTexture reloadable) || texture.getTexture() == null) return;
|
||||||
|
if (java.util.Objects.equals(painted.get(texture), paint)) return;
|
||||||
|
if (upload(mc, reloadable, paint)) {
|
||||||
|
if (paint == null) painted.remove(texture);
|
||||||
|
else painted.put(texture, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean upload(Minecraft mc, ReloadableTexture texture, Paint paint) {
|
||||||
|
Optional<Resource> resource = mc.getResourceManager().getResource(texture.resourceId());
|
||||||
|
if (resource.isEmpty()) return false;
|
||||||
|
try (InputStream in = resource.get().open(); NativeImage image = NativeImage.read(in)) {
|
||||||
|
if (paint != null) repaint(image, paint);
|
||||||
|
RenderSystem.getDevice().createCommandEncoder().writeToTexture(texture.getTexture(), image);
|
||||||
|
return true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void repaint(NativeImage image, Paint paint) {
|
||||||
|
int width = image.getWidth();
|
||||||
|
int height = image.getHeight();
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
image.setPixel(x, y, GlintTint.pixel(image.getPixel(x, y), (x + 0.5f) / width, (y + 0.5f) / height,
|
||||||
|
paint.style(), paint.primary(), paint.secondary(), paint.strength()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.items.glint;
|
||||||
|
|
||||||
|
import net.minecraft.util.Mth;
|
||||||
|
|
||||||
|
/** Pure pixel maths for repainting the enchantment glint texture (ARGB ints, u and v in 0..1). */
|
||||||
|
public final class GlintTint {
|
||||||
|
public static final String SOLID = "Solid";
|
||||||
|
public static final String GRADIENT = "Gradient";
|
||||||
|
public static final String RAINBOW = "Rainbow";
|
||||||
|
public static final String HIDDEN = "Hidden";
|
||||||
|
|
||||||
|
private static final float RAINBOW_SKEW = 0.25f;
|
||||||
|
private static final float GRADIENT_SKEW = 0.25f;
|
||||||
|
|
||||||
|
private GlintTint() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colours the glint streaks in a style; the pixel's strongest channel keeps the streak shape. */
|
||||||
|
public static int pixel(int argb, float u, float v, String style, int primary, int secondary, float strength) {
|
||||||
|
if (HIDDEN.equals(style)) return argb & 0x00FFFFFF;
|
||||||
|
float level = Math.min(1.0f, (Math.max(argb >> 16 & 0xFF, Math.max(argb >> 8 & 0xFF, argb & 0xFF)) / 255.0f) * strength);
|
||||||
|
int color = switch (style) {
|
||||||
|
case RAINBOW -> Mth.hsvToRgb(frac(u * (1.0f - RAINBOW_SKEW) + v * RAINBOW_SKEW), 1.0f, 1.0f);
|
||||||
|
case GRADIENT -> lerp(primary, secondary, frac(u * (1.0f - GRADIENT_SKEW) + v * GRADIENT_SKEW));
|
||||||
|
default -> primary;
|
||||||
|
};
|
||||||
|
int r = Math.round((color >> 16 & 0xFF) * level);
|
||||||
|
int g = Math.round((color >> 8 & 0xFF) * level);
|
||||||
|
int b = Math.round((color & 0xFF) * level);
|
||||||
|
return (argb & 0xFF000000) | r << 16 | g << 8 | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int lerp(int from, int to, float t) {
|
||||||
|
int r = Math.round(Mth.lerp(t, from >> 16 & 0xFF, to >> 16 & 0xFF));
|
||||||
|
int g = Math.round(Mth.lerp(t, from >> 8 & 0xFF, to >> 8 & 0xFF));
|
||||||
|
int b = Math.round(Mth.lerp(t, from & 0xFF, to & 0xFF));
|
||||||
|
return r << 16 | g << 8 | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float frac(float x) {
|
||||||
|
return x - (float) Math.floor(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,9 @@ package dev.loki.lovisual.features.module.modules.visuals.lyrics;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LrcLibClient;
|
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLineSplitter;
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLineSplitter;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source.LyricsFinder;
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.lyrics.mode.LyricModes;
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.mode.LyricModes;
|
||||||
import dev.loki.lovisual.util.media.session.MediaSessionService;
|
import dev.loki.lovisual.util.media.session.MediaSessionService;
|
||||||
import net.minecraft.client.Minecraft;
|
import net.minecraft.client.Minecraft;
|
||||||
|
|
@ -24,6 +24,9 @@ final class LyricPlayback {
|
||||||
/** Регрессия больше этого — пауза/откат: строки после индекса уже не догнать, нужен resync. */
|
/** Регрессия больше этого — пауза/откат: строки после индекса уже не догнать, нужен resync. */
|
||||||
private static final long RESYNC_BACKWARD_MS = 750L;
|
private static final long RESYNC_BACKWARD_MS = 750L;
|
||||||
|
|
||||||
|
private static final int MAX_RETRIES = 6;
|
||||||
|
private static final long RETRY_STEP_MS = 8_000L;
|
||||||
|
|
||||||
private final KineticLyrics owner;
|
private final KineticLyrics owner;
|
||||||
private final LyricStage stage;
|
private final LyricStage stage;
|
||||||
|
|
||||||
|
|
@ -36,6 +39,9 @@ final class LyricPlayback {
|
||||||
private String currentTrackArtist = "";
|
private String currentTrackArtist = "";
|
||||||
private long lastAudioTimeMs = -1L;
|
private long lastAudioTimeMs = -1L;
|
||||||
private boolean isPlaying = false;
|
private boolean isPlaying = false;
|
||||||
|
private boolean fetching = false;
|
||||||
|
private int retries = 0;
|
||||||
|
private long retryAtMs = 0L;
|
||||||
|
|
||||||
LyricPlayback(KineticLyrics owner, LyricStage stage) {
|
LyricPlayback(KineticLyrics owner, LyricStage stage) {
|
||||||
this.owner = owner;
|
this.owner = owner;
|
||||||
|
|
@ -69,6 +75,9 @@ final class LyricPlayback {
|
||||||
currentLyricIndex = 0;
|
currentLyricIndex = 0;
|
||||||
lastAudioTimeMs = -1L;
|
lastAudioTimeMs = -1L;
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
|
fetching = false;
|
||||||
|
retries = 0;
|
||||||
|
retryAtMs = 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Poll the media session once: switch tracks, resync on seek/pause and spawn due lines. */
|
/** Poll the media session once: switch tracks, resync on seek/pause and spawn due lines. */
|
||||||
|
|
@ -87,10 +96,15 @@ final class LyricPlayback {
|
||||||
String key = (title + " - " + artist).toLowerCase();
|
String key = (title + " - " + artist).toLowerCase();
|
||||||
|
|
||||||
if (!key.equalsIgnoreCase(lastTrackKey)) {
|
if (!key.equalsIgnoreCase(lastTrackKey)) {
|
||||||
loadTrack(title, artist, key);
|
loadTrack(title, artist, key, snapshot.durationSeconds());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lyricsQueue.isEmpty() && !fetching && retryAtMs > 0L && System.currentTimeMillis() >= retryAtMs) {
|
||||||
|
retryAtMs = 0L;
|
||||||
|
requestLyrics(title, artist, key, snapshot.durationSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
if (!isPlaying && !lyricsQueue.isEmpty()) {
|
if (!isPlaying && !lyricsQueue.isEmpty()) {
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
}
|
}
|
||||||
|
|
@ -118,21 +132,30 @@ final class LyricPlayback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void loadTrack(String title, String artist, String key) {
|
private void loadTrack(String title, String artist, String key, long durationSeconds) {
|
||||||
|
// reset() forgets the track key, so it has to run before the new track is recorded
|
||||||
|
reset();
|
||||||
this.currentTrackTitle = title;
|
this.currentTrackTitle = title;
|
||||||
this.currentTrackArtist = artist;
|
this.currentTrackArtist = artist;
|
||||||
this.lastTrackKey = key;
|
this.lastTrackKey = key;
|
||||||
|
requestLyrics(title, artist, key, durationSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
reset();
|
private void requestLyrics(String title, String artist, String key, long durationSeconds) {
|
||||||
|
fetching = true;
|
||||||
LrcLibClient.fetchLyricsAsync(title, artist, owner.debugMode.get()).thenAccept(lines ->
|
LyricsFinder.findAsync(title, artist, durationSeconds, owner.debugMode.get()).thenAccept(result ->
|
||||||
Minecraft.getInstance().execute(() -> {
|
Minecraft.getInstance().execute(() -> {
|
||||||
if (!this.lastTrackKey.equalsIgnoreCase(key)) return;
|
if (!this.lastTrackKey.equalsIgnoreCase(key)) return;
|
||||||
|
fetching = false;
|
||||||
|
|
||||||
|
if (result.lines().isEmpty()) {
|
||||||
|
scheduleRetry(result.retryLater());
|
||||||
|
return;
|
||||||
|
}
|
||||||
stage.clear();
|
stage.clear();
|
||||||
rawLyrics.clear();
|
rawLyrics.clear();
|
||||||
lyricsQueue.clear();
|
lyricsQueue.clear();
|
||||||
rawLyrics.addAll(lines);
|
rawLyrics.addAll(result.lines());
|
||||||
rebuildLyricsQueue();
|
rebuildLyricsQueue();
|
||||||
currentLyricIndex = 0;
|
currentLyricIndex = 0;
|
||||||
isPlaying = !lyricsQueue.isEmpty();
|
isPlaying = !lyricsQueue.isEmpty();
|
||||||
|
|
@ -140,6 +163,13 @@ final class LyricPlayback {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A busy service is asked again after a growing pause, a few times; a song nobody has is left alone. */
|
||||||
|
private void scheduleRetry(boolean retryLater) {
|
||||||
|
if (!retryLater || retries >= MAX_RETRIES) return;
|
||||||
|
retries++;
|
||||||
|
retryAtMs = System.currentTimeMillis() + RETRY_STEP_MS * retries;
|
||||||
|
}
|
||||||
|
|
||||||
private void rebuildLyricsQueue() {
|
private void rebuildLyricsQueue() {
|
||||||
lyricsQueue.clear();
|
lyricsQueue.clear();
|
||||||
if (owner.splitMode.get() == LyricModes.SplitMode.SMART_SPLIT) {
|
if (owner.splitMode.get() == LyricModes.SplitMode.SMART_SPLIT) {
|
||||||
|
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc;
|
|
||||||
|
|
||||||
import com.google.gson.JsonArray;
|
|
||||||
import com.google.gson.JsonElement;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.google.gson.JsonParser;
|
|
||||||
import dev.loki.lovisual.util.logging.DebugLog;
|
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.lyrics.KineticLyrics;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LrcParser;
|
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches synced lyrics from lrclib.net (no API key required).
|
|
||||||
*/
|
|
||||||
public final class LrcLibClient {
|
|
||||||
private static final String API_GET = "https://lrclib.net/api/get";
|
|
||||||
private static final String API_SEARCH = "https://lrclib.net/api/search";
|
|
||||||
|
|
||||||
private static final String USER_AGENT = "LoVisual/1.0";
|
|
||||||
|
|
||||||
private static final Map<String, List<LyricLine>> LYRICS_CACHE = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
|
|
||||||
.connectTimeout(Duration.ofSeconds(12))
|
|
||||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
private LrcLibClient() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static CompletableFuture<List<LyricLine>> fetchLyricsAsync(String trackName, String artistName, boolean debugMode) {
|
|
||||||
if (trackName == null || trackName.isBlank()) {
|
|
||||||
return CompletableFuture.completedFuture(Collections.emptyList());
|
|
||||||
}
|
|
||||||
|
|
||||||
String cacheKey = (trackName.trim() + " - " + (artistName != null ? artistName.trim() : "")).toLowerCase();
|
|
||||||
List<LyricLine> cached = LYRICS_CACHE.get(cacheKey);
|
|
||||||
if (cached != null) {
|
|
||||||
if (debugMode) {
|
|
||||||
DebugLog.info("[KineticLyrics] Cache hit: %s (%d lines)", cacheKey, cached.size());
|
|
||||||
}
|
|
||||||
return CompletableFuture.completedFuture(cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
|
||||||
List<LyricLine> lines = tryFetchGet(trackName, artistName, debugMode);
|
|
||||||
if (!lines.isEmpty()) {
|
|
||||||
LYRICS_CACHE.put(cacheKey, lines);
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
lines = tryFetchSearch(trackName, artistName, debugMode);
|
|
||||||
if (!lines.isEmpty()) {
|
|
||||||
LYRICS_CACHE.put(cacheKey, lines);
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Collections.<LyricLine>emptyList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<LyricLine> tryFetchGet(String trackName, String artistName, boolean debugMode) {
|
|
||||||
String cleanTrack = cleanTrackName(trackName);
|
|
||||||
String cleanArtist = artistName != null ? cleanTrackName(artistName) : "";
|
|
||||||
|
|
||||||
String query = String.format("?track_name=%s&artist_name=%s",
|
|
||||||
URLEncoder.encode(cleanTrack, StandardCharsets.UTF_8),
|
|
||||||
URLEncoder.encode(cleanArtist, StandardCharsets.UTF_8));
|
|
||||||
|
|
||||||
return executeRequest(API_GET + query, debugMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<LyricLine> tryFetchSearch(String trackName, String artistName, boolean debugMode) {
|
|
||||||
String cleanTrack = cleanTrackName(trackName);
|
|
||||||
String cleanArtist = artistName != null ? cleanTrackName(artistName) : "";
|
|
||||||
String searchQuery = (cleanTrack + " " + cleanArtist).trim();
|
|
||||||
|
|
||||||
String query = "?q=" + URLEncoder.encode(searchQuery, StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
try {
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(API_SEARCH + query))
|
|
||||||
.header("User-Agent", USER_AGENT)
|
|
||||||
.timeout(Duration.ofSeconds(14))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
if (response.statusCode() == 200) {
|
|
||||||
JsonArray array = JsonParser.parseString(response.body()).getAsJsonArray();
|
|
||||||
for (JsonElement elem : array) {
|
|
||||||
if (elem.isJsonObject()) {
|
|
||||||
JsonObject obj = elem.getAsJsonObject();
|
|
||||||
if (obj.has("syncedLyrics") && !obj.get("syncedLyrics").isJsonNull()) {
|
|
||||||
String syncedLrc = obj.get("syncedLyrics").getAsString();
|
|
||||||
List<LyricLine> parsed = LrcParser.parse(syncedLrc);
|
|
||||||
if (!parsed.isEmpty()) {
|
|
||||||
if (debugMode) {
|
|
||||||
DebugLog.info("[KineticLyrics] Found lyrics via search fallback");
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (response.statusCode() == 404) {
|
|
||||||
if (debugMode) {
|
|
||||||
DebugLog.info("[KineticLyrics] Search: track not found (404)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
if (debugMode) {
|
|
||||||
DebugLog.error("[KineticLyrics] Search fallback failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Collections.emptyList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<LyricLine> executeRequest(String url, boolean debugMode) {
|
|
||||||
for (int attempt = 1; attempt <= 2; attempt++) {
|
|
||||||
try {
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(url))
|
|
||||||
.header("User-Agent", USER_AGENT)
|
|
||||||
.timeout(Duration.ofSeconds(14))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
|
|
||||||
if (response.statusCode() == 200) {
|
|
||||||
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
|
|
||||||
if (json.has("syncedLyrics") && !json.get("syncedLyrics").isJsonNull()) {
|
|
||||||
String syncedLrc = json.get("syncedLyrics").getAsString();
|
|
||||||
return LrcParser.parse(syncedLrc);
|
|
||||||
}
|
|
||||||
if (debugMode) {
|
|
||||||
DebugLog.info("[KineticLyrics] No syncedLyrics in response");
|
|
||||||
}
|
|
||||||
return Collections.emptyList();
|
|
||||||
} else if (response.statusCode() == 404) {
|
|
||||||
if (debugMode) {
|
|
||||||
DebugLog.info("[KineticLyrics] Track not found (404)");
|
|
||||||
}
|
|
||||||
return Collections.emptyList();
|
|
||||||
} else {
|
|
||||||
if (debugMode) {
|
|
||||||
DebugLog.info("[KineticLyrics] Attempt %d returned %d", attempt, response.statusCode());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
if (debugMode) {
|
|
||||||
DebugLog.error("[KineticLyrics] Attempt " + attempt + " failed", e);
|
|
||||||
}
|
|
||||||
if (attempt < 2) {
|
|
||||||
try {
|
|
||||||
Thread.sleep(400);
|
|
||||||
} catch (InterruptedException ignored) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Collections.emptyList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String cleanTrackName(String trackName) {
|
|
||||||
if (trackName == null) return "";
|
|
||||||
return trackName
|
|
||||||
.replaceAll("\\(.*?\\)", "")
|
|
||||||
.replaceAll("\\[.*?\\]", "")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Pure scoring of a search hit against the track that is playing (0 = unrelated, 1 = certain). */
|
||||||
|
public final class LyricMatch {
|
||||||
|
/** Lowest score still trusted as "the same song". */
|
||||||
|
public static final double ACCEPT = 0.6;
|
||||||
|
/** A hit this good ends the search early. */
|
||||||
|
public static final double CERTAIN = 0.92;
|
||||||
|
|
||||||
|
private static final double TITLE_FLOOR = 0.5;
|
||||||
|
private static final long CLOSE_SECONDS = 2;
|
||||||
|
private static final long FAR_SECONDS = 15;
|
||||||
|
|
||||||
|
private LyricMatch() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double score(String hitTitle, String hitArtist, long hitDurationSeconds, TrackQuery query) {
|
||||||
|
double title = similarity(hitTitle, query.cleanTitle());
|
||||||
|
if (title < TITLE_FLOOR) return 0.0;
|
||||||
|
double artist = artistScore(hitArtist, query);
|
||||||
|
double duration = durationScore(hitDurationSeconds, query.durationSeconds());
|
||||||
|
return 0.55 * title + 0.30 * artist + 0.15 * duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Token similarity: 1 for equal text, high when one contains the other, Jaccard otherwise. */
|
||||||
|
public static double similarity(String a, String b) {
|
||||||
|
Set<String> left = tokens(a);
|
||||||
|
Set<String> right = tokens(b);
|
||||||
|
if (left.isEmpty() || right.isEmpty()) return 0.0;
|
||||||
|
if (left.equals(right)) return 1.0;
|
||||||
|
if (left.containsAll(right) || right.containsAll(left)) return 0.85;
|
||||||
|
Set<String> common = new HashSet<>(left);
|
||||||
|
common.retainAll(right);
|
||||||
|
Set<String> all = new HashSet<>(left);
|
||||||
|
all.addAll(right);
|
||||||
|
return (double) common.size() / all.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double artistScore(String hitArtist, TrackQuery query) {
|
||||||
|
if (query.artist().isEmpty()) return 0.5;
|
||||||
|
return Math.max(similarity(hitArtist, query.cleanArtist()), similarity(hitArtist, query.primaryArtist()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double durationScore(long hitSeconds, long wantedSeconds) {
|
||||||
|
if (hitSeconds <= 0 || wantedSeconds <= 0) return 0.5;
|
||||||
|
long diff = Math.abs(hitSeconds - wantedSeconds);
|
||||||
|
if (diff <= CLOSE_SECONDS) return 1.0;
|
||||||
|
if (diff >= FAR_SECONDS) return 0.0;
|
||||||
|
return 1.0 - (double) (diff - CLOSE_SECONDS) / (FAR_SECONDS - CLOSE_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> tokens(String text) {
|
||||||
|
if (text == null) return Set.of();
|
||||||
|
String flat = text.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim();
|
||||||
|
if (flat.isEmpty()) return Set.of();
|
||||||
|
return new HashSet<>(Arrays.asList(flat.split(" ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Gives unsynced lyrics a plausible timeline by spreading the lines over the song, weighted by length. */
|
||||||
|
public final class PlainLyricTimer {
|
||||||
|
private static final double INTRO = 0.06;
|
||||||
|
private static final double OUTRO = 0.94;
|
||||||
|
private static final int MIN_WEIGHT = 8;
|
||||||
|
|
||||||
|
private PlainLyricTimer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<LyricLine> estimate(String plain, long durationSeconds) {
|
||||||
|
List<LyricLine> out = new ArrayList<>();
|
||||||
|
if (plain == null || durationSeconds <= 0) return out;
|
||||||
|
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
for (String raw : plain.split("\\r?\\n")) {
|
||||||
|
String line = raw.trim();
|
||||||
|
if (!line.isEmpty() && !line.matches("\\[[^\\]]*]")) lines.add(line);
|
||||||
|
}
|
||||||
|
if (lines.isEmpty()) return out;
|
||||||
|
|
||||||
|
double total = 0;
|
||||||
|
for (String line : lines) total += Math.max(MIN_WEIGHT, line.length());
|
||||||
|
double startMs = durationSeconds * 1000.0 * INTRO;
|
||||||
|
double span = durationSeconds * 1000.0 * (OUTRO - INTRO);
|
||||||
|
double cursor = 0;
|
||||||
|
for (String line : lines) {
|
||||||
|
out.add(new LyricLine(Math.round(startMs + span * cursor / total), line));
|
||||||
|
cursor += Math.max(MIN_WEIGHT, line.length());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** The track being looked up: raw player metadata plus the cleaned forms the lyric services want. */
|
||||||
|
public record TrackQuery(String title, String artist, long durationSeconds) {
|
||||||
|
private static final Pattern BRACKETS = Pattern.compile("\\(.*?\\)|\\[.*?]");
|
||||||
|
private static final Pattern ARTIST_SPLIT =
|
||||||
|
Pattern.compile("\\s*(?:,|;|&|\\bfeat\\.?\\b|\\bft\\.?\\b|\\bwith\\b|\\bx\\b| / )\\s*", Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
|
public TrackQuery {
|
||||||
|
title = title == null ? "" : title.trim();
|
||||||
|
artist = artist == null ? "" : artist.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Title without "(feat. X)", "[Remix]" and similar decoration. */
|
||||||
|
public static String clean(String text) {
|
||||||
|
if (text == null) return "";
|
||||||
|
return BRACKETS.matcher(text).replaceAll("").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String cleanTitle() {
|
||||||
|
String cleaned = clean(title);
|
||||||
|
return cleaned.isEmpty() ? title : cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String cleanArtist() {
|
||||||
|
return clean(artist);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First credited artist ("A, B & C" gives "A"). */
|
||||||
|
public String primaryArtist() {
|
||||||
|
String cleaned = cleanArtist();
|
||||||
|
String[] parts = ARTIST_SPLIT.split(cleaned, 2);
|
||||||
|
return parts.length == 0 ? cleaned : parts[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free-text query for services that search by one string. */
|
||||||
|
public String searchText() {
|
||||||
|
return (cleanTitle() + " " + cleanArtist()).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LrcParser;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.LyricMatch;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.PlainLyricTimer;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.TrackQuery;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source.LyricHttp.encode;
|
||||||
|
|
||||||
|
/** lrclib.net: exact lookup by duration first, then several searches ranked by how well they match. */
|
||||||
|
final class LrcLibSource {
|
||||||
|
private static final String API = "https://lrclib.net/api/";
|
||||||
|
private static final double SYNCED_BONUS = 0.08;
|
||||||
|
|
||||||
|
private LrcLibSource() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<LyricLine> find(TrackQuery query, LyricHttp http) {
|
||||||
|
String title = query.cleanTitle();
|
||||||
|
if (!query.artist().isEmpty() && query.durationSeconds() > 0) {
|
||||||
|
String body = http.get(API + "get?artist_name=" + encode(query.cleanArtist())
|
||||||
|
+ "&track_name=" + encode(title) + "&duration=" + query.durationSeconds());
|
||||||
|
JsonObject exact = body == null ? null : object(body);
|
||||||
|
if (exact != null) {
|
||||||
|
List<LyricLine> synced = timed(exact);
|
||||||
|
if (!synced.isEmpty()) return synced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject best = null;
|
||||||
|
double bestScore = 0.0;
|
||||||
|
for (String url : searchUrls(query)) {
|
||||||
|
String body = http.get(url);
|
||||||
|
JsonArray hits = body == null ? null : array(body);
|
||||||
|
if (hits == null) continue;
|
||||||
|
for (JsonElement element : hits) {
|
||||||
|
if (!element.isJsonObject()) continue;
|
||||||
|
JsonObject hit = element.getAsJsonObject();
|
||||||
|
double score = scoreOf(hit, query);
|
||||||
|
if (score > bestScore) {
|
||||||
|
best = hit;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestScore >= LyricMatch.CERTAIN) break;
|
||||||
|
}
|
||||||
|
if (best == null || bestScore < LyricMatch.ACCEPT) return List.of();
|
||||||
|
return lines(best, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Set<String> searchUrls(TrackQuery query) {
|
||||||
|
String title = encode(query.cleanTitle());
|
||||||
|
Set<String> urls = new LinkedHashSet<>();
|
||||||
|
if (!query.artist().isEmpty()) {
|
||||||
|
urls.add(API + "search?track_name=" + title + "&artist_name=" + encode(query.cleanArtist()));
|
||||||
|
urls.add(API + "search?track_name=" + title + "&artist_name=" + encode(query.primaryArtist()));
|
||||||
|
}
|
||||||
|
urls.add(API + "search?q=" + encode(query.searchText()));
|
||||||
|
urls.add(API + "search?track_name=" + title);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hit score; instrumentals and hits without any lyrics score zero, synced ones get a small bonus. */
|
||||||
|
static double scoreOf(JsonObject hit, TrackQuery query) {
|
||||||
|
if (flag(hit, "instrumental")) return 0.0;
|
||||||
|
boolean synced = text(hit, "syncedLyrics") != null;
|
||||||
|
if (!synced && text(hit, "plainLyrics") == null) return 0.0;
|
||||||
|
double base = LyricMatch.score(text(hit, "trackName"), text(hit, "artistName"),
|
||||||
|
Math.round(number(hit, "duration")), query);
|
||||||
|
return base > 0.0 ? base + (synced ? SYNCED_BONUS : 0.0) : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<LyricLine> lines(JsonObject hit, TrackQuery query) {
|
||||||
|
List<LyricLine> synced = timed(hit);
|
||||||
|
if (!synced.isEmpty()) return synced;
|
||||||
|
long duration = Math.round(number(hit, "duration"));
|
||||||
|
return PlainLyricTimer.estimate(text(hit, "plainLyrics"), duration > 0 ? duration : query.durationSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<LyricLine> timed(JsonObject hit) {
|
||||||
|
String synced = text(hit, "syncedLyrics");
|
||||||
|
return synced == null ? List.of() : LrcParser.parse(synced);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject object(String body) {
|
||||||
|
try {
|
||||||
|
JsonElement parsed = JsonParser.parseString(body);
|
||||||
|
return parsed.isJsonObject() ? parsed.getAsJsonObject() : null;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonArray array(String body) {
|
||||||
|
try {
|
||||||
|
JsonElement parsed = JsonParser.parseString(body);
|
||||||
|
return parsed.isJsonArray() ? parsed.getAsJsonArray() : null;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonObject object, String key) {
|
||||||
|
JsonElement element = object.get(key);
|
||||||
|
if (element == null || !element.isJsonPrimitive()) return null;
|
||||||
|
String value = element.getAsString();
|
||||||
|
return value.isBlank() ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double number(JsonObject object, String key) {
|
||||||
|
JsonElement element = object.get(key);
|
||||||
|
return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isNumber() ? element.getAsDouble() : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean flag(JsonObject object, String key) {
|
||||||
|
JsonElement element = object.get(key);
|
||||||
|
return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isBoolean() && element.getAsBoolean();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One lookup's HTTP session. A 404 is a plain "not found"; a 429, 5xx or network error is remembered as
|
||||||
|
* transient so the caller can tell "nothing exists" from "the service was busy, try again later".
|
||||||
|
*/
|
||||||
|
final class LyricHttp {
|
||||||
|
private static final HttpClient CLIENT = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
private static final String AGENT = "Mozilla/5.0 (X11; Linux x86_64) LoVisual/1.0";
|
||||||
|
private static final int ATTEMPTS = 2;
|
||||||
|
|
||||||
|
private boolean transientFailure;
|
||||||
|
|
||||||
|
static String encode(String value) {
|
||||||
|
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean sawTransientFailure() {
|
||||||
|
return transientFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the response body, or null when there is none (missing or failed). */
|
||||||
|
String get(String url) {
|
||||||
|
for (int attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
|
.header("User-Agent", AGENT)
|
||||||
|
.timeout(Duration.ofSeconds(12))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
int status = response.statusCode();
|
||||||
|
if (status == 200) return response.body();
|
||||||
|
if (status == 404) return null;
|
||||||
|
if (status != 429 && status < 500) return null;
|
||||||
|
} catch (IOException e) {
|
||||||
|
// network trouble is worth retrying
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (attempt < ATTEMPTS) pause();
|
||||||
|
}
|
||||||
|
transientFailure = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void pause() {
|
||||||
|
try {
|
||||||
|
Thread.sleep(350);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.TrackQuery;
|
||||||
|
import dev.loki.lovisual.util.logging.DebugLog;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds timed lyrics for a track: lrclib first (exact lookup, then ranked searches, unsynced text gets an
|
||||||
|
* estimated timeline), NetEase as the fallback. Only successes are cached; a result that failed because a
|
||||||
|
* service was busy says so, so the caller retries instead of giving up on the song.
|
||||||
|
*/
|
||||||
|
public final class LyricsFinder {
|
||||||
|
/** @param retryLater no lyrics were found, but a service was busy or unreachable, so asking again may help */
|
||||||
|
public record Result(List<LyricLine> lines, boolean retryLater) {
|
||||||
|
static final Result NOTHING = new Result(List.of(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<String, List<LyricLine>> CACHE = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private LyricsFinder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CompletableFuture<Result> findAsync(String title, String artist, long durationSeconds, boolean debug) {
|
||||||
|
TrackQuery query = new TrackQuery(title, artist, durationSeconds);
|
||||||
|
if (query.title().isEmpty()) return CompletableFuture.completedFuture(Result.NOTHING);
|
||||||
|
|
||||||
|
String key = (query.title() + " - " + query.artist()).toLowerCase();
|
||||||
|
List<LyricLine> cached = CACHE.get(key);
|
||||||
|
if (cached != null) return CompletableFuture.completedFuture(new Result(cached, false));
|
||||||
|
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
LyricHttp http = new LyricHttp();
|
||||||
|
List<LyricLine> lines = LrcLibSource.find(query, http);
|
||||||
|
String source = "lrclib";
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
lines = NeteaseSource.find(query, http);
|
||||||
|
source = "netease";
|
||||||
|
}
|
||||||
|
if (debug) {
|
||||||
|
DebugLog.info("[KineticLyrics] %s: %d lines via %s%s", key, lines.size(), source,
|
||||||
|
http.sawTransientFailure() ? " (a service was busy)" : "");
|
||||||
|
}
|
||||||
|
if (!lines.isEmpty()) {
|
||||||
|
CACHE.put(key, lines);
|
||||||
|
return new Result(lines, false);
|
||||||
|
}
|
||||||
|
return new Result(List.of(), http.sawTransientFailure());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LrcParser;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.LyricMatch;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.TrackQuery;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source.LyricHttp.encode;
|
||||||
|
|
||||||
|
/** NetEase Cloud Music: the fallback for songs lrclib does not have (it covers a lot of non-Western music). */
|
||||||
|
final class NeteaseSource {
|
||||||
|
private static final String SEARCH = "https://music.163.com/api/cloudsearch/pc?type=1&limit=10&offset=0&s=";
|
||||||
|
private static final String LYRIC = "https://music.163.com/api/song/lyric?os=pc&lv=-1&kv=-1&tv=-1&id=";
|
||||||
|
private static final Pattern CREDIT = Pattern.compile("^[^::]{1,24}\\s*[::]\\s*.{0,60}$");
|
||||||
|
private static final long CREDIT_WINDOW_MS = 2000;
|
||||||
|
|
||||||
|
private NeteaseSource() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<LyricLine> find(TrackQuery query, LyricHttp http) {
|
||||||
|
String body = http.get(SEARCH + encode(query.searchText()));
|
||||||
|
long id = body == null ? 0 : bestSongId(body, query);
|
||||||
|
if (id == 0) return List.of();
|
||||||
|
String lyricBody = http.get(LYRIC + id);
|
||||||
|
return lyricBody == null ? List.of() : stripCredits(LrcParser.parse(lyric(lyricBody)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Id of the best matching song in a cloudsearch response, or 0 when nothing is close enough. */
|
||||||
|
static long bestSongId(String json, TrackQuery query) {
|
||||||
|
try {
|
||||||
|
JsonElement songs = JsonParser.parseString(json).getAsJsonObject().getAsJsonObject("result").get("songs");
|
||||||
|
if (songs == null || !songs.isJsonArray()) return 0;
|
||||||
|
long bestId = 0;
|
||||||
|
double bestScore = 0.0;
|
||||||
|
for (JsonElement element : songs.getAsJsonArray()) {
|
||||||
|
JsonObject song = element.getAsJsonObject();
|
||||||
|
double score = LyricMatch.score(song.get("name").getAsString(), artists(song.getAsJsonArray("ar")),
|
||||||
|
song.has("dt") ? Math.round(song.get("dt").getAsLong() / 1000.0) : 0, query);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestId = song.get("id").getAsLong();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestScore >= LyricMatch.ACCEPT ? bestId : 0;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the "Lyrics by / Composer :" lines NetEase puts at the very start of a song. */
|
||||||
|
static List<LyricLine> stripCredits(List<LyricLine> lines) {
|
||||||
|
List<LyricLine> out = new ArrayList<>(lines);
|
||||||
|
while (!out.isEmpty() && out.get(0).timestampMs() <= CREDIT_WINDOW_MS && CREDIT.matcher(out.get(0).text()).matches()) {
|
||||||
|
out.remove(0);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String lyric(String json) {
|
||||||
|
try {
|
||||||
|
JsonObject lrc = JsonParser.parseString(json).getAsJsonObject().getAsJsonObject("lrc");
|
||||||
|
return lrc == null || !lrc.has("lyric") ? "" : lrc.get("lyric").getAsString();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String artists(JsonArray array) {
|
||||||
|
if (array == null) return "";
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
for (JsonElement element : array) {
|
||||||
|
JsonElement name = element.getAsJsonObject().get("name");
|
||||||
|
if (name != null && !name.isJsonNull()) out.append(name.getAsString()).append(' ');
|
||||||
|
}
|
||||||
|
return out.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.models;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implemented by LivingEntityRenderState. The model a player is drawn with is chosen once per frame, while the
|
||||||
|
* entity is still at hand, and carried here to the moment the model is submitted.
|
||||||
|
*/
|
||||||
|
public interface CustomModelState {
|
||||||
|
/** The chosen model name, or null when this player is drawn as a player. */
|
||||||
|
String lovisual$customModel();
|
||||||
|
|
||||||
|
void lovisual$setCustomModel(String model);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.models;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import dev.loki.lovisual.config.values.mode.ModeValue;
|
||||||
|
import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||||
|
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||||
|
import dev.loki.lovisual.features.module.core.Module;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.models.mesh.ChickenMesh;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.models.mesh.RabbitModel;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.models.mesh.VerityMesh;
|
||||||
|
import dev.loki.lovisual.features.relations.category.CategoryService;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.model.Model;
|
||||||
|
import net.minecraft.client.model.player.PlayerModel;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.world.entity.Avatar;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws chosen players with a model of their own instead of their skin. Only this client sees it: nothing about
|
||||||
|
* the players changes and nothing is sent anywhere. The choice is made per player at render-state extraction
|
||||||
|
* (AvatarCustomModelMixin) and the replacement happens where the model is submitted (LivingEntityCustomModelMixin).
|
||||||
|
*/
|
||||||
|
@ModuleInfo(
|
||||||
|
id = "custommodels",
|
||||||
|
displayName = "Custom Models",
|
||||||
|
aliases = {"custommodels", "models"},
|
||||||
|
category = ModuleCategory.VISUALS
|
||||||
|
)
|
||||||
|
public class CustomModels extends Module {
|
||||||
|
public static final String CHICKEN = "Chicken";
|
||||||
|
public static final String VERITY = "Verity";
|
||||||
|
public static final String RABBIT = "Rabbit";
|
||||||
|
|
||||||
|
private static final String[] FACES = {"Normal", "Talking", "Disturbing", "Disturbing talking",
|
||||||
|
"Disturbing yelling", "Bad", "Very bad"};
|
||||||
|
private static final String[] FACE_FILES = {"verity", "veritytalk", "veritydisturbing", "veritydisturbingtalk",
|
||||||
|
"veritydisturbingyell", "veritybad", "verityverybad"};
|
||||||
|
private static final int MAX_RABBITS = 128;
|
||||||
|
|
||||||
|
private final ModeValue model = modeSetting("customModelsModel", "model", CHICKEN, CHICKEN, VERITY, RABBIT);
|
||||||
|
private final BooleanValue self = bool("customModelsSelf", "self", true);
|
||||||
|
private final BooleanValue friends = bool("customModelsFriends", "friends", false);
|
||||||
|
private final BooleanValue others = bool("customModelsOthers", "others", false);
|
||||||
|
private final BooleanValue hand = visibleWhen(bool("customModelsHand", "hand", true),
|
||||||
|
() -> CHICKEN.equals(model.get()) && self.get());
|
||||||
|
private final NumberValue<Float> size = visibleWhen(num("customModelsSize", "size", 1.0f, 0.3f, 3.0f),
|
||||||
|
() -> VERITY.equals(model.get()));
|
||||||
|
private final ModeValue face = visibleWhen(modeSetting("customModelsFace", "face", FACES[0], FACES),
|
||||||
|
() -> VERITY.equals(model.get()));
|
||||||
|
|
||||||
|
private final ChickenMesh chicken = new ChickenMesh();
|
||||||
|
private final VerityMesh verity = new VerityMesh();
|
||||||
|
private final Map<Integer, RabbitModel> rabbits = new HashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisable() {
|
||||||
|
rabbits.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The model this player is to be drawn with, or null to leave them as they are. */
|
||||||
|
public String modelFor(Avatar avatar) {
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (!isEnabled() || avatar == null || mc.player == null) return null;
|
||||||
|
boolean wanted;
|
||||||
|
if (avatar.getUUID().equals(mc.player.getUUID())) {
|
||||||
|
wanted = self.get();
|
||||||
|
} else if (avatar instanceof Player player && CategoryService.isFriend(player)) {
|
||||||
|
wanted = friends.get();
|
||||||
|
} else {
|
||||||
|
wanted = others.get();
|
||||||
|
}
|
||||||
|
return wanted ? model.get() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the hand the player looks at is drawn as the model's arm rather than their own. */
|
||||||
|
public boolean handFromModel() {
|
||||||
|
return isEnabled() && CHICKEN.equals(model.get()) && self.get() && hand.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void submitHand(PoseStack pose, SubmitNodeCollector collector, int light, boolean right) {
|
||||||
|
chicken.submitArm(pose, collector, light, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws {@code custom} in place of the player model; false when the name is not one of ours. */
|
||||||
|
public boolean submit(String custom, Model<?> vanilla, AvatarRenderState state, PoseStack pose,
|
||||||
|
SubmitNodeCollector collector, int light, int overlay, int color, int outlineColor) {
|
||||||
|
if (RABBIT.equals(custom) && vanilla instanceof PlayerModel player) {
|
||||||
|
if (rabbits.size() > MAX_RABBITS) rabbits.clear();
|
||||||
|
rabbits.computeIfAbsent(state.id, id -> new RabbitModel())
|
||||||
|
.submit(player, state, pose, collector, light, overlay, color, outlineColor);
|
||||||
|
} else if (VERITY.equals(custom)) {
|
||||||
|
verity.submit(pose, collector, light, overlay, verityTexture(), size.get());
|
||||||
|
} else if (CHICKEN.equals(custom)) {
|
||||||
|
chicken.submit(state, pose, collector, light, overlay);
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The face texture chosen for Verity. */
|
||||||
|
Identifier verityTexture() {
|
||||||
|
return Identifier.fromNamespaceAndPath("lovisual", "models/verity/" + faceFile(face.get()) + ".png");
|
||||||
|
}
|
||||||
|
|
||||||
|
static String faceFile(String faceName) {
|
||||||
|
for (int i = 0; i < FACES.length; i++) {
|
||||||
|
if (FACES[i].equals(faceName)) return FACE_FILES[i];
|
||||||
|
}
|
||||||
|
return FACE_FILES[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.models.mesh;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.mojang.math.Axis;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
|
||||||
|
import net.minecraft.client.renderer.item.ItemStackRenderState;
|
||||||
|
import net.minecraft.client.renderer.texture.OverlayTexture;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.entity.HumanoidArm;
|
||||||
|
|
||||||
|
import java.io.DataInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chicken: textured geometry split into movable parts, drawn in place of the player model. Parts are
|
||||||
|
* stored as triangle lists, one file per part and material, read on first use. Limbs swing with the walk, the
|
||||||
|
* hitting arm swings out with the attack, and held items ride the paws.
|
||||||
|
*/
|
||||||
|
public final class ChickenMesh {
|
||||||
|
private static final String[] PARTS = {"body", "head", "right_arm", "left_arm", "right_leg", "left_leg"};
|
||||||
|
private static final int RIGHT_ARM = 2;
|
||||||
|
private static final int LEFT_ARM = 3;
|
||||||
|
private static final Identifier[] TEXTURES = {
|
||||||
|
Identifier.fromNamespaceAndPath("lovisual", "models/chicken/rudy_sc_1.png"),
|
||||||
|
Identifier.fromNamespaceAndPath("lovisual", "models/chicken/bruce_col.png")
|
||||||
|
};
|
||||||
|
/** Rotation pivots of each part, measured from the converted mesh, in the model's own space. */
|
||||||
|
private static final float[][] PIVOTS = {
|
||||||
|
{0, 0, 0}, {0, 0.55f, 0}, {-0.52f, 0.55f, 0}, {0.52f, 0.55f, 0}, {-0.18f, 1.12f, 0}, {0.18f, 1.12f, 0}
|
||||||
|
};
|
||||||
|
private static final int MAX_VERTICES = 1_000_000;
|
||||||
|
|
||||||
|
private float[][][] geometry;
|
||||||
|
|
||||||
|
public void submit(AvatarRenderState state, PoseStack pose, SubmitNodeCollector collector, int light, int overlay) {
|
||||||
|
float[][][] data = geometry();
|
||||||
|
float stride = Math.min(state.walkAnimationSpeed, 1.0f);
|
||||||
|
float swing = (float) Math.cos(state.walkAnimationPos * 0.6662f) * 1.2f * stride;
|
||||||
|
float rightArm = -swing;
|
||||||
|
float leftArm = swing;
|
||||||
|
if (state.attackTime > 0) {
|
||||||
|
float hit = (float) Math.sin(Math.sqrt(state.attackTime) * Math.PI) * 1.3f;
|
||||||
|
if (state.attackArm == HumanoidArm.RIGHT) rightArm -= hit;
|
||||||
|
else leftArm -= hit;
|
||||||
|
}
|
||||||
|
if (state.isUsingItem) {
|
||||||
|
boolean mainHand = state.useItemHand == InteractionHand.MAIN_HAND;
|
||||||
|
if (mainHand == (state.mainArm == HumanoidArm.RIGHT)) rightArm = -0.8f;
|
||||||
|
else leftArm = -0.8f;
|
||||||
|
}
|
||||||
|
float[] angles = {0, 0, rightArm, leftArm, swing, -swing};
|
||||||
|
for (int part = 0; part < PARTS.length; part++) {
|
||||||
|
pose.pushPose();
|
||||||
|
rotatePart(pose, part, angles[part]);
|
||||||
|
for (int material = 0; material < TEXTURES.length; material++) {
|
||||||
|
MeshVertices.submit(pose, collector, TEXTURES[material], data[part][material], light, overlay);
|
||||||
|
}
|
||||||
|
pose.popPose();
|
||||||
|
}
|
||||||
|
submitItem(state.rightHandItemState, pose, collector, light, state.outlineColor, RIGHT_ARM, rightArm);
|
||||||
|
submitItem(state.leftHandItemState, pose, collector, light, state.outlineColor, LEFT_ARM, leftArm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One arm on its own, for the hand the player sees in front of them. It is shifted so its shoulder sits where
|
||||||
|
* the vanilla arm's shoulder does; the swing, item and equip movement are whatever the game already set up.
|
||||||
|
*/
|
||||||
|
public void submitArm(PoseStack pose, SubmitNodeCollector collector, int light, boolean right) {
|
||||||
|
float[][][] data = geometry();
|
||||||
|
int part = right ? RIGHT_ARM : LEFT_ARM;
|
||||||
|
float[] pivot = PIVOTS[part];
|
||||||
|
pose.pushPose();
|
||||||
|
pose.translate(-pivot[0], -pivot[1], -pivot[2]);
|
||||||
|
for (int material = 0; material < TEXTURES.length; material++) {
|
||||||
|
MeshVertices.submit(pose, collector, TEXTURES[material], data[part][material], light, OverlayTexture.NO_OVERLAY);
|
||||||
|
}
|
||||||
|
pose.popPose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rotatePart(PoseStack pose, int part, float angle) {
|
||||||
|
if (angle == 0) return;
|
||||||
|
float[] pivot = PIVOTS[part];
|
||||||
|
pose.translate(pivot[0], pivot[1], pivot[2]);
|
||||||
|
pose.mulPose(Axis.XP.rotation(angle));
|
||||||
|
pose.translate(-pivot[0], -pivot[1], -pivot[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void submitItem(ItemStackRenderState item, PoseStack pose, SubmitNodeCollector collector,
|
||||||
|
int light, int outlineColor, int arm, float angle) {
|
||||||
|
if (item.isEmpty()) return;
|
||||||
|
pose.pushPose();
|
||||||
|
rotatePart(pose, arm, angle);
|
||||||
|
// the paw tip, with the item in the standard third-person hand orientation
|
||||||
|
pose.translate(arm == RIGHT_ARM ? -0.57f : 0.57f, 0.92f, -0.02f);
|
||||||
|
pose.mulPose(Axis.XP.rotationDegrees(-90));
|
||||||
|
pose.mulPose(Axis.YP.rotationDegrees(180));
|
||||||
|
pose.translate(arm == RIGHT_ARM ? 0.0625f : -0.0625f, 0.125f, 0);
|
||||||
|
item.submit(pose, collector, light, OverlayTexture.NO_OVERLAY, outlineColor);
|
||||||
|
pose.popPose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private float[][][] geometry() {
|
||||||
|
if (geometry == null) {
|
||||||
|
float[][][] all = new float[PARTS.length][TEXTURES.length][];
|
||||||
|
for (int part = 0; part < PARTS.length; part++) {
|
||||||
|
for (int material = 0; material < TEXTURES.length; material++) {
|
||||||
|
all[part][material] = load(PARTS[part], material);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
geometry = all;
|
||||||
|
}
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] load(String part, int material) {
|
||||||
|
String path = "/assets/lovisual/models/chicken/mesh_" + part + "_" + material + ".bin";
|
||||||
|
try (InputStream raw = ChickenMesh.class.getResourceAsStream(path)) {
|
||||||
|
return raw == null ? new float[0] : read(raw);
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
return new float[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A vertex count followed by that many vertices of eight big-endian floats each. */
|
||||||
|
static float[] read(InputStream raw) throws IOException {
|
||||||
|
DataInputStream in = new DataInputStream(raw);
|
||||||
|
int count = in.readInt();
|
||||||
|
if (count < 0 || count > MAX_VERTICES || count % 3 != 0) throw new IOException("Invalid vertex count");
|
||||||
|
float[] data = new float[count * MeshVertices.FLOATS_PER_VERTEX];
|
||||||
|
for (int i = 0; i < data.length; i++) data[i] = in.readFloat();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.models.mesh;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.client.renderer.rendertype.RenderTypes;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triangle lists as plain floats: eight per vertex (position, uv, normal), three vertices per triangle.
|
||||||
|
* Shared by every mesh model so they all emit geometry the same way.
|
||||||
|
*/
|
||||||
|
public final class MeshVertices {
|
||||||
|
public static final int FLOATS_PER_VERTEX = 8;
|
||||||
|
private static final int FLOATS_PER_TRIANGLE = 3 * FLOATS_PER_VERTEX;
|
||||||
|
|
||||||
|
private MeshVertices() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Submits the triangles as a cutout entity layer; a quad's fourth corner repeats the third. */
|
||||||
|
public static void submit(PoseStack pose, SubmitNodeCollector collector, Identifier texture,
|
||||||
|
float[] triangles, int light, int overlay) {
|
||||||
|
if (triangles.length == 0) return;
|
||||||
|
collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), (matrix, consumer) -> {
|
||||||
|
for (int t = 0; t + FLOATS_PER_TRIANGLE <= triangles.length; t += FLOATS_PER_TRIANGLE) {
|
||||||
|
vertex(matrix, consumer, triangles, t, light, overlay);
|
||||||
|
vertex(matrix, consumer, triangles, t + FLOATS_PER_VERTEX, light, overlay);
|
||||||
|
vertex(matrix, consumer, triangles, t + 2 * FLOATS_PER_VERTEX, light, overlay);
|
||||||
|
vertex(matrix, consumer, triangles, t + 2 * FLOATS_PER_VERTEX, light, overlay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void vertex(PoseStack.Pose pose, VertexConsumer consumer, float[] data, int i, int light, int overlay) {
|
||||||
|
consumer.addVertex(pose, data[i], data[i + 1], data[i + 2])
|
||||||
|
.setColor(-1).setUv(data[i + 3], data[i + 4])
|
||||||
|
.setOverlay(overlay).setLight(light)
|
||||||
|
.setNormal(pose, data[i + 5], data[i + 6], data[i + 7]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.models.mesh;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
import net.minecraft.client.model.geom.PartPose;
|
||||||
|
import net.minecraft.client.model.geom.builders.CubeListBuilder;
|
||||||
|
import net.minecraft.client.model.geom.builders.LayerDefinition;
|
||||||
|
import net.minecraft.client.model.geom.builders.MeshDefinition;
|
||||||
|
import net.minecraft.client.model.geom.builders.PartDefinition;
|
||||||
|
import net.minecraft.client.model.player.PlayerModel;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
|
||||||
|
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
|
||||||
|
import net.minecraft.client.renderer.rendertype.RenderTypes;
|
||||||
|
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rabbit: a box model drawn in place of the player. It does not animate itself: the player model it
|
||||||
|
* replaces is posed by the game and those angles are copied onto the matching parts, so the rabbit walks,
|
||||||
|
* swings and looks around exactly as the player would. One instance per player, because a submitted model is
|
||||||
|
* only read later and two players sharing one would both come out in the last pose set.
|
||||||
|
*/
|
||||||
|
public final class RabbitModel {
|
||||||
|
private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath("lovisual", "models/rabbit.png");
|
||||||
|
/** The arms sit at a slight angle of their own, which the copied pose is added onto. */
|
||||||
|
private static final float ARM_TILT = 0.0873f;
|
||||||
|
|
||||||
|
private final ModelPart root;
|
||||||
|
private final ModelPart body;
|
||||||
|
private final ModelPart head;
|
||||||
|
private final ModelPart leftArm;
|
||||||
|
private final ModelPart rightArm;
|
||||||
|
private final ModelPart leftLeg;
|
||||||
|
private final ModelPart rightLeg;
|
||||||
|
|
||||||
|
public RabbitModel() {
|
||||||
|
MeshDefinition mesh = new MeshDefinition();
|
||||||
|
PartDefinition rabbit = mesh.getRoot().addOrReplaceChild("rabbit",
|
||||||
|
box(28, 45, -5.0f, -13.0f, -5.0f, 10.0f, 11.0f, 8.0f), PartPose.offset(0.0f, 24.0f, 0.0f));
|
||||||
|
rabbit.addOrReplaceChild("right_leg", box(0, 32, -2.0f, 0.0f, -2.0f, 4.0f, 2.0f, 4.0f),
|
||||||
|
PartPose.offset(-3.0f, -2.0f, -1.0f));
|
||||||
|
rabbit.addOrReplaceChild("left_leg", box(0, 32, -2.0f, 0.0f, -2.0f, 4.0f, 2.0f, 4.0f),
|
||||||
|
PartPose.offset(3.0f, -2.0f, -1.0f));
|
||||||
|
rabbit.addOrReplaceChild("left_arm", box(24, 16, 0.0f, 0.0f, -2.0f, 2.0f, 8.0f, 4.0f),
|
||||||
|
PartPose.offsetAndRotation(5.0f, -13.0f, -1.0f, 0.0f, 0.0f, -ARM_TILT));
|
||||||
|
rabbit.addOrReplaceChild("right_arm", box(24, 16, -2.0f, 0.0f, -2.0f, 2.0f, 8.0f, 4.0f),
|
||||||
|
PartPose.offsetAndRotation(-5.0f, -13.0f, -1.0f, 0.0f, 0.0f, ARM_TILT));
|
||||||
|
// the face is the head's own boxes: ears, cheeks and the muzzle
|
||||||
|
rabbit.addOrReplaceChild("head", CubeListBuilder.create()
|
||||||
|
.texOffs(0, 0).addBox(-3.0f, 0.0f, -4.0f, 6.0f, 1.0f, 6.0f)
|
||||||
|
.texOffs(0, 45).addBox(-4.0f, -11.0f, -4.0f, 8.0f, 11.0f, 8.0f)
|
||||||
|
.texOffs(56, 0).addBox(-5.0f, -9.0f, -5.0f, 2.0f, 3.0f, 2.0f)
|
||||||
|
.texOffs(56, 0).addBox(3.0f, -9.0f, -5.0f, 2.0f, 3.0f, 2.0f)
|
||||||
|
.texOffs(46, 0).addBox(1.0f, -20.0f, 0.0f, 3.0f, 9.0f, 1.0f)
|
||||||
|
.texOffs(46, 0).addBox(-4.0f, -20.0f, 0.0f, 3.0f, 9.0f, 1.0f),
|
||||||
|
PartPose.offset(0.0f, -14.0f, -1.0f));
|
||||||
|
|
||||||
|
root = LayerDefinition.create(mesh, 64, 64).bakeRoot();
|
||||||
|
body = root.getChild("rabbit");
|
||||||
|
head = body.getChild("head");
|
||||||
|
leftArm = body.getChild("left_arm");
|
||||||
|
rightArm = body.getChild("right_arm");
|
||||||
|
leftLeg = body.getChild("left_leg");
|
||||||
|
rightLeg = body.getChild("right_leg");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws the rabbit in the pose the player model is in this frame. */
|
||||||
|
public void submit(PlayerModel vanilla, AvatarRenderState state, PoseStack pose, SubmitNodeCollector collector,
|
||||||
|
int light, int overlay, int color, int outlineColor) {
|
||||||
|
// the game poses the player model only after handing it over, so its angles are still last frame's
|
||||||
|
vanilla.setupAnim(state);
|
||||||
|
copyAngles(vanilla);
|
||||||
|
|
||||||
|
pose.pushPose();
|
||||||
|
pose.scale(1.25f, 1.25f, 1.25f);
|
||||||
|
pose.translate(0.0f, -0.3f, 0.0f);
|
||||||
|
collector.submitModelPart(root, pose, RenderTypes.entityTranslucent(TEXTURE), light, overlay,
|
||||||
|
(TextureAtlasSprite) null, color, (ModelFeatureRenderer.CrumblingOverlay) null, outlineColor);
|
||||||
|
pose.popPose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void copyAngles(PlayerModel vanilla) {
|
||||||
|
copy(vanilla.head, head, 0.0f);
|
||||||
|
// the body stays upright instead of following the player's own body rotation, which would tip it over
|
||||||
|
body.xRot = 0.0f;
|
||||||
|
body.yRot = 0.0f;
|
||||||
|
body.zRot = 0.0f;
|
||||||
|
copy(vanilla.leftArm, leftArm, -ARM_TILT);
|
||||||
|
copy(vanilla.rightArm, rightArm, ARM_TILT);
|
||||||
|
copy(vanilla.leftLeg, leftLeg, 0.0f);
|
||||||
|
copy(vanilla.rightLeg, rightLeg, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void copy(ModelPart from, ModelPart to, float zTilt) {
|
||||||
|
to.xRot = from.xRot;
|
||||||
|
to.yRot = from.yRot;
|
||||||
|
to.zRot = from.zRot + zTilt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CubeListBuilder box(int u, int v, float x, float y, float z, float width, float height, float depth) {
|
||||||
|
return CubeListBuilder.create().texOffs(u, v).addBox(x, y, z, width, height, depth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.models.mesh;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.models.mesh.blockbench.BbModelParser;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verity: a free-form ball mesh read from its Blockbench file on first use, with the face painted on the
|
||||||
|
* texture (the avatar ships one texture per mood). Drawn floating at the player's position.
|
||||||
|
*/
|
||||||
|
public final class VerityMesh {
|
||||||
|
private static final String MODEL = "/assets/lovisual/models/verity/verity.bbmodel";
|
||||||
|
/** Where the ball sits on the player, in entity model space (y grows downwards). */
|
||||||
|
private static final float CENTER_Y = 0.75f;
|
||||||
|
/** Blockbench units are sixteenths of a block; this also gives the ball a player's presence. */
|
||||||
|
private static final float UNIT = 2.2f / 16.0f;
|
||||||
|
|
||||||
|
private float[] triangles;
|
||||||
|
|
||||||
|
public void submit(PoseStack pose, SubmitNodeCollector collector, int light, int overlay,
|
||||||
|
Identifier texture, float scale) {
|
||||||
|
float[] data = geometry();
|
||||||
|
if (data.length == 0) return;
|
||||||
|
pose.pushPose();
|
||||||
|
pose.translate(0.0f, CENTER_Y, 0.0f);
|
||||||
|
pose.scale(scale, scale, scale);
|
||||||
|
MeshVertices.submit(pose, collector, texture, data, light, overlay);
|
||||||
|
pose.popPose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private float[] geometry() {
|
||||||
|
if (triangles == null) triangles = load();
|
||||||
|
return triangles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] load() {
|
||||||
|
try (InputStream stream = VerityMesh.class.getResourceAsStream(MODEL)) {
|
||||||
|
if (stream == null) return new float[0];
|
||||||
|
return BbModelParser.parse(new String(stream.readAllBytes(), StandardCharsets.UTF_8), UNIT);
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
return new float[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.models.mesh.blockbench;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the free-form mesh elements of a Blockbench {@code .bbmodel} into triangles (position, uv, normal per
|
||||||
|
* vertex). Each element's own rotation about its origin is baked in, and the result is moved into entity model
|
||||||
|
* space (y grows downwards) at {@code unit} blocks per Blockbench unit.
|
||||||
|
*/
|
||||||
|
public final class BbModelParser {
|
||||||
|
private static final float DEFAULT_UV_SIZE = 64.0f;
|
||||||
|
private static final int FLOATS_PER_VERTEX = 8;
|
||||||
|
|
||||||
|
private BbModelParser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float[] parse(String json, float unit) {
|
||||||
|
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
|
||||||
|
float uvWidth = DEFAULT_UV_SIZE;
|
||||||
|
float uvHeight = DEFAULT_UV_SIZE;
|
||||||
|
if (root.has("resolution")) {
|
||||||
|
JsonObject resolution = root.getAsJsonObject("resolution");
|
||||||
|
uvWidth = resolution.get("width").getAsFloat();
|
||||||
|
uvHeight = resolution.get("height").getAsFloat();
|
||||||
|
}
|
||||||
|
FloatList out = new FloatList();
|
||||||
|
for (JsonElement entry : root.getAsJsonArray("elements")) {
|
||||||
|
JsonObject element = entry.getAsJsonObject();
|
||||||
|
if (!element.has("vertices") || !element.has("faces")) continue;
|
||||||
|
float[] origin = triple(element, "origin");
|
||||||
|
float[] rotation = triple(element, "rotation");
|
||||||
|
JsonObject vertices = element.getAsJsonObject("vertices");
|
||||||
|
for (Map.Entry<String, JsonElement> face : element.getAsJsonObject("faces").entrySet()) {
|
||||||
|
JsonObject data = face.getValue().getAsJsonObject();
|
||||||
|
JsonArray order = data.getAsJsonArray("vertices");
|
||||||
|
JsonObject uv = data.getAsJsonObject("uv");
|
||||||
|
// a quad is two triangles around its first corner; larger faces fan the same way
|
||||||
|
for (int corner = 2; corner < order.size(); corner++) {
|
||||||
|
String[] ids = {order.get(0).getAsString(), order.get(corner - 1).getAsString(), order.get(corner).getAsString()};
|
||||||
|
triangle(out, vertices, uv, origin, rotation, uvWidth, uvHeight, unit, ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void triangle(FloatList out, JsonObject vertices, JsonObject uv, float[] origin, float[] rotation,
|
||||||
|
float uvWidth, float uvHeight, float unit, String[] ids) {
|
||||||
|
float[][] points = new float[3][];
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
points[i] = point(vertices, origin, rotation, ids[i], unit);
|
||||||
|
if (points[i] == null) return;
|
||||||
|
}
|
||||||
|
float[] normal = normal(points[0], points[1], points[2]);
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
float[] texture = uvOf(uv, ids[i], uvWidth, uvHeight);
|
||||||
|
out.add(points[i][0]).add(points[i][1]).add(points[i][2]);
|
||||||
|
out.add(texture[0]).add(texture[1]);
|
||||||
|
out.add(normal[0]).add(normal[1]).add(normal[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] point(JsonObject vertices, float[] origin, float[] rotation, String id, float unit) {
|
||||||
|
if (!vertices.has(id)) return null;
|
||||||
|
JsonArray raw = vertices.getAsJsonArray(id);
|
||||||
|
float[] rotated = rotate(raw.get(0).getAsFloat() - origin[0], raw.get(1).getAsFloat() - origin[1],
|
||||||
|
raw.get(2).getAsFloat() - origin[2], rotation);
|
||||||
|
// Blockbench has y growing upwards, entity model space has it growing down
|
||||||
|
return new float[]{(rotated[0] + origin[0]) * unit, -(rotated[1] + origin[1]) * unit, (rotated[2] + origin[2]) * unit};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** X, then Y, then Z: the order Blockbench applies an element's own rotation in. */
|
||||||
|
static float[] rotate(float x, float y, float z, float[] rotationDegrees) {
|
||||||
|
double rx = Math.toRadians(rotationDegrees[0]);
|
||||||
|
double ry = Math.toRadians(rotationDegrees[1]);
|
||||||
|
double rz = Math.toRadians(rotationDegrees[2]);
|
||||||
|
float y1 = (float) (y * Math.cos(rx) - z * Math.sin(rx));
|
||||||
|
float z1 = (float) (y * Math.sin(rx) + z * Math.cos(rx));
|
||||||
|
float x2 = (float) (x * Math.cos(ry) + z1 * Math.sin(ry));
|
||||||
|
float z2 = (float) (-x * Math.sin(ry) + z1 * Math.cos(ry));
|
||||||
|
float x3 = (float) (x2 * Math.cos(rz) - y1 * Math.sin(rz));
|
||||||
|
float y3 = (float) (x2 * Math.sin(rz) + y1 * Math.cos(rz));
|
||||||
|
return new float[]{x3, y3, z2};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] uvOf(JsonObject uv, String id, float uvWidth, float uvHeight) {
|
||||||
|
if (uv == null || !uv.has(id)) return new float[]{0.0f, 0.0f};
|
||||||
|
JsonArray raw = uv.getAsJsonArray(id);
|
||||||
|
return new float[]{raw.get(0).getAsFloat() / uvWidth, raw.get(1).getAsFloat() / uvHeight};
|
||||||
|
}
|
||||||
|
|
||||||
|
static float[] normal(float[] a, float[] b, float[] c) {
|
||||||
|
float ux = b[0] - a[0];
|
||||||
|
float uy = b[1] - a[1];
|
||||||
|
float uz = b[2] - a[2];
|
||||||
|
float vx = c[0] - a[0];
|
||||||
|
float vy = c[1] - a[1];
|
||||||
|
float vz = c[2] - a[2];
|
||||||
|
float nx = uy * vz - uz * vy;
|
||||||
|
float ny = uz * vx - ux * vz;
|
||||||
|
float nz = ux * vy - uy * vx;
|
||||||
|
float length = (float) Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||||||
|
if (length < 1.0e-6f) return new float[]{0.0f, -1.0f, 0.0f};
|
||||||
|
return new float[]{nx / length, ny / length, nz / length};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] triple(JsonObject element, String key) {
|
||||||
|
if (!element.has(key)) return new float[]{0.0f, 0.0f, 0.0f};
|
||||||
|
JsonArray raw = element.getAsJsonArray(key);
|
||||||
|
return new float[]{raw.get(0).getAsFloat(), raw.get(1).getAsFloat(), raw.get(2).getAsFloat()};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Growable float buffer, so a thousand-triangle mesh is not boxed into Float objects. */
|
||||||
|
private static final class FloatList {
|
||||||
|
private float[] data = new float[FLOATS_PER_VERTEX * 3 * 256];
|
||||||
|
private int size;
|
||||||
|
|
||||||
|
FloatList add(float value) {
|
||||||
|
if (size == data.length) data = java.util.Arrays.copyOf(data, size * 2);
|
||||||
|
data[size++] = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
float[] toArray() {
|
||||||
|
return java.util.Arrays.copyOf(data, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.placeanimation;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||||
|
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||||
|
import dev.loki.lovisual.features.module.core.Module;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.multiplayer.ClientLevel;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.tags.BlockTags;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.level.block.ChestBlock;
|
||||||
|
import net.minecraft.world.level.block.DoorBlock;
|
||||||
|
import net.minecraft.world.level.block.DoublePlantBlock;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.block.state.properties.ChestType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks you place slide in from your hand, tilting and growing into position. The block is hidden from the
|
||||||
|
* chunk mesh for the duration and drawn as a moving block instead (hooks: BlockItemPlaceMixin and friends).
|
||||||
|
*/
|
||||||
|
@ModuleInfo(
|
||||||
|
id = "placeanimation",
|
||||||
|
displayName = "Place Animation",
|
||||||
|
aliases = {"placeanimation"},
|
||||||
|
category = ModuleCategory.VISUALS
|
||||||
|
)
|
||||||
|
public class PlaceAnimation extends Module {
|
||||||
|
private final NumberValue<Integer> duration = num("placeAnimationDuration", "duration", 3, 1, 12);
|
||||||
|
private final NumberValue<Float> slide = num("placeAnimationSlide", "slide", 1.0f, 0.0f, 4.0f);
|
||||||
|
private final NumberValue<Float> tilt = num("placeAnimationTilt", "tilt", 6.0f, 0.0f, 45.0f);
|
||||||
|
private final NumberValue<Float> startScale = num("placeAnimationStartScale", "start_scale", 1.0f, 0.1f, 1.0f);
|
||||||
|
private final BooleanValue blockEntities = bool("placeAnimationBlockEntities", "block_entities", false);
|
||||||
|
|
||||||
|
private final PlaceAnimations animations = new PlaceAnimations();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTick() {
|
||||||
|
ClientLevel level = Minecraft.getInstance().level;
|
||||||
|
if (level != null) animations.tick(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisable() {
|
||||||
|
animations.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when the local player has just placed {@code state} at {@code pos}. */
|
||||||
|
public void onBlockPlaced(ClientLevel level, BlockPos pos, BlockState state, Player player, InteractionHand hand) {
|
||||||
|
if (!isEnabled() || player == null || !isAnimatable(level, pos, state)) return;
|
||||||
|
PlacedBlock.Params params = new PlacedBlock.Params(duration.get(), slide.get(),
|
||||||
|
(float) Math.toRadians(tilt.get()), startScale.get());
|
||||||
|
animations.start(level, pos, state, player, hand, params, blockEntities.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isHidden(BlockPos pos) {
|
||||||
|
return animations.isHidden(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void submit(PoseStack stack, SubmitNodeCollector collector, float partial) {
|
||||||
|
if (isEnabled()) animations.submit(stack, collector, partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void applyBlockEntity(BlockPos pos, PoseStack stack, float partial) {
|
||||||
|
if (isEnabled()) animations.applyBlockEntity(pos, stack, partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multi-block structures and non-solid blocks animate badly, so they are left alone. */
|
||||||
|
private static boolean isAnimatable(ClientLevel level, BlockPos pos, BlockState state) {
|
||||||
|
if (state.is(BlockTags.BEDS) || state.getBlock() instanceof DoorBlock || state.getBlock() instanceof DoublePlantBlock) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (state.hasProperty(ChestBlock.TYPE) && state.getValue(ChestBlock.TYPE) != ChestType.SINGLE) return false;
|
||||||
|
return !state.getCollisionShape(level, pos).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.placeanimation;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.multiplayer.ClientLevel;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.level.block.RenderShape;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active place animations plus the set of positions the chunk mesher must skip. The hidden set is read
|
||||||
|
* from chunk-compile threads, so it is concurrent; everything else stays on the client thread.
|
||||||
|
*/
|
||||||
|
final class PlaceAnimations {
|
||||||
|
private static final double MAX_DISTANCE_SQR = 1024.0;
|
||||||
|
private static final int REBUILD_GRACE_TICKS = 2;
|
||||||
|
private static final int REBUILD_FLAGS = 10;
|
||||||
|
|
||||||
|
private final Map<BlockPos, PlacedBlock> active = new HashMap<>();
|
||||||
|
private final Set<BlockPos> hidden = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
void start(ClientLevel level, BlockPos pos, BlockState state, Player player, InteractionHand hand,
|
||||||
|
PlacedBlock.Params params, boolean animateBlockEntities) {
|
||||||
|
boolean model = state.getRenderShape() == RenderShape.MODEL;
|
||||||
|
if (!model && !animateBlockEntities) return;
|
||||||
|
if (pos.distToCenterSqr(Minecraft.getInstance().gameRenderer.mainCamera().position()) > MAX_DISTANCE_SQR) return;
|
||||||
|
|
||||||
|
BlockPos key = pos.immutable();
|
||||||
|
active.put(key, new PlacedBlock(level, key, state, player, hand, params));
|
||||||
|
if (model) {
|
||||||
|
hidden.add(key);
|
||||||
|
rebuild(level, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isHidden(BlockPos pos) {
|
||||||
|
return !hidden.isEmpty() && hidden.contains(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tick(ClientLevel level) {
|
||||||
|
if (active.isEmpty()) return;
|
||||||
|
for (Iterator<PlacedBlock> it = active.values().iterator(); it.hasNext(); ) {
|
||||||
|
PlacedBlock block = it.next();
|
||||||
|
if (block.level() != level) continue;
|
||||||
|
if (level.getBlockState(block.pos()) != block.state()) {
|
||||||
|
reveal(level, block.pos());
|
||||||
|
it.remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
block.tick();
|
||||||
|
if (block.finished()) reveal(level, block.pos());
|
||||||
|
if (block.expired(REBUILD_GRACE_TICKS)) it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void submit(PoseStack stack, SubmitNodeCollector collector, float partial) {
|
||||||
|
if (active.isEmpty()) return;
|
||||||
|
Vec3 camera = Minecraft.getInstance().gameRenderer.mainCamera().position();
|
||||||
|
for (PlacedBlock block : active.values()) {
|
||||||
|
if (block.rendersModel()) block.submit(stack, collector, camera, partial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyBlockEntity(BlockPos pos, PoseStack stack, float partial) {
|
||||||
|
if (active.isEmpty() || pos == null) return;
|
||||||
|
PlacedBlock block = active.get(pos);
|
||||||
|
if (block != null) block.apply(stack, partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
ClientLevel level = Minecraft.getInstance().level;
|
||||||
|
for (PlacedBlock block : active.values()) {
|
||||||
|
if (level != null && block.level() == level) reveal(level, block.pos());
|
||||||
|
}
|
||||||
|
active.clear();
|
||||||
|
hidden.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reveal(ClientLevel level, BlockPos pos) {
|
||||||
|
if (hidden.remove(pos)) rebuild(level, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rebuild(ClientLevel level, BlockPos pos) {
|
||||||
|
BlockState state = level.getBlockState(pos);
|
||||||
|
level.sendBlockUpdated(pos, state, state, REBUILD_FLAGS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.placeanimation;
|
||||||
|
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Pure maths of the place animation: easing curves and the hand-side slide direction. */
|
||||||
|
public final class PlaceMotion {
|
||||||
|
public static final float FINAL_SCALE = 0.999f;
|
||||||
|
|
||||||
|
private static final float SIDE_SHIFT = 0.1f;
|
||||||
|
private static final float FORWARD_SHIFT = 0.2f;
|
||||||
|
private static final float VERTICAL_SHIFT = 0.2f;
|
||||||
|
private static final float SLIDE_BASE = easeBase(0.9f);
|
||||||
|
private static final float TILT_BASE = easeBase(-0.08f);
|
||||||
|
private static final float SCALE_BASE = easeBase(-0.7f);
|
||||||
|
|
||||||
|
private PlaceMotion() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Animation progress 0..1; the extra tick of length lets the last frame land exactly. */
|
||||||
|
public static float progress(int age, float partial, int lifetime) {
|
||||||
|
return Math.min(1.0f, (age + partial) / (lifetime + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far the block still is from its place: 1 at the start, 0 when landed (accelerates into place). */
|
||||||
|
public static float slideRemaining(float progress) {
|
||||||
|
return 1.0f - ease(progress, SLIDE_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tilt in radians: starts at {@code maxTilt} and settles quickly. */
|
||||||
|
public static float tilt(float progress, float maxTilt) {
|
||||||
|
return (1.0f - ease(progress, TILT_BASE)) * maxTilt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block scale from {@code start} up to a hair under a full block (avoids z-fighting with the mesh). */
|
||||||
|
public static float scale(float start, float progress) {
|
||||||
|
return start + (FINAL_SCALE - start) * ease(progress, SCALE_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offset the block starts from, in world axes: back towards the player, to the hand's side, and up or
|
||||||
|
* down depending on where the player looks. {@code rightSide} is the side of the hand that placed it.
|
||||||
|
*/
|
||||||
|
public static Vector3f slideStart(float yawDegrees, float pitchDegrees, boolean rightSide, float distance) {
|
||||||
|
double yaw = Math.toRadians(yawDegrees);
|
||||||
|
float backX = (float) Math.sin(yaw);
|
||||||
|
float backZ = (float) -Math.cos(yaw);
|
||||||
|
float side = rightSide ? 1.0f : -1.0f;
|
||||||
|
float vertical = pitchDegrees > 0.0f ? VERTICAL_SHIFT : -VERTICAL_SHIFT;
|
||||||
|
return new Vector3f(
|
||||||
|
(backZ * side * SIDE_SHIFT + backX * FORWARD_SHIFT) * distance,
|
||||||
|
vertical * distance,
|
||||||
|
(-backX * side * SIDE_SHIFT + backZ * FORWARD_SHIFT) * distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the components that would push the block into a blocked neighbouring side. */
|
||||||
|
public static Vector3f restrict(Vector3f offset, Set<Direction> blocked) {
|
||||||
|
Vector3f out = new Vector3f(offset);
|
||||||
|
if (out.x > 0.0f && blocked.contains(Direction.EAST) || out.x < 0.0f && blocked.contains(Direction.WEST)) out.x = 0.0f;
|
||||||
|
if (out.y > 0.0f && blocked.contains(Direction.UP) || out.y < 0.0f && blocked.contains(Direction.DOWN)) out.y = 0.0f;
|
||||||
|
if (out.z > 0.0f && blocked.contains(Direction.SOUTH) || out.z < 0.0f && blocked.contains(Direction.NORTH)) out.z = 0.0f;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exponential ease through (0,0) and (1,1); positive bases accelerate, negative bases decelerate. */
|
||||||
|
static float ease(float t, float base) {
|
||||||
|
return (float) (base * Math.pow(1.0 / base + 1.0, t) - base);
|
||||||
|
}
|
||||||
|
|
||||||
|
static float easeBase(float k) {
|
||||||
|
return k > 0.0f ? (float) -Math.log(k) : (float) (Math.log(-k) - 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.placeanimation;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import net.minecraft.client.multiplayer.ClientLevel;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.client.renderer.block.MovingBlockRenderState;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.entity.HumanoidArm;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.level.block.RenderShape;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
|
||||||
|
import java.util.EnumSet;
|
||||||
|
|
||||||
|
/** One block sliding, tilting and scaling into its place; drawn as a moving block while the mesh hides it. */
|
||||||
|
final class PlacedBlock {
|
||||||
|
/** Animation settings captured at placement time. */
|
||||||
|
record Params(int ticks, float slide, float tiltRadians, float startScale) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final float EPSILON = 1.0E-6f;
|
||||||
|
private static final double NEAR_EDGE = 0.25;
|
||||||
|
private static final double FAR_EDGE = 0.75;
|
||||||
|
|
||||||
|
private final ClientLevel level;
|
||||||
|
private final BlockPos pos;
|
||||||
|
private final BlockState state;
|
||||||
|
private final boolean rendersModel;
|
||||||
|
private final Params params;
|
||||||
|
private final Vec3 offset;
|
||||||
|
private final float yaw;
|
||||||
|
private final float slideY;
|
||||||
|
private final float slideZ;
|
||||||
|
private final MovingBlockRenderState renderState = new MovingBlockRenderState();
|
||||||
|
private int age;
|
||||||
|
|
||||||
|
PlacedBlock(ClientLevel level, BlockPos pos, BlockState state, Player player, InteractionHand hand, Params params) {
|
||||||
|
this.level = level;
|
||||||
|
this.pos = pos;
|
||||||
|
this.state = state;
|
||||||
|
this.rendersModel = state.getRenderShape() == RenderShape.MODEL;
|
||||||
|
this.params = params;
|
||||||
|
this.offset = state.getOffset(pos);
|
||||||
|
|
||||||
|
boolean rightHand = (player.getMainArm() == HumanoidArm.RIGHT) == (hand == InteractionHand.MAIN_HAND);
|
||||||
|
Vector3f start = PlaceMotion.slideStart(player.getYRot(), player.getXRot(), rightHand, params.slide());
|
||||||
|
float length = start.length();
|
||||||
|
Vector3f free = restrictToFreeNeighbours(player, start);
|
||||||
|
float freeLength = free.length();
|
||||||
|
if (length < EPSILON || freeLength < EPSILON) {
|
||||||
|
yaw = 0.0f;
|
||||||
|
slideY = 0.0f;
|
||||||
|
slideZ = 0.0f;
|
||||||
|
} else {
|
||||||
|
free.div(freeLength);
|
||||||
|
yaw = (float) Math.atan2(free.x, free.z);
|
||||||
|
slideY = free.y * length;
|
||||||
|
slideZ = (float) (free.z * Math.cos(-yaw) - free.x * Math.sin(-yaw)) * length;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderState.blockState = state;
|
||||||
|
renderState.blockPos = pos;
|
||||||
|
renderState.randomSeedPos = pos;
|
||||||
|
renderState.biome = level.getBiome(pos);
|
||||||
|
renderState.cardinalLighting = level.cardinalLighting();
|
||||||
|
renderState.lightEngine = level.getLightEngine();
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientLevel level() {
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockPos pos() {
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockState state() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean rendersModel() {
|
||||||
|
return rendersModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
void tick() {
|
||||||
|
age++;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean finished() {
|
||||||
|
return age >= params.ticks();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean expired(int graceTicks) {
|
||||||
|
return age >= params.ticks() + graceTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
void submit(PoseStack stack, SubmitNodeCollector collector, Vec3 camera, float partial) {
|
||||||
|
stack.pushPose();
|
||||||
|
stack.translate(pos.getX() - camera.x, pos.getY() - camera.y, pos.getZ() - camera.z);
|
||||||
|
apply(stack, partial);
|
||||||
|
collector.submitMovingBlock(stack, renderState, 0);
|
||||||
|
stack.popPose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transforms a pose stack positioned at the block's corner into the animated pose. */
|
||||||
|
void apply(PoseStack stack, float partial) {
|
||||||
|
float progress = PlaceMotion.progress(age, partial, params.ticks());
|
||||||
|
float remaining = PlaceMotion.slideRemaining(progress);
|
||||||
|
float scale = PlaceMotion.scale(params.startScale(), progress);
|
||||||
|
float tilt = PlaceMotion.tilt(progress, params.tiltRadians());
|
||||||
|
|
||||||
|
stack.translate(0.5 + offset.x, 0.5 + offset.y, 0.5 + offset.z);
|
||||||
|
stack.mulPose(new Quaternionf().rotationY(yaw));
|
||||||
|
stack.translate(0.0f, slideY * remaining, slideZ * remaining);
|
||||||
|
stack.translate(0.0f, -0.5f, 0.0f);
|
||||||
|
stack.mulPose(new Quaternionf().rotationX(tilt));
|
||||||
|
stack.translate(0.0f, 0.5f, 0.0f);
|
||||||
|
stack.scale(scale, scale, scale);
|
||||||
|
stack.mulPose(new Quaternionf().rotationY(-yaw));
|
||||||
|
stack.translate(-0.5 - offset.x, -0.5 - offset.y, -0.5 - offset.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the slide from pushing the block through a solid neighbour on the side it would move towards. */
|
||||||
|
private Vector3f restrictToFreeNeighbours(Player player, Vector3f start) {
|
||||||
|
EnumSet<Direction> blocked = EnumSet.noneOf(Direction.class);
|
||||||
|
BlockPos.MutableBlockPos probe = new BlockPos.MutableBlockPos();
|
||||||
|
for (Direction direction : Direction.values()) {
|
||||||
|
probe.setWithOffset(pos, direction);
|
||||||
|
VoxelShape shape = level.getBlockState(probe).getCollisionShape(level, probe);
|
||||||
|
if (shape.isEmpty()) continue;
|
||||||
|
Direction.Axis axis = direction.getAxis();
|
||||||
|
boolean touches = direction.getAxisDirection() == Direction.AxisDirection.POSITIVE
|
||||||
|
? shape.min(axis) <= NEAR_EDGE
|
||||||
|
: shape.max(axis) >= FAR_EDGE;
|
||||||
|
if (touches) blocked.add(direction);
|
||||||
|
}
|
||||||
|
Vector3f free = PlaceMotion.restrict(start, blocked);
|
||||||
|
if (free.lengthSquared() >= EPSILON || blocked.isEmpty()) return free;
|
||||||
|
for (Direction direction : Direction.orderedByNearest(player)) {
|
||||||
|
if (!blocked.contains(direction)) return new Vector3f(direction.getStepX(), direction.getStepY(), direction.getStepZ());
|
||||||
|
}
|
||||||
|
return free;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.totems;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||||
|
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||||
|
import dev.loki.lovisual.features.module.core.Module;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
import dev.loki.lovisual.features.module.phase.HudPhase;
|
||||||
|
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
||||||
|
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
|
||||||
|
/** Totems of Undying in your inventory, drawn next to the hotbar; red when running low. */
|
||||||
|
@ModuleInfo(
|
||||||
|
id = "totemcounter",
|
||||||
|
displayName = "Totem Counter",
|
||||||
|
aliases = {"totemcounter", "totems"},
|
||||||
|
category = ModuleCategory.VISUALS
|
||||||
|
)
|
||||||
|
public class TotemCounter extends Module {
|
||||||
|
private static final int WHITE = 0xFFFFFFFF;
|
||||||
|
private static final int LOW = 0xFFFF5555;
|
||||||
|
|
||||||
|
private final BooleanValue showZero = bool("totemCounterShowZero", "show_zero", true);
|
||||||
|
private final NumberValue<Integer> lowAt = num("totemCounterLowAt", "low_at", 2, 0, 10);
|
||||||
|
private final NumberValue<Integer> offsetX = num("totemCounterOffsetX", "offset_x", 0, -300, 300);
|
||||||
|
private final NumberValue<Integer> offsetY = num("totemCounterOffsetY", "offset_y", 0, -300, 300);
|
||||||
|
|
||||||
|
private ItemStack icon;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HudPhase getHudPhase() {
|
||||||
|
return HudPhase.AFTER_SUBTITLES;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRenderHudEngine(Renderer2D renderer, TextRenderer textRenderer,
|
||||||
|
GuiGraphicsExtractor ctx, float tickDelta) {
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
if (!isEnabled() || mc.player == null) return;
|
||||||
|
|
||||||
|
int count = count(mc.player.getInventory());
|
||||||
|
String label = label(count, showZero.get());
|
||||||
|
if (label == null) return;
|
||||||
|
|
||||||
|
if (icon == null) icon = new ItemStack(Items.TOTEM_OF_UNDYING);
|
||||||
|
int x = ctx.guiWidth() / 2 + 98 + offsetX.get();
|
||||||
|
int y = ctx.guiHeight() - 20 + offsetY.get();
|
||||||
|
ctx.item(icon, x, y);
|
||||||
|
ctx.text(mc.font, label, x + 18, y + 4, color(count, lowAt.get()), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int count(Inventory inventory) {
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < inventory.getContainerSize(); i++) {
|
||||||
|
ItemStack stack = inventory.getItem(i);
|
||||||
|
if (stack.is(Items.TOTEM_OF_UNDYING)) total += stack.getCount();
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text shown beside the icon, or null when nothing should be drawn. */
|
||||||
|
static String label(int count, boolean showZero) {
|
||||||
|
if (count <= 0 && !showZero) return null;
|
||||||
|
return "x" + Math.max(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Red once the stock is at or below the threshold (threshold 0 only flags an empty stock). */
|
||||||
|
static int color(int count, int lowAt) {
|
||||||
|
return count <= lowAt ? LOW : WHITE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,9 @@ public final class LoVisualMixinPlugin implements IMixinConfigPlugin {
|
||||||
"dev.loki.lovisual.mixins.sodium.terrain.SodiumChunkMeshFormatsMixin",
|
"dev.loki.lovisual.mixins.sodium.terrain.SodiumChunkMeshFormatsMixin",
|
||||||
"dev.loki.lovisual.mixins.sodium.vertex.SodiumChunkVertexMixin",
|
"dev.loki.lovisual.mixins.sodium.vertex.SodiumChunkVertexMixin",
|
||||||
"dev.loki.lovisual.mixins.sodium.terrain.SodiumShaderChunkRendererMixin",
|
"dev.loki.lovisual.mixins.sodium.terrain.SodiumShaderChunkRendererMixin",
|
||||||
"dev.loki.lovisual.mixins.sodium.vertex.SodiumVertexConsumerTrackerMixin"
|
"dev.loki.lovisual.mixins.sodium.vertex.SodiumVertexConsumerTrackerMixin",
|
||||||
|
"dev.loki.lovisual.mixins.sodium.placement.SodiumPlaceCullMixin",
|
||||||
|
"dev.loki.lovisual.mixins.sodium.placement.SodiumPlaceHideMixin"
|
||||||
);
|
);
|
||||||
private static final Set<String> OPTIONAL_IRIS_MIXINS = Set.of(
|
private static final Set<String> OPTIONAL_IRIS_MIXINS = Set.of(
|
||||||
"dev.loki.lovisual.mixins.iris.render.IrisCommonUniformsMixin",
|
"dev.loki.lovisual.mixins.iris.render.IrisCommonUniformsMixin",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import org.spongepowered.asm.mixin.Shadow;
|
||||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.items.armor.ArmorColor;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.items.armor.ArmorColorState;
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.nametags.NameTags;
|
import dev.loki.lovisual.features.module.modules.visuals.nametags.NameTags;
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.visibility.NoRender;
|
import dev.loki.lovisual.features.module.modules.visuals.visibility.NoRender;
|
||||||
import dev.loki.lovisual.render.fade.ViewObstructionFadeContext;
|
import dev.loki.lovisual.render.fade.ViewObstructionFadeContext;
|
||||||
|
|
@ -73,6 +75,18 @@ public abstract class LivingEntityRendererMixin<T extends LivingEntity, S extend
|
||||||
fadeState.lovisual$setViewObstructionFadeAlpha(active ? alpha : 1.0f);
|
fadeState.lovisual$setViewObstructionFadeAlpha(active ? alpha : 1.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Inject(
|
||||||
|
method = "extractRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;F)V",
|
||||||
|
at = @At("TAIL")
|
||||||
|
)
|
||||||
|
private void lovisual$markArmorColored(T entity, S state, float tickProgress, CallbackInfo ci) {
|
||||||
|
if (!(state instanceof ArmorColorState armorState)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ArmorColor armorColor = Modules.get(ArmorColor.class);
|
||||||
|
armorState.lovisual$setArmorColored(armorColor != null && armorColor.covers(entity));
|
||||||
|
}
|
||||||
|
|
||||||
@ModifyExpressionValue(
|
@ModifyExpressionValue(
|
||||||
method = "extractRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;F)V",
|
method = "extractRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;F)V",
|
||||||
at = @At(value = "INVOKE", target = "Lnet/minecraft/util/Mth;rotLerp(FFF)F")
|
at = @At(value = "INVOKE", target = "Lnet/minecraft/util/Mth;rotLerp(FFF)F")
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,18 @@ package dev.loki.lovisual.mixins.render.entity;
|
||||||
import net.minecraft.client.renderer.entity.state.LivingEntityRenderState;
|
import net.minecraft.client.renderer.entity.state.LivingEntityRenderState;
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
import org.spongepowered.asm.mixin.Unique;
|
import org.spongepowered.asm.mixin.Unique;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.items.armor.ArmorColorState;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.models.CustomModelState;
|
||||||
import dev.loki.lovisual.render.fade.ViewObstructionFadeState;
|
import dev.loki.lovisual.render.fade.ViewObstructionFadeState;
|
||||||
|
|
||||||
@Mixin(LivingEntityRenderState.class)
|
@Mixin(LivingEntityRenderState.class)
|
||||||
public abstract class LivingEntityRenderStateMixin implements ViewObstructionFadeState {
|
public abstract class LivingEntityRenderStateMixin implements ViewObstructionFadeState, ArmorColorState, CustomModelState {
|
||||||
|
@Unique
|
||||||
|
private boolean lovisual$armorColored;
|
||||||
|
|
||||||
|
@Unique
|
||||||
|
private String lovisual$customModel;
|
||||||
|
|
||||||
@Unique
|
@Unique
|
||||||
private boolean lovisual$viewObstructionFadeActive;
|
private boolean lovisual$viewObstructionFadeActive;
|
||||||
|
|
||||||
|
|
@ -32,4 +40,24 @@ public abstract class LivingEntityRenderStateMixin implements ViewObstructionFad
|
||||||
public void lovisual$setViewObstructionFadeAlpha(float alpha) {
|
public void lovisual$setViewObstructionFadeAlpha(float alpha) {
|
||||||
this.lovisual$viewObstructionFadeAlpha = alpha;
|
this.lovisual$viewObstructionFadeAlpha = alpha;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean lovisual$isArmorColored() {
|
||||||
|
return lovisual$armorColored;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void lovisual$setArmorColored(boolean colored) {
|
||||||
|
this.lovisual$armorColored = colored;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String lovisual$customModel() {
|
||||||
|
return lovisual$customModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void lovisual$setCustomModel(String model) {
|
||||||
|
this.lovisual$customModel = model;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package dev.loki.lovisual.mixins.render.glint;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.items.glint.EnchantGlint;
|
||||||
|
import net.minecraft.client.renderer.rendertype.TextureTransform;
|
||||||
|
import net.minecraft.client.renderer.state.OptionsRenderState;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.ModifyVariable;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||||
|
|
||||||
|
/** EnchantGlint: скорость прокрутки и масштаб полос блеска в матрице текстуры. */
|
||||||
|
@Mixin(TextureTransform.class)
|
||||||
|
public abstract class GlintTextureMixin {
|
||||||
|
|
||||||
|
@ModifyVariable(method = "setupGlintTexturing", at = @At("HEAD"), argsOnly = true)
|
||||||
|
private static float lovisual$glintScale(float scale) {
|
||||||
|
EnchantGlint glint = Modules.get(EnchantGlint.class);
|
||||||
|
return glint == null ? scale : scale * glint.scaleFactor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Redirect(method = "setupGlintTexturing",
|
||||||
|
at = @At(value = "FIELD", target = "Lnet/minecraft/client/renderer/state/OptionsRenderState;glintSpeed:D"))
|
||||||
|
private static double lovisual$glintSpeed(OptionsRenderState state) {
|
||||||
|
EnchantGlint glint = Modules.get(EnchantGlint.class);
|
||||||
|
return glint == null ? state.glintSpeed : state.glintSpeed * glint.speedFactor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
package dev.loki.lovisual.mixins.render.item;
|
package dev.loki.lovisual.mixins.render.item;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.items.armor.ArmorColor;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.items.armor.ArmorTint;
|
||||||
|
import net.minecraft.client.model.Model;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
import net.minecraft.client.renderer.entity.layers.EquipmentLayerRenderer;
|
import net.minecraft.client.renderer.entity.layers.EquipmentLayerRenderer;
|
||||||
|
import net.minecraft.client.resources.model.EquipmentClientInfo;
|
||||||
|
import net.minecraft.resources.ResourceKey;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.equipment.EquipmentAsset;
|
||||||
|
import org.spongepowered.asm.mixin.Unique;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.ModifyArg;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
import net.minecraft.client.renderer.rendertype.RenderType;
|
import net.minecraft.client.renderer.rendertype.RenderType;
|
||||||
import net.minecraft.client.renderer.rendertype.RenderTypes;
|
import net.minecraft.client.renderer.rendertype.RenderTypes;
|
||||||
import net.minecraft.resources.Identifier;
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
@ -25,4 +39,24 @@ public abstract class EquipmentLayerRendererMixin {
|
||||||
}
|
}
|
||||||
return RenderTypes.armorCutoutNoCull(texture);
|
return RenderTypes.armorCutoutNoCull(texture);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Unique
|
||||||
|
private int lovisual$armorTint = ArmorTint.NO_TINT;
|
||||||
|
|
||||||
|
/** ArmorColor: тонировка считается один раз на кусок брони (прочность и цель из состояния рендера). */
|
||||||
|
@Inject(method = "renderLayers(Lnet/minecraft/client/resources/model/EquipmentClientInfo$LayerType;Lnet/minecraft/resources/ResourceKey;Lnet/minecraft/client/model/Model;Ljava/lang/Object;Lnet/minecraft/world/item/ItemStack;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;ILnet/minecraft/resources/Identifier;II)V", at = @At("HEAD"))
|
||||||
|
private void lovisual$captureArmorTint(EquipmentClientInfo.LayerType layerType, ResourceKey<EquipmentAsset> asset,
|
||||||
|
Model<?> model, Object renderState, ItemStack stack, PoseStack matrices,
|
||||||
|
SubmitNodeCollector queue, int light, Identifier texture,
|
||||||
|
int outlineColor, int order, CallbackInfo ci) {
|
||||||
|
ArmorColor module = Modules.get(ArmorColor.class);
|
||||||
|
lovisual$armorTint = module == null ? ArmorTint.NO_TINT : module.tintFor(stack, renderState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModifyArg(method = "renderLayers(Lnet/minecraft/client/resources/model/EquipmentClientInfo$LayerType;Lnet/minecraft/resources/ResourceKey;Lnet/minecraft/client/model/Model;Ljava/lang/Object;Lnet/minecraft/world/item/ItemStack;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;ILnet/minecraft/resources/Identifier;II)V",
|
||||||
|
at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/OrderedSubmitNodeCollector;submitModel(Lnet/minecraft/client/model/Model;Ljava/lang/Object;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/rendertype/RenderType;IIILnet/minecraft/client/renderer/texture/TextureAtlasSprite;ILnet/minecraft/client/renderer/feature/ModelFeatureRenderer$CrumblingOverlay;)V"),
|
||||||
|
index = 6)
|
||||||
|
private int lovisual$tintArmorLayer(int color) {
|
||||||
|
return ArmorTint.apply(color, lovisual$armorTint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
package dev.loki.lovisual.mixins.render.models;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.models.CustomModelState;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.models.CustomModels;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.client.renderer.entity.player.AvatarRenderer;
|
||||||
|
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.world.entity.Avatar;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
/** CustomModels: выбор модели на кадр, без слоёв брони и своя рука от первого лица. */
|
||||||
|
@Mixin(AvatarRenderer.class)
|
||||||
|
public abstract class AvatarCustomModelMixin {
|
||||||
|
|
||||||
|
@Inject(method = "extractRenderState(Lnet/minecraft/world/entity/Avatar;Lnet/minecraft/client/renderer/entity/state/AvatarRenderState;F)V",
|
||||||
|
at = @At("TAIL"))
|
||||||
|
private void lovisual$captureCustomModel(Avatar avatar, AvatarRenderState state, float tickProgress, CallbackInfo ci) {
|
||||||
|
CustomModels models = Modules.get(CustomModels.class);
|
||||||
|
((CustomModelState) state).lovisual$setCustomModel(models == null ? null : models.modelFor(avatar));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Броня, элитры и прочие слои принадлежат модели игрока: у кастомной модели их нет. */
|
||||||
|
@Inject(method = "shouldRenderLayers(Lnet/minecraft/client/renderer/entity/state/AvatarRenderState;)Z",
|
||||||
|
at = @At("HEAD"), cancellable = true)
|
||||||
|
private void lovisual$hideLayersOnCustomModel(AvatarRenderState state, CallbackInfoReturnable<Boolean> cir) {
|
||||||
|
if (((CustomModelState) state).lovisual$customModel() != null) cir.setReturnValue(Boolean.FALSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(method = "renderRightHand(Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;ILnet/minecraft/resources/Identifier;Z)V",
|
||||||
|
at = @At("HEAD"), cancellable = true)
|
||||||
|
private void lovisual$customRightHand(PoseStack pose, SubmitNodeCollector collector, int light,
|
||||||
|
Identifier skin, boolean sleeve, CallbackInfo ci) {
|
||||||
|
lovisual$hand(pose, collector, light, true, ci);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(method = "renderLeftHand(Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;ILnet/minecraft/resources/Identifier;Z)V",
|
||||||
|
at = @At("HEAD"), cancellable = true)
|
||||||
|
private void lovisual$customLeftHand(PoseStack pose, SubmitNodeCollector collector, int light,
|
||||||
|
Identifier skin, boolean sleeve, CallbackInfo ci) {
|
||||||
|
lovisual$hand(pose, collector, light, false, ci);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void lovisual$hand(PoseStack pose, SubmitNodeCollector collector, int light, boolean right, CallbackInfo ci) {
|
||||||
|
CustomModels models = Modules.get(CustomModels.class);
|
||||||
|
if (models == null || !models.handFromModel()) return;
|
||||||
|
models.submitHand(pose, collector, light, right);
|
||||||
|
ci.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package dev.loki.lovisual.mixins.render.models;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.models.CustomModelState;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.models.CustomModels;
|
||||||
|
import net.minecraft.client.model.Model;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.client.renderer.entity.LivingEntityRenderer;
|
||||||
|
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
|
||||||
|
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
|
||||||
|
import net.minecraft.client.renderer.rendertype.RenderType;
|
||||||
|
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||||
|
|
||||||
|
/** CustomModels: модель игрока подменяется ровно там, где её передают на отрисовку. */
|
||||||
|
@Mixin(LivingEntityRenderer.class)
|
||||||
|
public abstract class LivingEntityCustomModelMixin {
|
||||||
|
|
||||||
|
@Redirect(
|
||||||
|
method = "submit(Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/client/renderer/state/level/CameraRenderState;)V",
|
||||||
|
at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/SubmitNodeCollector;submitModel(Lnet/minecraft/client/model/Model;Ljava/lang/Object;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/rendertype/RenderType;IIILnet/minecraft/client/renderer/texture/TextureAtlasSprite;ILnet/minecraft/client/renderer/feature/ModelFeatureRenderer$CrumblingOverlay;)V"))
|
||||||
|
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||||
|
private void lovisual$submitCustomModel(SubmitNodeCollector collector, Model model, Object state, PoseStack pose,
|
||||||
|
RenderType renderType, int light, int overlay, int color,
|
||||||
|
TextureAtlasSprite sprite, int outlineColor,
|
||||||
|
ModelFeatureRenderer.CrumblingOverlay crumbling) {
|
||||||
|
String custom = state instanceof CustomModelState carrier ? carrier.lovisual$customModel() : null;
|
||||||
|
CustomModels models = custom == null ? null : Modules.get(CustomModels.class);
|
||||||
|
if (models != null && state instanceof AvatarRenderState avatarState
|
||||||
|
&& models.submit(custom, model, avatarState, pose, collector, light, overlay, color, outlineColor)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// the raw types are needed here: the intercepted call has an erased state type
|
||||||
|
collector.submitModel(model, state, pose, renderType, light, overlay, color, sprite, outlineColor, crumbling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package dev.loki.lovisual.mixins.render.placement;
|
||||||
|
|
||||||
|
import com.llamalad7.mixinextras.sugar.Local;
|
||||||
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.placeanimation.PlaceAnimation;
|
||||||
|
import net.minecraft.client.multiplayer.ClientLevel;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.world.InteractionResult;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.BlockItem;
|
||||||
|
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
/** PlaceAnimation: старт анимации сразу после того, как блок поставлен на клиентском уровне. */
|
||||||
|
@Mixin(BlockItem.class)
|
||||||
|
public abstract class BlockItemPlaceMixin {
|
||||||
|
|
||||||
|
@Inject(method = "place(Lnet/minecraft/world/item/context/BlockPlaceContext;)Lnet/minecraft/world/InteractionResult;",
|
||||||
|
at = @At(value = "INVOKE",
|
||||||
|
target = "Lnet/minecraft/world/item/BlockItem;updateBlockStateFromTag(Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/Level;Lnet/minecraft/world/item/ItemStack;Lnet/minecraft/world/level/block/state/BlockState;)Lnet/minecraft/world/level/block/state/BlockState;"))
|
||||||
|
private void lovisual$startPlaceAnimation(BlockPlaceContext context, CallbackInfoReturnable<InteractionResult> cir,
|
||||||
|
@Local Level level, @Local BlockPos pos, @Local Player player,
|
||||||
|
@Local(ordinal = 0) BlockState placementState,
|
||||||
|
@Local(ordinal = 1) BlockState placedState) {
|
||||||
|
if (!(level instanceof ClientLevel clientLevel) || !placedState.is(placementState.getBlock())) return;
|
||||||
|
PlaceAnimation module = Modules.get(PlaceAnimation.class);
|
||||||
|
if (module != null) module.onBlockPlaced(clientLevel, pos, placedState, player, context.getHand());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package dev.loki.lovisual.mixins.render.placement;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.placeanimation.PlaceAnimation;
|
||||||
|
import net.minecraft.client.renderer.block.BlockAndTintGetter;
|
||||||
|
import net.minecraft.client.renderer.block.ModelBlockRenderer;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
/** PlaceAnimation: грани соседей рядом с анимируемым блоком рисуются, пока тот скрыт из меша. */
|
||||||
|
@Mixin(ModelBlockRenderer.class)
|
||||||
|
public abstract class ModelBlockRendererPlaceMixin {
|
||||||
|
|
||||||
|
@Inject(method = "shouldRenderFace(Lnet/minecraft/client/renderer/block/BlockAndTintGetter;Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/core/Direction;Lnet/minecraft/core/BlockPos;)Z",
|
||||||
|
at = @At("HEAD"), cancellable = true)
|
||||||
|
private void lovisual$revealFacesNextToPlacingBlock(BlockAndTintGetter level, BlockState state, Direction direction,
|
||||||
|
BlockPos neighborPos, CallbackInfoReturnable<Boolean> cir) {
|
||||||
|
PlaceAnimation module = Modules.get(PlaceAnimation.class);
|
||||||
|
if (module != null && module.isHidden(neighborPos)) cir.setReturnValue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package dev.loki.lovisual.mixins.render.placement;
|
||||||
|
|
||||||
|
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
|
||||||
|
import com.llamalad7.mixinextras.sugar.Local;
|
||||||
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.placeanimation.PlaceAnimation;
|
||||||
|
import net.minecraft.client.renderer.chunk.SectionCompiler;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.world.level.block.RenderShape;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
|
||||||
|
/** PlaceAnimation: блок, который сейчас анимируется, не попадает в меш секции и не закрывает соседей. */
|
||||||
|
@Mixin(SectionCompiler.class)
|
||||||
|
public abstract class SectionCompilerPlaceMixin {
|
||||||
|
private static final String COMPILE = "compile(Lnet/minecraft/core/SectionPos;Lnet/minecraft/client/renderer/chunk/RenderSectionRegion;Lcom/mojang/blaze3d/vertex/VertexSorting;Lnet/minecraft/client/renderer/SectionBufferBuilderPack;)Lnet/minecraft/client/renderer/chunk/SectionCompiler$Results;";
|
||||||
|
|
||||||
|
@ModifyExpressionValue(method = COMPILE,
|
||||||
|
at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;getRenderShape()Lnet/minecraft/world/level/block/RenderShape;"))
|
||||||
|
private RenderShape lovisual$hidePlacingBlock(RenderShape original, @Local(ordinal = 2) BlockPos pos) {
|
||||||
|
return original == RenderShape.MODEL && lovisual$hidden(pos) ? RenderShape.INVISIBLE : original;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModifyExpressionValue(method = COMPILE,
|
||||||
|
at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/state/BlockState;isSolidRender()Z"))
|
||||||
|
private boolean lovisual$unblockPlacingBlock(boolean original, @Local(ordinal = 2) BlockPos pos) {
|
||||||
|
return original && !lovisual$hidden(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean lovisual$hidden(BlockPos pos) {
|
||||||
|
PlaceAnimation module = Modules.get(PlaceAnimation.class);
|
||||||
|
return module != null && module.isHidden(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package dev.loki.lovisual.mixins.render.placement.submit;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.placeanimation.PlaceAnimation;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.client.renderer.blockentity.BlockEntityRenderDispatcher;
|
||||||
|
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
|
||||||
|
import net.minecraft.client.renderer.state.level.CameraRenderState;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
/** PlaceAnimation: блок-сущности (сундуки, таблички) анимируются преобразованием позы, а не подменой меша. */
|
||||||
|
@Mixin(BlockEntityRenderDispatcher.class)
|
||||||
|
public abstract class BlockEntityPlaceMixin {
|
||||||
|
|
||||||
|
@Inject(method = "submit(Lnet/minecraft/client/renderer/blockentity/state/BlockEntityRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/client/renderer/state/level/CameraRenderState;)V",
|
||||||
|
at = @At("HEAD"))
|
||||||
|
private void lovisual$animatePlacingBlockEntity(BlockEntityRenderState renderState, PoseStack poseStack,
|
||||||
|
SubmitNodeCollector collector, CameraRenderState camera, CallbackInfo ci) {
|
||||||
|
PlaceAnimation module = Modules.get(PlaceAnimation.class);
|
||||||
|
if (module == null) return;
|
||||||
|
module.applyBlockEntity(renderState.blockPos, poseStack, Minecraft.getInstance().getDeltaTracker().getGameTimeDeltaPartialTick(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package dev.loki.lovisual.mixins.render.placement.submit;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.placeanimation.PlaceAnimation;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.renderer.LevelRenderer;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.client.renderer.state.level.LevelRenderState;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
/** PlaceAnimation: анимируемые блоки отправляются на отрисовку вместе с сущностями. */
|
||||||
|
@Mixin(LevelRenderer.class)
|
||||||
|
public abstract class LevelRendererPlaceMixin {
|
||||||
|
|
||||||
|
@Inject(method = "submitEntities(Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/state/level/LevelRenderState;Lnet/minecraft/client/renderer/SubmitNodeCollector;)V",
|
||||||
|
at = @At("TAIL"))
|
||||||
|
private void lovisual$submitPlacingBlocks(PoseStack poseStack, LevelRenderState state, SubmitNodeCollector collector, CallbackInfo ci) {
|
||||||
|
PlaceAnimation module = Modules.get(PlaceAnimation.class);
|
||||||
|
if (module == null) return;
|
||||||
|
module.submit(poseStack, collector, Minecraft.getInstance().getDeltaTracker().getGameTimeDeltaPartialTick(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package dev.loki.lovisual.mixins.sodium.placement;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.placeanimation.PlaceAnimation;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import org.spongepowered.asm.mixin.Final;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Pseudo;
|
||||||
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
/** PlaceAnimation под Sodium: соседние грани рисуются, пока анимируемый блок скрыт из меша. */
|
||||||
|
@Pseudo
|
||||||
|
@Mixin(targets = "net.caffeinemc.mods.sodium.client.render.model.AbstractBlockRenderContext")
|
||||||
|
public abstract class SodiumPlaceCullMixin {
|
||||||
|
|
||||||
|
@Shadow(remap = false)
|
||||||
|
@Final
|
||||||
|
private BlockPos.MutableBlockPos cachedPositionObject;
|
||||||
|
|
||||||
|
@Inject(method = "shouldDrawSide(Lnet/minecraft/core/Direction;)Z",
|
||||||
|
at = @At(value = "INVOKE",
|
||||||
|
target = "Lnet/minecraft/client/renderer/block/BlockAndTintGetter;getBlockState(Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/block/state/BlockState;",
|
||||||
|
shift = At.Shift.BEFORE),
|
||||||
|
cancellable = true, remap = false, require = 0)
|
||||||
|
private void lovisual$revealFacesNextToPlacingBlock(Direction direction, CallbackInfoReturnable<Boolean> cir) {
|
||||||
|
PlaceAnimation module = Modules.get(PlaceAnimation.class);
|
||||||
|
if (module != null && module.isHidden(cachedPositionObject)) cir.setReturnValue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package dev.loki.lovisual.mixins.sodium.placement;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.placeanimation.PlaceAnimation;
|
||||||
|
import net.minecraft.client.renderer.block.dispatch.BlockStateModel;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Pseudo;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlaceAnimation под Sodium: блок не попадает в меш. Приоритет ниже, чем у SodiumBlockRendererMixin,
|
||||||
|
* чтобы отмена шла до push состояния поверхности и стек не терял баланс.
|
||||||
|
*/
|
||||||
|
@Pseudo
|
||||||
|
@Mixin(targets = "net.caffeinemc.mods.sodium.client.render.chunk.compile.pipeline.BlockRenderer", priority = 500)
|
||||||
|
public abstract class SodiumPlaceHideMixin {
|
||||||
|
|
||||||
|
@Inject(method = "renderModel", at = @At("HEAD"), cancellable = true, remap = false, require = 0)
|
||||||
|
private void lovisual$hidePlacingBlock(BlockStateModel model, BlockState state, BlockPos pos, BlockPos origin, CallbackInfo ci) {
|
||||||
|
PlaceAnimation module = Modules.get(PlaceAnimation.class);
|
||||||
|
if (module != null && module.isHidden(pos)) ci.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3735,5 +3735,65 @@
|
||||||
"setting.elytratrails.color_mode.option.Fade": "Fade",
|
"setting.elytratrails.color_mode.option.Fade": "Fade",
|
||||||
"setting.elytratrails.color_mode.option.DoubleColor": "Double Color",
|
"setting.elytratrails.color_mode.option.DoubleColor": "Double Color",
|
||||||
"setting.elytratrails.color_mode.option.Analogous": "Analogous",
|
"setting.elytratrails.color_mode.option.Analogous": "Analogous",
|
||||||
"setting.elytratrails.color_mode.option.Theme": "Theme"
|
"setting.elytratrails.color_mode.option.Theme": "Theme",
|
||||||
|
"module.autosprint.description": "Holds the sprint key for you while you move forward.",
|
||||||
|
"setting.autosprint.all_directions": "All directions",
|
||||||
|
"setting.autosprint.while_using": "While using an item",
|
||||||
|
"module.totemcounter.description": "Shows how many totems of undying you carry next to the hotbar.",
|
||||||
|
"setting.totemcounter.show_zero": "Show when zero",
|
||||||
|
"setting.totemcounter.low_at": "Warn at or below",
|
||||||
|
"setting.totemcounter.offset_x": "Offset X",
|
||||||
|
"setting.totemcounter.offset_y": "Offset Y",
|
||||||
|
"module.enchantglint.description": "Restyles the enchantment glint: rainbow, solid or gradient colours, strength, speed and stripe size.",
|
||||||
|
"setting.enchantglint.style": "Style",
|
||||||
|
"setting.enchantglint.style.option.Rainbow": "Rainbow",
|
||||||
|
"setting.enchantglint.style.option.Solid": "Solid",
|
||||||
|
"setting.enchantglint.style.option.Gradient": "Gradient",
|
||||||
|
"setting.enchantglint.style.option.Hidden": "Hidden",
|
||||||
|
"setting.enchantglint.color": "Colour",
|
||||||
|
"setting.enchantglint.color2": "Second colour",
|
||||||
|
"setting.enchantglint.strength": "Strength",
|
||||||
|
"setting.enchantglint.speed": "Speed",
|
||||||
|
"setting.enchantglint.scale": "Stripe scale",
|
||||||
|
"setting.enchantglint.items": "Items",
|
||||||
|
"setting.enchantglint.armor": "Armor",
|
||||||
|
"module.armorcolor.description": "Colours worn armor by its remaining durability.",
|
||||||
|
"setting.armorcolor.targets": "Targets",
|
||||||
|
"setting.armorcolor.targets.option.players": "Players",
|
||||||
|
"setting.armorcolor.targets.option.friends": "Friends",
|
||||||
|
"setting.armorcolor.targets.option.self": "Self",
|
||||||
|
"setting.armorcolor.targets.option.mobs": "Mobs",
|
||||||
|
"setting.armorcolor.color_mode": "Colour mode",
|
||||||
|
"setting.armorcolor.color_mode.option.Gradient": "Gradient",
|
||||||
|
"setting.armorcolor.color_mode.option.Steps": "Steps",
|
||||||
|
"setting.armorcolor.full_color": "Full colour",
|
||||||
|
"setting.armorcolor.warn_color": "Warning colour",
|
||||||
|
"setting.armorcolor.broken_color": "Broken colour",
|
||||||
|
"setting.armorcolor.strength": "Strength",
|
||||||
|
"setting.armorcolor.only_below": "Only below (%)",
|
||||||
|
"setting.armorcolor.pulse": "Low pulse",
|
||||||
|
"module.placeanimation.description": "Placed blocks slide in from your hand, tilting and growing into position.",
|
||||||
|
"setting.placeanimation.duration": "Duration (ticks)",
|
||||||
|
"setting.placeanimation.slide": "Slide",
|
||||||
|
"setting.placeanimation.tilt": "Tilt",
|
||||||
|
"setting.placeanimation.start_scale": "Start scale",
|
||||||
|
"setting.placeanimation.block_entities": "Block entities",
|
||||||
|
"module.custommodels.description": "Draws chosen players as a chicken, rabbit or Verity instead of their skin. Only you see it.",
|
||||||
|
"setting.custommodels.model": "Model",
|
||||||
|
"setting.custommodels.model.option.Chicken": "Chicken",
|
||||||
|
"setting.custommodels.model.option.Verity": "Verity",
|
||||||
|
"setting.custommodels.model.option.Rabbit": "Rabbit",
|
||||||
|
"setting.custommodels.self": "On myself",
|
||||||
|
"setting.custommodels.friends": "On friends",
|
||||||
|
"setting.custommodels.others": "On all other players",
|
||||||
|
"setting.custommodels.hand": "Hand from the model",
|
||||||
|
"setting.custommodels.size": "Size",
|
||||||
|
"setting.custommodels.face": "Face",
|
||||||
|
"setting.custommodels.face.option.Normal": "Normal",
|
||||||
|
"setting.custommodels.face.option.Talking": "Talking",
|
||||||
|
"setting.custommodels.face.option.Disturbing": "Disturbing",
|
||||||
|
"setting.custommodels.face.option.Disturbing talking": "Disturbing talking",
|
||||||
|
"setting.custommodels.face.option.Disturbing yelling": "Disturbing yelling",
|
||||||
|
"setting.custommodels.face.option.Bad": "Bad",
|
||||||
|
"setting.custommodels.face.option.Very bad": "Very bad"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3526,5 +3526,65 @@
|
||||||
"setting.elytratrails.color_mode.option.Fade": "Плавный",
|
"setting.elytratrails.color_mode.option.Fade": "Плавный",
|
||||||
"setting.elytratrails.color_mode.option.DoubleColor": "Два цвета",
|
"setting.elytratrails.color_mode.option.DoubleColor": "Два цвета",
|
||||||
"setting.elytratrails.color_mode.option.Analogous": "Аналоговый",
|
"setting.elytratrails.color_mode.option.Analogous": "Аналоговый",
|
||||||
"setting.elytratrails.color_mode.option.Theme": "Тема"
|
"setting.elytratrails.color_mode.option.Theme": "Тема",
|
||||||
|
"module.autosprint.description": "Сам удерживает бег, пока вы двигаетесь вперёд.",
|
||||||
|
"setting.autosprint.all_directions": "Во всех направлениях",
|
||||||
|
"setting.autosprint.while_using": "При использовании предмета",
|
||||||
|
"module.totemcounter.description": "Показывает у хотбара, сколько тотемов бессмертия у вас с собой.",
|
||||||
|
"setting.totemcounter.show_zero": "Показывать при нуле",
|
||||||
|
"setting.totemcounter.low_at": "Предупреждать при",
|
||||||
|
"setting.totemcounter.offset_x": "Смещение X",
|
||||||
|
"setting.totemcounter.offset_y": "Смещение Y",
|
||||||
|
"module.enchantglint.description": "Меняет вид блеска зачарования: радуга, один цвет или градиент, сила, скорость и размер полос.",
|
||||||
|
"setting.enchantglint.style": "Стиль",
|
||||||
|
"setting.enchantglint.style.option.Rainbow": "Радуга",
|
||||||
|
"setting.enchantglint.style.option.Solid": "Один цвет",
|
||||||
|
"setting.enchantglint.style.option.Gradient": "Градиент",
|
||||||
|
"setting.enchantglint.style.option.Hidden": "Скрыт",
|
||||||
|
"setting.enchantglint.color": "Цвет",
|
||||||
|
"setting.enchantglint.color2": "Второй цвет",
|
||||||
|
"setting.enchantglint.strength": "Сила",
|
||||||
|
"setting.enchantglint.speed": "Скорость",
|
||||||
|
"setting.enchantglint.scale": "Размер полос",
|
||||||
|
"setting.enchantglint.items": "Предметы",
|
||||||
|
"setting.enchantglint.armor": "Броня",
|
||||||
|
"module.armorcolor.description": "Красит надетую броню по оставшейся прочности.",
|
||||||
|
"setting.armorcolor.targets": "Цели",
|
||||||
|
"setting.armorcolor.targets.option.players": "Игроки",
|
||||||
|
"setting.armorcolor.targets.option.friends": "Друзья",
|
||||||
|
"setting.armorcolor.targets.option.self": "Вы",
|
||||||
|
"setting.armorcolor.targets.option.mobs": "Мобы",
|
||||||
|
"setting.armorcolor.color_mode": "Режим цвета",
|
||||||
|
"setting.armorcolor.color_mode.option.Gradient": "Градиент",
|
||||||
|
"setting.armorcolor.color_mode.option.Steps": "Ступени",
|
||||||
|
"setting.armorcolor.full_color": "Цвет целой брони",
|
||||||
|
"setting.armorcolor.warn_color": "Цвет предупреждения",
|
||||||
|
"setting.armorcolor.broken_color": "Цвет почти сломанной",
|
||||||
|
"setting.armorcolor.strength": "Сила",
|
||||||
|
"setting.armorcolor.only_below": "Только ниже (%)",
|
||||||
|
"setting.armorcolor.pulse": "Пульс при износе",
|
||||||
|
"module.placeanimation.description": "Поставленные блоки вылетают из руки, наклоняясь и вырастая на своё место.",
|
||||||
|
"setting.placeanimation.duration": "Длительность (тики)",
|
||||||
|
"setting.placeanimation.slide": "Сдвиг",
|
||||||
|
"setting.placeanimation.tilt": "Наклон",
|
||||||
|
"setting.placeanimation.start_scale": "Начальный размер",
|
||||||
|
"setting.placeanimation.block_entities": "Блок-сущности",
|
||||||
|
"module.custommodels.description": "Рисует выбранных игроков курицей, кроликом или Verity вместо их скина. Видите только вы.",
|
||||||
|
"setting.custommodels.model": "Модель",
|
||||||
|
"setting.custommodels.model.option.Chicken": "Курица",
|
||||||
|
"setting.custommodels.model.option.Verity": "Verity",
|
||||||
|
"setting.custommodels.model.option.Rabbit": "Кролик",
|
||||||
|
"setting.custommodels.self": "На себя",
|
||||||
|
"setting.custommodels.friends": "На друзей",
|
||||||
|
"setting.custommodels.others": "На всех остальных игроков",
|
||||||
|
"setting.custommodels.hand": "Рука от модели",
|
||||||
|
"setting.custommodels.size": "Размер",
|
||||||
|
"setting.custommodels.face": "Лицо",
|
||||||
|
"setting.custommodels.face.option.Normal": "Обычное",
|
||||||
|
"setting.custommodels.face.option.Talking": "Говорит",
|
||||||
|
"setting.custommodels.face.option.Disturbing": "Странное",
|
||||||
|
"setting.custommodels.face.option.Disturbing talking": "Странное говорит",
|
||||||
|
"setting.custommodels.face.option.Disturbing yelling": "Странное кричит",
|
||||||
|
"setting.custommodels.face.option.Bad": "Плохое",
|
||||||
|
"setting.custommodels.face.option.Very bad": "Очень плохое"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 20 KiB |
BIN
mod/src/main/resources/assets/lovisual/models/rabbit.png
Normal file
|
After Width: | Height: | Size: 391 B |
BIN
mod/src/main/resources/assets/lovisual/models/verity/verity.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
|
@ -149,12 +149,22 @@
|
||||||
"render.entity.item.ItemEntityRendererMixin",
|
"render.entity.item.ItemEntityRendererMixin",
|
||||||
"render.entity.LivingEntityRenderStateMixin",
|
"render.entity.LivingEntityRenderStateMixin",
|
||||||
"render.fog.AtmosphericFogEnvironmentMixin",
|
"render.fog.AtmosphericFogEnvironmentMixin",
|
||||||
|
"render.glint.GlintTextureMixin",
|
||||||
"render.weather.BlizzardFogMixin",
|
"render.weather.BlizzardFogMixin",
|
||||||
"render.fog.FogRendererMixin",
|
"render.fog.FogRendererMixin",
|
||||||
"render.fog.LavaFogModifierMixin",
|
"render.fog.LavaFogModifierMixin",
|
||||||
"render.fog.WaterFogEnvironmentMixin",
|
"render.fog.WaterFogEnvironmentMixin",
|
||||||
"render.fogmod.PowderSnowFogModifierMixin",
|
"render.fogmod.PowderSnowFogModifierMixin",
|
||||||
"render.item.EquipmentLayerRendererMixin",
|
"render.item.EquipmentLayerRendererMixin",
|
||||||
|
"render.models.AvatarCustomModelMixin",
|
||||||
|
"render.models.LivingEntityCustomModelMixin",
|
||||||
|
"render.placement.submit.BlockEntityPlaceMixin",
|
||||||
|
"render.placement.BlockItemPlaceMixin",
|
||||||
|
"render.placement.ModelBlockRendererPlaceMixin",
|
||||||
|
"render.placement.SectionCompilerPlaceMixin",
|
||||||
|
"render.placement.submit.LevelRendererPlaceMixin",
|
||||||
|
"sodium.placement.SodiumPlaceCullMixin",
|
||||||
|
"sodium.placement.SodiumPlaceHideMixin",
|
||||||
"render.item.ItemFeatureRendererMixin",
|
"render.item.ItemFeatureRendererMixin",
|
||||||
"render.item.ItemInHandAlwaysVisibleMixin",
|
"render.item.ItemInHandAlwaysVisibleMixin",
|
||||||
"render.item.model.ItemModelAnimatorMixin",
|
"render.item.model.ItemModelAnimatorMixin",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.player;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
public final class AutoSprintTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sprintsOnlyForwardByDefault() {
|
||||||
|
assertTrue(AutoSprint.wantsSprint(1f, 0f, false, false, false, false));
|
||||||
|
assertTrue(AutoSprint.wantsSprint(1f, 1f, false, false, false, false));
|
||||||
|
assertFalse(AutoSprint.wantsSprint(0f, 1f, false, false, false, false));
|
||||||
|
assertFalse(AutoSprint.wantsSprint(-1f, 0f, false, false, false, false));
|
||||||
|
assertFalse(AutoSprint.wantsSprint(0f, 0f, false, false, false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allDirectionsAcceptsAnyMovement() {
|
||||||
|
assertTrue(AutoSprint.wantsSprint(0f, -1f, true, false, false, false));
|
||||||
|
assertTrue(AutoSprint.wantsSprint(-1f, 0f, true, false, false, false));
|
||||||
|
assertFalse(AutoSprint.wantsSprint(0f, 0f, true, false, false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sneakingAlwaysCancels() {
|
||||||
|
assertFalse(AutoSprint.wantsSprint(1f, 0f, true, true, false, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usingItemCancelsUnlessAllowed() {
|
||||||
|
assertFalse(AutoSprint.wantsSprint(1f, 0f, false, false, true, false));
|
||||||
|
assertTrue(AutoSprint.wantsSprint(1f, 0f, false, false, true, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.items.armor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
public final class ArmorTintTest {
|
||||||
|
private static final int FULL = 0x00FF00;
|
||||||
|
private static final int WARN = 0xFFFF00;
|
||||||
|
private static final int BROKEN = 0xFF0000;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fractionIsRemainingDurability() {
|
||||||
|
assertEquals(1.0f, ArmorTint.fraction(0, 100), 1e-6f);
|
||||||
|
assertEquals(0.25f, ArmorTint.fraction(75, 100), 1e-6f);
|
||||||
|
assertEquals(0.0f, ArmorTint.fraction(200, 100), 1e-6f);
|
||||||
|
assertEquals(-1.0f, ArmorTint.fraction(0, 0), 1e-6f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gradientBlendsBetweenStops() {
|
||||||
|
assertEquals(FULL, ArmorTint.ramp(1.0f, ArmorTint.GRADIENT, FULL, WARN, BROKEN));
|
||||||
|
assertEquals(WARN, ArmorTint.ramp(0.5f, ArmorTint.GRADIENT, FULL, WARN, BROKEN));
|
||||||
|
assertEquals(BROKEN, ArmorTint.ramp(0.0f, ArmorTint.GRADIENT, FULL, WARN, BROKEN));
|
||||||
|
assertEquals(0x80FF00, ArmorTint.ramp(0.75f, ArmorTint.GRADIENT, FULL, WARN, BROKEN));
|
||||||
|
assertEquals(0xFF8000, ArmorTint.ramp(0.25f, ArmorTint.GRADIENT, FULL, WARN, BROKEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stepsSnapToOneStop() {
|
||||||
|
assertEquals(FULL, ArmorTint.ramp(0.9f, ArmorTint.STEPS, FULL, WARN, BROKEN));
|
||||||
|
assertEquals(WARN, ArmorTint.ramp(0.4f, ArmorTint.STEPS, FULL, WARN, BROKEN));
|
||||||
|
assertEquals(WARN, ArmorTint.ramp(0.26f, ArmorTint.STEPS, FULL, WARN, BROKEN));
|
||||||
|
assertEquals(BROKEN, ArmorTint.ramp(0.25f, ArmorTint.STEPS, FULL, WARN, BROKEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tintBlendsFromWhiteByStrength() {
|
||||||
|
assertEquals(0xFF00FF00, ArmorTint.tint(0x00FF00, 1.0f));
|
||||||
|
assertEquals(0xFFFFFFFF, ArmorTint.tint(0x00FF00, 0.0f));
|
||||||
|
assertEquals(0xFF80FF80, ArmorTint.tint(0x00FF00, 0.5f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyMultipliesLayerColorAndKeepsNoTint() {
|
||||||
|
assertEquals(0xFF804020, ArmorTint.apply(0xFF804020, ArmorTint.NO_TINT));
|
||||||
|
assertEquals(0xFF00FF00, ArmorTint.apply(0xFFFFFFFF, 0xFF00FF00));
|
||||||
|
assertEquals(0xFF400000, ArmorTint.apply(0xFF800000, 0xFF808080));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pulseNeverBrightensAndStaysAboveSixtyPercent() {
|
||||||
|
for (long ms = 0; ms < 2000; ms += 37) {
|
||||||
|
int out = ArmorTint.pulse(0xFF8040, ms);
|
||||||
|
assertTrue((out >> 16 & 0xFF) <= 0xFF && (out >> 16 & 0xFF) >= Math.round(0xFF * 0.6f) - 1);
|
||||||
|
assertTrue((out >> 8 & 0xFF) <= 0x80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.items.glint;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
public final class GlintTintTest {
|
||||||
|
private static final int RED = 0xFF0000;
|
||||||
|
private static final int BLUE = 0x0000FF;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void solidKeepsBrightnessPatternAndAlpha() {
|
||||||
|
assertEquals(0xFF00FF00, GlintTint.pixel(0xFF8040FF, 0f, 0f, GlintTint.SOLID, 0x00FF00, 0, 1.0f));
|
||||||
|
assertEquals(0x80000000, GlintTint.pixel(0x80000000, 0f, 0f, GlintTint.SOLID, 0xFFFFFF, 0, 1.0f));
|
||||||
|
assertEquals(0xFF800000, GlintTint.pixel(0xFF808080, 0f, 0f, GlintTint.SOLID, RED, 0, 1.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void strengthBrightensButNeverOverflows() {
|
||||||
|
assertEquals(0xFF800000, GlintTint.pixel(0xFF404040, 0f, 0f, GlintTint.SOLID, RED, 0, 2.0f));
|
||||||
|
assertEquals(0xFFFF0000, GlintTint.pixel(0xFFC0C0C0, 0f, 0f, GlintTint.SOLID, RED, 0, 3.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gradientBlendsAcrossTheTexture() {
|
||||||
|
int left = GlintTint.pixel(0xFFFFFFFF, 0.0f, 0.0f, GlintTint.GRADIENT, RED, BLUE, 1.0f);
|
||||||
|
int right = GlintTint.pixel(0xFFFFFFFF, 0.99f, 0.99f, GlintTint.GRADIENT, RED, BLUE, 1.0f);
|
||||||
|
assertEquals(0xFFFF0000, left);
|
||||||
|
assertEquals(true, (right & 0xFF) > 0xF0 && (right >> 16 & 0xFF) < 0x10);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rainbowChangesHueAlongTheTexture() {
|
||||||
|
int a = GlintTint.pixel(0xFFFFFFFF, 0.0f, 0.0f, GlintTint.RAINBOW, 0, 0, 1.0f);
|
||||||
|
int b = GlintTint.pixel(0xFFFFFFFF, 0.5f, 0.0f, GlintTint.RAINBOW, 0, 0, 1.0f);
|
||||||
|
assertEquals(0xFFFF0000, a);
|
||||||
|
assertEquals(true, a != b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hiddenMakesTheTextureTransparent() {
|
||||||
|
assertEquals(0x00FFFFFF, GlintTint.pixel(0xFFFFFFFF, 0f, 0f, GlintTint.HIDDEN, RED, BLUE, 1.0f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LrcLibClient;
|
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
|
|
||||||
|
|
||||||
public final class LrcLibClientTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void cleanTrackNameRemovesParenthetical() {
|
|
||||||
assertEquals("Song Title", LrcLibClient.cleanTrackName("Song Title (feat. Guest)"));
|
|
||||||
assertEquals("Song Title", LrcLibClient.cleanTrackName("Song Title (Remix)"));
|
|
||||||
assertEquals("", LrcLibClient.cleanTrackName("(Song)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void cleanTrackNameRemovesBrackets() {
|
|
||||||
assertEquals("Song Title", LrcLibClient.cleanTrackName("Song Title [Deluxe]"));
|
|
||||||
assertEquals("", LrcLibClient.cleanTrackName("[Song]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void cleanTrackNameHandlesNull() {
|
|
||||||
assertEquals("", LrcLibClient.cleanTrackName(null));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void cleanTrackNameHandlesEmpty() {
|
|
||||||
assertEquals("", LrcLibClient.cleanTrackName(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void cleanTrackNameHandlesWhitespaceOnly() {
|
|
||||||
assertEquals("", LrcLibClient.cleanTrackName(" "));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void cleanTrackNameTrims() {
|
|
||||||
assertEquals("Song Title", LrcLibClient.cleanTrackName(" Song Title "));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void fetchLyricsAsyncReturnsEmptyForBlankTrack() {
|
|
||||||
var future = LrcLibClient.fetchLyricsAsync("", "Artist", false);
|
|
||||||
List<LyricLine> result = future.join();
|
|
||||||
assertTrue(result.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void fetchLyricsAsyncReturnsEmptyForNullTrack() {
|
|
||||||
var future = LrcLibClient.fetchLyricsAsync(null, "Artist", false);
|
|
||||||
List<LyricLine> result = future.join();
|
|
||||||
assertTrue(result.isEmpty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.TrackQuery;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
public final class SourceParsingTest {
|
||||||
|
private static final TrackQuery QUERY = new TrackQuery("Insomnia", "Nürnberg", 190);
|
||||||
|
|
||||||
|
private static JsonObject hit(String json) {
|
||||||
|
return JsonParser.parseString(json).getAsJsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void syncedHitOutranksAnIdenticalPlainOne() {
|
||||||
|
double synced = LrcLibSource.scoreOf(hit("{\"trackName\":\"Insomnia\",\"artistName\":\"Nürnberg\",\"duration\":190,\"syncedLyrics\":\"[00:01.00] a\"}"), QUERY);
|
||||||
|
double plain = LrcLibSource.scoreOf(hit("{\"trackName\":\"Insomnia\",\"artistName\":\"Nürnberg\",\"duration\":190,\"plainLyrics\":\"a\"}"), QUERY);
|
||||||
|
assertTrue(synced > plain);
|
||||||
|
assertTrue(plain > 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void instrumentalOrLyriclessHitsScoreZero() {
|
||||||
|
assertEquals(0.0, LrcLibSource.scoreOf(hit("{\"trackName\":\"Insomnia\",\"artistName\":\"Nürnberg\",\"instrumental\":true,\"plainLyrics\":\"a\"}"), QUERY));
|
||||||
|
assertEquals(0.0, LrcLibSource.scoreOf(hit("{\"trackName\":\"Insomnia\",\"artistName\":\"Nürnberg\",\"duration\":190}"), QUERY));
|
||||||
|
assertEquals(0.0, LrcLibSource.scoreOf(hit("{\"trackName\":\"Insomnia\",\"artistName\":\"Nürnberg\",\"syncedLyrics\":null}"), QUERY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchUrlsStartWithTheFieldSearchAndEndWithTitleOnly() {
|
||||||
|
List<String> urls = List.copyOf(LrcLibSource.searchUrls(QUERY));
|
||||||
|
assertTrue(urls.get(0).contains("track_name=Insomnia") && urls.get(0).contains("artist_name=N%C3%BCrnberg"));
|
||||||
|
assertTrue(urls.get(urls.size() - 1).endsWith("search?track_name=Insomnia"));
|
||||||
|
assertEquals(3, urls.size(), "primary artist equals the full artist, so that variant collapses");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void neteaseSearchPicksTheClosestSong() {
|
||||||
|
String json = "{\"result\":{\"songs\":["
|
||||||
|
+ "{\"id\":1,\"name\":\"Insomnia\",\"dt\":420000,\"ar\":[{\"name\":\"Faithless\"}]},"
|
||||||
|
+ "{\"id\":2,\"name\":\"Insomnia\",\"dt\":190500,\"ar\":[{\"name\":\"Nürnberg\"}]}]}}";
|
||||||
|
assertEquals(2L, NeteaseSource.bestSongId(json, QUERY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void neteaseSearchReturnsZeroForNoMatchOrGarbage() {
|
||||||
|
assertEquals(0L, NeteaseSource.bestSongId("{\"result\":{\"songs\":[{\"id\":9,\"name\":\"Other\",\"dt\":1,\"ar\":[]}]}}", QUERY));
|
||||||
|
assertEquals(0L, NeteaseSource.bestSongId("not json", QUERY));
|
||||||
|
assertEquals(0L, NeteaseSource.bestSongId("{\"result\":{}}", QUERY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void neteaseCreditLinesAtTheStartAreStripped() {
|
||||||
|
List<LyricLine> lines = List.of(
|
||||||
|
new LyricLine(0, "作词 : Someone"),
|
||||||
|
new LyricLine(0, "作曲 : Other"),
|
||||||
|
new LyricLine(15_000, "first real line: with a colon"),
|
||||||
|
new LyricLine(20_000, "second"));
|
||||||
|
List<LyricLine> out = NeteaseSource.stripCredits(lines);
|
||||||
|
assertEquals(2, out.size());
|
||||||
|
assertEquals("first real line: with a colon", out.get(0).text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.match;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.LyricMatch;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.TrackQuery;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
public final class LyricMatchTest {
|
||||||
|
private static final TrackQuery QUERY = new TrackQuery("Insomnia", "Nürnberg", 190);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void similarityIgnoresCaseAndPunctuation() {
|
||||||
|
assertEquals(1.0, LyricMatch.similarity("Hello, World", "hello world"), 1e-9);
|
||||||
|
assertEquals(0.85, LyricMatch.similarity("Insomnia", "Insomnia (live)"), 1e-9);
|
||||||
|
assertEquals(0.0, LyricMatch.similarity("abc", "xyz"), 1e-9);
|
||||||
|
assertEquals(0.0, LyricMatch.similarity("", "xyz"), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exactHitScoresAboveAcceptanceAndNearCertain() {
|
||||||
|
double score = LyricMatch.score("Insomnia", "Nürnberg", 190, QUERY);
|
||||||
|
assertTrue(score >= LyricMatch.CERTAIN, "score was " + score);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void differentTitleIsRejectedOutright() {
|
||||||
|
assertEquals(0.0, LyricMatch.score("Completely Other", "Nürnberg", 190, QUERY), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameTitleByAnotherArtistAndLengthFallsBelowAcceptance() {
|
||||||
|
double score = LyricMatch.score("Insomnia", "Faithless", 420, QUERY);
|
||||||
|
assertTrue(score < LyricMatch.ACCEPT, "score was " + score);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void durationScoreFallsOffBetweenCloseAndFar() {
|
||||||
|
assertEquals(1.0, LyricMatch.durationScore(191, 190), 1e-9);
|
||||||
|
assertEquals(0.0, LyricMatch.durationScore(220, 190), 1e-9);
|
||||||
|
assertEquals(0.5, LyricMatch.durationScore(0, 190), 1e-9);
|
||||||
|
double mid = LyricMatch.durationScore(198, 190);
|
||||||
|
assertTrue(mid > 0.0 && mid < 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownArtistIsNeutral() {
|
||||||
|
assertEquals(0.5, LyricMatch.artistScore("Anyone", new TrackQuery("x", "", 0)), 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.match;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.PlainLyricTimer;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
public final class PlainLyricTimerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyWithoutTextOrDuration() {
|
||||||
|
assertTrue(PlainLyricTimer.estimate(null, 100).isEmpty());
|
||||||
|
assertTrue(PlainLyricTimer.estimate("a\nb", 0).isEmpty());
|
||||||
|
assertTrue(PlainLyricTimer.estimate("\n \n", 100).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sectionMarkersAndBlankLinesAreDropped() {
|
||||||
|
List<LyricLine> lines = PlainLyricTimer.estimate("[Chorus]\nfirst line here\n\nsecond line here", 200);
|
||||||
|
assertEquals(2, lines.size());
|
||||||
|
assertEquals("first line here", lines.get(0).text());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timelineStartsAfterTheIntroAndStaysInsideTheSong() {
|
||||||
|
List<LyricLine> lines = PlainLyricTimer.estimate("one two three\nfour five six\nseven eight nine", 200);
|
||||||
|
assertEquals(3, lines.size());
|
||||||
|
assertEquals(12_000, lines.get(0).timestampMs());
|
||||||
|
for (int i = 1; i < lines.size(); i++) {
|
||||||
|
assertTrue(lines.get(i).timestampMs() > lines.get(i - 1).timestampMs());
|
||||||
|
}
|
||||||
|
assertTrue(lines.get(2).timestampMs() < 200_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void longerLinesGetMoreTime() {
|
||||||
|
List<LyricLine> lines = PlainLyricTimer.estimate("short line!\nthis one is a much much longer line of lyrics\nend line!!", 300);
|
||||||
|
long first = lines.get(1).timestampMs() - lines.get(0).timestampMs();
|
||||||
|
long second = lines.get(2).timestampMs() - lines.get(1).timestampMs();
|
||||||
|
assertTrue(second > first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.match;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.TrackQuery;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
public final class TrackQueryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanRemovesBracketsAndTrims() {
|
||||||
|
assertEquals("Song Title", TrackQuery.clean("Song Title (feat. Guest)"));
|
||||||
|
assertEquals("Song Title", TrackQuery.clean("Song Title [Deluxe]"));
|
||||||
|
assertEquals("Song Title", TrackQuery.clean(" Song Title "));
|
||||||
|
assertEquals("", TrackQuery.clean("(Song)"));
|
||||||
|
assertEquals("", TrackQuery.clean(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanTitleFallsBackToRawWhenEverythingWasDecoration() {
|
||||||
|
assertEquals("(Intro)", new TrackQuery("(Intro)", "A", 0).cleanTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void primaryArtistIsTheFirstCredit() {
|
||||||
|
assertEquals("Nürnberg", new TrackQuery("x", "Nürnberg", 0).primaryArtist());
|
||||||
|
assertEquals("A", new TrackQuery("x", "A, B & C", 0).primaryArtist());
|
||||||
|
assertEquals("A", new TrackQuery("x", "A feat. B", 0).primaryArtist());
|
||||||
|
assertEquals("", new TrackQuery("x", null, 0).primaryArtist());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchTextJoinsCleanedTitleAndArtist() {
|
||||||
|
assertEquals("Insomnia Nürnberg", new TrackQuery("Insomnia (Remix)", "Nürnberg", 190).searchText());
|
||||||
|
assertEquals("Insomnia", new TrackQuery("Insomnia", "", 190).searchText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.lyrics.source;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.TrackQuery;
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source.LyricsFinder;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
public final class LyricSourcesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankTitleFindsNothingWithoutRetry() {
|
||||||
|
LyricsFinder.Result result = LyricsFinder.findAsync("", "Artist", 100, false).join();
|
||||||
|
assertTrue(result.lines().isEmpty());
|
||||||
|
assertTrue(!result.retryLater());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullTitleFindsNothing() {
|
||||||
|
List<LyricLine> lines = LyricsFinder.findAsync(null, "Artist", 100, false).join().lines();
|
||||||
|
assertTrue(lines.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queryKeepsRawFieldsTrimmed() {
|
||||||
|
TrackQuery query = new TrackQuery(" T ", " A ", 5);
|
||||||
|
assertTrue(query.title().equals("T") && query.artist().equals("A"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.models;
|
||||||
|
|
||||||
|
import dev.loki.lovisual.features.module.modules.visuals.models.mesh.blockbench.BbModelParser;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
public final class CustomModelsTest {
|
||||||
|
private static final String QUAD = "{\"resolution\":{\"width\":32,\"height\":16},\"elements\":[{"
|
||||||
|
+ "\"origin\":[0,0,0],\"rotation\":[0,0,0],\"vertices\":{\"a\":[0,0,0],\"b\":[16,0,0],\"c\":[16,16,0],\"d\":[0,16,0]},"
|
||||||
|
+ "\"faces\":{\"f\":{\"vertices\":[\"a\",\"b\",\"c\",\"d\"],\"uv\":{\"a\":[0,0],\"b\":[32,0],\"c\":[32,16],\"d\":[0,16]}}}}]}";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void faceNamesMapToTextureFilesWithNormalAsFallback() {
|
||||||
|
assertEquals("verity", CustomModels.faceFile("Normal"));
|
||||||
|
assertEquals("veritytalk", CustomModels.faceFile("Talking"));
|
||||||
|
assertEquals("verityverybad", CustomModels.faceFile("Very bad"));
|
||||||
|
assertEquals("verity", CustomModels.faceFile("nonsense"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aQuadBecomesTwoTrianglesWithNormalisedUvAndScaledPositions() {
|
||||||
|
float[] out = BbModelParser.parse(QUAD, 0.5f);
|
||||||
|
assertEquals(2 * 3 * 8, out.length);
|
||||||
|
// first corner is "a" at the origin, second is "b" at x = 16 * 0.5
|
||||||
|
assertEquals(0.0f, out[0], 1e-6f);
|
||||||
|
assertEquals(8.0f, out[8], 1e-5f);
|
||||||
|
// uv of "b" is 32/32 = 1 across, 0 down
|
||||||
|
assertEquals(1.0f, out[8 + 3], 1e-6f);
|
||||||
|
assertEquals(0.0f, out[8 + 4], 1e-6f);
|
||||||
|
// y grows downwards in entity model space, so "c" at y = 16 lands at -8
|
||||||
|
assertEquals(-8.0f, out[16 + 1], 1e-5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalsAreUnitLength() {
|
||||||
|
float[] out = BbModelParser.parse(QUAD, 1.0f);
|
||||||
|
for (int v = 0; v < out.length; v += 8) {
|
||||||
|
float length = (float) Math.sqrt(out[v + 5] * out[v + 5] + out[v + 6] * out[v + 6] + out[v + 7] * out[v + 7]);
|
||||||
|
assertEquals(1.0f, length, 1e-4f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void elementsWithoutFacesAreSkipped() {
|
||||||
|
assertEquals(0, BbModelParser.parse("{\"elements\":[{\"origin\":[0,0,0]}]}", 1.0f).length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bundledVerityModelParsesToAMeshOfWholeTriangles() throws IOException {
|
||||||
|
try (InputStream stream = getClass().getResourceAsStream("/assets/lovisual/models/verity/verity.bbmodel")) {
|
||||||
|
assertNotNull(stream);
|
||||||
|
float[] mesh = BbModelParser.parse(new String(stream.readAllBytes(), StandardCharsets.UTF_8), 2.2f / 16.0f);
|
||||||
|
assertTrue(mesh.length > 0);
|
||||||
|
assertEquals(0, mesh.length % (3 * 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bundledChickenPartsExist() {
|
||||||
|
for (String part : new String[]{"body", "head", "right_arm", "left_arm", "right_leg", "left_leg"}) {
|
||||||
|
for (int material = 0; material < 2; material++) {
|
||||||
|
assertNotNull(getClass().getResourceAsStream("/assets/lovisual/models/chicken/mesh_" + part + "_" + material + ".bin"), part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNotNull(getClass().getResourceAsStream("/assets/lovisual/models/rabbit.png"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.placeanimation;
|
||||||
|
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.EnumSet;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
public final class PlaceMotionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void easeStartsAtZeroAndEndsAtOne() {
|
||||||
|
for (float base : new float[]{PlaceMotion.easeBase(0.9f), PlaceMotion.easeBase(-0.08f), PlaceMotion.easeBase(-0.7f)}) {
|
||||||
|
assertEquals(0.0f, PlaceMotion.ease(0.0f, base), 1e-5f);
|
||||||
|
assertEquals(1.0f, PlaceMotion.ease(1.0f, base), 1e-5f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void slideRemainingShrinksMonotonicallyToZero() {
|
||||||
|
assertEquals(1.0f, PlaceMotion.slideRemaining(0.0f), 1e-5f);
|
||||||
|
assertEquals(0.0f, PlaceMotion.slideRemaining(1.0f), 1e-5f);
|
||||||
|
float previous = 1.0f;
|
||||||
|
for (float p = 0.1f; p <= 1.0f; p += 0.1f) {
|
||||||
|
float now = PlaceMotion.slideRemaining(p);
|
||||||
|
assertTrue(now <= previous + 1e-6f);
|
||||||
|
previous = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tiltSettlesAndScaleLandsJustUnderFull() {
|
||||||
|
assertEquals(0.5f, PlaceMotion.tilt(0.0f, 0.5f), 1e-5f);
|
||||||
|
assertEquals(0.0f, PlaceMotion.tilt(1.0f, 0.5f), 1e-5f);
|
||||||
|
assertEquals(0.4f, PlaceMotion.scale(0.4f, 0.0f), 1e-5f);
|
||||||
|
assertEquals(PlaceMotion.FINAL_SCALE, PlaceMotion.scale(0.4f, 1.0f), 1e-5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void progressIsCappedAtOne() {
|
||||||
|
assertEquals(0.0f, PlaceMotion.progress(0, 0.0f, 3), 1e-6f);
|
||||||
|
assertEquals(0.5f, PlaceMotion.progress(1, 1.0f, 3), 1e-6f);
|
||||||
|
assertEquals(1.0f, PlaceMotion.progress(10, 0.5f, 3), 1e-6f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void slideStartPointsBackTowardsThePlayerAndFlipsWithTheHand() {
|
||||||
|
// yaw 0 faces +Z, so "back" is -Z
|
||||||
|
Vector3f right = PlaceMotion.slideStart(0.0f, 0.0f, true, 1.0f);
|
||||||
|
Vector3f left = PlaceMotion.slideStart(0.0f, 0.0f, false, 1.0f);
|
||||||
|
assertTrue(right.z < 0.0f);
|
||||||
|
assertEquals(right.z, left.z, 1e-6f);
|
||||||
|
assertEquals(-right.x, left.x, 1e-6f);
|
||||||
|
assertEquals(0.0f, PlaceMotion.slideStart(0.0f, 0.0f, true, 0.0f).length(), 1e-6f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void slideStartDipsWhenLookingUpAndRisesWhenLookingDown() {
|
||||||
|
assertTrue(PlaceMotion.slideStart(0.0f, -30.0f, true, 1.0f).y < 0.0f);
|
||||||
|
assertTrue(PlaceMotion.slideStart(0.0f, 30.0f, true, 1.0f).y > 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restrictZeroesOnlyComponentsPushingIntoBlockedSides() {
|
||||||
|
Vector3f offset = new Vector3f(1.0f, -1.0f, 1.0f);
|
||||||
|
Vector3f out = PlaceMotion.restrict(offset, EnumSet.of(Direction.EAST, Direction.UP));
|
||||||
|
assertEquals(0.0f, out.x);
|
||||||
|
assertEquals(-1.0f, out.y);
|
||||||
|
assertEquals(1.0f, out.z);
|
||||||
|
assertEquals(1.0f, offset.x, "input is not modified");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package dev.loki.lovisual.features.module.modules.visuals.totems;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
|
||||||
|
public final class TotemCounterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void labelShowsCount() {
|
||||||
|
assertEquals("x3", TotemCounter.label(3, true));
|
||||||
|
assertEquals("x3", TotemCounter.label(3, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void zeroIsHiddenWhenDisabled() {
|
||||||
|
assertEquals("x0", TotemCounter.label(0, true));
|
||||||
|
assertNull(TotemCounter.label(0, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void negativeCountNeverLeaksIntoLabel() {
|
||||||
|
assertEquals("x0", TotemCounter.label(-4, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void colorTurnsRedAtThreshold() {
|
||||||
|
assertEquals(0xFFFFFFFF, TotemCounter.color(3, 2));
|
||||||
|
assertEquals(0xFFFF5555, TotemCounter.color(2, 2));
|
||||||
|
assertEquals(0xFFFF5555, TotemCounter.color(0, 2));
|
||||||
|
assertEquals(0xFFFFFFFF, TotemCounter.color(1, 0));
|
||||||
|
assertEquals(0xFFFF5555, TotemCounter.color(0, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||