com.cryptomorin.xseries.particles.ParticleDisplay Maven / Gradle / Ivy
Show all versions of XSeries Show documentation
/*
* The MIT License (MIT)
*
* Copyright (c) 2024 Crypto Morin
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
* PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
* FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
* ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.cryptomorin.xseries.particles;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.World;
import org.bukkit.block.data.BlockData;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.material.MaterialData;
import org.bukkit.util.NumberConversions;
import org.bukkit.util.Vector;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.awt.*;
import java.util.List;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Represents how particles should be spawned. The simplest use case would be the following code
* which spawns a single particle in front of the player:
* {@code
* ParticleDisplay.of(Particle.FLAME).spawn(player.getEyeLocation());
* }
* This class is disposable by {@link Particles} methods.
* It should not be used across multiple methods. I.e. it should not be
* used even to spawn a simple particle after it was used by one of {@link Particles} methods.
*
* By default, the particle xyz offsets and speed aren't 0, but
* everything will be 0 by default in this class.
* Particles are spawned to a location. So all the nearby players can see it.
*
* For cross-version compatibility, instead of Bukkit's {@link org.bukkit.Color}
* the java awt {@link Color} class is used.
*
* the data field is used to store special particle data, such as colored particles.
* For colored particles a float list is used since the particle size is a float.
* The format of float list data for a colored particle is:
* [r, g, b, size]
*
* @author Crypto Morin
* @version 11.0.1
* @see Particles
*/
@SuppressWarnings("CallToSimpleGetterFromWithinClass")
public class ParticleDisplay implements Cloneable {
/**
* Checks if spawn methods should use particle data classes such as {@link org.bukkit.Particle.DustOptions}
* which is only available from 1.13+ (FOOTSTEP was removed in 1.13)
*
* @since 1.0.0
*/
private static final boolean ISFLAT;
static {
boolean isFlat;
try {
World.class.getDeclaredMethod("spawnParticle", Particle.class, Location.class, int.class,
double.class, double.class, double.class,
double.class, Object.class, boolean.class
);
isFlat = true;
} catch (NoSuchMethodException e) {
isFlat = false;
}
ISFLAT = isFlat;
}
/**
* Checks if spawn methods should use particle data classes such as {@link org.bukkit.Particle.DustTransition}
* which is only available from 1.17+ (DUST_COLOR_TRANSITION was released in 1.17)
*
* @since 8.6.0.0.1
*/
private static final boolean SUPPORTS_DUST_TRANSITION = XParticle.DUST_COLOR_TRANSITION.isSupported();
// private static final Axis[] DEFAULT_ROTATION_ORDER = {Axis.X, Axis.Y, Axis.Z};
/**
* Flames seem to be the simplest particles that allows you to get a good visual
* on how precise shapes that depend on complex algorithms play out.
*/
@Nonnull
private static final XParticle DEFAULT_PARTICLE = XParticle.FLAME;
public int count = 1;
public double extra;
public boolean force;
@Nonnull
private XParticle particle = DEFAULT_PARTICLE;
@Nullable
private Location location, lastLocation;
@Nullable
private Vector offset = new Vector();
/**
* The direction is mostly used for APIs to call {@link #advanceInDirection(double)}
* instead of handling the direction in a specific axis.
* This makes it easier for them as well and allows easier use of the {@link #rotations} API.
*/
@Nonnull
private Vector direction = new Vector(0, 1, 0);
/**
* The xyz axis order of how the particle's matrix should be rotated.
* Yes, it matters which axis you rotate first as it'll have an impact on the
* other rotations.
*
* Check this stackoverflow question.
* Quaternions are a solution to this problem which is already present in {@link org.bukkit.entity.Display} entities API.
* See this 3Blue1Brown YouTube video.
*
* You could use an axis two times such as yaw -> roll -> yaw sequence which is the canonical Euler sequence.
* But here for the standard {@link Particles} methods, we're going to be using Tait–Bryan angles.
* Minecraft Euler angles use XYZ order.
* Source
* Gimbal lock.
*
* For 2D shapes, it's recommended that your algorithm uses the x and z axis and leave y as 0.
*
* Each list within the main list represents the rotations that are going to be applied individually in order.
* While it's true that in order to combine multiple quaternion rotations you'd have to multiply them,
* quaternion multiplication is not commutative and some rotations should be done separately.
*/
@Nonnull
public List> rotations = new ArrayList<>();
@Nullable
private List cachedFinalRotationQuaternions;
@Nullable
private Object data;
@Nullable
private Consumer preCalculation;
@Nullable
private Consumer postCalculation;
@Nullable
private Function onAdvance;
@Nullable
private Set players;
/**
* Builds a simple ParticleDisplay object with cross-version
* compatible {@link org.bukkit.Particle.DustOptions} properties.
* Only REDSTONE particle type can be colored like this.
*
* @param location the location of the display.
* @param size the size of the dust.
* @return a redstone colored dust.
* @see #simple(Location, Particle)
* @since 1.0.0
* @deprecated use {@link #withColor(float, float, float, float)}
*/
@Nonnull
@Deprecated
public static ParticleDisplay colored(@Nullable Location location, int r, int g, int b, float size) {
return ParticleDisplay.of(XParticle.DUST).withLocation(location).withColor(r, g, b, size);
}
/**
* @return the players that this particle will be visible to or null if it's visible to all.
* @since 9.0.0
*/
@Nullable
public Set getPlayers() {
return players;
}
/**
* Makes this particle only visible to certain players.
*
* @since 9.0.0
*/
public ParticleDisplay onlyVisibleTo(Collection players) {
if (players.isEmpty()) return this;
if (this.players == null) this.players = Collections.newSetFromMap(new WeakHashMap<>());
this.players.addAll(players);
return this;
}
/**
* @see #onlyVisibleTo(Collection)
* @since 9.0.0
*/
public ParticleDisplay onlyVisibleTo(Player... players) {
if (players.length == 0) return this;
if (this.players == null) this.players = Collections.newSetFromMap(new WeakHashMap<>());
Collections.addAll(this.players, players);
return this;
}
/**
* Builds a simple ParticleDisplay object with cross-version
* compatible {@link org.bukkit.Particle.DustOptions} properties.
* Only REDSTONE particle type can be colored like this.
*
* @param color the color of the particle.
* @param size the size of the dust.
* @return a redstone colored dust.
* @since 3.0.0
* @deprecated use {@link #withColor(Color, float)}
*/
@Nonnull
@Deprecated
public static ParticleDisplay colored(Location location, @Nonnull Color color, float size) {
return colored(location, color.getRed(), color.getGreen(), color.getBlue(), size);
}
/**
* Builds a simple ParticleDisplay object.
* An invocation of this method yields exactly the same result as the expression:
*
*
* new ParticleDisplay(particle, location, 1, 0, 0, 0, 0);
*
*
* @param location the location of the display.
* @param particle the particle of the display.
* @return a simple ParticleDisplay with count 1 and no offset, rotation etc.
* @since 1.0.0
* @deprecated use {@link #of(XParticle)} and {@link #withLocation(Location)}
*/
@Nonnull
@Deprecated
public static ParticleDisplay simple(@Nullable Location location, @Nonnull Particle particle) {
Objects.requireNonNull(particle, "Cannot build ParticleDisplay with null particle");
ParticleDisplay display = new ParticleDisplay();
display.particle = XParticle.of(particle);
display.location = location;
return display;
}
/**
* @deprecated use {@link #of(XParticle)} instead.
*/
@Nonnull
@Deprecated
public static ParticleDisplay of(@Nonnull Particle particle) {
return of(XParticle.of(particle));
}
/**
* @since 6.0.0.1
*/
@Nonnull
public static ParticleDisplay of(@Nonnull XParticle particle) {
ParticleDisplay display = new ParticleDisplay();
display.particle = particle;
return display;
}
/**
* A quick access method to display a simple particle.
* An invocation of this method yields the same result as the expression:
*
*
* ParticleDisplay.simple(location, particle).spawn();
*
*
* @param location the location of the particle.
* @param particle the particle to show.
* @return a simple ParticleDisplay with count 1 and no offset, rotation etc.
* @since 1.0.0
* @deprecated use {@link #of(Particle)} and {@link #withLocation(Location)}
*/
@Nullable
@Deprecated
public static ParticleDisplay display(@Nonnull Location location, @Nonnull Particle particle) {
Objects.requireNonNull(location, "Cannot display particle in null location");
ParticleDisplay display = simple(location, particle);
display.spawn();
return display;
}
/**
* Builds particle settings from a configuration section.
*
* @param config the config section for the settings.
* @return a parsed ParticleDisplay from the config.
* @since 1.0.0
*/
public static ParticleDisplay fromConfig(@Nonnull ConfigurationSection config) {
return edit(new ParticleDisplay(), config);
}
private static int toInt(String str) {
try {
return Integer.parseInt(str);
} catch (NumberFormatException nfe) {
return 0;
}
}
private static double toDouble(String str) {
try {
return Double.parseDouble(str);
} catch (NumberFormatException nfe) {
return 0;
}
}
private static java.util.List split(@Nonnull String str, @SuppressWarnings("SameParameterValue") char separatorChar) {
List list = new ArrayList<>(5);
boolean match = false, lastMatch = false;
int len = str.length();
int start = 0;
for (int i = 0; i < len; i++) {
if (str.charAt(i) == separatorChar) {
if (match) {
list.add(str.substring(start, i));
match = false;
lastMatch = true;
}
// This is important, it should not be i++
start = i + 1;
continue;
}
lastMatch = false;
match = true;
}
if (match || lastMatch) {
list.add(str.substring(start, len));
}
return list;
}
/**
* Builds particle settings from a configuration section. Keys in config can be :
*
* - particle : the particle type.
*
- count : the count as integer, at least 0.
*
- extra : the particle speed, most of the time.
*
- force : true or false, if the particle has force or not.
*
- offset : the offset where values are separated by commas "dx, dy, dz".
*
- rotation : the rotation of the particles in degrees.
*
- color : the data representing color "R, G, B, size" where RGB values are integers
* between 0 and 255 and size is a positive (or null) float.
*
- blockdata : the data representing block data. Given by a material name that's a block.
*
- materialdata : same than blockdata, but with legacy data before 1.12.
* Do not use this in 1.13 and above.
*
- itemstack : the data representing item. Given by a material name that's an item.
*
*
* @param display the particle display settings to update.
* @param config the config section for the settings.
* @return the same ParticleDisplay, but edited.
* @since 5.0.0
*/
@Nonnull
public static ParticleDisplay edit(@Nonnull ParticleDisplay display, @Nonnull ConfigurationSection config) {
Objects.requireNonNull(display, "Cannot edit a null particle display");
Objects.requireNonNull(config, "Cannot parse ParticleDisplay from a null config section");
String particleName = config.getString("particle");
XParticle particle = particleName == null ? null : XParticle.of(particleName);
if (particle != null) display.particle = particle;
if (config.isSet("count")) display.withCount(config.getInt("count"));
if (config.isSet("extra")) display.withExtra(config.getDouble("extra"));
if (config.isSet("force")) display.forceSpawn(config.getBoolean("force"));
String offset = config.getString("offset");
if (offset != null) {
List offsets = split(offset.replace(" ", ""), ',');
if (offsets.size() >= 3) {
double offsetx = toDouble(offsets.get(0));
double offsety = toDouble(offsets.get(1));
double offsetz = toDouble(offsets.get(2));
display.offset(offsetx, offsety, offsetz);
} else {
double masterOffset = toDouble(offsets.get(0));
display.offset(masterOffset);
}
}
ConfigurationSection rotations = config.getConfigurationSection("rotations");
if (rotations != null) {
/*
rotations:
group-1:
0:
angle: 3.14
axis: "Y"
1:
angle: 4
axis: "3, 5, 3.4"
group-2:
0:
angle: 1.6
axis: "6, 4, 2"
*/
for (String rotationGroupName : rotations.getKeys(false)) {
ConfigurationSection rotationGroup = rotations.getConfigurationSection(rotationGroupName);
List grouped = new ArrayList<>();
for (String rotationName : rotationGroup.getKeys(false)) {
ConfigurationSection rotation = rotationGroup.getConfigurationSection(rotationName);
double angle = rotation.getDouble("angle");
Vector axis;
String axisStr = rotation.getString("vector").toUpperCase(Locale.ENGLISH).replace(" ", "");
if (axisStr.length() == 1) {
axis = Axis.valueOf(axisStr).vector;
} else {
String[] split = axisStr.split(",");
axis = new Vector(Math.toRadians(Double.parseDouble(split[0])),
Math.toRadians(Double.parseDouble(split[1])),
Math.toRadians(Double.parseDouble(split[2])));
}
grouped.add(Rotation.of(angle, axis));
}
display.rotations.add(grouped);
}
}
String color = config.getString("color"); // array-like "R, G, B"
String blockdata = config.getString("blockdata"); // material name
String item = config.getString("itemstack"); // material name
String materialdata = config.getString("materialdata"); // material name
float size;
if (config.isSet("size")) {
size = (float) config.getDouble("size");
if (display.data instanceof float[]) {
float[] datas = (float[]) display.data;
if (datas.length > 3) {
datas[3] = size;
}
}
} else {
size = 1f;
}
if (color != null) {
List colors = split(color.replace(" ", ""), ',');
if (colors.size() <= 3 || colors.size() == 6) { // 1 or 3 : single color, 2 or 6 : two colors for DUST_TRANSITION
Color parsedColor1 = Color.white;
Color parsedColor2 = null;
if (colors.size() <= 2) {
try {
parsedColor1 = Color.decode(colors.get(0));
if (colors.size() == 2)
parsedColor2 = Color.decode(colors.get(1));
} catch (NumberFormatException ex) {
/* I don't think it's worth it.
try {
parsedColor = (Color) Color.class.getField(colors[0].toUpperCase(Locale.ENGLISH)).get(null);
} catch (IllegalArgumentException | IllegalAccessException | NoSuchFieldException | SecurityException ignored) { }
*/
}
} else {
parsedColor1 = new Color(toInt(colors.get(0)), toInt(colors.get(1)), toInt(colors.get(2)));
if (colors.size() == 6)
parsedColor2 = new Color(toInt(colors.get(3)), toInt(colors.get(4)), toInt(colors.get(5)));
}
if (parsedColor2 != null) {
display.data = new float[]{
parsedColor1.getRed(), parsedColor1.getGreen(), parsedColor1.getBlue(),
size,
parsedColor2.getRed(), parsedColor2.getGreen(), parsedColor2.getBlue()
};
} else {
display.data = new float[]{
parsedColor1.getRed(), parsedColor1.getGreen(), parsedColor1.getBlue(),
size
};
}
}
} else if (blockdata != null) {
Material material = Material.getMaterial(blockdata);
if (material != null && material.isBlock()) {
display.data = material.createBlockData();
}
} else if (item != null) {
Material material = Material.getMaterial(item);
if (material != null && material.isItem()) {
display.data = new ItemStack(material, 1);
}
} else if (materialdata != null) {
Material material = Material.getMaterial(materialdata);
if (material != null && material.isBlock()) {
display.data = material.getData();
}
}
return display;
}
/**
* Serialize a ParticleDisplay into a ConfigurationSection
*
* @param display The ParticleDisplay to serialize
* @param section The ConfigurationSection to serialize into
*/
@SuppressWarnings("deprecation")
public static void serialize(ParticleDisplay display, ConfigurationSection section) {
section.set("particle", display.particle.name());
if (display.count != 1) {
section.set("count", display.count);
}
if (display.extra != 0) {
section.set("extra", display.extra);
}
if (display.force) {
section.set("force", true);
}
if (display.offset != null) {
Vector offset = display.offset;
section.set("offset", offset.getX() + ", " + offset.getY() + ", " + offset.getZ());
}
if (!display.rotations.isEmpty()) {
ConfigurationSection rotations = section.createSection("rotations");
int index = 1;
for (List rotationGroup : display.rotations) {
ConfigurationSection rotationGroupSection = rotations.createSection("group-" + index++);
int groupIndex = 1;
for (Rotation rotation : rotationGroup) {
ConfigurationSection rotationSection = rotationGroupSection.createSection(
String.valueOf(groupIndex++));
rotationSection.set("angle", rotation.angle);
Vector axis = rotation.axis;
Optional mainAxis = Arrays.stream(Axis.values())
.filter(x -> x.vector.equals(axis))
.findFirst();
if (mainAxis.isPresent()) {
rotationSection.set("axis", mainAxis.get().name());
} else {
rotationSection.set("axis", axis.getX() + ", " + axis.getY() + ", " + axis.getZ());
}
}
}
}
if (display.data instanceof float[]) {
float size = 1f;
float[] datas = (float[]) display.data;
StringJoiner colorJoiner = new StringJoiner(", ");
if (datas.length >= 3) {
if (datas.length > 3) {
size = datas[3];
}
Color color1 = new Color(datas[0], datas[1], datas[2]);
colorJoiner.add(Integer.toString(color1.getRed()));
colorJoiner.add(Integer.toString(color1.getGreen()));
colorJoiner.add(Integer.toString(color1.getBlue()));
}
if (datas.length >= 7) {
Color color2 = new Color(datas[4], datas[5], datas[6]);
colorJoiner.add(Integer.toString(color2.getRed()));
colorJoiner.add(Integer.toString(color2.getGreen()));
colorJoiner.add(Integer.toString(color2.getBlue()));
}
section.set("color", colorJoiner.toString());
section.set("size", size);
}
if (ISFLAT) {
if (display.data instanceof BlockData) {
section.set("blockdata", ((BlockData) display.data).getMaterial().name());
}
}
if (display.data instanceof ItemStack) {
section.set("itemstack", ((ItemStack) display.data).getType().name());
} else if (display.data instanceof MaterialData) {
section.set("materialdata", ((MaterialData) display.data).getItemType().name());
}
}
/**
* Rotates the given location vector around a certain axis.
*
* @param location the location to rotate.
* @param axis the axis to rotate the location around.
* @param rotation the rotation vector that contains the degrees of the rotation. The number is taken from this vector according to the given axis.
* @since 7.0.0
*/
public static Vector rotateAround(@Nonnull Vector location, @Nonnull Axis axis, @Nonnull Vector rotation) {
Objects.requireNonNull(axis, "Cannot rotate around null axis");
Objects.requireNonNull(rotation, "Rotation vector cannot be null");
switch (axis) {
case X:
return rotateAround(location, axis, rotation.getX());
case Y:
return rotateAround(location, axis, rotation.getY());
case Z:
return rotateAround(location, axis, rotation.getZ());
default:
throw new AssertionError("Unknown rotation axis: " + axis);
}
}
/**
* Rotates the given location vector around a certain axis.
*
* @param location the location to rotate.
* @since 7.0.0
*/
public static Vector rotateAround(@Nonnull Vector location, double x, double y, double z) {
rotateAround(location, Axis.X, x);
rotateAround(location, Axis.Y, y);
rotateAround(location, Axis.Z, z);
return location;
}
/**
* Rotates the given location vector around a certain axis.
* It simply uses the rotation matrix.
*
* @param location the location to rotate.
* @param axis the axis to rotate the location around.
* @param angle the rotation angle in radians.
* @since 7.0.0
*/
public static Vector rotateAround(@Nonnull Vector location, @Nonnull Axis axis, double angle) {
Objects.requireNonNull(location, "Cannot rotate a null location");
Objects.requireNonNull(axis, "Cannot rotate around null axis");
if (angle == 0) return location;
double cos = Math.cos(angle);
double sin = Math.sin(angle);
switch (axis) {
case X: {
double y = location.getY() * cos - location.getZ() * sin;
double z = location.getY() * sin + location.getZ() * cos;
return location.setY(y).setZ(z);
}
case Y: {
double x = location.getX() * cos + location.getZ() * sin;
double z = location.getX() * -sin + location.getZ() * cos;
return location.setX(x).setZ(z);
}
case Z: {
double x = location.getX() * cos - location.getY() * sin;
double y = location.getX() * sin + location.getY() * cos;
return location.setX(x).setY(y);
}
default:
throw new AssertionError("Unknown rotation axis: " + axis);
}
}
/**
* Called before rotation is applied to the xyz spawn location. The xyz provided
* in this method is implementation-specific, but it should be the xyz values that
* are going to be {@link Location#add(Vector)} to {@link #getLocation()}.
*
* The provided xyz local coordinates vector might be null.
*
* @return the same particle display.
* @since 9.0.0
*/
public ParticleDisplay preCalculation(@Nullable Consumer preCalculation) {
this.preCalculation = preCalculation;
return this;
}
/**
* Called after rotation is applied to the xyz spawn location. This is the final
* location that's going to spawn a single particle.
*
* The provided xyz local coordinates vector might be null.
*
* @return the same particle display.
* @since 10.0.0
*/
public ParticleDisplay postCalculation(@Nullable Consumer postCalculation) {
this.postCalculation = postCalculation;
return this;
}
/**
* Called when {@link #advanceInDirection(double)} is called.
*
* @param onAdvance The argument and the return values are the amount of blocks to advance.
* @return the same particle display.
* @since 9.0.0
*/
public ParticleDisplay onAdvance(@Nullable Function onAdvance) {
this.onAdvance = onAdvance;
return this;
}
public ParticleDisplay withParticle(@Nonnull Particle particle) {
return withParticle(XParticle.of(Objects.requireNonNull(particle, "Particle cannot be null")));
}
/**
* @since 7.0.0
*/
public ParticleDisplay withParticle(@Nonnull XParticle particle) {
this.particle = Objects.requireNonNull(particle, "Particle cannot be null");
return this;
}
/**
* @see #direction
* @since 8.0.0
*/
@Nonnull
public Vector getDirection() {
return direction;
}
/**
* Changes the current {@link #location} in {@link #direction} by {@code distance} blocks.
*
* @since 8.0.0
*/
public void advanceInDirection(double distance) {
Objects.requireNonNull(direction, "Cannot advance with null direction");
if (distance == 0) return;
if (this.onAdvance != null) distance = onAdvance.apply(distance);
this.location.add(this.direction.clone().multiply(distance));
}
/**
* @see #direction
* @since 8.0.0
*/
public ParticleDisplay withDirection(@Nullable Vector direction) {
this.direction = direction.clone().normalize();
return this;
}
/**
* Get the particle.
*
* @return the particle.
*/
@Nonnull
public XParticle getParticle() {
return particle;
}
/**
* Get the count of the particle.
*
* @return the count of the particle.
*/
public int getCount() {
return count;
}
/**
* Get the extra data of the particle.
*
* @return the extra data of the particle.
*/
public double getExtra() {
return extra;
}
/**
* Get the data object. Currently, it can be instance of float[] with [R, G, B, size],
* or instance of {@link BlockData}, {@link MaterialData} for legacy usage or {@link ItemStack}
*
* @return the data object.
* @since 5.1.0
*/
@SuppressWarnings("deprecation")
@Nullable
public Object getData() {
return data;
}
@Override
public String toString() {
return "ParticleDisplay:[" +
"Particle=" + particle + ", " +
"Count=" + count + ", " +
"Offset:{" + offset.getX() + ", " + offset.getY() + ", " + offset.getZ() + "}, " +
(location != null ? (
"Location:{" + location.getWorld().getName() + location.getX() + ", " + location.getY() + ", " + location.getZ() + "}, "
) : "") +
"Rotation:" + this.rotations + ", " +
"Extra=" + extra + ", " +
"Force=" + force + ", " +
"Data=" + (data == null ? "null" : data instanceof float[] ? Arrays.toString((float[]) data) : data);
}
/**
* Changes the particle count of the particle settings.
*
* @param count the particle count.
* @return the same particle display.
* @since 3.0.0
*/
@Nonnull
public ParticleDisplay withCount(int count) {
this.count = count;
return this;
}
/**
* In most cases extra is the speed of the particles.
*
* @param extra the extra number.
* @return the same particle display.
* @since 3.0.1
*/
@Nonnull
public ParticleDisplay withExtra(double extra) {
this.extra = extra;
return this;
}
/**
* A displayed particle with force can be seen further
* away for all player regardless of their particle
* settings. Force has no effect if specific players
* are added with {@link #spawn(Location)}.
*
* @param force the force argument.
* @return the same particle display, but modified.
* @since 5.0.1
*/
@Nonnull
public ParticleDisplay forceSpawn(boolean force) {
this.force = force;
return this;
}
/**
* Adds color properties to the particle settings.
* The particle must be {@link Particle#DUST}
* to get custom colors.
*
* @param color the RGB color of the particle.
* @param size the size of the particle.
* @return the same particle display, but modified.
* @see #colored(Location, Color, float)
* @since 3.0.0
*/
@Nonnull
public ParticleDisplay withColor(@Nonnull Color color, float size) {
return withColor(color.getRed(), color.getGreen(), color.getBlue(), size);
}
@Nonnull
public ParticleDisplay withColor(@Nonnull Color color) {
// TODO separate withColor() and withSize()
return withColor(color, 1f);
}
// public ParticleDisplay withSize(float size) {
// if (data == null) {
// this.data = new float[]{red, green, blue, size};
// }
// return this;
// }
/**
* @since 7.1.0
* @deprecated use {@link #withColor(Color, float)}
*/
@Nonnull
@Deprecated
public ParticleDisplay withColor(float red, float green, float blue, float size) {
this.data = new float[]{red, green, blue, size};
return this;
}
/**
* Adds color properties to the particle settings.
* The particle must be {@link Particle#DUST_COLOR_TRANSITION}
* to get custom colors.
*
* @param fromColor the RGB color of the particle on spawn.
* @param size the size of the particle.
* @param toColor the RGB color of the particle at the end.
* @return the same particle display, but modified.
* @see #colored(Location, Color, float)
* @since 8.6.0.0.1
*/
@Nonnull
public ParticleDisplay withTransitionColor(@Nonnull Color fromColor, float size, @Nonnull Color toColor) {
this.data = new float[]{
fromColor.getRed(), fromColor.getGreen(), fromColor.getBlue(),
size,
toColor.getRed(), toColor.getGreen(), toColor.getBlue()
};
return this;
}
/**
* @since 8.6.0.0.1
* @deprecated use {@link #withTransitionColor(Color, float, Color)}
*/
@Nonnull
@Deprecated
public ParticleDisplay withTransitionColor(float red1, float green1, float blue1,
float size,
float red2, float green2, float blue2) {
this.data = new float[]{red1, green1, blue1, size, red2, green2, blue2};
return this;
}
/**
* Adds data for {@code BLOCK_CRACK}, {@code BLOCK_DUST},
* {@link Particle#FALLING_DUST} and {@link Particle#BLOCK_MARKER} particles.
* The displayed particle will depend on the given block data for its color.
*
* Only works on minecraft version 1.13 and more, because
* {@link BlockData} didn't exist before.
*
* @param blockData the block data that will change the particle data.
* @return the same particle display, but modified.
* @since 5.1.0
*/
@Nonnull
public ParticleDisplay withBlock(@Nonnull BlockData blockData) {
this.data = blockData;
return this;
}
/**
* Adds data for {@code LEGACY_BLOCK_CRACK}, {@code LEGACY_BLOCK_DUST}
* and {@code LEGACY_FALLING_DUST} particles if the minecraft version is 1.13 or more.
*
* If version is at most 1.12, old particles {@code BLOCK_CRACK},
* {@code BLOCK_DUST} and {@code FALLING_DUST} will support this data.
*
* @param materialData the material data that will change the particle data.
* @return the same particle display, but modified.
* @see #withBlock(BlockData)
* @since 5.1.0
*/
@SuppressWarnings("deprecation")
@Nonnull
public ParticleDisplay withBlock(@Nonnull MaterialData materialData) {
this.data = materialData;
return this;
}
/**
* Adds extra data for {@code ITEM_CRACK}
* particle, depending on the given item stack.
*
* @param item the item stack that will change the particle data.
* @return the same particle display, but modified.
* @since 5.1.0
*/
@Nonnull
public ParticleDisplay withItem(@Nonnull ItemStack item) {
this.data = item;
return this;
}
@Nullable
public Vector getOffset() {
return offset;
}
/**
* Saves an instance of an entity to track the location from.
*
* @param entity the entity to track the location from.
* @return the same particle settings with the caller added.
* @since 3.1.0
*/
@Nonnull
public ParticleDisplay withEntity(@Nonnull Entity entity) {
return withLocationCaller(entity::getLocation);
}
/**
* Sets a caller for location changes.
*
* @param locationCaller the caller to call to get the new location.
* @return the same particle settings with the caller added.
* @since 3.1.0
*/
@Nonnull
public ParticleDisplay withLocationCaller(@Nullable Callable locationCaller) {
this.preCalculation = (context) -> {
try {
context.location = locationCaller.call();
} catch (Exception e) {
throw new RuntimeException(e);
}
};
return this;
}
/**
* Gets the location of an entity if specified or the constant location.
*
* This method is usually the center of the shape if the algorithm which uses
* it supports the use of {@link #advanceInDirection(double)}.
*
* @return the location of the particle.
* @since 3.1.0
*/
@Nullable
public Location getLocation() {
return location;
}
/**
* Sets the location that this particle should spawn.
*
* @param location the new location.
* @since 7.0.0
*/
public ParticleDisplay withLocation(@Nullable Location location) {
this.location = location;
return this;
}
/**
* Adjusts the rotation settings to face the entity's direction.
* Only some of the shapes support this method.
*
* @param entity the entity to face.
* @return the same particle display.
* @since 3.0.0
*/
@Nonnull
public ParticleDisplay face(@Nonnull Entity entity) {
return face(Objects.requireNonNull(entity, "Cannot face null entity").getLocation());
}
/**
* Adjusts the rotation settings to face the locations pitch and yaw.
* Only some of the shapes support this method.
*
* @param location the location to face.
* @return the same particle display.
* @since 6.1.0
*/
@Nonnull
public ParticleDisplay face(@Nonnull Location location) {
Objects.requireNonNull(location, "Cannot face null location");
rotate(
Rotation.of(Math.toRadians(location.getYaw()), Axis.Y),
Rotation.of(Math.toRadians(-location.getPitch()), Axis.X)
);
this.direction = location.getDirection().clone().normalize();
return this;
}
/**
* Clones the location of this particle display and adds xyz.
*
* @param x the x to add to the location.
* @param y the y to add to the location.
* @param z the z to add to the location.
* @return the cloned location.
* @see #clone()
* @since 1.0.0
*/
@Nullable
public Location cloneLocation(double x, double y, double z) {
return location == null ? null : cloneLocation(location).add(x, y, z);
}
/**
* We don't want to use {@link Location#clone()} since it doesn't copy to constructor and Java's clone method
* is known to be inefficient and broken.
*
* @since 3.0.3
*/
@Nonnull
private static Location cloneLocation(@Nonnull Location location) {
return new Location(location.getWorld(), location.getX(), location.getY(), location.getZ(), location.getYaw(), location.getPitch());
}
/**
* Clones this particle settings and adds xyz to its location.
*
* @param x the x to add.
* @param y the y to add.
* @param z the z to add.
* @return the cloned ParticleDisplay.
* @see #clone()
* @since 1.0.0
*/
@Nonnull
public ParticleDisplay cloneWithLocation(double x, double y, double z) {
ParticleDisplay display = clone();
if (location == null) return display;
display.location.add(x, y, z);
return display;
}
/**
* Clones this particle settings.
*
* @return the cloned ParticleDisplay.
* @see #cloneWithLocation(double, double, double)
* @see #cloneLocation(double, double, double)
*/
@SuppressWarnings("MethodDoesntCallSuperMethod")
@Override
@Nonnull
public ParticleDisplay clone() {
ParticleDisplay display = ParticleDisplay.of(particle)
.withDirection(direction)
.withCount(count).offset(offset.clone())
.forceSpawn(force)
.preCalculation(this.preCalculation)
.postCalculation(this.postCalculation);
if (location != null) display.location = cloneLocation(location);
if (!rotations.isEmpty()) {
display.rotations = new ArrayList<>(this.rotations);
}
display.data = data;
return display;
}
/**
* @see #getPrincipalAxesRotation(float, float, float)
*/
public static Vector getPrincipalAxesRotation(Location location) {
return getPrincipalAxesRotation(location.getPitch(), location.getYaw(), 0);
}
/**
* Taken from Aircraft principal axes.
*
* @return The vector representating how a point should be rotated to face these axes.
* @since 8.1.0
*/
public static Vector getPrincipalAxesRotation(float pitch, float yaw, float roll) {
// First the pitch has to be rotated around the x-axis because if we were to rotate the yaw around
// the y-axis first, the point could be facing either x or z axis and rotating the pitch around the
// x-axis would no longer be viable. But when we start from zero rotations, the point would be facing
// towards positive z-axis (why?) and rotating the pitch around the x-axis now works fine. After that,
// rotating the yaw around the y-axis would always work no matter the pitch of the point.
// https://danceswithcode.net/engineeringnotes/rotations_in_3d/rotations_in_3d_part2.html
return new Vector(
// We add 90 degrees to compensate for the non-standard use of pitch degrees in Minecraft.
Math.toRadians(pitch + 90),
Math.toRadians(-yaw),
roll
);
}
/**
* Gets yaw and pitch from a given direction.
*
* @return the first element is the yaw and the second is the pitch.
* @since 8.0.0
*/
public static float[] getYawPitch(Vector vector) {
/*
* Sin = Opp / Hyp
* Cos = Adj / Hyp
* Tan = Opp / Adj
*
* x = -Opp
* z = Adj
*/
final double _2PI = 2 * Math.PI;
final double x = vector.getX();
final double z = vector.getZ();
float pitch, yaw;
if (x == 0 && z == 0) {
yaw = 0;
pitch = vector.getY() > 0 ? -90 : 90;
} else {
double theta = Math.atan2(-x, z);
yaw = (float) Math.toDegrees((theta + _2PI) % _2PI);
double x2 = NumberConversions.square(x);
double z2 = NumberConversions.square(z);
double xz = Math.sqrt(x2 + z2);
pitch = (float) Math.toDegrees(Math.atan(-vector.getY() / xz));
}
return new float[]{yaw, pitch};
}
/**
* Gets the final calculated rotation.
*
* @param forceUpdate whether to update the cached rotation quaternion.
* Used when a new rotation is added.
* @since 9.0.0
*/
@Nonnull
public List getRotation(boolean forceUpdate) {
if (this.rotations.isEmpty()) return new ArrayList<>();
if (forceUpdate) cachedFinalRotationQuaternions = null;
if (cachedFinalRotationQuaternions == null) {
this.cachedFinalRotationQuaternions = new ArrayList<>();
for (List rotationGroup : this.rotations) {
Quaternion groupedQuat = null;
for (Rotation rotation : rotationGroup) {
Quaternion q = Quaternion.rotation(rotation.angle, rotation.axis);
if (groupedQuat == null) groupedQuat = q;
else groupedQuat = groupedQuat.mul(q);
}
this.cachedFinalRotationQuaternions.add(groupedQuat);
}
}
return cachedFinalRotationQuaternions;
}
/**
* Rotates the particle position based on this XYZ vector without overriding previous rotations.
* The xyz values must be radians which represent the angles
* to rotate the particle around x, y and then z axis in that order.
*
* @see #rotate(double, double, double)
* @since 1.0.0
*/
@Nonnull
public ParticleDisplay rotate(double x, double y, double z) {
return rotate(
Rotation.of(x, Axis.X),
Rotation.of(y, Axis.Y),
Rotation.of(z, Axis.Z)
);
}
/**
* @since 10.0.0
*/
public ParticleDisplay rotate(Rotation... rotations) {
Objects.requireNonNull(rotations, "Null rotations");
if (rotations.length != 0) {
List finalRots = Arrays.stream(rotations).filter(x -> x.angle != 0).collect(Collectors.toList());
if (!finalRots.isEmpty()) {
this.rotations.add(finalRots);
if (this.cachedFinalRotationQuaternions != null) this.cachedFinalRotationQuaternions.clear();
}
}
return this;
}
/**
* @since 10.0.0
*/
public ParticleDisplay rotate(Rotation rotation) {
Objects.requireNonNull(rotation, "Null rotation");
if (rotation.angle != 0) {
this.rotations.add(Collections.singletonList(rotation));
if (this.cachedFinalRotationQuaternions != null) this.cachedFinalRotationQuaternions.clear();
}
return this;
}
/**
* @return the location of the last particle spawned with this object.
* @since 8.0.0
*/
@Nullable
public Location getLastLocation() {
return lastLocation == null ? getLocation() : lastLocation;
}
/**
* Runs {@link #preCalculation}, rotates the given xyz with the given rotation radians and
* adds them to the specified location, and then calls {@link #postCalculation}.
*
* @return a cloned rotated location.
* @since 3.0.0
*/
@Nullable
public Location finalizeLocation(@Nullable Vector local) {
CalculationContext preContext = new CalculationContext(location, local);
if (this.preCalculation != null) this.preCalculation.accept(preContext);
if (!preContext.shouldSpawn) return null;
Location location = preContext.location;
if (location == null) throw new IllegalStateException("Attempting to spawn particle when no location is set");
// Exception check after preCalculation to account for dynamic location callers from withEntity()
local = preContext.local;
if (local != null && !rotations.isEmpty()) {
List rotations = getRotation(false);
for (Quaternion grouped : rotations) {
local = Quaternion.rotate(local, grouped);
}
}
location = cloneLocation(location);
if (local != null) location.add(local);
CalculationContext postContext = new CalculationContext(location, local);
if (this.postCalculation != null) this.postCalculation.accept(postContext);
if (!postContext.shouldSpawn) return null;
return location;
}
public final class CalculationContext {
private Location location;
private Vector local;
private boolean shouldSpawn = true;
public CalculationContext(Location location, Vector local) {
this.location = location;
this.local = local;
}
@Nullable
public Location getLocation() {
return location;
}
@Nullable
public Vector getLocal() {
return local;
}
public void setLocal(Vector local) {
this.local = local;
}
public void setLocation(Location location) {
this.location = location;
}
public void dontSpawn() {
this.shouldSpawn = false;
}
public ParticleDisplay getDisplay() {
return ParticleDisplay.this;
}
}
/**
* Set the xyz offset of the particle settings.
*
* @since 1.1.0
*/
@Nonnull
public ParticleDisplay offset(double x, double y, double z) {
return offset(new Vector(x, y, z));
}
/**
* Set the xyz offset of the particle settings.
*
* @since 7.0.0
*/
@Nonnull
public ParticleDisplay offset(@Nonnull Vector offset) {
this.offset = Objects.requireNonNull(offset, "Particle offset cannot be null");
return this;
}
/**
* Set the xyz offset of the particle settings to a single number.
*
* @since 6.0.0.1
*/
@Nonnull
public ParticleDisplay offset(double offset) {
return offset(offset, offset, offset);
}
/**
* When a particle is set to be directional it'll only
* spawn one particle and the xyz offset values are used for
* the direction of the particle.
*
* Colored particles in 1.12 and below don't support this.
*
* @return the same particle display.
* @see #isDirectional()
* @since 1.1.0
*/
@Nonnull
public ParticleDisplay directional() {
count = 0;
return this;
}
/**
* Check if this particle setting is a directional particle.
*
* @return true if the particle is directional, otherwise false.
* @see #directional()
* @since 2.1.0
*/
public boolean isDirectional() {
return count == 0;
}
/**
* Spawns the particle at the current location.
*
* @since 2.0.1
*/
@Nullable
public Location spawn() {
return spawn(finalizeLocation(null));
}
/**
* Adds xyz of the given vector to the cloned location before
* spawning particles.
*
* @param local the xyz to add.
* @since 1.0.0
*/
@Nullable
public Location spawn(@Nullable Vector local) {
return spawn(finalizeLocation(local));
}
/**
* Adds xyz to the cloned location before spawning particle.
*
* @since 1.0.0
*/
@Nullable
public Location spawn(double x, double y, double z) {
return spawn(finalizeLocation(new Vector(x, y, z)));
}
/**
* Displays the particle in the specified location.
* This method does not support rotations if used directly.
*
* @param loc the location to display the particle at.
* @see #spawn(double, double, double)
* @since 5.0.0
*/
@Nullable
public Location spawn(Location loc) {
if (loc == null) return null;
Particle particle = this.particle.get();
Objects.requireNonNull(particle, () -> "Cannot unsupported particle: " + particle);
World world = loc.getWorld();
double offsetx = offset.getX();
double offsety = offset.getY();
double offsetz = offset.getZ();
if (data != null && data instanceof float[]) {
float[] datas = (float[]) data;
if (ISFLAT && particle.getDataType() == Particle.DustOptions.class) {
Particle.DustOptions dust = new Particle.DustOptions(org.bukkit.Color
.fromRGB((int) datas[0], (int) datas[1], (int) datas[2]), datas[3]);
if (players == null)
world.spawnParticle(particle, loc, count, offsetx, offsety, offsetz, extra, dust, force);
else for (Player player : players)
player.spawnParticle(particle, loc, count, offsetx, offsety, offsetz, extra, dust);
} else if (SUPPORTS_DUST_TRANSITION && particle.getDataType() == Particle.DustTransition.class) {
// Having the variable type as Particle.DustOptions causes NoClassDefFoundError for DustOptions
// because of some weird upcasting stuff.
Particle.DustTransition dust = new Particle.DustTransition(
org.bukkit.Color.fromRGB((int) datas[0], (int) datas[1], (int) datas[2]),
org.bukkit.Color.fromRGB((int) datas[4], (int) datas[5], (int) datas[6]),
datas[3]);
if (players == null)
world.spawnParticle(particle, loc, count, offsetx, offsety, offsetz, extra, dust, force);
else for (Player player : players)
player.spawnParticle(particle, loc, count, offsetx, offsety, offsetz, extra, dust);
} else if (isDirectional()) {
// With count=0, color on offset e.g. for MOB_SPELL or 1.12 REDSTONE
float[] rgb = {datas[0] / 255f, datas[1] / 255f, datas[2] / 255f};
if (players == null) {
if (ISFLAT)
world.spawnParticle(particle, loc, count, rgb[0], rgb[1], rgb[2], datas[3], null, force);
else world.spawnParticle(particle, loc, count, rgb[0], rgb[1], rgb[2], datas[3], null);
} else for (Player player : players)
player.spawnParticle(particle, loc, count, rgb[0], rgb[1], rgb[2], datas[3]);
} else {
// Else color can't have any effect, keep default param
if (players == null) {
if (ISFLAT)
world.spawnParticle(particle, loc, count, offsetx, offsety, offsetz, extra, null, force);
else world.spawnParticle(particle, loc, count, offsetx, offsety, offsetz, extra, null);
} else for (Player player : players)
player.spawnParticle(particle, loc, count, offsetx, offsety, offsetz, extra);
}
} else {
// Checks without data or block crack, block dust, falling dust, item crack or if data isn't right type
Object datas = particle.getDataType().isInstance(data) ? data : null;
if (players == null) {
if (ISFLAT) world.spawnParticle(particle, loc, count, offsetx, offsety, offsetz, extra, datas, force);
else world.spawnParticle(particle, loc, count, offsetx, offsety, offsetz, extra, datas);
} else for (Player player : players)
player.spawnParticle(particle, loc, count, offsetx, offsety, offsetz, extra, datas);
}
this.lastLocation = loc;
return loc;
}
/**
* As an alternative to {@link org.bukkit.Axis} because it doesn't exist in 1.12
*
* @since 7.0.0
*/
public enum Axis {
X(new Vector(1, 0, 0)), Y(new Vector(0, 1, 0)), Z(new Vector(0, 0, 1));
private final Vector vector;
Axis(Vector vector) {
this.vector = vector;
}
public Vector getVector() {
return vector;
}
}
public static class Rotation implements Cloneable {
public double angle;
public Vector axis;
public Rotation(double angle, Vector axis) {
this.angle = angle;
this.axis = axis;
}
@SuppressWarnings("MethodDoesntCallSuperMethod")
@Override
public Object clone() {
return new Rotation(angle, axis.clone());
}
public static Rotation of(double angle, Vector axis) {
return new Rotation(angle, axis);
}
public static Rotation of(double angle, Axis axis) {
return new Rotation(angle, axis.vector);
}
}
public static class Quaternion implements Cloneable {
/**
* Only change these values directly if you know what you're doing.
*/
public final double w, x, y, z;
public Quaternion(double w, double x, double y, double z) {
this.w = w;
this.x = x;
this.y = y;
this.z = z;
}
@SuppressWarnings("MethodDoesntCallSuperMethod")
@Override
public Quaternion clone() {
return new Quaternion(w, x, y, z);
}
// Rotate a vector using a rotation quaternion.
public static Vector rotate(Vector vector, Quaternion rotation) {
return rotation.mul(Quaternion.from(vector)).mul(rotation.inverse()).toVector();
}
// Rotate a vector theta degrees around an axis.
public static Vector rotate(Vector vector, Vector axis, double deg) {
return Quaternion.rotate(vector, Quaternion.rotation(deg, axis));
}
// Create quaternion from a vector.
public static Quaternion from(Vector vector) {
return new Quaternion(0, vector.getX(), vector.getY(), vector.getZ());
}
public static Quaternion rotation(double degrees, Vector vector) {
vector = vector.normalize();
degrees = degrees / 2;
double sin = Math.sin(degrees);
return new Quaternion(Math.cos(degrees), vector.getX() * sin, vector.getY() * sin, vector.getZ() * sin);
}
public String getInverseString() {
double rads = Math.acos(this.w);
double deg = Math.toDegrees(rads) * 2;
double sin = Math.sin(rads);
Vector axis = new Vector(this.x / sin, this.y / sin, this.z / sin);
return deg + ", " + axis.getX() + ", " + axis.getY() + ", " + axis.getZ();
}
public Vector toVector() {
return new Vector(x, y, z);
}
public Quaternion inverse() {
double l = w * w + x * x + y * y + z * z;
return new Quaternion(w / l, -x / l, -y / l, -z / l);
}
public Quaternion conjugate() {
return new Quaternion(w, -x, -y, -z);
}
// Multiply this quaternion and another.
// Returns the Hamilton product of this quaternion and r.
public Quaternion mul(Quaternion r) {
double n0 = r.w * w - r.x * x - r.y * y - r.z * z;
double n1 = r.w * x + r.x * w + r.y * z - r.z * y;
double n2 = r.w * y - r.x * z + r.y * w + r.z * x;
double n3 = r.w * z + r.x * y - r.y * x + r.z * w;
return new Quaternion(n0, n1, n2, n3);
}
public Vector mul(Vector point) {
// https://github.com/Unity-Technologies/UnityCsReference/blob/7c95a72366b5ed9b6d9e804de8b5e869c962f5a9/Runtime/Export/Math/Quaternion.cs#L96-L117
double x = this.x * 2;
double y = this.y * 2;
double z = this.z * 2;
double xx = this.x * x;
double yy = this.y * y;
double zz = this.z * z;
double xy = this.x * y;
double xz = this.x * z;
double yz = this.y * z;
double wx = this.w * x;
double wy = this.w * y;
double wz = this.w * z;
double vx = (1F - (yy + zz)) * point.getX() + (xy - wz) * point.getY() + (xz + wy) * point.getZ();
double vy = (xy + wz) * point.getX() + (1F - (xx + zz)) * point.getY() + (yz - wx) * point.getZ();
double vz = (xz - wy) * point.getX() + (yz + wx) * point.getY() + (1F - (xx + yy)) * point.getZ();
return new Vector(vx, vy, vz);
}
}
}