/*
* Copyright 2015 Daniel Dittmar
*
* 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 dan.dit.whatsthat.riddle.control;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.os.Handler;
import android.os.HandlerThread;
import android.support.annotation.NonNull;
import android.util.Log;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.ViewGroup;
import android.view.animation.AccelerateInterpolator;
import com.github.johnpersano.supertoasts.SuperToast;
import com.plattysoft.leonids.ParticleField;
import com.plattysoft.leonids.ParticleSystem;
import java.sql.Types;
import dan.dit.whatsthat.R;
import dan.dit.whatsthat.achievement.AchievementManager;
import dan.dit.whatsthat.achievement.AchievementProperties;
import dan.dit.whatsthat.riddle.Riddle;
import dan.dit.whatsthat.riddle.RiddleInitializer;
import dan.dit.whatsthat.riddle.RiddleManager;
import dan.dit.whatsthat.riddle.RiddleView;
import dan.dit.whatsthat.riddle.achievement.AchievementDataRiddleType;
import dan.dit.whatsthat.riddle.types.PracticalRiddleType;
import dan.dit.whatsthat.riddle.types.TypesHolder;
import dan.dit.whatsthat.testsubject.TestSubject;
import dan.dit.whatsthat.testsubject.TestSubjectToast;
import dan.dit.whatsthat.util.general.MathFunction;
/**
* A riddle controller is the class between the RiddleView and the RiddleGame. If closed the controller can
* no longer be used, it is directly bound to the lifecycle of a RiddleGame.
* The controller manages the communication between the different threads involved for keeping
* the riddle running. The game thread is always running and a single thread dedicated to process
* events like motion events, orientation events and periodic events in a single thread. Also the
* periodic game drawing is done on this thread. In the background there can exist a separate
* periodic thread that produces periodic events for the riddle (if requested on startup) or for
* riddle animations. Keep in mind that starting a ParticleSystem needs to be done over the
* RiddleGame.<br>The ui thread is invoked for some drawing, for startup and closing a riddle.
* main UI thread is
* Created by daniel on 05.04.15.
*/
public class RiddleController implements RiddleAnimationController.OnAnimationCountChangedListener {
private volatile RiddleGame mRiddleGame;
private Riddle mRiddle;
private ViewGroup mRiddleViewContainer;
private volatile RiddleView mRiddleView;
private GamePeriodicThread mPeriodicThread;
private RiddleAnimationController mRiddleAnimationController;
private Handler mMainHandler;
private GameHandlerThread mGameThread;
private final Runnable mDrawAction;
private final Runnable mPeriodicAction;
private volatile int mPeriodActionPostedCount;
private volatile boolean mIsClosing;
/**
* Initializes the RiddleController with the RiddleGame that decorates the given Riddle.
* @param riddleGame The game that decorates the Riddle parameter.
* @param riddle The riddle decorated by the game.
*/
RiddleController(@NonNull RiddleGame riddleGame, @NonNull Riddle riddle) {
mRiddleGame = riddleGame;
mRiddle = riddle;
mRiddleAnimationController = new RiddleAnimationController(this);
mDrawAction = new Runnable() {
@Override
public void run() {
if (mRiddleView != null) {
mRiddleView.draw();
}
}
};
mPeriodicAction = new Runnable() {
private long mMissingUpdateTime; // will only be zero at start for first game controlled
@Override
public void run() {
long requiredDrawingTime = mRiddleView.performDrawRiddle();
long updateTime = mMissingUpdateTime + requiredDrawingTime;
long periodicEventStartTime = System.nanoTime();
if (updateTime > 0) {
mRiddleGame.onPeriodicEvent(updateTime);
mRiddleAnimationController.update(updateTime);
}
mMissingUpdateTime = (System.nanoTime() - periodicEventStartTime) / 1000000;
--mPeriodActionPostedCount;
}
};
}
public Riddle getRiddle() {
return mRiddle;
}
public void forbidRiddleBonusScore() {
mRiddleGame.setForbidBonus();
}
private class GameHandlerThread extends HandlerThread {
private static final long MIN_TIME_BETWEEN_MOTION_MOVE_EVENTS = 30L;
private Handler mHandler;
private long mLastMotionMoveTimestamp;
public GameHandlerThread() {
super("GameHandlerThread");
start();
Log.d("Riddle", "GameThread started.");
mHandler = new Handler(getLooper());
}
public void onMotionEvent(MotionEvent event) {
if (event.getActionMasked() == MotionEvent.ACTION_MOVE) {
long now = System.currentTimeMillis();
if (now - mLastMotionMoveTimestamp < MIN_TIME_BETWEEN_MOTION_MOVE_EVENTS) {
return;
}
mLastMotionMoveTimestamp = now;
}
final MotionEvent eventCopy = MotionEvent.obtain(event);
mHandler.post(new Runnable() {
@Override
public void run() {
if (mRiddleGame != null && mRiddleGame.onMotionEvent(eventCopy)) {
mDrawAction.run();
}
eventCopy.recycle();
}
});
}
public void onOrientationEvent(final float azimuth, final float pitch, final float roll) {
mHandler.post(new Runnable() {
@Override
public void run() {
if (mRiddleGame != null && mRiddleGame.onOrientationEvent(azimuth, pitch,
roll)) {
mDrawAction.run();
}
}
});
}
public void onPeriodicEvent() {
if (mPeriodActionPostedCount == 0 && !mIsClosing) {
++mPeriodActionPostedCount;
mHandler.post(mPeriodicAction);
}
}
public Handler getHandler() {
return mHandler;
}
public void onCloseRiddle(final Context context) {
mIsClosing = true;
mHandler.post(new Runnable() {
@Override
public void run() {
//at this point we can be sure that the looper doesn't currently process a
// periodic event which could lead to concurrency issues
mGameThread.quit(); // do not process any more actions!
Log.d("Riddle", "Game thread quit.");
// stop periodic event in a safe way, as soon as it is stopped really close
// riddle in the main ui thread
stopPeriodicEvent(mMainHandler, new Runnable() {
@Override
public void run() {
if (riddleAvailable()) {
Log.d("Riddle", "Executing close riddle!");
onPreRiddleClose();
mRiddleAnimationController.clear();
mRiddleGame.close();
mRiddleGame = null;
onRiddleClosed(context);
}
mRiddleView = null;
mRiddleViewContainer = null;
}
});
}
});
}
}
/**
* The controller is closing, close the riddle and make it save its state. After this method
* returns the controller is invalid.
* @param context A context object required for saving state to permanent storage.
*/
public final void onCloseRiddle(@NonNull final Context context) {
Log.d("Riddle", "On close riddle.");
if (mGameThread != null) {
mGameThread.onCloseRiddle(context);
}
}
/**
* Invoked on closure of the controller before the RiddleGame's onClose method is invoked.
*/
void onPreRiddleClose() {
RiddleManager.addToCache(mRiddle, mRiddleGame.makeSnapshot());
}
// this is overwritten if we don't want the manager to know of this riddle and dont want it saved
/**
* Invoked on closure of the controller after the RiddleGame's onClose method returned. The RiddleGame is not
* a valid member anymore. By default saving achievement data and decorated riddle object.
* @param context The context required to save to permanent storage.
*/
void onRiddleClosed(final Context context) {
Log.d("Riddle", "On riddle closed.");
if (mRiddle.isSolved() && (mRiddle.isRemade() || !mRiddle.isCustom())) {
mRiddle.getType().getAchievementData(AchievementManager.getInstance()).onSolvedGame();
}
mRiddle.saveToDatabase(context);
if (mRiddle.isSolved()) {
RiddleInitializer.INSTANCE.getRiddleManager().onRiddleSolved(mRiddle);
} else {
RiddleInitializer.INSTANCE.getRiddleManager().onUnsolvedRiddle(mRiddle);
}
}
/* ************* LAYOUT RELATED METHODS ********************************************************/
/**
* Draws the RiddleGame on the given canvas if there is a valid riddle.
* @param canvas The canvas to draw onto.
*/
public void draw(Canvas canvas) {
if (riddleAvailable()) {
mRiddleAnimationController.draw(canvas, null, RiddleAnimationController
.LEVEL_GROUNDING);
mRiddleAnimationController.draw(canvas, null, RiddleAnimationController
.LEVEL_BACKGROUND);
mRiddleGame.draw(canvas);
mRiddleAnimationController.draw(canvas, null, RiddleAnimationController
.LEVEL_ON_TOP);
}
}
protected void addAnimation(@NonNull RiddleAnimation animation) {
mRiddleAnimationController.addAnimation(animation);
}
protected void addAnimation(@NonNull RiddleAnimation animation, long delay) {
mRiddleAnimationController.addAnimation(animation, delay);
}
/* ************ INPUT RELATED METHODS *********************************************************/
/**
* Invoked if there happened some MotionEvent of any kind to the RiddleView.
* If possible forwards the event to the RiddleGame, redrawing the game if onMotionEvent suggests so.
* @param event The event to forward.
*/
public void onMotionEvent(MotionEvent event) {
if (riddleAvailable()) {
mGameThread.onMotionEvent(event);
}
}
/**
* Invoked if there happened some OrientationEvent that changed the orientation of the device in the world's
* coordinate system and orientation sensor is required.
* If possible forwards the orientation event to the RiddleGame, redrawing the game if onOrientationEvent suggests so.
* Given angles in radians, for specification see Wikipedia.
* @param azimuth The new azimuth.
* @param pitch The new pitch.
* @param roll The new roll.
*/
public void onOrientationEvent(float azimuth, float pitch, float roll) {
if (riddleAvailable() && requiresOrientationSensor()) {
mGameThread.onOrientationEvent(azimuth, pitch, roll);
}
}
private boolean riddleAvailable() {
return mRiddleGame != null && mRiddleGame.isNotClosed();
}
/**
* Invoked when the RiddleView got visible and valid. On the UI thread.
* @param riddleViewContainer The container that contains the valid RiddleView
*/
public final void onRiddleVisible(@NonNull ViewGroup riddleViewContainer) {
mRiddleViewContainer = riddleViewContainer;
mIsClosing = false;
mRiddleView = (RiddleView) mRiddleViewContainer.findViewById(R.id.riddle_view);
mMainHandler = new Handler();
mGameThread = new GameHandlerThread();
mGameThread.setUncaughtExceptionHandler(Thread.currentThread().getUncaughtExceptionHandler());
mRiddleGame.onGotVisible();
//startRiddleGotVisibleAnimation(); // nice but requires extra periodic thread to be
// started and overall extra work for little gain
onRiddleGotVisible();
}
protected void startRiddleGotVisibleAnimation() {
long animationTime = 450;
float yDelta = -100f;
mRiddleAnimationController.addAnimation(new RiddleCanvasAnimation.Builder()
.setInterpolator(new MathFunction.AnimationInterpolator(new
AccelerateInterpolator(2.f)))
.addTranslate(0, yDelta, 0, -yDelta, animationTime)
.addScale(1f, 1f, 0.5f, 0.0f, animationTime)
.build());
}
// this is overwritten if we don't want the manager to know of this riddle
/**
* The riddle just got visible, by default tell the manager this happened.
*/
void onRiddleGotVisible() {
RiddleInitializer.INSTANCE.getRiddleManager().onUnsolvedRiddle(mRiddle); // especially important that, if saving this riddle when finished excludes the image from the list since saving is async
}
/**
* Get the id of the currently used riddle. Not necessarily a valid id for newly created riddles!
* @return The riddle id. Can be an invalid id!
*/
public long getRiddleId() {
return mRiddle.getId();
}
/**
* If any valid game, check if its type requires the orientation sensor.
* @return If the orientation sensor is required.
*/
public boolean requiresOrientationSensor() {
return riddleAvailable() && mRiddleGame.requiresOrientationSensor();
}
/**
* Pause the periodic event, stopping future invocations and periodic renderings.
*/
private synchronized void stopPeriodicEvent(Handler handler, final Runnable toExecute) {
if (mPeriodicThread != null && mPeriodicThread.isRunning()) {
Log.d("Riddle", "Stopping periodic event that is running.");
mPeriodicThread.stopPeriodicEvent(handler, new Runnable() {
@Override
public void run() {
if (toExecute != null) {
toExecute.run();
}
onPeriodicThreadStopped();
}
});
} else if (toExecute != null) {
Log.d("Riddle", "Stopping periodic event that was not running.");
if (handler != null) {
handler.post(toExecute);
} else {
toExecute.run();
}
}
}
public synchronized void stopPeriodicEvent() {
stopPeriodicEvent(null, null);
}
private void resumePeriodicEventExecute() {
mPeriodicThread = new GamePeriodicThread(RiddleController.this);
mPeriodicThread.setUncaughtExceptionHandler(Thread.getDefaultUncaughtExceptionHandler());
mPeriodicThread.startPeriodicEvent();
}
// invoked on ui thread. periodic event stopped completely, any other code to execute after
// stopping was executed, so check if there is still a riddle available before doing stuff to
// riddle or periodic thread
private synchronized void onPeriodicThreadStopped() {
onAnimationCountChanged(); // check again if we need to resume the periodic thread, can
// be relevant when the thread is about to be stopped when another animation is added
}
/**
* If there is a valid riddle and a positive periodic event period, resume (or restart) the rendering and periodic threads.
*/
public synchronized void resumePeriodicEventIfRequired() {
if (mRiddleGame != null && requiresPeriodicEvent()) {
resumePeriodicEvent();
}
}
private boolean requiresPeriodicEvent() {
return mRiddleGame.requiresPeriodicEvent()
|| mRiddleView.getActiveParticleSystemsCount() > 0
|| mRiddleAnimationController.getActiveAnimationsCount() > 0;
}
private synchronized void resumePeriodicEvent() {
if (riddleAvailable() && mRiddleView != null) {
if (mPeriodicThread == null || !mPeriodicThread.isRunning()) {
// if thread is not running yet or not anymore, (re)start.
// use runnable that is posted by previous running thread, if any, to the ui
// thread to ensure that no concurrency issues can appear
stopPeriodicEvent(mMainHandler, new Runnable() {
@Override
public void run() {
resumePeriodicEventExecute();
}
});
}
}
}
/**
* If the type requires orientation sensor but the device does not supply (all) required sensors, the game is told so
* and can enable an alternative to the orientation sensor if possible.
*/
public void enableNoOrientationSensorAlternative() {
if (riddleAvailable()) {
mRiddleGame.enableNoOrientationSensorAlternative();
}
}
/**
* Returns the type of controller's Riddle.
* @return The type of the current riddle.
*/
public PracticalRiddleType getRiddleType() {
return mRiddle.getType();
}
/**
* Returns the image's hash of the controller's Riddle ('s image).
* @return The hash of the current riddle.
*/
public String getImageHash() {
return mRiddle.getImageHash();
}
/**
* The periodic event happened, forward to the RiddleGame if possible.
*/
void onPeriodicEvent() {
if (riddleAvailable()) {
mGameThread.onPeriodicEvent();
}
}
public boolean hasRunningPeriodicThread() {
return mPeriodicThread != null && mPeriodicThread.isRunning();
}
public void checkParty(@NonNull Resources res, @NonNull RiddleView.PartyCallback callback) {
if (!TestSubject.isInitialized()) {
return;
}
TestSubject subject = TestSubject.getInstance();
TestSubjectToast toast = new TestSubjectToast(Gravity.CENTER, 0, 0, mRiddle.getType().getIconResId(), 0, SuperToast.Duration.MEDIUM);
toast.mAnimations = SuperToast.Animations.POPUP;
toast.mBackgroundColor = res.getColor(R.color.main_background);
String[] candies = res.getStringArray(subject.getRiddleSolvedResIds());
RiddleScore riddleScore = mRiddleGame.getGainedScore(false);
int score = riddleScore.getTotalScore();
int party = riddleScore.getBonus();
if (riddleScore.hasBonus()) {
AchievementProperties data = mRiddle.getType().getAchievementData(null);
if (data != null) {
data.increment(AchievementDataRiddleType.KEY_BONUS_GAINED_COUNT, 1L, 0L);
}
}
StringBuilder builder = new StringBuilder();
if (candies != null && candies.length > 0) {
int candyIndex = score - TypesHolder.SCORE_MINIMAL;
if (candyIndex >= candies.length) {
float bestFrac = 1f/3f;
candyIndex = (int) (candies.length * (1f - bestFrac)
+ Math.random() * candies.length * bestFrac);
}
builder.append(candies[candyIndex < 0 ? 0 : candyIndex >= candies.length ?
candies.length - 1 : candyIndex]);
}
if (score > 0) {
builder.append(" +")
.append(score);
}
// for each multiplier add an exclamation mark
for (int i = 0; i < riddleScore.getMultiplicator() - 1; i++) {
builder.append("!");
}
toast.mText = builder.toString();
toast.mTextSize = 40;
callback.giveCandy(toast);
if (party > 0) {
callback.doParty(party);
}
if (score > 0) {
callback.showMoneyEarned(score);
}
}
@Override
public void onAnimationCountChanged() {
if (!riddleAvailable() || mRiddleGame.requiresPeriodicEvent()) {
return;
}
int count = mRiddleAnimationController.getActiveAnimationsCount();
handlePeriodicEventForCount(count);
}
public void onParticleSystemCountChanged() {
if (!riddleAvailable() || mRiddleGame.requiresPeriodicEvent()) {
return;
}
int count = mRiddleView.getActiveParticleSystemsCount();
handlePeriodicEventForCount(count);
}
private void handlePeriodicEventForCount(int count) {
if (mRiddleView != null && mRiddleView.isPaused()) {
return;
}
// ensure the following actions take place on ui thread
if (count == 0) {
mMainHandler.post(new Runnable() {
@Override
public void run() {
stopPeriodicEvent(mGameThread.getHandler(), mDrawAction);
}
});
} else if (count > 0) {
mMainHandler.post(new Runnable() {
@Override
public void run() {
resumePeriodicEvent();
}
});
}
}
public ParticleSystem makeParticleSystem(Resources res, int maxParticles, int drawableResId,
long timeToLive) {
ParticleField field = mRiddleView;
if (field == null) {
return null;
}
ParticleSystem system = new ParticleSystem(field, res, maxParticles, timeToLive);
system.initParticles(res.getDrawable(drawableResId));
system.setIgnorePositionInParent();
return system;
}
}