/*
* Copyright (C) 2016 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.apps.santatracker.doodles.shared;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.util.Log;
import android.util.Pair;
import com.google.android.apps.santatracker.doodles.shared.physics.Util;
import java.util.ArrayList;
import java.util.List;
/**
* An animated image. This also handles static, non-animated images: those are just animations with
* only 1 frame.
*/
public class AnimatedSprite {
private static final String TAG = AnimatedSprite.class.getSimpleName();
private static final int DEFAULT_FPS = 24;
private static final int NUM_TRIES_TO_LOAD_FROM_MEMORY = 3;
// When loading any sprite, this was the last successful sampleSize. We start loading the next
// Sprite with this sampleSize.
public static int lastUsedSampleSize = 1;
private static BitmapCache bitmapCache;
private Bitmap[] frames;
public int frameWidth;
public int frameHeight;
private int fps = DEFAULT_FPS;
private int numFrames;
private boolean loop = true;
private boolean paused = false;
private boolean flippedX = false;
private List<AnimatedSpriteListener> listeners;
private Vector2D position = Vector2D.get();
public Vector2D anchor = Vector2D.get();
private boolean hidden;
private float scaleX = 1;
private float scaleY = 1;
private float rotation;
private Paint paint;
private int sampleSize = 1;
private float frameIndex;
// These are fields in order to avoid allocating memory in draw(). Not threadsafe, but why would
// draw get called from multiple threads?
private Rect srcRect = new Rect();
private RectF dstRect = new RectF();
/**
* Use fromFrames() to construct an AnimatedSprite.
*/
private AnimatedSprite(Bitmap[] frames, int sampleSize) {
this.frames = frames;
this.sampleSize = sampleSize;
if (lastUsedSampleSize < sampleSize) {
lastUsedSampleSize = sampleSize;
}
numFrames = this.frames.length;
if (numFrames == 0) {
throw new IllegalArgumentException("Can't have AnimatedSprite with zero frames.");
}
frameWidth = frames[0].getWidth() * sampleSize;
frameHeight = frames[0].getHeight() * sampleSize;
listeners = new ArrayList<>();
paint = new Paint();
paint.setAntiAlias(true);
paint.setFilterBitmap(true);
}
/**
* Return AnimatedSprite built from separate images (one image per frame).
*/
public static AnimatedSprite fromFrames(Resources resources, int[] ids) {
int sampleSize = lastUsedSampleSize;
Bitmap frames[] = new Bitmap[ids.length];
for (int i = 0; i < ids.length; i++) {
Pair<Bitmap, Integer> pair = getBitmapFromCache(ids[i], 0);
if (pair != null) {
frames[i] = pair.first;
sampleSize = pair.second;
}
if (frames[i] == null) {
final BitmapFactory.Options options = new BitmapFactory.Options();
for (int tries = 0; tries < NUM_TRIES_TO_LOAD_FROM_MEMORY; tries++) {
try {
// Decode bitmap with inSampleSize set
options.inSampleSize = sampleSize;
frames[i] = BitmapFactory.decodeResource(resources, ids[i], options);
} catch (OutOfMemoryError oom) {
sampleSize *= 2;
Log.d(TAG, "loading failed, trying sampleSize: " + sampleSize, oom);
}
}
putBitmapInCache(frames[i], ids[i], 0, sampleSize);
}
}
return new AnimatedSprite(frames, sampleSize);
}
/**
* Return AnimatedSprite built from the given Bitmap objects. (For testing).
*/
public static AnimatedSprite fromBitmapsForTest(Bitmap frames[]) {
return new AnimatedSprite(frames, 1);
}
/**
* Return AnimatedSprite built from the same frames as another animated sprite. This isn't a
* deep clone, only the frames & FPS of the original sprite are copied.
*/
public static AnimatedSprite fromAnimatedSprite(AnimatedSprite other) {
AnimatedSprite sprite = new AnimatedSprite(other.frames, other.sampleSize);
sprite.setFPS(other.fps);
return sprite;
}
private static Pair<Bitmap, Integer> getBitmapFromCache(int id, int frame) {
if (bitmapCache == null) {
bitmapCache = new BitmapCache();
}
return bitmapCache.getBitmapFromCache(id, frame);
}
private static void putBitmapInCache(Bitmap bitmap, int id, int frame, int sampleSize) {
if (bitmapCache == null) {
bitmapCache = new BitmapCache();
}
bitmapCache.putBitmapInCache(bitmap, id, frame, sampleSize);
}
/**
* Set whether to loop the animation or not.
*/
public void setLoop(boolean loop) {
this.loop = loop;
}
public void setFlippedX(boolean value) {
this.flippedX = value;
}
public boolean isFlippedX() {
return flippedX;
}
public void setPaused(boolean value) {
this.paused = value;
}
/**
* Pause this sprite and return a process chain which can be updated to unpause the sprite after
* the specified length of time.
*
* @param durationMs how many milliseconds to pause the sprite for.
* @return a process chain which will unpause the sprite after the duration has completed.
*/
public ProcessChain pauseFor(long durationMs) {
setPaused(true);
CallbackProcess unpause = new CallbackProcess() {
@Override
public void updateLogic(float deltaMs) {
setPaused(false);
}
};
return new WaitProcess(durationMs).then(unpause);
}
/**
* Change the speed of the animation.
*/
public void setFPS(int fps) {
this.fps = fps;
}
/**
* Sets the current frame.
*/
public void setFrameIndex(int frame) {
frameIndex = frame;
}
public int getFrameIndex() {
return (int) frameIndex;
}
public int getNumFrames() {
return numFrames;
}
public float getDurationSeconds() {
return numFrames / (float) fps;
}
/**
* Update the animation based on deltaMs having passed.
*/
public void update(float deltaMs) {
if (paused) {
return;
}
float deltaFrames = (deltaMs / 1000.0f) * fps;
// In order to make sure that we don't skip any frames when notifying listeners, this carefully
// accumulates deltaFrames instead of just immediately adding it into frameIndex. Be careful
// of floating point precision issues below.
while (deltaFrames > 0) {
// First, try accumulating the remaining deltaFrames and see if we make it to the next frame.
float newFrameIndex = frameIndex + deltaFrames;
if ((int) newFrameIndex == (int) frameIndex) {
// Didn't make it to the next frame. Done accumulating.
frameIndex = newFrameIndex;
deltaFrames = 0;
} else {
// Move forward to next frame, notify listeners, then keep accumlating.
float oldFrameIndex = frameIndex;
frameIndex = 1 + (int) frameIndex; // ignores numFrames, will handle it below.
deltaFrames -= frameIndex - oldFrameIndex;
if (frameIndex < numFrames) {
sendOnFrameNotification((int) frameIndex);
} else {
if (loop) {
frameIndex = 0;
sendOnLoopNotification();
sendOnFrameNotification((int) frameIndex);
} else {
frameIndex = numFrames - 1;
sendOnFinishNotification();
// In this branch, there are no further onFrame notifications.
deltaFrames = 0; // No more changes to frameIndex, done accumulating.
}
}
}
}
}
void sendOnLoopNotification() {
for (int i = 0; i < listeners.size(); i++) { // Avoiding iterators to avoid garbage.
// Call the on-loop callbacks.
listeners.get(i).onLoop();
}
}
void sendOnFinishNotification() {
for (int i = 0; i < listeners.size(); i++) { // Avoiding iterators to avoid garbage
// Call the on-finished callbacks.
listeners.get(i).onFinished();
}
}
void sendOnFrameNotification(int frame) {
for (int i = 0; i < listeners.size(); i++) { // Avoiding iterators to avoid garbage.
listeners.get(i).onFrame(frame);
}
}
public void draw(Canvas canvas) {
if (!hidden) {
// Integer cast should round down, but clamp it just in case the synchronization with the
// update thread isn't perfect.
int frameIndexFloor = Util.clamp((int) frameIndex, 0, numFrames - 1);
float scaleX = flippedX ? -this.scaleX : this.scaleX;
canvas.save();
srcRect.set(0, 0, frameWidth, frameHeight);
dstRect.set(-anchor.x, -anchor.y, -anchor.x + frameWidth, -anchor.y + frameHeight);
canvas.translate(position.x, position.y);
canvas.scale(scaleX, scaleY, 0, 0);
canvas.rotate((float) Math.toDegrees(rotation), 0, 0);
canvas.drawBitmap(frames[frameIndexFloor], srcRect, dstRect, paint);
canvas.restore();
}
}
// Unlike Actors, AnimatedSprites use setters instead of public fields for position, scale, etc.
// This matches how it works on iOS, which uses setters because the actual values must be passed
// down into SKNodes.
public void setPosition(float x, float y) {
position.x = x;
position.y = y;
}
/**
* @param alpha: 0.0 = transparent, 1.0 = opaque.
*/
public void setAlpha(float alpha) {
paint.setAlpha((int) (alpha * 255));
}
// You can use this to more closely match the logic of iOS, where there is no draw method so the
// only way to hide something is to set this flag. On Android, you can also just not call draw
// if you want a sprite hidden.
public void setHidden(boolean hidden) {
this.hidden = hidden;
}
public void setScale(float scaleX, float scaleY) {
this.scaleX = scaleX;
this.scaleY = scaleY;
}
public float getScaledWidth() {
return scaleX * frameWidth;
}
public float getScaledHeight() {
return scaleY * frameHeight;
}
public void setRotation(float rotation) {
this.rotation = rotation;
}
// Sets the anchor point which determines where the sprite is drawn relative to its position. This
// is also the point around which sprite rotates & scales. (x, y) are in pixels, relative to
// top-left corner. Initially set to the upper-left corner.
public void setAnchor(float x, float y) {
anchor.x = x;
anchor.y = y;
}
public void addListener(AnimatedSpriteListener listener) {
listeners.add(listener);
}
public void clearListeners() {
listeners.clear();
}
public int getNumListeners() {
return listeners.size();
}
public static void clearCache() {
if (AnimatedSprite.bitmapCache != null) {
AnimatedSprite.bitmapCache.clear();
}
}
// Reverse the frames of the animation. This doesn't update frameIndex, which may or may not
// be what you want.
public void reverseFrames() {
for (int i = 0; i < frames.length / 2; i++) {
Bitmap temp = frames[i];
frames[i] = frames[frames.length - i - 1];
frames[frames.length - i - 1] = temp;
}
}
/**
* A class which can be implemented to provide callbacks for AnimatedSprite events.
*/
public static class AnimatedSpriteListener {
public void onFinished() {
}
public void onLoop() {
}
public void onFrame(int index) {
}
}
}