<?xml version="1.0" encoding="utf-8"?><FrameLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/dialog_gray200_background" > <androidx.fragment.app.FragmentContainerView android:id="@+id/container" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout>
<?xml version="1.0" encoding="utf-8"?><selector xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"> <item> <shape android:shape="rectangle"> <solid android:color="@color/gray200" /> <corners android:bottomLeftRadius="0dp" android:bottomRightRadius="0dp" android:topLeftRadius="10dp" android:topRightRadius="10dp" /> </shape> </item></selector>
<?xml version="1.0" encoding="utf-8"?><FrameLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/dialog_gray200_background" > <FrameLayout android:id="@+id/container" android:layout_width="match_parent" android:layout_height="match_parent" android:animateLayoutChanges="true" /> </FrameLayout>
<?xml version="1.0" encoding="utf-8"?><set xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android" android:duration="500" android:interpolator="@android:anim/accelerate_interpolator"> <translate android:toXDelta="100%" /></set>
<?xml version="1.0" encoding="utf-8"?><set xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android" android:duration="500" android:interpolator="@android:anim/accelerate_interpolator"> <translate android:fromXDelta="-100%" /></set>
<?xml version="1.0" encoding="utf-8"?><set xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android" android:duration="500" android:interpolator="@android:anim/accelerate_interpolator"> <translate android:toXDelta="-100%" /></set>
<?xml version="1.0" encoding="utf-8"?><set xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android" android:duration="500" android:interpolator="@android:anim/accelerate_interpolator"> <translate android:fromXDelta="100%" /></set>
fragmentManager .beginTransaction() .setCustomAnimations(R.anim.to_left_in, R.anim.to_left_out, R.anim.to_right_in, R.anim.to_right_out) .replace(containerId, newFragment) .addToBackStack(newFragment.tag) .commit()
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android" xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto" xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:transitionName="checkoutTransition" >
Важно: это будет работать, поскольку мы используем REPLACE в транзакции фрагментов. Если вы используете ADD (или используете ADD и скрываете предыдущий фрагмент с помощью previousFragment.hide() [не надо так делать]), то transitionName придётся задавать динамически и очищать после завершения анимации. Так приходится делать, потому что в один момент времени в текущей иерархии View не может быть две View с одинаковым transitionName. Осуществить это можно, но будет лучше, если вы сможете обойтись без такого хака. Если вам всё-таки очень нужно использовать ADD, вдохновение для реализации можно найти в этой статье.
newFragment.sharedElementEnterTransition = AutoTransition()
fragmentManager .beginTransaction() .apply{ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { addSharedElement(currentFragment.requireView(), currentFragment.requireView().transitionName) setReorderingAllowed(true) } } .replace(containerId, newFragment) .addToBackStack(newFragment.tag) .commit()
Важно: обратите внимание, что transitionName (как и весь Transition API) доступен начиная с версии Android Lollipop.
@TargetApi(VERSION_CODES.LOLLIPOP)class BottomSheetSharedTransition : Transition {@Suppress("unused")constructor() : super() @Suppress("unused")constructor( context: Context?, attrs: AttributeSet?) : super(context, attrs)}
Напоминаю, что Transition API доступен с версии Android
Lollipop.
companion object { private const val PROP_HEIGHT = "heightTransition:height" private val TransitionProperties = arrayOf(PROP_HEIGHT)} override fun getTransitionProperties(): Array<String> = TransitionProperties
override fun captureStartValues(transitionValues: TransitionValues) { transitionValues.values[PROP_HEIGHT] = transitionValues.view.height}
override fun captureStartValues(transitionValues: TransitionValues) { // Запоминаем начальную высоту View... transitionValues.values[PROP_HEIGHT] = transitionValues.view.height // ... и затем закрепляем высоту контейнера фрагмента transitionValues.view.parent .let { it as? View } ?.also { view -> view.updateLayoutParams<ViewGroup.LayoutParams> { height = view.height } } }
override fun captureEndValues(transitionValues: TransitionValues) { // Измеряем и запоминаем высоту View transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)}
private fun getViewHeight(view: View): Int { // Получаем ширину экрана val deviceWidth = getScreenWidth(view) // Попросим View измерить себя при указанной ширине экрана val widthMeasureSpec = MeasureSpec.makeMeasureSpec(deviceWidth, MeasureSpec.EXACTLY) val heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) return view // измеряем .apply { measure(widthMeasureSpec, heightMeasureSpec) } // получаем измеренную высоту .measuredHeight // если View хочет занять высоту больше доступной высоты экрана, мы должны вернуть высоту экрана .coerceAtMost(getScreenHeight(view))} private fun getScreenHeight(view: View) = getDisplaySize(view).y - getStatusBarHeight(view.context) private fun getScreenWidth(view: View) = getDisplaySize(view).x private fun getDisplaySize(view: View) = Point().also { (view.context.getSystemService( Context.WINDOW_SERVICE ) as WindowManager).defaultDisplay.getSize(it) } private fun getStatusBarHeight(context: Context): Int = context.resources .getIdentifier("status_bar_height", "dimen", "android") .takeIf { resourceId -> resourceId > 0 } ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) } ?: 0
private fun prepareFadeInAnimator(view: View): Animator = ObjectAnimator.ofFloat(view, "alpha", 0f, 1f)
private fun prepareHeightAnimator( startHeight: Int, endHeight: Int, view: View) = ValueAnimator.ofInt(startHeight, endHeight) .apply { val container = view.parent.let { it as View } // изменяем высоту контейнера фрагментов addUpdateListener { animation -> container.updateLayoutParams<ViewGroup.LayoutParams> { height = animation.animatedValue as Int } } }
private fun prepareHeightAnimator( startHeight: Int, endHeight: Int, view: View) = ValueAnimator.ofInt(startHeight, endHeight) .apply { val container = view.parent.let { it as View } // изменяем высоту контейнера фрагментов addUpdateListener { animation -> container.updateLayoutParams<ViewGroup.LayoutParams> { height = animation.animatedValue as Int } } // окончании анимации устанавливаем высоту контейнера WRAP_CONTENT doOnEnd { container.updateLayoutParams<ViewGroup.LayoutParams> { height = ViewGroup.LayoutParams.WRAP_CONTENT } } }
override fun createAnimator( sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator? { if (startValues == null || endValues == null) { return null } val animators = listOf<Animator>( prepareHeightAnimator( startValues.values[PROP_HEIGHT] as Int, endValues.values[PROP_HEIGHT] as Int, endValues.view ), prepareFadeInAnimator(endValues.view) ) return AnimatorSet() .apply { interpolator = FastOutSlowInInterpolator() duration = ANIMATION_DURATION playTogether(animators) }}
companion object { private const val PROP_HEIGHT = "heightTransition:height" private const val PROP_VIEW_TYPE = "heightTransition:viewType" private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE)} override fun getTransitionProperties(): Array<String> = TransitionProperties override fun captureStartValues(transitionValues: TransitionValues) { // Запоминаем начальную высоту View... transitionValues.values[PROP_HEIGHT] = transitionValues.view.height transitionValues.values[PROP_VIEW_TYPE] = "start" // ... и затем закрепляем высоту контейнера фрагмента transitionValues.view.parent .let { it as? View } ?.also { view -> view.updateLayoutParams<ViewGroup.LayoutParams> { height = view.height } } } override fun captureEndValues(transitionValues: TransitionValues) { // Измеряем и запоминаем высоту View transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View) transitionValues.values[PROP_VIEW_TYPE] = "end"}
newFragment.sharedElementEnterTransition = BottomSheetSharedTransition()
package com.maleev.bottomsheetanimation import android.animation.Animatorimport android.animation.AnimatorSetimport android.animation.ObjectAnimatorimport android.animation.ValueAnimatorimport android.annotation.TargetApiimport android.content.Contextimport android.graphics.Pointimport android.os.Buildimport android.transition.Transitionimport android.transition.TransitionValuesimport android.util.AttributeSetimport android.view.Viewimport android.view.ViewGroupimport android.view.WindowManagerimport android.view.animation.AccelerateInterpolatorimport androidx.core.animation.doOnEndimport androidx.core.view.updateLayoutParams @TargetApi(Build.VERSION_CODES.LOLLIPOP)class BottomSheetSharedTransition : Transition { @Suppress("unused") constructor() : super() @Suppress("unused") constructor( context: Context?, attrs: AttributeSet? ) : super(context, attrs) companion object { private const val PROP_HEIGHT = "heightTransition:height" // the property PROP_VIEW_TYPE is workaround that allows to run transition always // even if height was not changed. It's required as we should set container height // to WRAP_CONTENT after animation complete private const val PROP_VIEW_TYPE = "heightTransition:viewType" private const val ANIMATION_DURATION = 400L private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE) } override fun getTransitionProperties(): Array<String> = TransitionProperties override fun captureStartValues(transitionValues: TransitionValues) { // Запоминаем начальную высоту View... transitionValues.values[PROP_HEIGHT] = transitionValues.view.height transitionValues.values[PROP_VIEW_TYPE] = "start" // ... и затем закрепляем высоту контейнера фрагмента transitionValues.view.parent .let { it as? View } ?.also { view -> view.updateLayoutParams<ViewGroup.LayoutParams> { height = view.height } } } override fun captureEndValues(transitionValues: TransitionValues) { // Измеряем и запоминаем высоту View transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View) transitionValues.values[PROP_VIEW_TYPE] = "end" } override fun createAnimator( sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues? ): Animator? { if (startValues == null || endValues == null) { return null } val animators = listOf<Animator>( prepareHeightAnimator( startValues.values[PROP_HEIGHT] as Int, endValues.values[PROP_HEIGHT] as Int, endValues.view ), prepareFadeInAnimator(endValues.view) ) return AnimatorSet() .apply { duration = ANIMATION_DURATION playTogether(animators) } } private fun prepareFadeInAnimator(view: View): Animator = ObjectAnimator .ofFloat(view, "alpha", 0f, 1f) .apply { interpolator = AccelerateInterpolator() } private fun prepareHeightAnimator( startHeight: Int, endHeight: Int, view: View ) = ValueAnimator.ofInt(startHeight, endHeight) .apply { val container = view.parent.let { it as View } // изменяем высоту контейнера фрагментов addUpdateListener { animation -> container.updateLayoutParams<ViewGroup.LayoutParams> { height = animation.animatedValue as Int } } // окончании анимации устанавливаем высоту контейнера WRAP_CONTENT doOnEnd { container.updateLayoutParams<ViewGroup.LayoutParams> { height = ViewGroup.LayoutParams.WRAP_CONTENT } } } private fun getViewHeight(view: View): Int { // Получаем ширину экрана val deviceWidth = getScreenWidth(view) // Попросим View измерить себя при указанной ширине экрана val widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(deviceWidth, View.MeasureSpec.EXACTLY) val heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) return view // измеряем: .apply { measure(widthMeasureSpec, heightMeasureSpec) } // получаем измеренную высоту: .measuredHeight // если View хочет занять высоту больше доступной высоты экрана, мы должны вернуть высоту экрана: .coerceAtMost(getScreenHeight(view)) } private fun getScreenHeight(view: View) = getDisplaySize(view).y - getStatusBarHeight(view.context) private fun getScreenWidth(view: View) = getDisplaySize(view).x private fun getDisplaySize(view: View) = Point().also { point -> view.context.getSystemService(Context.WINDOW_SERVICE) .let { it as WindowManager } .defaultDisplay .getSize(point) } private fun getStatusBarHeight(context: Context): Int = context.resources .getIdentifier("status_bar_height", "dimen", "android") .takeIf { resourceId -> resourceId > 0 } ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) } ?: 0}
private fun transitToFragment(newFragment: Fragment) { val currentFragmentRoot = childFragmentManager.fragments[0].requireView() childFragmentManager .beginTransaction() .apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { addSharedElement(currentFragmentRoot, currentFragmentRoot.transitionName) setReorderingAllowed(true) newFragment.sharedElementEnterTransition = BottomSheetSharedTransition() } } .replace(R.id.container, newFragment) .addToBackStack(newFragment.javaClass.name) .commit()}
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android" xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto" xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:transitionName="checkoutTransition" >
Демо проект можно найти вот тут на GitHub. А вакансию Android-разработчика (Нижний Новгород) вот здесь на Хабр Карьера.
igAnimationDatabase--igSkeletonList---igSkeleton - Скелет, который, возможно, будет использоваться нами----igSkeletonBoneInfoList-----igSkeletonBoneInfo - Нода скелета--igAnimationList---igAnimation - Наша анимация----igAnimationBindingList-----igAnimationBinding - Ссылается на igSkeleton. Служит для линковки скелета к анимации----igAnimationTrackList-----igAnimationTrack - Сам трек анимации------igEnbayaTransformSource-------igEnbayaAnimationSource--------igData - Тут уже хранятся сырые данные EnbayaigData - Нода с данными, которые уже не являются нодами.
История нашей игры началась в 2016 году с покупки приставки Apple TV. Это был очень желанный девайс, на котором я обнаружил несколько игр, в том числе детских. Одна из них была от знаменитой студии, и она настолько мне понравилась, что я сказал своей супруге и музе: Дорогая, я хочу сделать игру для нашей дочери! Она меня поддержала.
Мысль о создании своей игры или приложения появлялась у меня и раньше, но мне казалось это нереальным, просто запредельным. А в тот момент загорелся огонь, и я решил идти только вперед. Нужны ведь деньги и единомышленники, чтобы создать игру, скажете вы. Да, все верно. На тот момент я был топовым видеографом и хорошо зарабатывал, поэтому наивно думал, что мы с супругой найдем команду и будем просто финансировать и руководить проектом.
Первым делом мы должны были найти сценариста. Нам повезло, поиск
был недолгим. Мы объяснили автору свои цели и задачи, рассказали,
какие игры нам импонируют и стали с нетерпением ждать результат.
Получив на руки текст, мы были удивлены супер кратким содержанием
написанного. Оказалось, что вместо ожидаемого сценария, нам сделали
так называемую Библию игры, в которой были описаны 6 характеров
персонажей, игровой мир и практически тезисно игровой процесс,
можно сказать, идея игровой механики. Так как, оформляя заказ на
библию, в тот момент мы не понимали, чем она отличается от
сценария, это стало хорошим уроком для нас впредь обговаривать до
мелочей за что мы платим деньги.
Получив на руки этот документ, мы не совсем понимали, что с ним
делать, но содержание настолько вдохновляло, что мы приступили к
поиску следующего исполнителя, который мог бы в деталях расписать
все взаимодействия в игровом мире. В тот момент я был настолько
возбужден процессом создания игры, что у меня уже не было страха и
сомнений двигаться вперед, начало было увлекательным, но если бы я
только знал, что ждет меня впереди...
Так как много лет мы занимались видеопродакшном, то хорошо понимали, что нам необходим режиссерский сценарий, и подыскивали людей, которые могли бы компетентно справиться с этой задачей. При общении с первыми исполнителями нас поправили, что это вовсе не сценарий, а дизайн-документ. Было, конечно, не по себе от того, что мы фактически ничего не знаем о создании игр, и это стало поводом начать изучать термины и некоторые важные вещи, связанные с игровыми разработками.
При дальнейшем общении с разными исполнителями мы слышали много негатива и пессимизма по поводу наших планов, целей, многие люди говорили нам: Вы ничего не знаете, у вас ничего не выйдет, игры не делаются за короткие сроки (к слову, мы рассчитывали уложиться в 6 месяцев), на детском сегменте не заработаешь и т.д. Слыша подобные разговоры, мы понимали, что с такими людьми работать не имеет смысла, ничего творческого они нам не предложат. Все это вгоняло в депрессию, и меня стали посещать мысли, типа: кто я такой, куда я полез, это мне не под силу, нужно остановиться. Не знаю можно ли верить в гороскопы, которые говорят, что овны очень упрямые и целеустремленные, но такие черты в моем характере есть, поэтому, поразмыслив несколько дней, я решил послать всех подальше и предложил супруге самим расписать дизайн-документ, как мы расписывали сценарии для съемок.
Основываясь на сеттинге игры и характерах персонажей, мы приступили к написанию дизайн-документа, создав в нем 3 категории: визуальный ряд, механика, действия героя. Мы не знали, как это делают профи, но найденные в интернете образцы диздоков показались нам не совсем понятными для исполнителей, поэтому решили остановиться на своей схеме. Шаг за шагом мы перебирали в голове идеи как насытить игровой мир незаурядной механикой, пересмотрели много игр детской тематики, нашли в них минусы и попытались избавиться от этих минусов в сценарии своей будущей игры.
Вдохновляясь Зеленой шапочкой, Спокойной ночи, цирк, сериями игр Sago Mini, Cut the rope, мы хотели сделать что-то простое в исполнении, но при этом функциональное и красивое. Помимо анализа игр, мы нашли множество референсов для исполнителей. Выбор был сложным, попадалось много иллюстраций бэкграундов и персонажей как во flat стиле, так и full, предпочтение отдали flat стилю, потому что его можно было быстрее отрисовать.
Диздок разрастался страница за страницей, все больше менялась и дополнялась первоначальная концепция, чтобы игра была еще интереснее и сложнее на фоне других игр из детской категории. Нашей фишкой было придумать такую механику, которая позволила бы ребенку понять что нужно делать без голосовых и текстовых подсказок, плюс это позволяло сделать локализацию универсальной. Бывали ситуации, когда механика была настолько непонятной, что приходилось делать зарисовки или аппликации, и играть на бумаге.
В итоге мы написали дизайн документ на 40 страниц. Это было вау!, мы преодолели еще одну полосу препятствий. Моя упёртость меня не подвела.
Первым этапом мы решили найти иллюстратора персонажника. Базой для поиска служили сайты Behance, Illustrator.ru и Artstation. На 2016 год таких специалистов было не очень много на постсоветском пространстве, а из тех, кто нравился, многие были заняты или не походили под наш бюджет.
Наконец, мы остановились на одной армянской студии, которая предложила полный цикл разработки персонажей, начиная от скетчей до финального цветного рисунка, а также создание бэкграундов и анимацию. От них мы узнали про среду разработки Unity 3D и замечательный пакет Spine 2D для анимации будущей игры. В ходе переговоров оказалось, что их цены слишком высоки для нас, и мы остановились на паре других исполнителей. Ребята нарисовали несколько вариантов пробного персонажа и бэкраунд, но это было не то, что нам хотелось. Мы заплатили за работу, хотя по договору могли этого не делать, но сами исполнители настолько мне импонировали, что хотелось отблагодарить их за труд. В последующем я понял, что помимо отличного результата мне нужны люди, с которыми будет комфортно работать, ведь у меня появился опыт взаимодействия с разными ребятами, и, бывало, я чувствовал себя дискомфортно из-за того, что мои хотелки рубили на корню или демонстрировали звездный характер.
Мы решили снова переговорить с армянской студией и пересмотреть их полный цикл, в итоге успешно договорились на разработку персонажей и setup их в Spine 2D без анимации. С этого момента началось все самое интересное. Ребята очень понравились мне в работе и общении, мы стали получать первые скетчи слоненка и это было словно появление ребенка на свет. Скетчи были нарисованы разными художниками одной студии, и перед нами встал выбор стилистики персонажа.
Мы решили разослать варианты друзьям и знакомым, чтобы помогли определиться, в финале подключили к выбору свою 4-летнюю дочь и полностью доверились ей. Позже она активно участвовала в разработке игры в качестве главного тестировщика и все это время с нетерпением ждала релиза.
Итак, художник приступил рисовать скетчи остальных персонажей на основе выбранной стилистики. Их нужно было править, и я понял, что совсем не мог объяснить чего хочу, пришлось подключать свои способности и дорисовывать те детали, которые нужно было откорректировать. Это был интересный опыт, было скомкано очень много бумаги, потому что скетчи я поправлял карандашом. Работа была очень вдохновляющей, и мы порхали от счастья, глядя на своих милашек в карандашной технике.
Но вот мы получили цветные версии персонажей, и тут над моей головой сгустились тучи. То, что мы увидели, на мой взгляд, было ужасно.
Простите, ребята, если читаете меня, но реально персонажи в скетчах и в цвете были совершенно разными. Как оказалось, из студии ушел специалист, который отрисовывал цветные версии. Я решил посмотреть, что будет, когда они настроят setup персонажей в Spine 2D, надеялся, что все недостатки сгладятся и в анимации будут выглядеть иначе, но, увы, и этот результат меня не устроил.
Пришлось снова садиться и искать исполнителей для того, чтобы из скетчей получить красивых цветных персонажей. Это затянулось на долгие 3 месяца, я очень тщательно перебирал специалистов, просил пробные отрисовки, но все было безуспешно. Решил попробовать нарисовать персонажей в цвете сам. Я думал, что с моими навыками дизайнера это будет легко, но не тут-то было. Пришлось покупать графический планшет и осваивать его, в процессе отрисовки менять внешний вид персонажей, их пропорции и одежду, изучать уроки рисования, искать реферы, чтобы доработать картинку, и это было не просто, ведь дизайн это одно, а быть художником совсем другое, к тому же за очень маленький срок я хотел добиться нужного мне результата. Первый герой дикобраза рисовалась мучительно и долго, работать с глазами было особо тяжело, я никак не мог добиться того, чтобы она выглядела по-детски милой, плюс ко всему по ее образу я должен был рисовать всех остальных персонажей. Как только работа над ней закончилась, остальное пошло как по маслу, герой за героем.
После отрисовки встал вопрос анимации, в работу включилась моя супруга, она нарисовала для каждого героя замечательную спрайтовую мимику, которую, к сожалению, нам использовать не пришлось.
Работа над героями была закончена в конце августа, художником за лето я не стал, но со своей задачей справился. В октябре мы, наконец, нашли иллюстратора игрового мира (тоже не с первой попытки), а еще через месяц аниматора, который должен был приступить к своей работе в январе 2017. Параллельно я изучал Spine 2D, начал анимировать персонажей и предметы, но не планировал дальше этим заниматься, мне просто было интересно. К тому же эти знания помогали понять процесс работы в Spine 2D и интеграцию с Unity 3D, чтобы в дальнейшем я мог общаться с аниматором на понятном языке и ставить ему правильные задачи.
Январь 2017. Работа шла полным ходом, я продолжал заниматься бизнесом, зарабатывая деньги на игру, и параллельно был на связи с ребятами, ставя задачи и контролируя процесс.
С художником Антоном мы сразу нашли общий язык, он проникся нашим проектом и пообещал нарисовать фоны за 3-4 месяца, но, к сожалению, из-за имеющейся у него параллельной работы все растянулось примерно на 14 месяцев. Конечно, это не входило в мои планы, но работать с ним было комфортно, поэтому я решил проигнорировать этот момент. Та же самая история повторилась и с аниматором Андреем. Ох уж этот фриланс! Как легко все может выйти из под контроля! Время, к сожалению, беспощадно, и все планы закончить игру как можно скорее рушились на глазах.
В процессе работы Антон изучал каждый уровень и вносил много своих корректировок. К примеру, в диздоке было прописано, что игрок должен накачать шину для велосипеда, но Антон предложил подумать над более интересным методом (у нас же джунгли, где используются экоматериалы), и мы начали фантазировать. Первая идея: вместо шин использовать змею, долго думали как ее накачать, много смеялись, потом Антон предложил вместо змеи взять гусениц и кормить их соком тыквы, а когда они наедятся и распухнут, то просто упадут, а далее игрок перетащит их на обод колеса. Так и сделали. Этот уровень дети просто обожают.
По стилистике персонажей игра должна была быть во Flat стиле, но бэкграунды получились сложнее, и, на мой взгляд, Антону удалось гармонично совместить два разных стиля. UI решили нарисовать тоже во Flat и сделать его очень простым. С подсказками пришлось помучиться, изначально предполагалось лишь графически изобразить задание в отдельном окошке, но, как оказалось, найти и вызвать подсказку было затруднительно для игрока, поэтому решили добавить стрелочки и пальчики. В некоторых моментах и этого оказалось недостаточно, так родилась мысль добавить лампочку Эврика!, поэтому в нашей игре много самых разнообразных подсказок.
Над картой игры тоже пришлось ломать голову, нужно было гармонично разместить домики героев, чтобы создать небольшой уютный городок. Изначально для перехода на новый уровень планировалось кликать по домикам, но от этой идеи отказались, так как было не совсем понятно какой уровень пройден, а какой нет. Зато придумали доску на лианах, выпадающую на карту, где можно увидеть прогресс прохождения игры.
С аниматором Андреем было также комфортно работать, как и с Антоном. Анимацию начали с персонажей, для каждого из них нужен был такой комплект:
3 состояния ожидания действий игрока idle;
1 анимация наблюдения за действием игрока look;
1 реакция на правильное действие correctly;
1 реакция на неправильное действие wrong;
3 состояния радостных эмоций при завершении каждого задания
emotion;
около 5 анимаций, связанных с заданиями, интро, финальным
мультфильмом.
Анимированные персонажи получились очень классными, это была потрясающая работа. С предметами тоже было все хорошо, но большую часть пришлось переделывать самому при импорте в Unity 3D. Если бы работа по программированию велась одновременно с анимациями, я мог бы ставить Андрею правильные задачи, а так как работа шла вслепую, впоследствии пришлось закрыть глаза на необходимость переделок и надеяться на то, что правок будет не так много.
Параллельно с художником и аниматором я планировал работать с программистом, но поняв, что работа затягивается больше, чем на год, я начал изучать Unity, в частности, интеграцию со Spine 2D. Это был кошмар, я смотрел в монитор и хлопал глазами: что? куда? зачем? Попробовал закинуть в программу один анимированный уровень, и, когда я вывел первую анимацию на телефон без программирования, просто анимацию, над моей головой образовался ангельский нимб. Это звучит смешно, но я почувствовал себя программистом.
Вспомнились мои слова супруге о том, что если мы начнем делать игру, я буду только ставить задачи и контролировать процесс. И тут наступает момент, когда я говорю ей: Представляешь, я нашел способ сделать игру в Unity без кода! Я сейчас просто попробую собрать один уровень, чтобы дальше быть умнее в глазах программиста, на что она мне ответила: Я даже не сомневалась, что ты обязательно сделаешь что-то своими руками. Обожаю свою супругу за то, что она всегда позволяет мне двигаться вперед и творить, не тушит во мне этот огонь.
Я выбрал метод визуального программирования PlayMaker.
Просидел месяц и собрал в первом уровне один подуровень. Этот период чуть не сломил меня, хотелось бросить игру, все казалось очень сложным, в голове постоянно крутились страшные мысли, что я все соберу, а оно не будет работать. В итоге я собрался духом и сказал себе: все получится, я справлюсь, к тому же ребята радовали результатом, да и мои успехи в плане анимации и программной части с каждым этапом становились все больше, и я чувствовал себя увереннее. Очень хорошо, что я начал именно с визуального программирования, потому что вся та механика, которая была расписана в диздоке, на практике очень сильно поменялась, я даже думать стал немного по-другому. Со временем мои знания подросли настолько, что я научился писать экшены для Playmaker, необходимые, по большей части, для работы со Spine 2D.
В 2018 году пришло время писать музыку для игры, и я познакомился с замечательным композитором Геннадием. Ему понравилась наша игра, и он тоже, как говорится, вложил в нее частичку своей души. Я много лет работал в продакшн с видео и музыкой непосредственно, но, честно говоря, не мог толком объяснить какая музыка мне нужна. Мы доверились Геннадию, и он написал такие классные мелодии, которые дети напевают каждый раз, играя в Jungle Town.
Следующим этапом я начал искать исполнителей по озвучке персонажей и предметов. С 4-5 раза голоса персонажей мы утвердили, а вот со звуками не сложилось, пришлось снова взять нагрузку на себя. Я не знал как подойти к этому процессу и первым делом решил записать всю игру с музыкой, а поверх записи наложить звуки, которые скачивал в виде демо с Аudiojungle. После того, как я все смонтировал в Premiere Pro, купил используемые звуки, меня ожидал сюрприз: демо звуки шли одним файлом, а купленные отдельными файлами, пришлось искать и вручную синхронизировать нужный файл на таймлайне. Затем выяснилось, что звуки нужно экспортировать именно так, чтобы они совпадали с анимацией, иначе их не синхронизировать в Unity. Тут на моем жизненном пути повстречался еще один замечательный человек Илья, который хорошо знает C#, он помог мне написать код для Animator State в Unity. На этом эпопея со звуками не закончилась После их тестирования на мобильном устройстве, я обнаружил, что звуки скрипят, шумят, в общем, совсем не такие классные, как на компьютере. Выяснилось, что производители телефонов срезают частоты, тем самым повышая громкость звуков. Пришлось делать контроль для прослушивания звуков из Unity в реальном времени, работать с частотами, найти приемлемый вариант и применить его ко всем звукам.
Ура! На этом долгая-долгая разработка закончилась!
В феврале 2020 пришло время релиза. Были подготовлены версии для iOS и Android, при публикации в App Store возникли небольшие трудности с аналитикой в детских приложениях. Много раз игру отклоняли, но после всех исправлений и удаления аналитики нас, наконец, опубликовали.
В обоих магазинах мы подали на фичеринг, но, спустя 6 месяцев, так и не дождались его на главной странице.
Доходы были очень низкими, первые покупки делали наши друзья и знакомые, а потом все затихло. В апреле появились активные продажи, не так много, как хотелось бы, но они были. Оказалось, мы попали в фичеринг детской категории App We Love на iPad в США, пробыли там около двух месяцев, и снова остались почти без продаж.
Долгое время пытались понять, что происходит, настраивали ASO, меняли картинки, проводили эксперименты со скидками и бесплатной раздачей, строили и проверяли гипотезы. В итоге пришли к выводу, что модель премиум в нашем случае не работает. И что сделать продукт это одно, а вот продать его это совсем другое. Это целая наука, которую нам еще предстоит осваивать, прежде чем взяться за продолжение нашего замечательного Jungle Townа
Спасибо моей супруге за нашу прекрасную дочь, если бы не она, то возможно не родилась бы такая прекрасная история как Jungle town, за ее терпение и веру в меня!
На данный момент игра запущена в следующих магазинах: App Store, Mac App Store, Apple TV, Google Play, Amazon Store, HUAWEI AppGallery. [Прим. модератора: ссылки убраны, чтобы не нарушать правила. Ищите игру в сторах по названию]
Игра работает как на десктопных так и на мобильных платформах, есть адаптация под игровые приставки. В наших планах выпустить серию игр Jungle town для детишек. Мы очень надеемся, что результат наших трудов порадует вас и ваших детей.
Продолжение следует!
return p.applying(.init(translationX: 0, y: height * self.phase))
Rectangle() .fill(LinearGradient( gradient: Gradient(stops: [ .init(color: self.end, location: 0), .init(color: self.middle, location: 1 - self.middleGradientStop), .init(color: self.start, location: 1)]), startPoint: .leading, endPoint: .trailing)) .frame(width: self.gradientLength)
Rectangle() .fill(RadialGradient( gradient: Gradient(stops: [ .init(color: self.start, location: 0), .init(color: self.middle, location: self.middleGradientStop), .init(color: self.end, location: 1)]), center: .bottomTrailing, startRadius: self.topRadius, endRadius: self.topRadius + self.gradientLength) ) .frame(height: self.topRadius)
Rectangle() .fill(AngularGradient( gradient: Gradient(stops: [ .init(color: self.end, location: 0), .init(color: self.middle, location: 1 - self.angularGradientMiddleStop(blockWidth: geometry.size.width)), .init(color: self.start, location: 1)]), center: .bottomLeading, startAngle: self.directionTo(gradientPart: self.start, blockWidth: geometry.size.width), endAngle: Angle(degrees: 360)))... func directionTo(gradientPart: Color, blockWidth: CGFloat) -> Angle{ let angleOf = gradientAngles(blockWidth: blockWidth) var angle = Angle.zero switch gradientPart{ case start: angle = angleOf.start case middle: angle = angleOf.middle case end: angle = angleOf.end default: fatalError("there is no gradient stop with that color: \(gradientPart)") } return angle } func gradientAngles(blockWidth: CGFloat) -> (start: Angle, middle: Angle, end: Angle){ let blockHeight = self.bottomRadius let center = CGPoint(x: 0, y: blockHeight) let topRight = CGPoint(x: blockWidth, y: blockHeight - self.gradientLength) let topGradientStarts = CGPoint(x: blockWidth, y: blockHeight - self.gradientLength * (1 - self.middleGradientStop)) let startAngle = center.radialDirection(to: topRight) let middleAngle = center.radialDirection(to: topGradientStarts) let endAngle = Angle(degrees: 360) return (start: startAngle, middle: middleAngle, end: endAngle) }...extension CGPoint{ func radialDirection(to point: CGPoint) -> Angle{ let deltaX = point.x - self.x let deltaY = point.y - self.y var angle = Angle(degrees: 0) if deltaX == 0{ if deltaY > 0{ angle = Angle(degrees: 90) }else{ angle = Angle(degrees: 270) } }else if deltaY == 0{ if deltaX > 0{ angle = Angle(degrees: 0) }else{ angle = Angle(degrees: 180) } }else if deltaX > 0 && deltaY > 0{ angle = Angle(radians: atan(Double(deltaY / deltaX))) }else if deltaX > 0 && deltaY < 0{ angle = Angle(degrees: 270) + Angle(radians: atan(Double(deltaX / -deltaY))) }else if deltaX < 0 && deltaY > 0{ angle = Angle(degrees: 90) + Angle(radians: atan(Double(-deltaX / deltaY))) }else if deltaX < 0 && deltaY < 0{ angle = Angle(degrees: 180) + Angle(radians: atan(Double(deltaY / deltaX))) } return angle }}
начальный цвет 0
промежуточный цвет 0.3
конечный цвет 1
SwiftUI only resolves it to a concrete value just before using it in a given environment., но что имеется в виду под environment: цвет стенки за спиной пользователя, или тема оформления IOS непонятно. Если вам это не подходит, используйте UIColor изначально.
.onAppear()
путем
изменения @State
переменной. В результате, модификатор
.rotationEffect()
подписывается на получение
animatableData в промежутке от было к стало, согласованных с
системным таймером. Мы говорили об этом в прошлой части.struct AnimatedRectObservedObject: View{ @State var angle: Double = 0 var body: some View{ return VStack{ Spacer() Rectangle() .fill(Color.green) .frame(width: 200, height: 200) .rotationEffect(Angle(degrees: angle)) .animation(Animation.linear(duration: 1).repeatForever(autoreverses: false)) Spacer() } .onAppear{ self.angle += 90 } }}
struct AnimatedRectStopButton: View{ @State var angle: Double = 0 @State var animation = Animation.linear(duration: 1).repeatForever(autoreverses: false) var body: some View{ VStack{ Spacer() Rectangle() .fill(Color.green) .frame(width: 200, height: 200) .rotationEffect(Angle(degrees: angle)) .animation(animation) Spacer() Text("toggle Animation").onTapGesture { if self.angle == 90{ self.angle = 0 self.animation = .default }else{ self.angle = 90 self.animation = Animation.linear(duration: 1).repeatForever(autoreverses: false) } } } .onAppear{ self.angle = 90 } }}
@State
переменную типа
вкл/выкл:struct AnimatedRectStopButton: View{ @State var isStarted = false let animation = Animation.linear(duration: 1).repeatForever(autoreverses: false) var body: some View{ VStack{ Spacer() Rectangle() .fill(Color.green) .frame(width: 200, height: 200) .rotationEffect(Angle(degrees: isStarted ? 90 : 0)) .animation(isStarted ? animation : .default) Spacer() Text("toggle Animation").onTapGesture { self.isStarted.toggle() } } .onAppear{ self.isStarted.toggle() } }}
.onAppear{}
вызывается системой, а
onTapGesture{}
, понятно пользователем. Однако, как
быть, если вы хотите инкапсулировать всю анимацию внутри одной
View, передавая в нее лишь вкл/выкл? SwiftUI не предполагает
возможности из родительской view каких-то методов дочерних.
Теоретически, вы можете хранить дочернюю View как структуру, и
вызывать ее mutating-методы, но вот @State
переменные
дочерних View таким образом поменять не получится, я пробовал не
работает. Единственный способ сделать что-то подобное, это
воспользоваться PassthroughSubject из Combine, как это и сделали в
упомянутой статье.@State
это внутреннее состояние View, и не
пытаться манипулировать им извне, то правильное решение окажется
очень простым:struct AnimatedRectStopButtonFromOutside: View{ var isStarted: Bool let animation = Animation.linear(duration: 1).repeatForever(autoreverses: false) var body: some View{ VStack{ Spacer() Rectangle() .fill(Color.green) .frame(width: 200, height: 200) .rotationEffect(Angle(degrees: isStarted ? 90 : 0)) .animation(isStarted ? animation : .default) Spacer() } }}struct AnimatedRectParentView: View{ @State var isOn = false var body: some View{ VStack{ AnimatedRectStopButtonFromOutside(isStarted: isOn) Text("toggle Animation").onTapGesture { self.isOn.toggle() } }.onAppear(){ self.isOn = true } }}
@State
подписка на
обновление View внутри дочерней view она и так обновляется целиком
при изменении внешнего для нее параметра. Иногда это не удобно.
Иногда мы не хотели бы лишний раз инициализировать дочернюю view
например, чтобы не сломать анимацию, или в init() происходят
какие-то сложные и ресурсоемкие вычисления (запросы например). В
этих случаях лучше пользоваться объектными сущностями, за
изменениями которых можно следить с помощью модификатора
onReceive.struct AnimatedRect: View{ @State var startTime: Date = Date() @State var angle: Double = 0 @State var internalStarted: Bool let externalStarted = true var animation: Animation = Animation.linear(duration: 1).repeatForever(autoreverses: false) init(started externalStarted: Bool){ self.externalStarted = externalStarted self._internalStarted = State(initialValue: !externalStarted)//forse to start animation } var body: some View{ //thats wrong. It just hiding a problem from SwiftUI not solving it DispatchQueue.main.async { if self.internalStarted && self.externalStarted == false{ // print("stop animation") let timePassed = Date().timeIntervalSince(self.startTime) let fullSecondsPassed = Double(Int(timePassed)) let currentStage = timePassed - fullSecondsPassed self.internalStarted = false let newAngle = self.angle - 90 + currentStage * 90 self.angle = newAngle }else if self.internalStarted == false && self.externalStarted { // print("start animation") self.startTime = Date() self.internalStarted = true self.angle += 90 } } return VStack{ Rectangle() .fill(Color.red) .frame(width: 200, height: 200) .rotationEffect(Angle(degrees: angle)) .animation(internalStarted ? animation : .default) Spacer() } }}
@State
переменной угла поворота именно это значение.@State
параметрам для получения
предыдущего времени старта анимации. Это фишка @State
переменных. Внутри init вы можете установить лишь начальное
состояние этой переменной, но по окончании инициализации, если View
уже существовала до init(), значение @State
переменных
будет восстановлено.@State
var
internalStarted, с помощью которого мы управляем непосредственно
анимацией.@State
переменных, выдает
Modifying state during view update, this will cause undefined behavior.и блокирует такое изменение. Тогда я пошел на грязный хак, и использовал DispatchQueue.main.async. Но давайте разберемся, почему же это грязный хак, и почему так делать не следует никогда?
@State
переменная, то мы получим бесконечный цикл. В
момент рендера мы делаем View в памяти не актуальной ведь мы
изменили исходные данные для отрисовки. Значит, сразу по окончании
отрисовки наша View попадет в очередь на повторный рендер, но и
тогда мы тут же снова сделаем ее неактуальной. Мы своими руками
создаем бесконечный цикл. Асинхронный вызов в данном случае вообще
ничего не меняет. Он лишь немного сдвигает инициирование очередного
витка на момент сразу после рендера. Таким образом, асинхронный
вызов не решает проблему, а лишь маскирует ее, не давая SwiftUI
ткнуть нас в нее носом. Это как в автомобиле лампочку check engine
на приборке обрывать.struct AnimatedRectObservedObject: View{ @State var startTime: Date = Date() @State var angle: Double = 0 @ObservedObject var animationHandler: AnimationHandlerTest let animation: Animation = Animation.linear(duration: 1).repeatForever(autoreverses: false) init(animationHandler: AnimationHandlerTest){ self.animationHandler = animationHandler } var body: some View{ VStack{ Rectangle() .fill(Color.green) .frame(width: 200, height: 200) .rotationEffect(Angle(degrees: angle)) .animation(animationHandler.isStarted ? animation : .default) }.onReceive(animationHandler.objectWillChange){ let newValue = self.animationHandler.isStarted if newValue == false{ let timePassed = Date().timeIntervalSince(self.startTime) let fullSecondsPassed = Double(Int(timePassed)) let currentStage = timePassed - fullSecondsPassed let newAngle = self.angle - 90 + currentStage * 90 withAnimation(.none){//not working:((( self.angle = newAngle } }else { self.startTime = Date() self.angle += 90 } } .onAppear{ self.angle += 90 self.startTime = Date() } }}
.animation(animationHandler.isStarted ? animation : .default)
.animation(animationHandler.isStarted ? animation : Animation.linear(duration: 0))
@State
переменная, отвечающая за текущее
положение анимации. При состоянии 0 первая волна будет в самом
начале, а последняя в самом конце. Сами волны будут накладываться
друг на друга. Таким образом, длина видимой части каждой волны
будет зависеть от количества волн. Я реализовал это с помощью
ZStack, в котором перечислены все волны, и собственным
модификатором .wavePosition, внутри которого я вычисляю текущее
положение каждой волны в данный момент анимации, и порядок их
наложения друг на друга.struct SharpWavePosition: AnimatableModifier { let wave: WaveDescription let animationHandler: AnimationHandler var time: CGFloat var currentPosition: CGFloat public var animatableData: CGFloat { get { time} set { self.time = newValue let currentTime = newValue - CGFloat(Int(newValue)) self.currentPosition = SharpWavePosition.calculate(forWave: wave.ind, ofWaves: wave.totalWavesCount, overTime: currentTime) } } init(wave: WaveDescription, time: CGFloat, animationHandler: AnimationHandler){ self.wave = wave self.time = time self.animationHandler = animationHandler self.currentPosition = 0 } static func calculate(forWave: Int, ofWaves: Int, overTime: CGFloat) -> CGFloat{ let time = overTime - CGFloat(Int(overTime)) let oneWaveWidth = CGFloat(1) / CGFloat(ofWaves) let initialPosition = oneWaveWidth * CGFloat(forWave) let currentPosition = initialPosition + time let fullRounds = Int(currentPosition) var result = currentPosition - CGFloat(fullRounds) if fullRounds > 0 && result == 0{ // at the end of the round it should be 1, not 0 result = 1 } // print("wave \(forWave) in time \(overTime) was at position \(result)") return result } func body(content: Content) -> some View { let oneWaveWidth = CGFloat(1) / CGFloat(wave.totalWavesCount) var thisIsFirstWave = false if currentPosition < oneWaveWidth{ thisIsFirstWave = true } return Group{ content .offset(x: -wave.width + currentPosition * (wave.width + wave.gradientLength), //to watch how waves move uncoment this // y: CGFloat(self.waveInd * 20)) y:0) .zIndex(-Double(currentPosition)) .transition(.identity) .animation(nil) if thisIsFirstWave{ content .offset(x: wave.gradientLength, y: 0) .zIndex(-2) .transition(.identity) .animation(nil) } } }}extension View{ func positionOfSharp(wave: WaveDescription, inTime: CGFloat, animationHandler: AnimationHandler) -> some View { return self.modifier(SharpWavePosition(wave: wave, time: inTime, animationHandler: animationHandler)) }}struct SharpRainbowView: View{ let waves: [SharpGradientBorder] var animation: Animation = Animation.linear(duration: 1).repeatForever(autoreverses: false) //@ObservedObject var animationHandler: AnimationHandler @State var rainbowPosition: CGFloat = 0 init(animationHandler: AnimationHandler, backgroundColor: Color = .clear ){ self.animationHandler = animationHandler let bottomRadius = animationHandler.waveGeometry.bottomRadius let topRadius = animationHandler.waveGeometry.topRadius let gradientLength = animationHandler.waveGeometry.gradientLength let rainbowColors = animationHandler.rainbowColors guard var lastColor = rainbowColors.last else {fatalError("no colors to display in rainbow")} var allWaves = [SharpGradientBorder]() for color in rainbowColors{ let view = SharpGradientBorder(start: color, end: lastColor, bottomRadius: bottomRadius, topRadius: topRadius, gradientLength: gradientLength) allWaves.append(view) lastColor = color } self.waves = allWaves } var body: some View{ GeometryReader{geometry in VStack{ ZStack{ ForEach(self.waves.indices, id: \.self){ind in self.waves[ind] .positionOfSharp(wave: WaveDescription(ind: ind, totalWavesCount: self.waves.count, width: geometry.size.width, baseColor: self.waves[ind].end, gradientLength: self.waves[ind].bottomRadius + self.waves[ind].topRadius), inTime: self.rainbowPosition, animationHandler: self.animationHandler) .animation(self.animationHandler.isStarted ? self.animation : .linear(duration: 0)) } } // .clipped() } } .onAppear(){ if self.animationHandler.isStarted{ self.rainbowPosition = 1 } } .onReceive(animationHandler.objectWillChange){ let newValue = self.animationHandler.isStarted if newValue == false{ let newPosition = self.animationHandler.currentAnimationPosition print("animated from \(self.rainbowPosition - 1) to \(self.rainbowPosition) stopped at \(newPosition)") self.rainbowPosition = newPosition }else { self.rainbowPosition += 1 } } }}
@State
параметров в блок withAnimation{} прописываем определенную анимацию
в текущем потоке, а затем выполняем изменение какой-то
@State
переменной. В этом случае withAnimation{}, это
своего рода эквивалент транзакции, и все изменения выполненные в
этой транзакции будут анимированы. В результате, внутри этого же
потока запускается цикл трансляции этих изменений во все зависимые
View, модификаторы этих View получают новое значение, и
подписываются на получение промежуточных значений
AnimatableData. func getActualPosition(of position: CGFloat) -> CGFloat{ let correctPosition = max(min(position, 1), 0) / duration let trimmingCurve = TimingCurve.superEaseInPath if correctPosition < 0.0000001{ let reversedCurve = Path(UIBezierPath(cgPath: trimmingCurve.cgPath).reversing().cgPath) //trim to start point is impossible, so reverce the curve and get last point guard let point = reversedCurve.currentPoint else{fatalError("cant get current timing curve start point")} return point.y } guard let point = trimmingCurve.trimmedPath(from: 0, to: correctPosition).currentPoint else{fatalError("cant get current timing curve point at \(position)")} return point.y * self.duration }
self.timing = TimingCurve.superEaseIn(duration: 1)let animatedPosition = timing.getY(onX: currentPosition)
let animation = Animation.timingCurve(Double(TimingCurve.control.point1.x), Double(TimingCurve.control.point1.y), Double(TimingCurve.control.point2.x), Double(TimingCurve.control.point2.y), duration: 1)
@State
переменные внутри блока body. Нам не нужно
инициировать что-либо при изменении состояния анимации. Но вот если
нам потребуется, мы сможем узнать состояние анимации в любой
момент. public var animatableData: CGFloat { get { time} set { if animationHandler.isStarted{ self.time = newValue if self.time != self.animationHandler.currentAnimationPosition{ self.animationHandler.currentAnimationPosition = self.time } } let currentTime = newValue - CGFloat(Int(newValue)) self.currentPosition = SharpWavePosition.calculate(forWave: wave.ind, ofWaves: wave.totalWavesCount, overTime: currentTime) if currentPosition < 0.01{ animationHandler.currentWaveBaseColor = wave.baseColor } }
//@ObservedObject var animationHandler: AnimationHandler
Text("toggle animation").onTapGesture { withAnimation(){ var delay: Double = 0 if self.isShown{ let waveChangeTime: Double = Double(1) / Double(self.animationHandler.rainbowColors.count) let currentTime = Double(self.animationHandler.currentAnimationPosition) let wavesPassed = Double(Int(currentTime / waveChangeTime)) delay = (wavesPassed + 1) * waveChangeTime - currentTime delay = max(delay - 0.05, 0) print("currentTime: \(currentTime); delay \(delay)") } DispatchQueue.main.asyncAfter(deadline: .now() + delay) { self.isShown.toggle() } } }
struct StatusBarHider: View{ var isShown: Bool @State var internalIsShown = true var body: some View{ if isShown == false && self.internalIsShown == true{ DispatchQueue.main.asyncAfter(deadline: .now() + 0.7){ self.internalIsShown = self.isShown } }else if isShown == true && self.internalIsShown == false{ DispatchQueue.main.async(){ self.internalIsShown = self.isShown } } return Spacer() .statusBar(hidden: internalIsShown) .animation(Animation.linear(duration: 0.3)) }}
@State
переменной. Логика в целом та же, есть
параметр структуры, который передается извне, и @State
переменная, которая модифицируется спустя какое-то время, вызывая
исчезновение или появление статусбара.struct StatusBarFrame: EnvironmentKey { static var defaultValue: CGRect { CGRect() }}extension EnvironmentValues { var statusBarFrame: CGRect{ get { return self[StatusBarFrame.self] } set { self[StatusBarFrame.self] = newValue } }}
.environment(\.statusBarFrame, statusBarFrame)
@Environment(\.statusBarFrame) var statusBarframe: CGRect
pressed
, enabled
,
windows focused
, checked
и т. д. Если мы
не объявляем состояние для drawable, то подразумевается, что это
состояние по умолчанию в Android.res/drawable
.
<item android:drawable="@drawable/i" android:state_pressed="true"/>
<selector> <item android:drawable="@drawable/p" android:state_pressed="true"/> <item android:drawable="@drawable/default"/></selector>
<View android:layout_width="50dp" android:layout_height="50dp" android:foreground="@drawable/state_list_drawable" android:clickable="true"/>
Но погодите секундочку. Clickable? Зачем мы добавили этот атрибут? Нам что, еще и его добавлять нужно? Да. Но только для пользовательских вью. Чтобы это выяснить, нужно время. Кнопки отлично работают без добавления clickable, потому что они по умолчанию clickable. Но если вы хотите использовать StateListDrawable для View, ImageView, Custom View и т. д., вам необходимо добавить атрибут clickable.
(Lollipop имеет небольшую неприятную функцию, называемуюstateListAnimator
, которая обрабатывает высоту кнопок, производя тени.
УдалитеstateListAnimator
, чтобы избавиться от теней.
У вас есть несколько вариантов как сделать это:
В коде:
button.setStateListAnimator(null);)
class MyObject{ private int x; public int getX() { return x; } public void setX(int x) { this.x = x; }}
ValueAnimator
. Вы можете использовать ObjectAnimator,
если для вас больше подходит следующие:
<selector> <item android:state_pressed="true"> <objectAnimator android:duration="200" android:propertyName="translationZ" android:valueTo="6dp" android:valueType="floatType" /> </item> <item> <objectAnimator android:duration="200" android:propertyName="translationZ" android:valueTo="0dp" android:valueType="floatType"/> </item> </selector>
<
objectAnimator>
-ов в
<
set>
. Изменим еще одно свойство
View. Scale X и Scale Y.animator.xml
. Здесь вы можете найти больше информации
об использовании ObjectAnimator.Это наш первый пост на Habr и он, скорее всего, не будет полезен профессиональным аниматорам так как они делают анимации на завтрак, обед и ужин. Думаю их этим не удивить. Так же в посте отсутствует мат.часть ибо писал не для того чтобы учить кого-то. Пост в целом для того чтобы показать обобщенное направление работы и полученный нами результат.
Возникла необходимость сделать анимацию персонажа для игры, которую мы делаем на коленке". Чем нам не угодили mocap - анимации и анимации с mixamo.com:
нужно искать наиболее подходящие анимации
нужно много анимаций
визуально анимации должны сочетаться
очень трудно сделать что-то качественное из разношерстного набора анимационных сэмплов
Побираясь, как бездомный в чьем-то ведре, в поисках нужных анимаций в течении недели, мне удалось собрать некого Франкенштейна. Именно Франкенштейна потому, что анимаций надыбал отовсюду. Ходил персонаж как офисный работник, крался как эльф 80-го уровня, приседал как человек-паук. Шучу, все было не так уж и ужасно, конечно, для обывателя может и пойдет, а вот меня все же неустраивало разношерстность анимаций. Хотя блендинг и прочие процедурные фишки сильно улучшали дело... Да и ноги не прилипали к земле как надо. Меня это жутко раздражает, когда анимация персонажа не на 100% соответствует тому, что он делает, ноги проходят сквозь пол, руки сквозь стены... ну вы поняли, 21-й век как никак.
Что важно для анимации персонажа: нужно передать ощущение того как персонаж передвигается с учетом физики. Короче, анимация нужна процедурная. Это крайне важно для нашего 3D пазла от первого лица.
Итак поехали. Обобщенно задача состоит в разработке системы анимации персонажа таким образом, чтобы можно было относительно гибко настраивать анимацию под свои нужды (корче, еще раз, чтобы анимация была процедурной). Для реализации этого, нужно знать что есть инверсная кинематика и уметь ее реализовывать. Погуглив и ознакомившись собрал простейшую IK, подключил пару костей. Работает. Добавили ограничения, чтобы локти не выворачивались в другие стороны и чтобы кости были похожи на кости. Сделано.
Далее, настало время для машинного обучения. Подключаем обучалку к ногам и обучаем ноги ходить, а руки крутиться. На Github был очень хороший пример с какими-то бегающими человечками с разным количеством ног. Этот проект лег в основу. Далее не помню как закончил этот адский ад.
В итоге после 2-х недель возни по 12 часов со всей этой ерундой у меня на столе лежал набор как-то дергающихся тестовых конечностей. Кстати, некоторые видео сохранил. Тогда я не думал что решу писать об этом пост:
Отлично, цепляем все к телу, еще немного IK и получаем что-то типа (одно из приближений):
Обратите внимание на лодыжку. C ней вечно были проблемы. Дело в том, что наш робот сделан инженерами-конструкторами и соединения должны гнуться строго по оси и никак иначе. Хотя это далеко не последний вариант робота, но в целом все примерно так. (Спойлер: соединение ложыжки мы все же переделали. Инженеры поставили его на 3 гидропривода, что дало нужное число степеней свободы).
Далее используем наш ИИ для ноги. Не зря же нога у нас обучалась ходить сама по себе без тела XD Подключаем ИИ к ногам и говорим им болтаться:
А теперь ногам приказываем ходить. Здесь нет отклика от пола, ноги не воспринимают коллайдер пола. Иначе роботу на самом деле пришлось бы пойти.
Ну вот все что сделано выглядит вроде и ничего, по отдельности. Когда я соединил все в одно тело, то понял: емое Позиция ноги, руки, головы (короче, каждой части тела) должна вносить изменение в позицию всего тела и следовательно влиять на другие конечности. Иначе робот ходил как деревянный буратино. Ничего не поделаешь, видать именно так ходят "идеальные" машины лишенные уникальности (уровень робота "Вертер" достигнут!):
Повозившись еще с инверсной кинематикой и мозгами всех частей тела, все же удалось сделать анимацию более естественной.
Здесь экспериментирую с тем как робота придавливает плита. Робот должен корректно анимировать позицию своего тела:
На вторые руки не обращайте внимания. Сделал доп.пару рук чисто для себя, для сравнения того как разные алгоритмы будут обрабатывать стену.
Что хотелось бы добавить. Полностью избежать использования mocap анимации не удалось. Почему так? Дело в том, что роботу нужна индивидуальность, стиль перемещения. именно для этого ему даются наборы анимаций с которых он перенимает пластику движения и использует ее при расчете процедурной анимации перемещения. Как-то так.В тестах использовалась модель-аналог робота Федора. Извините, это неточная копия. Чертежей не было, собрали "на глаз" :D
Ссылки на наши некоторые наработки по игре (в виде скетчей):