diff --git a/runelite-api/src/main/java/net/runelite/api/Animation.java b/runelite-api/src/main/java/net/runelite/api/Animation.java index 00902b11703..180ac0f83df 100644 --- a/runelite-api/src/main/java/net/runelite/api/Animation.java +++ b/runelite-api/src/main/java/net/runelite/api/Animation.java @@ -36,6 +36,11 @@ public interface Animation */ int getId(); + /** + * Is this animation a newer-style "maya" animation + */ + boolean isMayaAnim(); + /** * Get how many distinct frames this animation has. * @@ -53,4 +58,21 @@ public interface Animation * @see #getRestartMode() */ void setRestartMode(int restartMode); + + /** + * How many frames the animation lasts + */ + int getDuration(); + + /** + * How many frames to go back when looping + */ + int getFrameStep(); + + /** + * How many ticks each frame is. + * + * {@code null} for {@link #isMayaAnim()} animations + */ + int[] getFrameLengths(); } diff --git a/runelite-api/src/main/java/net/runelite/api/AnimationController.java b/runelite-api/src/main/java/net/runelite/api/AnimationController.java new file mode 100644 index 00000000000..4703bb8d762 --- /dev/null +++ b/runelite-api/src/main/java/net/runelite/api/AnimationController.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2024, LlemonDuck + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.api; + +import java.util.function.Consumer; +import java.util.function.IntPredicate; +import javax.annotation.Nullable; +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +public class AnimationController +{ + private final Client client; + + @Getter + @Nullable + private Animation animation; + + @Setter + @NonNull + private Consumer onFinished = AnimationController::loop; + + @Getter + @Setter + private int frame; + + @Getter + @Setter + private int elapsedTicksThisFrame; + + public AnimationController(Client client, int animationID) + { + this(client, client.loadAnimation(animationID)); + } + + public AnimationController(Client client, Animation animation) + { + this.client = client; + setAnimation(animation); + } + + public void setAnimation(@Nullable Animation animation) + { + this.animation = animation; + reset(); + } + + public void reset() + { + frame = 0; + elapsedTicksThisFrame = 0; + } + + public void loop() + { + if (animation == null) + { + return; + } + + frame -= animation.getFrameStep(); + if (frame < 0 || frame >= animation.getDuration()) + { + frame = 0; + } + } + + public void tick(int ticks) + { + outer: + for (; ; ) + { + if (animation == null) + { + return; + } + + if (animation.isMayaAnim()) + { + frame += ticks; + ticks = 0; + if (frame >= animation.getDuration()) + { + onFinished.accept(this); + if (animation != null && frame < animation.getDuration()) + { + continue; + } + } + } + else + { + int[] frameLengths = animation.getFrameLengths(); + if (frameLengths == null) + { + return; + } + + elapsedTicksThisFrame += ticks; + ticks = 0; + + for (; ; ) + { + if (frame >= frameLengths.length) + { + onFinished.accept(this); + continue outer; + } + + if (elapsedTicksThisFrame > frameLengths[frame]) + { + elapsedTicksThisFrame -= frameLengths[frame]; + frame++; + } + else + { + break; + } + } + } + + return; + } + } + + public Model animate(Model model) + { + return animate(model, null); + } + + public Model animate(Model model, @Nullable AnimationController other) + { + if (other != null) + { + return client.applyTransformations(model, animation, getPackedFrame(), other.animation, other.getPackedFrame()); + } + else + { + return client.applyTransformations(model, animation, getPackedFrame(), null, 0); + } + } + + private int getPackedFrame() + { + if (animation == null) + { + return 0; + } + + IntPredicate interpFilter = client.getAnimationInterpolationFilter(); + if (interpFilter == null || !interpFilter.test(animation.getId())) + { + return frame; + } + + return Integer.MIN_VALUE + | elapsedTicksThisFrame << 16 + | frame; + } +} diff --git a/runelite-api/src/main/java/net/runelite/api/Client.java b/runelite-api/src/main/java/net/runelite/api/Client.java index 6f036f1a131..ee010dc140d 100644 --- a/runelite-api/src/main/java/net/runelite/api/Client.java +++ b/runelite-api/src/main/java/net/runelite/api/Client.java @@ -1079,6 +1079,21 @@ public interface Client extends OAuthApi, GameEngine */ RuneLiteObject createRuneLiteObject(); + /** + * Registers a new {@link RuneLiteObjectController} to its corresponding {@link WorldView}. + */ + void registerRuneLiteObject(RuneLiteObjectController controller); + + /** + * Removes a new {@link RuneLiteObjectController} from its corresponding {@link WorldView}. + */ + void removeRuneLiteObject(RuneLiteObjectController controller); + + /** + * Checks whether a {@link RuneLiteObjectController} is registered to any {@link WorldView}. + */ + boolean isRuneLiteObjectRegistered(RuneLiteObjectController controller); + /** * Loads an unlit model from the cache. The returned model shares * data such as faces, face colors, face transparencies, and vertex points with @@ -2357,4 +2372,12 @@ default Tile getSelectedSceneTile() { return getTopLevelWorldView().getSelectedSceneTile(); } + + /** + * Applies an animation to a Model. The returned model is shared and shouldn't be used + * after any other call to applyTransformations, including calls made by the client internally. + * Vertices are cloned from the source model. Face transparencies are copied if either animation + * animates transparency, otherwise it will share a reference. All other fields share a reference. + */ + Model applyTransformations(Model model, @Nullable Animation animA, int frameA, @Nullable Animation animB, int frameB); } diff --git a/runelite-api/src/main/java/net/runelite/api/RuneLiteObject.java b/runelite-api/src/main/java/net/runelite/api/RuneLiteObject.java index d48a2698856..b7639b5854a 100644 --- a/runelite-api/src/main/java/net/runelite/api/RuneLiteObject.java +++ b/runelite-api/src/main/java/net/runelite/api/RuneLiteObject.java @@ -25,93 +25,243 @@ */ package net.runelite.api; +import java.util.function.Consumer; +import javax.annotation.Nullable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; import net.runelite.api.coords.LocalPoint; -/** - * Represents a modified {@link GraphicsObject} - */ -public interface RuneLiteObject extends GraphicsObject +@RequiredArgsConstructor +public class RuneLiteObject extends RuneLiteObjectController { + private final Client client; + + @Getter + private Model baseModel; + /** - * Sets the model of the RuneLiteObject + * The animation of the RuneLiteObject. + * If animation is null then the model will be static. */ - void setModel(Model model); + @Getter + @Nullable + private AnimationController animationController; /** - * Sets the animation of the RuneLiteObject - * If animation is null model will be static + * The optional pose animation of the RuneLiteObject. + * If animation is null then the model from {@link RuneLiteObject#animationController} will be used. */ - void setAnimation(Animation animation); + @Setter + @Getter + @Nullable + private AnimationController poseAnimationController; + + @Getter + @Setter + private int startCycle; + + // tri-state for unset, true, false. Legacy option for pre-RuneLiteObjectController. + private Boolean shouldLoop; /** * Sets whether the animation of the RuneLiteObject should loop when the animation ends. * If this is false the object will despawn when the animation ends. * Does nothing if the animation is null. + * + * @deprecated Use {@link AnimationController#setOnFinished(Consumer)} with {@link AnimationController#loop()} instead. */ - void setShouldLoop(boolean shouldLoop); + @Deprecated + public void setShouldLoop(boolean shouldLoop) + { + this.shouldLoop = shouldLoop; + updateAnimationControllerLooping(); + } + + /** + * Sets the model to be rendered. + * If {@link RuneLiteObject#animationController} is not null, this model will be passed to {@link AnimationController#animate(Model)}. + */ + public void setModel(Model baseModel) + { + this.baseModel = baseModel; + } /** * Sets the location in the scene for the RuneLiteObject */ - void setLocation(LocalPoint point, int plane); + @Override + public void setLocation(LocalPoint point, int level) + { + boolean needReregister = isActive() && point.getWorldView() != getWorldView(); + if (needReregister) + { + setActive(false); + } + + super.setLocation(point, level); + setZ(client.getWorldView(getWorldView()).getTileHeights()[level][getX() / 128][getY() / 128]); + + if (needReregister) + { + setActive(true); + } + } /** - * Sets the state of the RuneLiteObject - * Set to true to spawn the object - * Set to false to despawn the object + * Sets the animation of the RuneLiteObject. + * If animation is null, the model will be static. */ - void setActive(boolean active); + public void setAnimation(Animation animation) + { + setAnimationController(new AnimationController(client, animation)); + } + + /** + * Sets the animation controller of the RuneLiteObject. + * If animationController is null, the model will be static. + */ + public void setAnimationController(@Nullable AnimationController animationController) + { + this.animationController = animationController; + updateAnimationControllerLooping(); + } + + /** + * Sets the state of the RuneLiteObject. + * Set to true to spawn the object. + * Set to false to despawn the object. + */ + public void setActive(boolean active) + { + if (active) + { + client.registerRuneLiteObject(this); + } + else + { + client.removeRuneLiteObject(this); + } + } /** * Gets the state of the RuneLiteObject * * @return true if the RuneLiteObject is added to the scene */ - boolean isActive(); + public boolean isActive() + { + return client.isRuneLiteObjectRegistered(this); + } /** - * Get the object orientation - * @see net.runelite.api.coords.Angle - * @return + * Called every frame the RuneLiteObject is registered and in the scene + * + * @param ticksSinceLastFrame The number of client ticks since the last frame */ - int getOrientation(); + @Override + public void tick(int ticksSinceLastFrame) + { + if (animationController != null) + { + animationController.tick(ticksSinceLastFrame); + } + } /** - * Set the object orientation - * @see net.runelite.api.coords.Angle - * @param orientation + * Called every frame to get a model to render. The returned model is not modified and + * can be a shared model. */ - void setOrientation(int orientation); + @Override + public Model getModel() + { + if (animationController != null) + { + return animationController.animate(this.baseModel, this.poseAnimationController); + } + else if (poseAnimationController != null) + { + return poseAnimationController.animate(this.baseModel); + } + else + { + return baseModel; + } + } /** - * Get the object radius. The radius is offset from the object position to form a 2d rectangle, and the tiles - * the corners are in are used to determine the min and max scene x/y the object is on. These tiles are then drawn - * first prior to the object being drawn, so that the object renders correctly over top of each tile. - * The default radius is 60, marginally less than 128/2, which works well for models the size of a single tile. - * @return + * @deprecated Use a custom {@link AnimationController} instead. */ - int getRadius(); + @Deprecated + public boolean finished() + { + return !this.isActive(); + } /** - * Set the object radius - * @see #getRadius() - * @param radius + * @deprecated Use a custom {@link AnimationController} instead + * to despawn the object when it completes its animation. */ - void setRadius(int radius); + @Deprecated + public void setFinished(boolean finished) + { + if (finished) + { + setActive(false); + } + } /** - * If true, the rectangle computed from the radius has 1 or 2 of its sides expanded by a full tile based on the - * orientation the object is facing. This causes the tiles the object is facing to be drawn first, even if the - * radius of the object would not place the object on that tile. - * The default is false. - * @return + * @deprecated Use {@link #getAnimationController} or {@link #getPoseAnimationController()} + * followed by {@link AnimationController#getFrame()}. */ - boolean drawFrontTilesFirst(); + public Animation getAnimation() + { + if (animationController != null) + { + return animationController.getAnimation(); + } + + if (poseAnimationController != null) + { + return poseAnimationController.getAnimation(); + } + + return null; + } /** - * Sets whether the front tiles are drawn first. - * @see #drawFrontTilesFirst() - * @param drawFrontTilesFirst + * @deprecated Use {@link #getAnimationController} or {@link #getPoseAnimationController()} + * followed by {@link AnimationController#getFrame()}. */ - void setDrawFrontTilesFirst(boolean drawFrontTilesFirst); + @Deprecated + public int getAnimationFrame() + { + if (animationController != null) + { + return animationController.getFrame(); + } + + if (poseAnimationController != null) + { + return poseAnimationController.getFrame(); + } + + return -1; + } + + private void updateAnimationControllerLooping() + { + if (this.shouldLoop != null && this.animationController != null) + { + if (this.shouldLoop) + { + animationController.setOnFinished(AnimationController::loop); + } + else + { + animationController.setOnFinished(_ac -> this.setActive(false)); + } + } + } } diff --git a/runelite-api/src/main/java/net/runelite/api/RuneLiteObjectController.java b/runelite-api/src/main/java/net/runelite/api/RuneLiteObjectController.java new file mode 100644 index 00000000000..b52c6dcc866 --- /dev/null +++ b/runelite-api/src/main/java/net/runelite/api/RuneLiteObjectController.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2024, LlemonDuck + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.api; + +import lombok.Getter; +import lombok.Setter; +import net.runelite.api.coords.LocalPoint; + +@Getter +@Setter +public abstract class RuneLiteObjectController +{ + + private int x; + private int y; + private int z; + + private int worldView; + private int level; + + /** + * The radius is offset from the object position to form a 2d rectangle, and the tiles + * the corners are in are used to determine the min and max scene x/y the object is on. These tiles are then drawn + * first prior to the object being drawn, so that the object renders correctly over top of each tile. + * The default radius is 60, marginally less than 128/2, which works well for models the size of a single tile. + */ + private int radius = 60; + + /** + * If true, the rectangle computed from the radius has 1 or 2 of its sides expanded by a full tile based on the + * orientation the object is facing. This causes the tiles the object is facing to be drawn first, even if the + * radius of the object would not place the object on that tile. + * The default is false. + */ + private boolean drawFrontTilesFirst = false; + + /** + * The object orientation + * @see net.runelite.api.coords.Angle + */ + private int orientation = 0; + + /** + * Sets the location in the scene for the RuneLiteObjectController + */ + public void setLocation(LocalPoint point, int level) + { + setX(point.getX()); + setY(point.getY()); + setWorldView(point.getWorldView()); + setLevel(level); + } + + public LocalPoint getLocation() + { + return new LocalPoint(x, y, worldView); + } + + /** + * Called every frame the RuneLiteObject is registered and in the scene + * + * @param ticksSinceLastFrame The number of client ticks since the last frame + */ + public void tick(int ticksSinceLastFrame) + { + } + + /** + * Called every frame to get a model to render. The returned model is not modified and + * can be a shared model. + */ + public abstract Model getModel(); + +}