/* * 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.games; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.PorterDuff; import android.graphics.PorterDuffXfermode; import android.support.annotation.NonNull; import android.util.Log; import android.view.MotionEvent; import android.view.animation.AccelerateInterpolator; import com.plattysoft.leonids.ParticleSystem; import com.plattysoft.leonids.ParticleSystemPool; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Timer; import java.util.TimerTask; import dan.dit.whatsthat.R; import dan.dit.whatsthat.achievement.AchievementProperties; import dan.dit.whatsthat.image.Image; import dan.dit.whatsthat.riddle.Riddle; import dan.dit.whatsthat.riddle.RiddleConfig; import dan.dit.whatsthat.riddle.achievement.holders.AchievementCircle; import dan.dit.whatsthat.riddle.control.LookRiddleAnimation; import dan.dit.whatsthat.riddle.control.RiddleAnimation; import dan.dit.whatsthat.riddle.control.RiddleGame; import dan.dit.whatsthat.riddle.control.RiddleScore; import dan.dit.whatsthat.riddle.types.TypesHolder; import dan.dit.whatsthat.testsubject.TestSubject; import dan.dit.whatsthat.testsubject.shopping.sortiment.SortimentHolder; import dan.dit.whatsthat.util.compaction.CompactedDataCorruptException; import dan.dit.whatsthat.util.compaction.Compacter; import dan.dit.whatsthat.util.flatworld.look.Frames; import dan.dit.whatsthat.util.flatworld.look.FramesOneshot; import dan.dit.whatsthat.util.general.PercentProgressListener; import dan.dit.whatsthat.util.image.ColorAnalysisUtil; import dan.dit.whatsthat.util.image.ImageUtil; import dan.dit.whatsthat.util.listlock.ListLockMaxIndex; import dan.dit.whatsthat.util.listlock.LockDistanceRefresher; import dan.dit.whatsthat.util.mosaic.reconstruction.pattern.CirclePatternReconstructor; /** * A specific riddle implementation that hides the image behind circles. * Each circle can be split into 4 smaller circles by clicking on it or moving nearby. * The circle color is a sample from the brightness of the pixels that are covered by the circle (square area). * Created by daniel on 31.03.15. */ public class RiddleCircle extends RiddleGame { private static final float MIN_RADIUS = 2.0f; // dp >=1, minimum radius for each circle, will click other nearby circles instead /* * A value that is kind of a magic number that makes clicking on circles feel less painful because of the finger and screen inaccuracy. * If the circle center and click point are within this euclidian distance from each other it will click the circle. * 0.433070866 = 11mm = average finger thickness , needs to be multiplied by screen density */ private static final float HUMAN_FINGER_THICKNESS = 20.f; // dp private static final float FRAME_WIDTH = 3f; // reasonably high, will be the biggest ones too, higher or unlimited can kill the main thread // for a test you can easily go up to 50k circles in no time (with no riddle limits and MIN_RADIUS=1.0f) on a <= 400x400 riddle private static final int MODE_MOVING_MAX_CIRCLES_CHECKED = 5000; private static final int MAX_CIRCLES_FOR_EXTRA_SCORE = 200; private static final int MAX_CIRCLES_FOR_EXTRA_EXTRA_SCORE = 100; /* * Holds the brightness for each pixel of the original bitmap (row wise pixel evaluation). */ private double[]mRaster; /* * Store each circles essential values for easy lightweight plotting. Could also be done by drawing * to a bitmap and only updating the required region, but so far the calculation overhead is ok. */ private List<Float> mCircleCenterX; private List<Float> mCircleCenterY; private List<Float> mCircleRadius; private List<Integer> mColor; /* * Required for drawing circle and background and color calculation. */ private Paint mPaint; private Paint mFramePaint; private Paint mClearPaint; private Bitmap mCirclesBitmap; private Canvas mCirclesCanvas; /* * Internal coordinate system is offset with these coordinates to center the canvas within the view * if the bitmap is smaller than the view. */ private float mTopLeftCornerY; private float mTopLeftCornerX; private double mAverageBrightness; private ListLockMaxIndex mLock; private LockDistanceRefresher mLockRefresher; private boolean mFeatureDivideByMove; private Resources mRes; private Timer mTimer; private boolean mForbidCircleDivision; private LookRiddleAnimation mBigBrotherAnim; private ParticleSystemPool mParticlePool; public RiddleCircle(Riddle riddle, Image image, Bitmap bitmap, Resources res, RiddleConfig config, PercentProgressListener listener) { super(riddle, image, bitmap, res, config, listener); } @Override public void onClose() { super.onClose(); if (mTimer != null) { mTimer.cancel(); mTimer = null; } mRaster = null; mPaint = null; mFramePaint = null; mCirclesCanvas = null; mCirclesBitmap = null; mCircleCenterX = null; mCircleCenterY = null; mCircleRadius = null; mClearPaint = null; mColor = null; } @Override public void initBitmap(Resources res, PercentProgressListener listener) { mRes = res; mParticlePool = new ParticleSystemPool(new ParticleSystemPool.ParticleSystemMaker() { @Override public ParticleSystem makeParticleSystem() { return RiddleCircle.this.makeParticleSystem(mRes, 10, R.drawable .spark, 400L).setFadeOut(200, new AccelerateInterpolator()); } }, 10); // fill raster with brightness and calculate average brightness { mRaster = new double[mBitmap.getHeight() * mBitmap.getWidth()]; int index = 0; for (int y = 0; y < mBitmap.getHeight(); y++) { for (int x = 0; x < mBitmap.getWidth(); x++) { mRaster[index] = ColorAnalysisUtil.getBrightnessWithAlpha(mBitmap.getPixel(x, y)); mAverageBrightness += mRaster[index]; index++; } } } mAverageBrightness /= mBitmap.getWidth() * mBitmap.getHeight(); listener.onProgressUpdate(35); mFeatureDivideByMove = TestSubject.getInstance().hasFeature(SortimentHolder.ARTICLE_KEY_CIRCLE_DIVIDE_BY_MOVE_FEATURE); mCirclesBitmap = Bitmap.createBitmap(mBitmap.getWidth(), mBitmap.getHeight(), mBitmap.getConfig()); mCirclesCanvas = new Canvas(mCirclesBitmap); listener.onProgressUpdate(50); //setup colors and paint mClearPaint = new Paint(); mClearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); mPaint = new Paint(); mPaint.setAntiAlias(true); mPaint.setStyle(Paint.Style.FILL); mFramePaint = new Paint(); mFramePaint.setAntiAlias(true); mFramePaint.setStyle(Paint.Style.STROKE); mFramePaint.setStrokeWidth(FRAME_WIDTH); //init value holder for circles mCircleCenterX = new ArrayList<>(); mCircleCenterY = new ArrayList<>(); mCircleRadius = new ArrayList<>(); mColor = new ArrayList<>(); mLock = new ListLockMaxIndex(mCircleCenterX, MODE_MOVING_MAX_CIRCLES_CHECKED); mLockRefresher = new LockDistanceRefresher(mLock, Math.max(mBitmap.getWidth(), mBitmap.getHeight()) / 2.f); listener.onProgressUpdate(66); // try reconstructing circles Compacter cmp = getCurrentState(); if (cmp != null && cmp.getSize() > 3) { // we are reconstructing this riddle, lets try it if dimensions kinda match double aspectRatio = mBitmap.getWidth() / ((double) mBitmap.getHeight()); int widthLoaded = -1; double aspectRatioLoaded = -1; try { widthLoaded = cmp.getInt(0); aspectRatioLoaded = widthLoaded / ((double) cmp.getInt(1)); } catch (CompactedDataCorruptException e) { Log.e("Riddle", "Could not load width/height from data to reconstruct circle " + e.getMessage()); } if (Math.abs(aspectRatio - aspectRatioLoaded) < 1E-3) { // equal ratios float scaling = mBitmap.getWidth() / ((float) widthLoaded); int index = 2; while (index + 3 < cmp.getSize()) { try { addCircle(scaling * cmp.getFloat(++index), scaling * cmp.getFloat(++index), scaling * cmp.getFloat(++index), true); } catch (CompactedDataCorruptException e) { Log.e("Riddle", "Could not circle data when reconstructing " + e.getMessage()); break; } } } } listener.onProgressUpdate(90); if (mCircleCenterX.size() == 0) { //init basic circle(s), one circle in the center with maximum radius in bounds, we prefer square views. initCircles(0.f, 0.f, mBitmap.getWidth(), mBitmap.getHeight()); } //riddle is now fully initialized and ready to be displayed and interacted with } @Override public void onGotVisible() { if (mCircleCenterX.size() == 1) { mTimer = new Timer(); mTimer.schedule(new TimerTask() { @Override public void run() { bigBrotherAnimationChecked(); } }, 60000L); } } private void bigBrotherAnimationChecked() { if (mCircleCenterX != null && mCircleCenterX.size() == 1) { long step1Duration = 5000L; long step2Duration = 200L; long step3Duration = 4000L; long step4Duration = 300L; long step5Duration = 4000L; long totalLifeTime = step1Duration + step2Duration + step3Duration + step4Duration + step5Duration; int sizeFraction = 2; Bitmap[] frames = new Bitmap[5]; frames[0] = ImageUtil.loadBitmap(mRes, R.drawable.googly_eyes, mConfig.mWidth / sizeFraction, mConfig .mHeight / sizeFraction, true); frames[1] = null; frames[2] = frames[0]; frames[3] = null; frames[4] = frames[0]; Frames look = new FramesOneshot(frames, totalLifeTime) .setFrameDuration(0, step1Duration) .setFrameDuration(1, step2Duration) .setFrameDuration(2, step3Duration) .setFrameDuration(3, step4Duration) .setFrameDuration(4, step5Duration); mBigBrotherAnim = new LookRiddleAnimation(look, mConfig.mWidth / 2 - look .getWidth() / 2, mConfig.mHeight / 3 - look.getHeight() / 2, totalLifeTime); mBigBrotherAnim.setStateListener(new RiddleAnimation.StateListener() { @Override public void onBorn() { mForbidCircleDivision = true; } @Override public void onKilled(boolean murdered) { mForbidCircleDivision = false; } }); addAnimation(mBigBrotherAnim); } } private boolean initCircles(float topLeftX, float topLeftY, float width, float height) { float halfWidth = width / 2.f; float halfHeight = height / 2.f; float r = Math.min(halfWidth, halfHeight); if (!addCircle(topLeftX + halfWidth, topLeftY + halfHeight, r, true)) { return false; } if (2 * r <= width - 4 * ImageUtil.convertDpToPixel(MIN_RADIUS, mConfig.mScreenDensity)) { // we got a landscape bitmap... and there is enough space on the left and right for some circles, fill it initCircles(topLeftX, topLeftY, halfWidth - r, height); initCircles(topLeftX + halfWidth + r, topLeftY, halfWidth - r, height); } else if (2 * r <= height - 4 * ImageUtil.convertDpToPixel(MIN_RADIUS, mConfig.mScreenDensity)) { // we got a portrait bitmap, fill it if possible, see landscape for more details initCircles(topLeftX, topLeftY, width, halfHeight- r); initCircles(topLeftX, topLeftY + halfHeight + r, width, halfHeight - r); } return true; } @Override protected void addBonusReward(@NonNull RiddleScore.Rewardable rewardable) { int bonus = (mCircleCenterX.size() < MAX_CIRCLES_FOR_EXTRA_SCORE ? TypesHolder.SCORE_SIMPLE : mCircleCenterX.size() < MAX_CIRCLES_FOR_EXTRA_EXTRA_SCORE ? TypesHolder .SCORE_MEDIUM : 0); rewardable.addBonus(bonus); } /** * Adds a circle at given internal coordinates if these are within bounds of the bitmap. * @param x The x center coordinate. * @param y The y center coordinate. * @param r The radius of the circle. * @param draw If the circle should draw itself * @return Only true if a new circle was added and this circle was fully inside bounds. */ private boolean addCircle(float x, float y, float r, boolean draw) { if (r < ImageUtil.convertDpToPixel(MIN_RADIUS, mConfig.mScreenDensity) || x - r < 0 || x + r > mBitmap.getWidth() || y - r < 0 || y + r > mBitmap.getHeight()) { return false; // out of bounds } mCircleCenterX.add(x); mCircleCenterY.add(y); mCircleRadius.add(r); int color = CirclePatternReconstructor.calculateColor(mRaster, mAverageBrightness, mBitmap.getWidth(), mBitmap.getHeight(), x, y, r); mColor.add(color); if (draw) { mPaint.setColor(color); mCirclesCanvas.drawCircle(x, y, r, mPaint); } return true; } @Override public void draw(Canvas canvas) { mTopLeftCornerX = Math.abs(mBitmap.getWidth() - canvas.getWidth()) / 2; mTopLeftCornerY = Math.abs(mBitmap.getHeight() - canvas.getHeight()) / 2; canvas.drawBitmap(mCirclesBitmap, mTopLeftCornerX, mTopLeftCornerY, null); drawBorder(canvas); } private void drawBorder(Canvas canvas) { canvas.drawRect(mTopLeftCornerX, mTopLeftCornerY, mTopLeftCornerX + mBitmap.getWidth(), mTopLeftCornerY + mBitmap.getHeight(), mFramePaint); } // splits the circle into 4 subcircles, appends them and removes itself from the list private void evolveCircleUnchecked(int index, float x, float y, float radius, boolean draw) { mCircleCenterX.remove(index); mCircleCenterY.remove(index); mCircleRadius.remove(index); mColor.remove(index); addCircle(x - radius, y - radius, radius, draw); addCircle(x + radius, y - radius, radius, draw); addCircle(x - radius, y + radius, radius, draw); addCircle(x + radius, y + radius, radius, draw); mConfig.mAchievementGameData.putValue(AchievementCircle.KEY_CIRCLE_COUNT, (long) mCircleCenterX.size(), AchievementProperties.UPDATE_POLICY_ALWAYS); } private void reDraw() { Iterator<Float> xIt = mCircleCenterX.iterator(); Iterator<Float> yIt = mCircleCenterY.iterator(); Iterator<Float> rIt = mCircleRadius.iterator(); Iterator<Integer> colorIt = mColor.iterator(); mCirclesCanvas.drawRect(0, 0, mCirclesCanvas.getWidth(), mCirclesCanvas.getHeight(), mClearPaint); while (rIt.hasNext()) { float x = xIt.next(); float y = yIt.next(); float r = rIt.next(); int color = colorIt.next(); mPaint.setColor(color); mCirclesCanvas.drawCircle(x, y, r, mPaint); } } private boolean onTouchDown(float clickX, float clickY) { // first step: find closest circle that still can split up into smaller circles double maxDist = Double.MAX_VALUE; Iterator<Float> xIt = mCircleCenterX.iterator(); Iterator<Float> yIt = mCircleCenterY.iterator(); Iterator<Float> rIt = mCircleRadius.iterator(); int closestIndex = 0; float closestX = 0; float closestY = 0; float closestRadius = 1; for (int i = 0; i < mCircleRadius.size(); i++) { float x = xIt.next(); float y = yIt.next(); float r = rIt.next(); double dist = Math.sqrt((clickX - x)*(clickX - x) + (clickY - y)*(clickY - y)); if (dist < maxDist && r >= 2.f * ImageUtil.convertDpToPixel(MIN_RADIUS, mConfig.mScreenDensity)) { maxDist = dist; closestIndex = i; closestX = x; closestY = y; closestRadius = r; } } // next step: remove closest circle, add 4 new ones inside the old one if we can split further float newRadius = closestRadius / 2.f; double distanceClickAndClosest = Math.sqrt((clickX - closestX) * (clickX - closestX) + (clickY - closestY) * (clickY - closestY)); if (newRadius >= ImageUtil.convertDpToPixel(MIN_RADIUS, mConfig.mScreenDensity) && (distanceClickAndClosest <= closestRadius || distanceClickAndClosest <= ImageUtil.convertDpToPixel(HUMAN_FINGER_THICKNESS, mConfig.mScreenDensity))) { mCirclesCanvas.drawRect(closestX - closestRadius, closestY - closestRadius, closestX + closestRadius, closestY+ closestRadius, mClearPaint); evolveCircleUnchecked(closestIndex, closestX, closestY, newRadius, true); mConfig.mAchievementGameData.increment(AchievementCircle.KEY_CIRCLE_DIVIDED_BY_CLICK, 1L, 0L); return true; } return false; } private boolean onMove(float x, float y) { int index = 0; float minR = 2.f * ImageUtil.convertDpToPixel(MIN_RADIUS, mConfig.mScreenDensity); float humanFingerThickness = ImageUtil.convertDpToPixel(HUMAN_FINGER_THICKNESS, mConfig.mScreenDensity); // since the number of circles can easily get very high we are not very strict here with picking a circle // the first one that can evolve and is close enough will be used while (index < mCircleCenterX.size() && mLock.isUnlocked(index)) { float currX = mCircleCenterX.get(index); float currY = mCircleCenterY.get(index); float currR = mCircleRadius.get(index); double dist = Math.sqrt((currX - x) * (currX - x) + (currY - y) * (currY - y)); if (dist <= Math.max(currR, humanFingerThickness) && currR >= minR) { mLock.lock(1); float newRadius = currR / 2.f; // don't redraw all but only the required area mCirclesCanvas.drawRect(currX - currR, currY - currR, currX + currR, currY + currR, mClearPaint); evolveCircleUnchecked(index, currX, currY, newRadius, true); mConfig.mAchievementGameData.increment(AchievementCircle.KEY_CIRCLE_DIVIDED_BY_MOVE, 1L, 0L); ParticleSystem system = mParticlePool.obtain(); if (system != null) { system.clearInitializers(); float scale = 1.f + 3f * (currR * 2f / mConfig.mWidth); // factor from 1 to 4 system.setSpeedModuleAndAngleRange(0.05f, 0.15f, 0, 360) .setAccelerationModuleAndAndAngleRange(0.0001f, 0.0002f, 0, 360) .setScaleRange(scale - 0.05f, scale + 0.05f); system.emit((int) currX, (int) currY, 10, 300); } return true; } index++; } return false; } @Override public boolean onMotionEvent(MotionEvent event) { if (mForbidCircleDivision) { if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { long clicksDone = mConfig.mAchievementGameData.increment(AchievementCircle .KEY_FORBIDDEN_CIRCLE_DIVISION_CLICK, 1L, 0L); if (clicksDone >= AchievementCircle.Achievement12.REQUIRED_CLICKS_TO_VICTORY && mBigBrotherAnim != null) { mBigBrotherAnim.murder(); } } return false; } mLockRefresher.update(event); if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { return onTouchDown(event.getX() - mTopLeftCornerX, event.getY() - mTopLeftCornerY); } else if (event.getActionMasked() == MotionEvent.ACTION_MOVE && mFeatureDivideByMove) { return onMove(event.getX() - mTopLeftCornerX, event.getY() - mTopLeftCornerY); } return false; } @Override protected @NonNull String compactCurrentState() { Compacter cmp = new Compacter(mCircleRadius.size() + 5); cmp.appendData(mBitmap.getWidth()); cmp.appendData(mBitmap.getHeight()); cmp.appendData(""); // in case we need the slot // save a bunch of circles, can take quite some memory if MIN_RADIUS is too small and fully evolved a huge bitmap Iterator<Float> xIt = mCircleCenterX.iterator(); Iterator<Float> yIt = mCircleCenterY.iterator(); Iterator<Float> rIt = mCircleRadius.iterator(); while (xIt.hasNext()) { cmp.appendData(xIt.next()); cmp.appendData(yIt.next()); cmp.appendData(rIt.next()); } return cmp.compact(); } @Override protected Bitmap makeSnapshot() { int width = SNAPSHOT_DIMENSION.getWidthForDensity(mConfig.mScreenDensity); int height = SNAPSHOT_DIMENSION.getHeightForDensity(mConfig.mScreenDensity); return Bitmap.createScaledBitmap(mCirclesBitmap, width, height, false); } @Override protected void initAchievementData() { mConfig.mAchievementGameData.putValue(AchievementCircle.KEY_CIRCLE_COUNT, (long) mCircleCenterX.size(), AchievementProperties.UPDATE_POLICY_ALWAYS); } }