From 609ad3a02a6c225798537806893eb52d6ff4057b Mon Sep 17 00:00:00 2001 From: Andrew Menden Date: Wed, 1 Apr 2026 15:37:36 -0500 Subject: [PATCH] Trail previews --- .../java/com/github/pop4959/srbot/Main.java | 1 + .../pop4959/srbot/commands/PreviewTrail.java | 170 ++++++++++ .../github/pop4959/srbot/trail/Camera.java | 11 + .../com/github/pop4959/srbot/trail/Color.java | 49 +++ .../com/github/pop4959/srbot/trail/Path.java | 35 +++ .../pop4959/srbot/trail/RendererCpu.java | 220 +++++++++++++ .../com/github/pop4959/srbot/trail/Trail.java | 232 ++++++++++++++ .../pop4959/srbot/trail/TrailAnimation.java | 273 ++++++++++++++++ .../pop4959/srbot/trail/TrailLayer.java | 25 ++ .../pop4959/srbot/trail/TrailParticle.java | 183 +++++++++++ .../srbot/trail/TrailParticleEmitter.java | 292 +++++++++++++++++ .../pop4959/srbot/trail/TrailStripe.java | 294 ++++++++++++++++++ .../github/pop4959/srbot/trail/Vector2.java | 64 ++++ .../github/pop4959/srbot/trail/Vertex.java | 19 ++ .../pop4959/srbot/trail/VertexArray.java | 61 ++++ 15 files changed, 1929 insertions(+) create mode 100644 src/main/java/com/github/pop4959/srbot/commands/PreviewTrail.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/Camera.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/Color.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/Path.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/RendererCpu.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/Trail.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/TrailAnimation.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/TrailLayer.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/TrailParticle.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/TrailParticleEmitter.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/TrailStripe.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/Vector2.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/Vertex.java create mode 100644 src/main/java/com/github/pop4959/srbot/trail/VertexArray.java diff --git a/src/main/java/com/github/pop4959/srbot/Main.java b/src/main/java/com/github/pop4959/srbot/Main.java index a9c7c0d..cae938b 100644 --- a/src/main/java/com/github/pop4959/srbot/Main.java +++ b/src/main/java/com/github/pop4959/srbot/Main.java @@ -49,6 +49,7 @@ public static void main(String[] args) { add(new Players(config, steamWebApiClient)); add(new Playtime(config, steamWebApiClient)); add(new Points(config, steamWebApiClient)); + add(new PreviewTrail()); add(new Private(config)); add(new RandomCharacter(config)); add(new Say()); diff --git a/src/main/java/com/github/pop4959/srbot/commands/PreviewTrail.java b/src/main/java/com/github/pop4959/srbot/commands/PreviewTrail.java new file mode 100644 index 0000000..e7d4857 --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/commands/PreviewTrail.java @@ -0,0 +1,170 @@ +package com.github.pop4959.srbot.commands; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; + +import javax.imageio.ImageIO; + +import org.jetbrains.annotations.NotNull; + +import com.github.pop4959.srbot.trail.Camera; +import com.github.pop4959.srbot.trail.Color; +import com.github.pop4959.srbot.trail.Path; +import com.github.pop4959.srbot.trail.RendererCpu; +import com.github.pop4959.srbot.trail.Trail; +import com.github.pop4959.srbot.trail.TrailAnimation; +import com.github.pop4959.srbot.trail.TrailLayer; +import com.github.pop4959.srbot.trail.TrailParticleEmitter; +import com.github.pop4959.srbot.trail.TrailStripe; +import com.github.pop4959.srbot.trail.Vector2; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; +import net.dv8tion.jda.api.utils.FileUpload; + +public class PreviewTrail extends Command { + public PreviewTrail() { + super("preview_trail", "Get a preview of the trail you will get when you use the command"); + } + + private void cpuRender(RendererCpu cpu, Trail trail) { + for (TrailLayer layer : trail.layers) { + if (layer.getLayerType() == 0) { + TrailStripe stripe = (TrailStripe) layer; + if (stripe.getEnabled() == 0 || !stripe.getIsVisible()) + continue; + cpu.setTexture(trail.getLoadedImages().getOrDefault(stripe.getImage(), null)); + for (int i = 0; i < stripe.getSegmentCount(); i++) { + TrailStripe.Segment segment = stripe.getSegment(i); + cpu.drawTriangleStrip(stripe.vertexArray, segment.startIndex, segment.endIndex); + } + } else if (layer.getLayerType() == 1) { + TrailParticleEmitter emitter = (TrailParticleEmitter) layer; + if (emitter.getEnabled() == 0 || !emitter.getIsVisible()) + continue; + cpu.setTexture(trail.getLoadedImages().getOrDefault(emitter.getImage(), null)); + cpu.drawQuads(emitter.vertexArray); + } else if (layer.getLayerType() == 2) { + TrailAnimation animation = (TrailAnimation) layer; + if (animation.getEnabled() == 0 || !animation.getIsVisible()) + continue; + cpu.setTexture(trail.getLoadedImages().getOrDefault(animation.getImage(), null)); + cpu.drawQuads(animation.vertexArray); + } + } + } + + private BufferedImage renderTrail(String filePath, Color clearColor) throws IOException { + Trail trail = new Trail(filePath); + + Camera camera = new Camera(); + RendererCpu cpu = new RendererCpu(1024, 512); + cpu.camera = camera; + camera.position = new Vector2(-512, -256); + + float startTime = 5.2f; + float endTime = (float) Math.PI * 2.0f - 0.05f; + int pointCount = 600; + float timeStep = (endTime - startTime) / (float) pointCount; + Path pathA = new Path(timeStep); + + float startSpeed = Trail.AFTERIMAGE_THRESHOLD; + float endSpeed = Trail.SUPERSPEED_THRESHOLD * 1.35f; + float speedStep = (endSpeed - startSpeed) / (float) pointCount; + + for (int i = 0; i < pointCount; i++) { + float currentTime = startTime + i * timeStep; + float currentSpeed = startSpeed + i * speedStep; + Vector2 position = pathA.infinityPath(currentTime, 430, new Vector2(0, 0), 3.0f); + Vector2 velocity = pathA.calculateVelocity(position); + velocity = Vector2.normalize(velocity); + velocity = Vector2.scale(velocity, currentSpeed); + + trail.update(timeStep, position, velocity); + } + + cpu.clear(clearColor); + cpuRender(cpu, trail); + return cpu.getFramebuffer(); + } + + @Override + public SlashCommandData getSlashCommand() { + return super.getSlashCommand() + .addOption(OptionType.STRING, "message_id", "The message id of the message you want to preview the trail of", true) + .addOption(OptionType.BOOLEAN, "transparent", "Whether the background should be transparent or not", true); + } + + @Override + public void execute(@NotNull SlashCommandInteractionEvent event) { + String messageId = event.getOption("message_id").getAsString(); + + long id; + try { + id = Long.parseLong(messageId); + } catch (NumberFormatException e) { + event.reply("Invalid message id").setEphemeral(true).queue(); + return; + } + + event.getChannel().retrieveMessageById(id).queue( + message -> processMessage(event, message), + error -> event.reply("Message not found.").setEphemeral(true).queue() + ); + } + + private void processMessage(SlashCommandInteractionEvent event, Message message) { + message.getAttachments().stream() + .filter(a -> a.getFileName().toLowerCase().endsWith(".srt")) + .findFirst() + .ifPresentOrElse( + attachment -> processAttachment(event, attachment), + () -> event.reply("No .srt attachment found.").setEphemeral(true).queue() + ); + } + + private void processAttachment(SlashCommandInteractionEvent event, Message.Attachment attachment) { + java.nio.file.Path tempPath; + boolean transparent = event.getOption("transparent") != null && event.getOption("transparent").getAsBoolean(); + try { + tempPath = Files.createTempFile("attachment", ".srt"); + System.out.println("Created temporary file: " + tempPath.toString()); + } catch (IOException e) { + event.reply("Failed to create temporary file.").setEphemeral(true).queue(); + return; + } + attachment.getProxy().downloadToFile(tempPath.toFile()).thenRun(() -> { + try { + Color clearColor = transparent ? new Color(0, 0, 0, 0) : new Color(0.2f, 0.2f, 0.2f, 1.0f); + BufferedImage preview = renderTrail(tempPath.toString(), clearColor); + + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + ImageIO.write(preview, "png", os); + byte[] imageData = os.toByteArray(); + FileUpload upload = FileUpload.fromData(imageData, "preview.png"); + ArrayList embeds = new ArrayList(); + + EmbedBuilder embed = new EmbedBuilder() + .setImage("attachment://preview.png") + .setColor(event.getGuild().getSelfMember().getColor()); + embeds.add(embed.build()); + + event.replyEmbeds(embeds).addFiles(upload).queue(); + } catch (IOException e) { + event.reply("Failed to create temporary image file.").setEphemeral(true).queue(); + return; + } + + } catch (IOException e) { + event.reply("Failed to read trail file.").setEphemeral(true).queue(); + } + }); + } +} diff --git a/src/main/java/com/github/pop4959/srbot/trail/Camera.java b/src/main/java/com/github/pop4959/srbot/trail/Camera.java new file mode 100644 index 0000000..bb43a5e --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/Camera.java @@ -0,0 +1,11 @@ +package com.github.pop4959.srbot.trail; + +public class Camera { + public Vector2 position; + public float zoom; + + public Camera() { + position = new Vector2(0, 0); + zoom = 1.0f; + } +} diff --git a/src/main/java/com/github/pop4959/srbot/trail/Color.java b/src/main/java/com/github/pop4959/srbot/trail/Color.java new file mode 100644 index 0000000..6582404 --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/Color.java @@ -0,0 +1,49 @@ +package com.github.pop4959.srbot.trail; + +public class Color { + public float r,g,b,a; + + public Color(float r, float g, float b, float a) { + this.r = r; + this.g = g; + this.b = b; + this.a = a; + } + + public static Color blend(Color src, Color dst) { + float outA = src.a + dst.a * (1 - src.a); + if (outA == 0) return new Color(0, 0, 0, 0); + float outR = (src.r * src.a + dst.r * dst.a * (1 - src.a)) / outA; + float outG = (src.g * src.a + dst.g * dst.a * (1 - src.a)) / outA; + float outB = (src.b * src.a + dst.b * dst.a * (1 - src.a)) / outA; + return new Color(outR, outG, outB, outA); + } + + public static Color multiply(Color a, Color b) { + return new Color(a.r * b.r, a.g * b.g, a.b * b.b, a.a * b.a); + } + + public int toRGB() { + int ia = (int)(a * 255.0); + int ir = (int)(r * 255.0); + int ig = (int)(g * 255.0); + int ib = (int)(b * 255.0); + return (ia << 24) | (ir << 16) | (ig << 8) | ib; + } + + static public Color parse(String s) { + String[] parts = s.split(","); + if (parts.length == 3) { + return new Color(Float.parseFloat(parts[0]), + Float.parseFloat(parts[1]), + Float.parseFloat(parts[2]), 1.0f); + } else if (parts.length == 4) { + return new Color(Float.parseFloat(parts[0]), + Float.parseFloat(parts[1]), + Float.parseFloat(parts[2]), + Float.parseFloat(parts[3])); + } else { + throw new IllegalArgumentException("Invalid color format: " + s); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/github/pop4959/srbot/trail/Path.java b/src/main/java/com/github/pop4959/srbot/trail/Path.java new file mode 100644 index 0000000..87be13c --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/Path.java @@ -0,0 +1,35 @@ +package com.github.pop4959.srbot.trail; + +public class Path { + private float deltaTime; + private Vector2 previousPosition; + + public Path(float deltaTime) { + this.deltaTime = deltaTime; + } + + public Vector2 calculateVelocity(Vector2 position) { + if (previousPosition == null) { + previousPosition = new Vector2(position.x, position.y); + } + Vector2 velocity = new Vector2(position.x - previousPosition.x, position.y - previousPosition.y); + previousPosition = new Vector2(position.x, position.y); + return Vector2.scale(velocity, 1.0f / deltaTime); + } + + public void reset() { + previousPosition = null; + } + + public Vector2 infinityPath(float time, float radius, Vector2 center, float speed) { + time *= speed; + return Vector2.sum( + center, + new Vector2( + radius * (float)Math.cos(time) / (1 + (float)Math.sin(time) * (float)Math.sin(time)), + radius * (float)Math.cos(time) * (float)Math.sin(time) / (1 + (float)Math.sin(time) * (float)Math.sin(time)) + ) + ); + } + +} diff --git a/src/main/java/com/github/pop4959/srbot/trail/RendererCpu.java b/src/main/java/com/github/pop4959/srbot/trail/RendererCpu.java new file mode 100644 index 0000000..16217d2 --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/RendererCpu.java @@ -0,0 +1,220 @@ +package com.github.pop4959.srbot.trail; + +import java.awt.image.BufferedImage; + +public class RendererCpu { + private int width; + private int height; + private BufferedImage texture; + private BufferedImage framebuffer; + public Camera camera; + + public RendererCpu(int width, int height) { + this.width = width; + this.height = height; + this.framebuffer = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + } + + private float edgeFunction(Vector2 a, Vector2 b, Vector2 c) { + return (float)((c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x)); + } + + private float[] calculateInterpolationFactors(Vertex v1, Vertex v2, Vertex v3, Vector2 p) { + float area = edgeFunction(v1.position, v2.position, v3.position); + float w0 = edgeFunction(v2.position, v3.position, p) / area; + float w1 = edgeFunction(v3.position, v1.position, p) / area; + float w2 = edgeFunction(v1.position, v2.position, p) / area; + return new float[]{w0, w1, w2}; + } + + private int min(float a, float b, float c) { + return (int)Math.floor(Math.min(a, Math.min(b, c))); + } + + private int max(float a, float b, float c) { + return (int)Math.ceil(Math.max(a, Math.max(b, c))); + } + + private boolean isTopLeftEdge(Vector2 a, Vector2 b) { + return (a.y < b.y) || (a.y == b.y && a.x > b.x); + } + + public void drawTriangle(Vertex v0, Vertex v1, Vertex v2) { + Vertex sv0 = vertexShader(v0); + Vertex sv1 = vertexShader(v1); + Vertex sv2 = vertexShader(v2); + + int minX = min(sv0.position.x, sv1.position.x, sv2.position.x); + int maxX = max(sv0.position.x, sv1.position.x, sv2.position.x); + int minY = min(sv0.position.y, sv1.position.y, sv2.position.y); + int maxY = max(sv0.position.y, sv1.position.y, sv2.position.y); + + for (int y = minY; y <= maxY; y++) { + for (int x = minX; x <= maxX; x++) { + if (x < 0 || x >= width || y < 0 || y >= height) { + continue; + } + + Vector2 p = new Vector2(x + 0.5f, y + 0.5f); + float[] factors = calculateInterpolationFactors(sv0, sv1, sv2, p); + + boolean e0 = isTopLeftEdge(sv1.position, sv2.position); + boolean e1 = isTopLeftEdge(sv2.position, sv0.position); + boolean e2 = isTopLeftEdge(sv0.position, sv1.position); + + boolean inside = (factors[0] > 0 || (factors[0] == 0 && e0)) && + (factors[1] > 0 || (factors[1] == 0 && e1)) && + (factors[2] > 0 || (factors[2] == 0 && e2)); + + if (!inside) { + continue; + } + + Vector2 normalizedFragCoord = new Vector2(p.x / width, p.y / height); + Color color = fragmentShader(normalizedFragCoord, + new Color( + factors[0] * sv0.color.r + factors[1] * sv1.color.r + factors[2] * sv2.color.r, + factors[0] * sv0.color.g + factors[1] * sv1.color.g + factors[2] * sv2.color.g, + factors[0] * sv0.color.b + factors[1] * sv1.color.b + factors[2] * sv2.color.b, + factors[0] * sv0.color.a + factors[1] * sv1.color.a + factors[2] * sv2.color.a + ), + new Vector2(factors[0] * sv0.textureCoordinate.x + factors[1] * sv1.textureCoordinate.x + factors[2] * sv2.textureCoordinate.x, + factors[0] * sv0.textureCoordinate.y + factors[1] * sv1.textureCoordinate.y + factors[2] * sv2.textureCoordinate.y) + ); + Color existingColor = new Color( + ((framebuffer.getRGB(x, y) >> 16) & 0xFF) / 255f, + ((framebuffer.getRGB(x, y) >> 8) & 0xFF) / 255f, + (framebuffer.getRGB(x, y) & 0xFF) / 255f, + ((framebuffer.getRGB(x, y) >> 24) & 0xFF) / 255f + ); + Color blendedColor = Color.blend(color, existingColor); + framebuffer.setRGB(x, y, blendedColor.toRGB()); + } + } + } + + public void drawTriangleStrip(VertexArray vertexArray, int startIndex, int endIndex) { + for (int i = startIndex; i < endIndex - 2; i++) { + if (i % 2 == 0) { + drawTriangle(vertexArray.getVertex(i), vertexArray.getVertex(i + 1), vertexArray.getVertex(i + 2)); + } else { + drawTriangle(vertexArray.getVertex(i), vertexArray.getVertex(i + 2), vertexArray.getVertex(i + 1)); + } + } + } + + public void drawTriangleStrip(VertexArray vertexArray) { + drawTriangleStrip(vertexArray, 0, vertexArray.getVertexCount()); + } + + public void drawQuads(VertexArray vertexArray) { + for (int i = 0; i < vertexArray.getVertexCount() - 3; i += 4) { + drawTriangle(vertexArray.getVertex(i), vertexArray.getVertex(i + 1), vertexArray.getVertex(i + 2)); + drawTriangle(vertexArray.getVertex(i + 2), vertexArray.getVertex(i + 3), vertexArray.getVertex(i + 1)); + } + } + + private Color sampleTextureNearest(Vector2 textureCoordinate) { + BufferedImage texture = getTexture(); + int x = (int)(textureCoordinate.x * (texture.getWidth() - 1)); + int y = (int)(textureCoordinate.y * (texture.getHeight() - 1)); + int rgb = texture.getRGB(x, y); + return new Color( + ((rgb >> 16) & 0xFF) / 255f, + ((rgb >> 8) & 0xFF) / 255f, + (rgb & 0xFF) / 255f, + ((rgb >> 24) & 0xFF) / 255f + ); + } + + private Color sampleTextureBilinear(Vector2 textureCoordinate) { + BufferedImage texture = getTexture(); + float x = textureCoordinate.x * (texture.getWidth() - 1); + float y = textureCoordinate.y * (texture.getHeight() - 1); + int x0 = (int)Math.floor(x); + int x1 = (int)Math.ceil(x); + int y0 = (int)Math.floor(y); + int y1 = (int)Math.ceil(y); + + Color c00 = sampleTextureNearest(new Vector2(x0 / (float)texture.getWidth(), y0 / (float)texture.getHeight())); + Color c10 = sampleTextureNearest(new Vector2(x1 / (float)texture.getWidth(), y0 / (float)texture.getHeight())); + Color c01 = sampleTextureNearest(new Vector2(x0 / (float)texture.getWidth(), y1 / (float)texture.getHeight())); + Color c11 = sampleTextureNearest(new Vector2(x1 / (float)texture.getWidth(), y1 / (float)texture.getHeight())); + + float tx = x - x0; + float ty = y - y0; + + return new Color( + c00.r * (1 - tx) * (1 - ty) + c10.r * tx * (1 - ty) + c01.r * (1 - tx) * ty + c11.r * tx * ty, + c00.g * (1 - tx) * (1 - ty) + c10.g * tx * (1 - ty) + c01.g * (1 - tx) * ty + c11.g * tx * ty, + c00.b * (1 - tx) * (1 - ty) + c10.b * tx * (1 - ty) + c01.b * (1 - tx) * ty + c11.b * tx * ty, + c00.a * (1 - tx) * (1 - ty) + c10.a * tx * (1 - ty) + c01.a * (1 - tx) * ty + c11.a * tx * ty + ); + } + + private Color sampleTexture(Vector2 textureCoordinate) { + return sampleTextureBilinear(textureCoordinate); + } + + private Vertex vertexShader(Vertex vertex) { + if (camera == null) { + return vertex; + } + Vector2 center = new Vector2(width / 2, height / 2); + Vertex transformedVertex = new Vertex( + new Vector2( + (int)((vertex.position.x - camera.position.x - center.x) * camera.zoom + center.x), + (int)((vertex.position.y - camera.position.y - center.y) * camera.zoom + center.y) + ), + vertex.color, + vertex.textureCoordinate + ); + return transformedVertex; + } + + private Color fragmentShader(Vector2 fragCoord, Color color, Vector2 textureCoordinate) { + return Color.multiply(color, sampleTexture(textureCoordinate)); + } + + private BufferedImage getTexture() { + if (texture == null) { + // Placeholder for texture loading logic + texture = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + texture.setRGB(0, 0, 0xFFFFFFFF); // White pixel + } + return texture; + } + + public void setTexture(BufferedImage texture) { + this.texture = texture; + } + + public void clear(Color clearColor) { + int rgb = clearColor.toRGB(); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + framebuffer.setRGB(x, y, rgb); + } + } + } + + public void clear(int rgb) { + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + framebuffer.setRGB(x, y, rgb); + } + } + } + + public void saveToFile(String filename) { + try { + javax.imageio.ImageIO.write(framebuffer, "png", new java.io.File(filename)); + } catch (java.io.IOException e) { + e.printStackTrace(); + } + } + + public BufferedImage getFramebuffer() { + return framebuffer; + } +} diff --git a/src/main/java/com/github/pop4959/srbot/trail/Trail.java b/src/main/java/com/github/pop4959/srbot/trail/Trail.java new file mode 100644 index 0000000..0100745 --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/Trail.java @@ -0,0 +1,232 @@ +package com.github.pop4959.srbot.trail; + +import java.awt.image.BufferedImage; +import java.io.DataInputStream; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class Trail { + //a lot of this is only used as a target to read to + private int version; + private String name; + private String author; + private String description; + private long lastUpdated; + private String icon; + private HashMap images; //key = image name, value = path in zip file + private HashMap loadedImages; //key = image name, value = loaded image + private Boolean keepDefaultTrail; + private long workshopId; + + public static final float SUPERSPEED_THRESHOLD = 800.0f; + public static final float AFTERIMAGE_THRESHOLD = 400.0f; + + public static final int ENABLED_NEVER = 0; + public static final int ENABLED_ALWAYS = 1; + public static final int ENABLED_ONLY_SUPERSPEED = 2; + public static final int ENABLED_NOT_SUPERSPEED = 3; + + public List layers; + private float totalTime; + + public Trail() { + version = 0; + name = ""; + author = ""; + description = ""; + lastUpdated = 0; + icon = "icon"; + images = new HashMap<>(); + loadedImages = new HashMap<>(); + keepDefaultTrail = false; + workshopId = 0; + totalTime = 0.0f; + + layers = new ArrayList(); + } + + public Trail(String filename) throws IOException { + images = new HashMap<>(); + loadedImages = new HashMap<>(); + + layers = new ArrayList(); + + readFromFile(filename); + } + + public HashMap getLoadedImages() { + return loadedImages; + } + + public static float calculateSpeed(Vector2 velocity) { + return Vector2.length(velocity); + // return Math.abs(velocity.x); + } + + public void readFromFile(String filename) throws IOException { + try (ZipFile zipFile = new ZipFile(filename)) { + ZipEntry settingsEntry = zipFile.getEntry("settings.trail"); + if (settingsEntry == null) { + throw new IOException("settings.trail not found in " + filename); + } + readSettings(new DataInputStream(zipFile.getInputStream(settingsEntry)), zipFile); + } catch (IOException e) { + throw new IOException("Failed to read trail file: " + filename, e); + } + } + + public void readFromFile(File file) throws IOException { + try (ZipFile zipFile = new ZipFile(file)) { + ZipEntry settingsEntry = zipFile.getEntry("settings.trail"); + if (settingsEntry == null) { + throw new IOException("settings.trail not found in " + file.getName()); + } + readSettings(new DataInputStream(zipFile.getInputStream(settingsEntry)), zipFile); + } catch (IOException e) { + throw new IOException("Failed to read trail file: " + file.getName(), e); + } + } + + public void addPoint(Vector2 position, Vector2 velocity) { + for (TrailLayer layer : layers) { + if (layer.getLayerType() == 0) { + TrailStripe stripe = (TrailStripe) layer; + stripe.addPoint(position, velocity, totalTime); + } + } + } + + public void update(float deltaTime, Vector2 position, Vector2 velocity) { + totalTime += deltaTime; + for (TrailLayer layer : layers) { + layer.update(deltaTime, position, velocity); + } + addPoint(position, velocity); + } + + public void reset() { + totalTime = 0.0f; + for (TrailLayer layer : layers) { + layer.reset(); + } + } + + private void readSettings(DataInputStream in, ZipFile zipFile) throws IOException { + version = readInt4(in); + name = readString(in); + author = readString(in); + description = readString(in); + lastUpdated = readLong8(in); + icon = readString(in); + int imageCount = readInt4(in); + images = new HashMap<>(); + for (int i = 0; i < imageCount; i++) { + String key = readString(in); + String path = readString(in); + images.put(key, path); + } + + for (HashMap.Entry entry : images.entrySet()) { + String imageName = entry.getKey(); + String imagePath = entry.getValue(); + ZipEntry imageEntry = zipFile.getEntry(imagePath); + if (imageEntry == null) { + throw new IOException("Image not found in zip: " + imagePath); + } + try (DataInputStream imageIn = new DataInputStream(zipFile.getInputStream(imageEntry))) { + BufferedImage image = javax.imageio.ImageIO.read(imageIn); + loadedImages.put(imageName, image); + } catch (IOException e) { + throw new IOException("Failed to read image: " + imagePath, e); + } + } + + int layerCount = readInt4(in); + for (int i = 0; i < layerCount; i++) { + byte type = in.readByte(); //0 = stripe, 1 = particle, 2 = animation + int propertyCount = readInt4(in); + HashMap properties = new HashMap<>(); + for (int j = 0; j < propertyCount; j++) { + String key = readString(in); + String value = readString(in); + properties.put(key, value); + } + + switch (type) { + case 0 -> { + TrailStripe stripe = new TrailStripe(); + stripe.loadFromHashMap(properties); + layers.add(stripe); + } + case 1 -> { + TrailParticleEmitter emitter = new TrailParticleEmitter(); + emitter.loadFromHashMap(properties); + if (!loadedImages.containsKey(emitter.getImage())) { + continue; + } + // emitter.imageWidth = loadedImages.get(emitter.image).getWidth(); + // emitter.imageHeight = loadedImages.get(emitter.image).getHeight(); + emitter.setImageDimensions(loadedImages.get(emitter.getImage()).getWidth(), loadedImages.get(emitter.getImage()).getHeight()); + layers.add(emitter); + } + case 2 -> { + TrailAnimation animation = new TrailAnimation(); + animation.loadFromHashMap(properties); + if (!loadedImages.containsKey(animation.getImage())) { + continue; + } + animation.setImageDimensions(loadedImages.get(animation.getImage()).getWidth(), loadedImages.get(animation.getImage()).getHeight()); + layers.add(animation); + } + default -> throw new IOException("Unknown layer type: " + type); + } + } + + sortByOrder(); + + //from pop + if (version >= 2) { + keepDefaultTrail = in.readByte() == 0; + } + if (version >= 3) { + workshopId = readLong8(in); + } + } + + private void sortByOrder() { + layers.sort((a, b) -> Integer.compare(a.order, b.order)); + } + + private int readInt4(DataInputStream in) throws IOException { + int b1 = in.readUnsignedByte(); + int b2 = in.readUnsignedByte(); + int b3 = in.readUnsignedByte(); + int b4 = in.readUnsignedByte(); + return (b4 << 24) | (b3 << 16) | (b2 << 8) | b1; + } + + private long readLong8(DataInputStream in) throws IOException { + long b1 = in.readUnsignedByte(); + long b2 = in.readUnsignedByte(); + long b3 = in.readUnsignedByte(); + long b4 = in.readUnsignedByte(); + long b5 = in.readUnsignedByte(); + long b6 = in.readUnsignedByte(); + long b7 = in.readUnsignedByte(); + long b8 = in.readUnsignedByte(); + return (b8 << 56) | (b7 << 48) | (b6 << 40) | (b5 << 32) | (b4 << 24) | (b3 << 16) | (b2 << 8) | b1; + } + + private String readString(DataInputStream in) throws IOException { + byte length = in.readByte(); + if (length == 0) return ""; + byte[] bytes = new byte[length]; + in.readFully(bytes); + return new String(bytes, "UTF-8"); + } +} \ No newline at end of file diff --git a/src/main/java/com/github/pop4959/srbot/trail/TrailAnimation.java b/src/main/java/com/github/pop4959/srbot/trail/TrailAnimation.java new file mode 100644 index 0000000..fd68e5a --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/TrailAnimation.java @@ -0,0 +1,273 @@ +package com.github.pop4959.srbot.trail; + +import java.util.HashMap; + +public class TrailAnimation extends TrailLayer { + + private byte enabled; //0 = never, 1 = always, 2 = only at superspeed, 3 = not at superspeed + // private String layer; + private boolean visible; + private Vector2 spriteCount; + private int startFrame; + private int endFrame; + private float fps; + private byte loop; //0 = loop, 1 = ping pong, 2 = once then freeze, 3 = once then disappear + private Color color; + private float opacity; + private Vector2 offset; + private float scale; + private float fadeIn; + private float fadeOut; + private float scaleOut; + private boolean forceRightSideUp; + private boolean rotateWithPlayer; + private boolean moveWhenInactive; + + private int imageWidth; + private int imageHeight; + + private int currentFrame; + private float notFastEnoughTimer; //only counts when player is not moving fast enough + private float frameTimer; //counts time since last frame change, resets when frame changes + private boolean pingPongForward; + + private Vector2 lastPosition; + private float lastRotation; + private float fadeValue; + private boolean activeSpeedReachedLastFrame; + + public TrailAnimation() { + super(0, (byte)2); + + imageWidth = 0; + imageHeight = 0; + currentFrame = 0; + frameTimer = 0; + notFastEnoughTimer = 0; + pingPongForward = true; + lastPosition = new Vector2(0, 0); + lastRotation = 0.0f; + fadeValue = 0.0f; + + enabled = 0; + // layer = "TrailBehindLocalPlayersLayer"; + image = ""; + visible = true; + spriteCount = new Vector2(1, 1); + startFrame = 0; + endFrame = 0; + fps = 30; + loop = 0; + color = new Color(1.0f, 1.0f, 1.0f, 1.0f); + opacity = 1.0f; + offset = new Vector2(0, 0); + scale = 1.0f; + fadeIn = 0.0f; + fadeOut = 0.0f; + scaleOut = 0.0f; + forceRightSideUp = false; + rotateWithPlayer = true; + moveWhenInactive = true; + } + + public void setImageDimensions(int width, int height) { + this.imageWidth = width; + this.imageHeight = height; + } + + public String getImage() { + return image; + } + + public boolean getIsVisible() { + return visible; + } + + public byte getEnabled() { + return enabled; + } + + public void loadImageDimensions(int width, int height) { + this.imageWidth = width; + this.imageHeight = height; + } + + private void nextFrame() { + int frameCount = endFrame - startFrame + 1; + if (frameCount <= 1) { + return; + } + switch (loop) { + // loop + case 0 -> currentFrame = (currentFrame + 1) % frameCount + startFrame; + // ping pong + case 1 -> { + if (pingPongForward) { + if (currentFrame < endFrame) { + currentFrame++; + } else { + pingPongForward = false; + currentFrame--; + } + } else { + if (currentFrame > startFrame) { + currentFrame--; + } else { + pingPongForward = true; + currentFrame++; + } + } + } + // once then freeze + case 2 -> { + if (currentFrame < endFrame) { + currentFrame++; + } + } + // once then disappear + case 3 -> { + if (currentFrame < endFrame) { + currentFrame++; + } else { + visible = false; + } + } + } + } + + @Override + public void update(float deltaTime, Vector2 position, Vector2 velocity) { + vertexArray.setSize(4); + float spriteWidth = imageWidth / spriteCount.x; + float spriteHeight = imageHeight / spriteCount.y; + if (spriteWidth <= 0 || spriteHeight <= 0) { + return; + } + + if (frameTimer >= 1.0f / fps) { + nextFrame(); + frameTimer = 0; + } + + float speed = Vector2.length(velocity); + boolean activeSpeedReached = (enabled == 1 && speed >= Trail.AFTERIMAGE_THRESHOLD) || (enabled == 2 && speed >= Trail.SUPERSPEED_THRESHOLD) || (enabled == 3 && speed < Trail.SUPERSPEED_THRESHOLD && speed >= Trail.AFTERIMAGE_THRESHOLD); + + float fadeValueIncrementUp = fadeIn > 0 ? deltaTime / fadeIn : Float.MAX_VALUE; + float fadeValueIncrementDown = fadeOut > 0 ? deltaTime / fadeOut : Float.MAX_VALUE; + float scaleValue = 1.0f; + if (activeSpeedReached) { + fadeValue = Math.min(1.0f, fadeValue + fadeValueIncrementUp); + } else { + fadeValue = Math.max(0.0f, fadeValue - fadeValueIncrementDown); + } + if (!activeSpeedReached && scaleOut > 0) { + scaleValue = Math.max(0.0f, 1.0f - notFastEnoughTimer / scaleOut); + } + + Color vertexColor = new Color(color.r, color.g, color.b, color.a * opacity); + vertexColor.a *= fadeValue; + + float u0 = (currentFrame % (int)spriteCount.x) * spriteWidth / imageWidth; + float v0 = (currentFrame / (int)spriteCount.x) * spriteHeight / imageHeight; + float u1 = u0 + spriteWidth / imageWidth; + float v1 = v0 + spriteHeight / imageHeight; + + float scaledHalfWidth = spriteWidth * scale * scaleValue / 2.0f; + float scaledHalfHeight = spriteHeight * scale * scaleValue / 2.0f; + + //idk what the devs were thinking with this nonsense, but it's true to the original + float rotation = rotateWithPlayer ? (float)Math.atan2(velocity.y, velocity.x) : 0; + if (forceRightSideUp && velocity.x < 0 && !rotateWithPlayer) { + rotation += (float)Math.PI/2; + } else if (forceRightSideUp && velocity.x < 0) { + rotation -= (float)Math.PI/2; + } else if (!rotateWithPlayer && !forceRightSideUp) { + rotation = (float)Math.PI/2; //???? + } + + if (!activeSpeedReached && !moveWhenInactive) { + rotation = lastRotation; + } + + Vector2 topLeft = new Vector2(-scaledHalfWidth, -scaledHalfHeight); + topLeft = Vector2.rotate(topLeft, rotation); + Vector2 bottomLeft = new Vector2(-topLeft.y, topLeft.x); + Vector2 topRight = new Vector2(topLeft.y, -topLeft.x); + Vector2 bottomRight = new Vector2(-topLeft.x, -topLeft.y); + + Vector2 positionA = !activeSpeedReached && !moveWhenInactive ? lastPosition : position; + + vertexArray.addVertex(new Vertex(Vector2.sum(positionA, topLeft, offset), vertexColor, new Vector2(u0, v0))); + vertexArray.addVertex(new Vertex(Vector2.sum(positionA, bottomLeft, offset), vertexColor, new Vector2(u0, v1))); + vertexArray.addVertex(new Vertex(Vector2.sum(positionA, topRight, offset), vertexColor, new Vector2(u1, v0))); + vertexArray.addVertex(new Vertex(Vector2.sum(positionA, bottomRight, offset), vertexColor, new Vector2(u1, v1))); + + if (activeSpeedReached) { + notFastEnoughTimer = 0; + } else { + notFastEnoughTimer += deltaTime; + } + frameTimer += deltaTime; + + if (activeSpeedReached && !activeSpeedReachedLastFrame) { + currentFrame = startFrame; + frameTimer = 0; + } + + lastPosition = positionA; + lastRotation = rotation; + activeSpeedReachedLastFrame = activeSpeedReached; + } + + @Override + public void reset() { + currentFrame = startFrame; + frameTimer = 0; + notFastEnoughTimer = 0; + pingPongForward = true; + lastPosition = new Vector2(0, 0); + lastRotation = 0.0f; + fadeValue = 0.0f; + } + + @Override + public void loadFromHashMap(HashMap properties) { + String enabledString = properties.getOrDefault("Enabled", "NEVER"); + String loopString = properties.getOrDefault("Loop", "LOOP"); + + switch (enabledString.toUpperCase()) { + case "NEVER" -> enabled = 0; + case "ALWAYS" -> enabled = 1; + case "ONLY AT SUPERSPEED" -> enabled = 2; + case "NOT AT SUPERSPEED" -> enabled = 3; + default -> throw new IllegalArgumentException("Invalid Enabled value: " + enabledString); + } + + // layer = properties.getOrDefault("Layer", "TrailBehindLocalPlayersLayer"); + image = properties.getOrDefault("Image", ""); + visible = properties.getOrDefault("Visible", "TRUE").equalsIgnoreCase("TRUE"); + spriteCount = Vector2.parse(properties.getOrDefault("SpriteCount", "1,1")); + startFrame = Integer.parseInt(properties.getOrDefault("Start frame", "0")); + endFrame = Integer.parseInt(properties.getOrDefault("End frame", "0")); + fps = Float.parseFloat(properties.getOrDefault("FPS", "30")); + switch (loopString.toUpperCase()) { + case "LOOP" -> loop = 0; + case "PING PONG" -> loop = 1; + case "ONCE THEN FREEZE" -> loop = 2; + case "ONCE THEN DISSAPEAR" -> loop = 3; //yes it's misspelled in the original + default -> throw new IllegalArgumentException("Invalid Loop value: " + loopString); + } + color = Color.parse(properties.getOrDefault("Color", "1,1,1")); + opacity = Float.parseFloat(properties.getOrDefault("Opacity", "1")); + offset = Vector2.parse(properties.getOrDefault("Offset", "0,0")); + scale = Float.parseFloat(properties.getOrDefault("Scale", "1")); + fadeIn = Float.parseFloat(properties.getOrDefault("FadeIn", "0")); + fadeOut = Float.parseFloat(properties.getOrDefault("FadeOut", "0")); + scaleOut = Float.parseFloat(properties.getOrDefault("ScaleOut", "0")); + forceRightSideUp = properties.getOrDefault("Force right side Up", "FALSE").equalsIgnoreCase("TRUE"); + rotateWithPlayer = properties.getOrDefault("Rotate with Player", "TRUE").equalsIgnoreCase("TRUE"); + moveWhenInactive = properties.getOrDefault("Move when inactive", "TRUE").equalsIgnoreCase("TRUE"); + + currentFrame = startFrame; + } +} diff --git a/src/main/java/com/github/pop4959/srbot/trail/TrailLayer.java b/src/main/java/com/github/pop4959/srbot/trail/TrailLayer.java new file mode 100644 index 0000000..f7cbc33 --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/TrailLayer.java @@ -0,0 +1,25 @@ +package com.github.pop4959.srbot.trail; + +import java.util.HashMap; + +public abstract class TrailLayer { + public int order; + private byte layerType; //0 = stripe, 1 = particle, 2 = animation + public VertexArray vertexArray; + public String image; + + public TrailLayer(int order, byte layerType) { + this.order = order; + this.vertexArray = new VertexArray(); + this.layerType = layerType; + this.image = null; + } + + public byte getLayerType() { + return layerType; + } + + public abstract void update(float deltaTime, Vector2 position, Vector2 velocity); + public abstract void loadFromHashMap(HashMap properties); + public abstract void reset(); +} diff --git a/src/main/java/com/github/pop4959/srbot/trail/TrailParticle.java b/src/main/java/com/github/pop4959/srbot/trail/TrailParticle.java new file mode 100644 index 0000000..fbf2e51 --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/TrailParticle.java @@ -0,0 +1,183 @@ +package com.github.pop4959.srbot.trail; + +import java.util.ArrayList; +import java.util.List; + +public class TrailParticle { + // int textureId; + private byte spriteMode; //default, animated, random, sequential + private int spriteCountX; + private int spriteCountY; + private float fps; + private float lifetime; private float totalLifetime; + private boolean fade; + private Vector2 scale; + private float scaleSpeed; + private float rotation; + private float rotationSpeed; + private Color color; + private float opacity; + + private Vector2 acceleration; //gravity + private Vector2 velocity; + private Vector2 position; + + private int imageWidth; + private int imageHeight; + + // precalculated from spriteCountX/Y and imageWidth/Height + private float spriteWidth; + private float spriteHeight; + private float timeSinceLastFrame; + + private int currentFrameIndex; + + private List vertices; + + public TrailParticle() { + vertices = new ArrayList<>(4); + + spriteMode = 0; + spriteCountX = 1; + spriteCountY = 1; + fps = 30; + lifetime = 1.0f; + totalLifetime = 1.0f; + fade = false; + scale = new Vector2(1, 1); + scaleSpeed = 0; + rotation = 0; + rotationSpeed = 0; + color = new Color(1, 1, 1, 1); + opacity = 1; + + acceleration = new Vector2(0, 0); + velocity = new Vector2(0, 0); + position = new Vector2(0, 0); + + imageWidth = 1; + imageHeight = 1; + + spriteWidth = imageWidth / spriteCountX; + spriteHeight = imageHeight / spriteCountY; + + currentFrameIndex = 0; + } + + public TrailParticle(byte spriteMode, int spriteCountX, int spriteCountY, float fps, float lifetime, boolean fade, + Vector2 scale, float scaleSpeed, float rotation, float rotationSpeed, Color color, float opacity, + Vector2 position, Vector2 velocity, Vector2 acceleration, int initialFrameIndex, + int imageWidth, int imageHeight) { + this.spriteMode = spriteMode; + this.spriteCountX = spriteCountX; + this.spriteCountY = spriteCountY; + this.fps = fps; + this.lifetime = lifetime; + this.totalLifetime = lifetime; + this.fade = fade; + this.scale = scale; + this.scaleSpeed = scaleSpeed; + this.rotation = rotation; + this.rotationSpeed = rotationSpeed; + this.color = color; + this.opacity = opacity; + this.acceleration = acceleration; + this.position = position; + this.velocity = velocity; + + this.imageWidth = imageWidth; + this.imageHeight = imageHeight; + + spriteWidth = imageWidth / spriteCountX; + spriteHeight = imageHeight / spriteCountY; + + currentFrameIndex = initialFrameIndex; + if (spriteMode == 2) { //random + currentFrameIndex = getRandomFrameIndex(); + } + + vertices = new ArrayList<>(4); + } + + public float getRemainingLifetime() { + return lifetime; + } + + public Vector2 getScale() { + return scale; + } + + public List getVertices() { + return vertices; + } + + public void updateVertexBuffer(float deltaTime) { + vertices = new ArrayList<>(4); + float alpha = fade ? (lifetime / totalLifetime) : 1.0f; + alpha *= opacity; + Color vertexColor = new Color(color.r, color.g, color.b, alpha); //color.a is always 1 + int frameX = currentFrameIndex % spriteCountX; + int frameY = currentFrameIndex / spriteCountX; + float u0, v0, u1, v1; + switch (spriteMode) { + case 0: //default (display full image, no animation) + u0 = 0.0f; + v0 = 0.0f; + u1 = 1.0f; + v1 = 1.0f; + break; + case 1: //animated + u0 = frameX * spriteWidth / imageWidth; + v0 = frameY * spriteHeight / imageHeight; + u1 = u0 + spriteWidth / imageWidth; + v1 = v0 + spriteHeight / imageHeight; + updateSpriteFrame(deltaTime); + break; + case 2: //random (chosen at initialization, does not change) + case 3: //sequential (chosen at initialization, changes every frame) + u0 = frameX * spriteWidth / imageWidth; + v0 = frameY * spriteHeight / imageHeight; + u1 = u0 + spriteWidth / imageWidth; + v1 = v0 + spriteHeight / imageHeight; + break; + default: + throw new IllegalArgumentException("Invalid sprite mode: " + spriteMode); + } + + float scaledHalfWidth = spriteWidth * scale.x / 2.0f; + float scaledHalfHeight = spriteHeight * scale.y / 2.0f; + + Vector2 topLeft = new Vector2(-scaledHalfWidth, -scaledHalfHeight); + topLeft = Vector2.rotate(topLeft, rotation); + Vector2 bottomLeft = new Vector2(-topLeft.y, topLeft.x); + Vector2 topRight = new Vector2(topLeft.y, -topLeft.x); + Vector2 bottomRight = new Vector2(-topLeft.x, -topLeft.y); + + vertices.add(new Vertex(Vector2.sum(position, topLeft), vertexColor, new Vector2(u0, v0))); + vertices.add(new Vertex(Vector2.sum(position, bottomLeft), vertexColor, new Vector2(u0, v1))); + vertices.add(new Vertex(Vector2.sum(position, topRight), vertexColor, new Vector2(u1, v0))); + vertices.add(new Vertex(Vector2.sum(position, bottomRight), vertexColor, new Vector2(u1, v1))); + } + + public void update(float deltaTime) { + velocity = Vector2.sum(velocity, Vector2.scale(acceleration, deltaTime)); + position = Vector2.sum(position, Vector2.scale(velocity, deltaTime)); + rotation += rotationSpeed * deltaTime; + scale = Vector2.sum(scale, Vector2.scale(new Vector2(scaleSpeed, scaleSpeed), deltaTime)); + lifetime -= deltaTime; + } + + private int getRandomFrameIndex() { + return (int)(Math.random() * spriteCountX * spriteCountY); + } + + private void updateSpriteFrame(float deltaTime) { + timeSinceLastFrame += deltaTime; + float frameDuration = 1.0f / fps; + while (timeSinceLastFrame >= frameDuration) { + timeSinceLastFrame -= frameDuration; + currentFrameIndex = (currentFrameIndex + 1) % (spriteCountX * spriteCountY); + } + } + +} diff --git a/src/main/java/com/github/pop4959/srbot/trail/TrailParticleEmitter.java b/src/main/java/com/github/pop4959/srbot/trail/TrailParticleEmitter.java new file mode 100644 index 0000000..db93213 --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/TrailParticleEmitter.java @@ -0,0 +1,292 @@ +package com.github.pop4959.srbot.trail; + +import java.util.HashMap; +import java.util.List; +import java.util.Random; + +public class TrailParticleEmitter extends TrailLayer { + private List particles; + + //trail settings + private byte enabled; //0 = never, 1 = always, 2 = only at superspeed, 3 = not at superspeed + // private String layer; //unused + private String image; + private boolean visible; + // private boolean isAnimated; + private byte spriteMode; + private Vector2 spriteCount; + private int spriteFPS; + private float spawnRate; //seconds between spawns + private int amount; + private float lifetime; + private boolean fade; + private float scale; + private float scaleSpeed; + private float scaleVariance; + private float rotation; + private float rotatationVariance; + private float rotationSpeed; + private float rotationSpeedVariance; + private boolean rotateWithPlayer; + private Color color; + private float alpha; + private Vector2 offset; + private Vector2 offsetVariance; + private float force; + private float forceVariance; + private Vector2 direction; + private Vector2 directionVariance; + private boolean useWorldAxis; + private boolean sameSideUp; + private boolean hasGravity; + private Vector2 gravityStrength; + // boolean isBetaTrail; + + private float timeSinceLastSpawn; + private Random random; + private int imageWidth; + private int imageHeight; + + private int sequentialFrameIndex; //for sequential sprite mode + + public TrailParticleEmitter() { + super(0,(byte)1); + + particles = new java.util.ArrayList<>(); + random = new Random(); + image = ""; + timeSinceLastSpawn = 0; + sequentialFrameIndex = 0; + + enabled = 0; + // layer = "ObjectLayer"; + imageWidth = 0; + imageHeight = 0; + visible = true; + // isAnimated = false; + spriteMode = 0; + // spriteSize = new Vector2(100, 100); + spriteCount = new Vector2(1, 1); + spriteFPS = 30; + spawnRate = 0.25f; + amount = 1; + lifetime = 1.0f; + fade = true; + scale = 1.0f; + scaleSpeed = -1.0f; + scaleVariance = 0.5f; + rotation = 0.0f; + rotatationVariance = 0.0f; + rotationSpeed = 0.0f; + rotationSpeedVariance = 0.0f; + rotateWithPlayer = false; + color = new Color(1.0f, 1.0f, 1.0f, 1.0f); + alpha = 1.0f; + offset = new Vector2(0, 0); + offsetVariance = new Vector2(0, 0); + force = 0.0f; + forceVariance = 0.0f; + direction = new Vector2(0, 0); + directionVariance = new Vector2(0, 0); + useWorldAxis = false; + sameSideUp = false; + hasGravity = false; + gravityStrength = new Vector2(0, 0); + } + + public TrailParticleEmitter(HashMap properties, int imageWidth, int imageHeight) { + this(); + this.imageWidth = imageWidth; + this.imageHeight = imageHeight; + loadFromHashMap(properties); + } + + public String getImage() { + return image; + } + + public byte getEnabled() { + return enabled; + } + + public boolean getIsVisible() { + return visible; + } + + private float randomFloat() { + return (random.nextFloat() - 0.5f) * 2; + } + + public void setImageDimensions(int width, int height) { + this.imageWidth = width; + this.imageHeight = height; + } + + public boolean isVisible() { + return visible; + } + + public void emit(Vector2 position, Vector2 velocity) { + float scaleA = scale + randomFloat() * scaleVariance; + float rotationA = rotation + randomFloat() * rotatationVariance; + float rotationSpeedA = rotationSpeed + randomFloat() * rotationSpeedVariance; + float playerRotation = (float)Math.atan2(velocity.y, velocity.x) + (velocity.x < 0 ? (sameSideUp ? (float)Math.PI : -(float)Math.PI/2.0f) : 0); + float offsetXA = offset.x + randomFloat() * offsetVariance.x; + float offsetYA = offset.y + randomFloat() * offsetVariance.y; + float forceA = force + randomFloat() * forceVariance; + float directionXA = direction.x + randomFloat() * directionVariance.x; + float directionYA = direction.y + randomFloat() * directionVariance.y; + Vector2 worldDirection = Vector2.normalize(new Vector2(directionXA, directionYA)); + Vector2 playerDirection = Vector2.normalize(velocity); + + rotationA = (float)Math.toRadians(rotationA); + rotationSpeedA = (float)Math.toRadians(rotationSpeedA); + + float initialRotation = rotateWithPlayer ? rotationA + playerRotation : rotationA; + + Vector2 initialPosition = new Vector2(position.x + offsetXA, position.y + offsetYA); + Vector2 initialVelocity = useWorldAxis ? Vector2.scale(worldDirection, forceA) : Vector2.scale(playerDirection, forceA); + Vector2 initialAcceleration = hasGravity ? gravityStrength : new Vector2(0, 0); + + int frameIndex = 0; + if (spriteMode == 3) { //sequential, so determine frame index at spawn time + frameIndex = sequentialFrameIndex; + sequentialFrameIndex = (sequentialFrameIndex + 1) % (int)(spriteCount.x * spriteCount.y); + } + + TrailParticle newParticle = new TrailParticle( + spriteMode, + (int)spriteCount.x, + (int)spriteCount.y, + spriteFPS, + lifetime, + fade, + new Vector2(scaleA, scaleA), + scaleSpeed, + initialRotation, + rotationSpeedA, + color, + alpha, + initialPosition, + initialVelocity, + initialAcceleration, + frameIndex, + imageWidth, + imageHeight + ); + + particles.add(newParticle); + } + + private void update(float deltaTime) { + for (int i = particles.size() - 1; i >= 0; i--) { + TrailParticle particle = particles.get(i); + particle.update(deltaTime); + if (particle.getRemainingLifetime() <= 0) { + particles.remove(i); + } else if (particle.getScale().x <= 0 || particle.getScale().y <= 0) { + particles.remove(i); + } + } + + vertexArray.setSize(particles.size() * 4); + for (TrailParticle particle : particles) { + particle.updateVertexBuffer(deltaTime); + vertexArray.addVertices(particle.getVertices()); + } + } + + @Override + public void update(float deltaTime, Vector2 position, Vector2 velocity) { + timeSinceLastSpawn += deltaTime; + update(deltaTime); + + if (enabled == Trail.ENABLED_NEVER) return; + if (enabled == Trail.ENABLED_ONLY_SUPERSPEED && Trail.calculateSpeed(velocity) < Trail.SUPERSPEED_THRESHOLD) { + return; + } + if (enabled == Trail.ENABLED_NOT_SUPERSPEED && Trail.calculateSpeed(velocity) >= Trail.SUPERSPEED_THRESHOLD) { + return; + } + if (enabled == Trail.ENABLED_ALWAYS && Vector2.length(velocity) < Trail.AFTERIMAGE_THRESHOLD) { + return; + } + + if (timeSinceLastSpawn >= spawnRate) { + timeSinceLastSpawn = timeSinceLastSpawn % spawnRate; + for (int i = 0; i < amount; i++) { + emit(position, velocity); + } + } + } + + @Override + public void reset() { + particles.clear(); + timeSinceLastSpawn = 0; + sequentialFrameIndex = 0; + } + + @Override + public void loadFromHashMap(HashMap properties) { + String enabledString = properties.getOrDefault("Enabled", "NEVER"); + String spriteModeString = properties.getOrDefault("spriteMode", "DEFAULT"); + + switch (enabledString.toUpperCase()) { + case "NEVER" -> enabled = 0; + case "ALWAYS" -> enabled = 1; + case "ONLY AT SUPERSPEED" -> enabled = 2; + case "NOT AT SUPERSPEED" -> enabled = 3; + default -> throw new IllegalArgumentException("Invalid Enabled value: " + enabledString); + } + + switch (spriteModeString.toUpperCase()) { + case "DEFAULT" -> spriteMode = 0; + case "ANIMATED" -> spriteMode = 1; + case "RANDOM" -> spriteMode = 2; + case "SEQUENTIAL" -> spriteMode = 3; + default -> throw new IllegalArgumentException("Invalid sprite mode value: " + spriteModeString); + } + + //enabled + order = Integer.parseInt(properties.getOrDefault("Order", "0")); + // layer = properties.getOrDefault("Layer", "TrailBehindLocalPlayersLayer"); + image = properties.getOrDefault("Image", ""); + // visible = Boolean.parseBoolean(properties.getOrDefault("Visible", "TRUE")); + // isAnimated = Boolean.parseBoolean(properties.getOrDefault("isAnimated", "FALSE")); + //sprite mode + // spriteSize = Vector2.Parse(properties.getOrDefault("SpriteSize", "100,100")); + spriteCount = Vector2.parse(properties.getOrDefault("SpriteCount", "1,1")); + spriteFPS = Integer.parseInt(properties.getOrDefault("FPS", "30")); + spawnRate = Float.parseFloat(properties.getOrDefault("Spawn Rate", "0.25")); + amount = Integer.parseInt(properties.getOrDefault("Amount", "1")); + lifetime = Float.parseFloat(properties.getOrDefault("LifeTime", "1")); + fade = Boolean.parseBoolean(properties.getOrDefault("FadeOut", "TRUE")); + scale = Float.parseFloat(properties.getOrDefault("Scale", "1")); + scaleSpeed = Float.parseFloat(properties.getOrDefault("ScaleSpeed", "-1")); + scaleVariance = Float.parseFloat(properties.getOrDefault("Scale Variance", "0.5")); + rotation = Float.parseFloat(properties.getOrDefault("Rotation", "0")); + rotatationVariance = Float.parseFloat(properties.getOrDefault("Rotation Variance", "0")); + rotationSpeed = Float.parseFloat(properties.getOrDefault("Rotation Speed", "0")); + rotationSpeedVariance = + Float.parseFloat(properties.getOrDefault("Rotation Speed Variance", "0")); + rotateWithPlayer = + Boolean.parseBoolean(properties.getOrDefault("Rotate with Player", "FALSE")); + color = Color.parse(properties.getOrDefault("Color", "1,1,1")); + alpha = Float.parseFloat(properties.getOrDefault("Opacity", "1")); + offset = Vector2.parse(properties.getOrDefault("Offset", "0,0")); + offsetVariance = + Vector2.parse(properties.getOrDefault("OffsetVariance", "0,0")); + force = Float.parseFloat(properties.getOrDefault("Force", "0")); + forceVariance = + Float.parseFloat(properties.getOrDefault("Force Variance", "0")); + direction = Vector2.parse(properties.getOrDefault("Direction", "0,0")); + directionVariance = + Vector2.parse(properties.getOrDefault("Direction Variance", "0,0")); + useWorldAxis = Boolean.parseBoolean(properties.getOrDefault("Use World Axis", "false")); + sameSideUp = Boolean.parseBoolean(properties.getOrDefault("Same Side Up", "false")); + hasGravity = Boolean.parseBoolean(properties.getOrDefault("hasGravity", "false")); + gravityStrength = Vector2.parse(properties.getOrDefault("gravity", "0,0")); + // isBetaTrail = Boolean.parseBoolean(properties.getOrDefault("Is Beta Trail", "false")); + } +} diff --git a/src/main/java/com/github/pop4959/srbot/trail/TrailStripe.java b/src/main/java/com/github/pop4959/srbot/trail/TrailStripe.java new file mode 100644 index 0000000..fc2ed5f --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/TrailStripe.java @@ -0,0 +1,294 @@ +package com.github.pop4959.srbot.trail; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +public class TrailStripe extends TrailLayer { + + class TrailStripeVertex { + Vector2 position; + float lifetime; + boolean isNewStart; + } + + public class Segment { + public int startIndex; + public int endIndex; + } + + //settings + private byte enabled; //0 = never, 1 = always, 2 = only at superspeed, 3 = not at superspeed + // private String layer; //unused + private boolean visible; + private float lifetime; + private Color color; + private float opacity; + private boolean taper; + private boolean fade; + // private float fadeOutSpeed; //unused-- likely won't implement since it's honestly uglier than doing nothing + private float width; + private Vector2 offset; + private boolean invertOffset; + private boolean flipHorizontal; + private boolean flipVertical; + private boolean sameSideUp; + private float noiseAmplitude; + private float waveAmplitude; + private float waveFrequency; + private float wavePhaseOffset; + + private List trailVertices; + private List newStartIndices; + + private boolean lastPointNotAdded = true; + + TrailStripe() { + super(0, (byte)0); + this.trailVertices = new ArrayList<>(); + this.newStartIndices = new ArrayList<>(); + + enabled = 0; + // layer = "TrailBehindLocalPlayersLayer"; + image = ""; + visible = true; + lifetime = 2.0f; + color = new Color(1.0f, 1.0f, 1.0f, 1.0f); + taper = false; + fade = false; + // fadeOutSpeed = 1.0f; + width = 40.0f; + offset = new Vector2(0, 0); + invertOffset = true; + flipHorizontal = false; + flipVertical = false; + sameSideUp = false; + noiseAmplitude = 0; + waveAmplitude = 0; + waveFrequency = 0; + wavePhaseOffset = 0; + } + + public String getImage() { + return image; + } + + public boolean getIsVisible() { + return visible; + } + + public byte getEnabled() { + return enabled; + } + + public float noise(float x) { + return (float)Math.sin(x); + } + + public void addPoint(Vector2 position, Vector2 velocity, float gameTime) { + if (enabled == Trail.ENABLED_NEVER) { + lastPointNotAdded = true; + return; + } else if (enabled == Trail.ENABLED_ONLY_SUPERSPEED && Trail.calculateSpeed(velocity) < Trail.SUPERSPEED_THRESHOLD) { + lastPointNotAdded = true; + return; + } else if (enabled == Trail.ENABLED_NOT_SUPERSPEED && Trail.calculateSpeed(velocity) >= Trail.SUPERSPEED_THRESHOLD) { + lastPointNotAdded = true; + return; + } else if (enabled == Trail.ENABLED_ALWAYS && Vector2.length(velocity) < Trail.AFTERIMAGE_THRESHOLD) { + lastPointNotAdded = true; + return; + } + + TrailStripeVertex newVertex = new TrailStripeVertex(); + newVertex.lifetime = this.lifetime; + newVertex.isNewStart = false; + + if (lastPointNotAdded) { + newVertex.isNewStart = true; + } + + lastPointNotAdded = false; + + float sin = this.waveAmplitude + * (float)Math.sin((this.waveFrequency * gameTime + this.wavePhaseOffset)); + Vector2 normal = Vector2.normalize(velocity); + Vector2 adjustedOffset = new Vector2(offset.x, -offset.y); + adjustedOffset.y *= !invertOffset && velocity.x > 0 ? -1 : 1; + adjustedOffset = Vector2.transform(adjustedOffset, normal); + normal = new Vector2(normal.y, -normal.x); + + Vector2 adjusted = Vector2.sum( + position, + adjustedOffset, + Vector2.scale(normal, sin)); + newVertex.position = adjusted; + + trailVertices.add(newVertex); + } + + private Vector2 calculateNormal(int index) { + Vector2 previous; + Vector2 next; + previous = trailVertices.get(Math.max(0, index - 1)).position; + next = trailVertices.get(Math.min(trailVertices.size() - 1, index + 1)).position; + if (trailVertices.get(index).isNewStart) { + previous = trailVertices.get(index).position; + } + if (index < trailVertices.size() - 1 && trailVertices.get(index + 1).isNewStart) { + next = trailVertices.get(index).position; + } + Vector2 velocity = Vector2.normalize(Vector2.subtract(next, previous)); + return new Vector2(velocity.y, -velocity.x); + } + + public void updateVertexBuffer() { + vertexArray.setSize(trailVertices.size() * 2); + newStartIndices.clear(); + + float length = 0; + for (int i = 0; i < trailVertices.size() - 1; i++) { + length += Vector2.distance(trailVertices.get(i).position, trailVertices.get(i + 1).position); + } + + if (length == 0) { + return; + } + + float accumulatedLength = 0; + for (int i = 0; i < trailVertices.size(); i++) { + if (trailVertices.get(i).isNewStart) { + newStartIndices.add(i); + } + Vector2 normal = calculateNormal(i); + + float t = accumulatedLength / length; + accumulatedLength += (i < trailVertices.size() - 1) + ? Vector2.distance(trailVertices.get(i).position, trailVertices.get(i + 1).position) + : 0; + float effectiveWidth = this.width; + if (this.taper) { + effectiveWidth *= (t); + } + float effectiveAlpha = this.color.a * this.opacity; + if (this.fade) { + effectiveAlpha *= (t); + } + float u = (float)i / (trailVertices.size() - 1); + if (flipHorizontal) { + u = 1 - u; + } + float v = flipVertical ? 1 : 0; + + if (sameSideUp && normal.y > 0) { + v = 1 - v; + } + + TrailStripeVertex vertex = trailVertices.get(i); + + float noise = noise(vertex.position.x); + Vector2 offset = Vector2.scale(normal, noise * this.noiseAmplitude); + + Vector2 v1 = Vector2.sum( + vertex.position, + Vector2.scale(normal, effectiveWidth / 2f), + offset); + + Vector2 v2 = Vector2.sum( + vertex.position, + Vector2.scale(normal, -effectiveWidth / 2f), + offset); + + Color color = new Color(this.color.r, this.color.g, this.color.b, + effectiveAlpha); + + Vertex v1Vertex = new Vertex(v1, color, new Vector2(u, v)); + Vertex v2Vertex = new Vertex(v2, color, new Vector2(u, 1 - v)); + + vertexArray.addVertex(v1Vertex); + vertexArray.addVertex(v2Vertex); + } + } + + public int getSegmentCount() { + return newStartIndices.size(); + } + + public Segment getSegment(int index) { + if (index >= newStartIndices.size()) { + throw new IndexOutOfBoundsException(); + } + Segment segment = new Segment(); + segment.startIndex = newStartIndices.get(index) * 2; //each vertex has 2 vertices in the vertex array + if (index == newStartIndices.size() - 1) { + segment.endIndex = vertexArray.getVertexCount(); + } else { + segment.endIndex = newStartIndices.get(index + 1) * 2; + } + return segment; + } + + @Override + public void update(float deltaTime, Vector2 position, Vector2 velocity) { + for (int i = trailVertices.size() - 1; i >= 0; i--) { + TrailStripeVertex vertex = trailVertices.get(i); + vertex.lifetime -= deltaTime; + if (vertex.lifetime <= 0) { + if (vertex.isNewStart) { + //transfer to next vertex + if (i + 1 < trailVertices.size()) { + trailVertices.get(i + 1).isNewStart = true; + } + } + trailVertices.remove(i); + } + } + updateVertexBuffer(); + } + + @Override + public void reset() { + trailVertices.clear(); + newStartIndices.clear(); + lastPointNotAdded = true; + } + + @Override + public void loadFromHashMap(HashMap properties) { + // layer = properties.getOrDefault("Layer", "TrailBehindLocalPlayersLayer"); + + switch (properties.getOrDefault("Enabled", "NEVER").toUpperCase()) { + case "ALWAYS": + enabled = 1; + break; + case "ONLY AT SUPERSPEED": + enabled = 2; + break; + case "NOT AT SUPERSPEED": + enabled = 3; + break; + default: + enabled = 0; + } + + order = Byte.parseByte(properties.getOrDefault("Order", "0")); + image = properties.getOrDefault("Image", ""); + visible = properties.getOrDefault("Visible", "TRUE").equalsIgnoreCase("TRUE"); + lifetime = Float.parseFloat(properties.getOrDefault("LifeTime", "2")); + color = Color.parse(properties.getOrDefault("Color", "1,1,1")); + opacity = Float.parseFloat(properties.getOrDefault("Opacity", "1")); + taper = properties.getOrDefault("Taper", "FALSE").equalsIgnoreCase("TRUE"); + fade = properties.getOrDefault("FadeOut", "FALSE").equalsIgnoreCase("TRUE"); + // fadeOutSpeed = Float.parseFloat(properties.getOrDefault("FadeOut Speed", "1")); + width = Float.parseFloat(properties.getOrDefault("Size", "40")); + offset = Vector2.parse(properties.getOrDefault("OffsetVector", "0,0")); + invertOffset = properties.getOrDefault("Invert Offset", "TRUE").equalsIgnoreCase("TRUE"); + flipHorizontal = properties.getOrDefault("Flip Horizontally", "FALSE").equalsIgnoreCase("TRUE"); + flipVertical = properties.getOrDefault("Flip Vertically", "FALSE").equalsIgnoreCase("TRUE"); + sameSideUp = properties.getOrDefault("Force right side Up", "FALSE").equalsIgnoreCase("TRUE"); + noiseAmplitude = Float.parseFloat(properties.getOrDefault("Noise", "0")); + waveAmplitude = Float.parseFloat(properties.getOrDefault("Sinewave Amplitude", "0")); + waveFrequency = Float.parseFloat(properties.getOrDefault("Sinewave Frequency", "0")); + wavePhaseOffset = Float.parseFloat(properties.getOrDefault("Sine Phase Offset", "0")); + } +} \ No newline at end of file diff --git a/src/main/java/com/github/pop4959/srbot/trail/Vector2.java b/src/main/java/com/github/pop4959/srbot/trail/Vector2.java new file mode 100644 index 0000000..cfec295 --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/Vector2.java @@ -0,0 +1,64 @@ +package com.github.pop4959.srbot.trail; + +public class Vector2 { + public float x, y; + + public Vector2(float x, float y) { + this.x = x; + this.y = y; + } + + public static Vector2 normalize(Vector2 x) { + float length = length(x); + if (length == 0) { + return new Vector2(0,0); + } else { + return new Vector2(x.x / length, x.y / length); + } + } + + public static float length(Vector2 x) { + return (float)Math.sqrt(x.x * x.x + x.y * x.y); + } + + public static Vector2 sum(Vector2... a) { + float sumX = 0; + float sumY = 0; + for (Vector2 v : a) { + sumX += v.x; + sumY += v.y; + } + return new Vector2(sumX, sumY); + } + + public static Vector2 subtract(Vector2 a, Vector2 b) { + return new Vector2(a.x - b.x, a.y - b.y); + } + + public static Vector2 scale(Vector2 a, float scalar) { + return new Vector2(a.x * scalar, a.y * scalar); + } + + public static Vector2 rotate(Vector2 a, float angle) { + float cos = (float)Math.cos(angle); + float sin = (float)Math.sin(angle); + return new Vector2(a.x * cos - a.y * sin, a.x * sin + a.y * cos); + } + + public static Vector2 transform(Vector2 vec, Vector2 normal) { + float angle = (float)Math.atan2(normal.y, normal.x); + return rotate(vec, angle); + } + + public static float distance(Vector2 a, Vector2 b) { + return length(subtract(a, b)); + } + + public static Vector2 parse(String s) { + String[] parts = s.split(","); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid Vector2 format: " + s); + } + return new Vector2(Float.parseFloat(parts[0]), Float.parseFloat(parts[1])); + } +} \ No newline at end of file diff --git a/src/main/java/com/github/pop4959/srbot/trail/Vertex.java b/src/main/java/com/github/pop4959/srbot/trail/Vertex.java new file mode 100644 index 0000000..002b05f --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/Vertex.java @@ -0,0 +1,19 @@ +package com.github.pop4959.srbot.trail; + +public class Vertex { + public Vector2 position; + public Color color; + public Vector2 textureCoordinate; + + public Vertex(Vector2 position, Color color, Vector2 textureCoordinate) { + this.position = position; + this.color = color; + this.textureCoordinate = textureCoordinate; + } + + public Vertex(float[] data, int offset) { + this.position = new Vector2(data[offset], data[offset + 1]); + this.color = new Color(data[offset + 2], data[offset + 3], data[offset + 4], data[offset + 5]); + this.textureCoordinate = new Vector2(data[offset + 6], data[offset + 7]); + } +} diff --git a/src/main/java/com/github/pop4959/srbot/trail/VertexArray.java b/src/main/java/com/github/pop4959/srbot/trail/VertexArray.java new file mode 100644 index 0000000..ef06f8a --- /dev/null +++ b/src/main/java/com/github/pop4959/srbot/trail/VertexArray.java @@ -0,0 +1,61 @@ +package com.github.pop4959.srbot.trail; + +public class VertexArray { + private float[] vertices; + private int vertexCount; + private int index; + + public VertexArray() { + vertices = new float[0]; + this.index = 0; + this.vertexCount = 0; + } + + public VertexArray(int size) { + vertices = new float[size]; + this.index = 0; + this.vertexCount = 0; + } + + public int getVertexCount() { + return vertexCount; + } + + public void setSize(int size) { + vertices = new float[size]; + this.index = 0; + this.vertexCount = 0; + } + + public float[] getVertices() { + return vertices; + } + + public Vertex getVertex(int i) { + return new Vertex(vertices, i * 8); + } + + public void addVertex(Vertex vertex) { + if (index + 8 >= vertices.length) { + float[] newVertices = new float[vertices.length * 2]; + System.arraycopy(vertices, 0, newVertices, 0, vertices.length); + vertices = newVertices; + } + + vertices[index++] = vertex.position.x; + vertices[index++] = vertex.position.y; + vertices[index++] = vertex.color.r; + vertices[index++] = vertex.color.g; + vertices[index++] = vertex.color.b; + vertices[index++] = vertex.color.a; + vertices[index++] = vertex.textureCoordinate.x; + vertices[index++] = vertex.textureCoordinate.y; + vertexCount++; + } + + public void addVertices(java.util.List vertices) { + for (Vertex vertex : vertices) { + addVertex(vertex); + } + } +}