Математика флешевого Number при твининге DisplayObject

    Однажды меня попросили разобраться с багом: при смене frameRate в произвольном количестве вложенных .swf начинал странно вести себя самописный «твинер» — класс, который интерполирует некоторое значение на заданное время. Вместо своей нормальной деятельности, твинер мог перескакивать значения, мог залипать на каком-то одном, а иногда просто в произвольный момент времени задавать переменной её конечное значение и отчитываться о завершении своей работы. Просящий связывал проблему именно с многоуровневой вложенностью и несовпадении собственного и родительского fps.

    Я попытался написать код твинера с нуля и оказалось, что мой вариант тоже ведёт себя странно, несмотря на то, что уровень был всего один и fps был постоянен. В процессе решения задачи я узнал пару замечательных трюков flash, коими и тороплюсь поделиться.

    Казалось бы, какие тут могут быть сложности? В конструктор собственного твинера передаём ссылку на объект, параметр, который надо бы менять, конечное значение и время/кадры, за которое этот параметр должен плавно принять конечный вид. Для простоты возьмём случай покадрового изменения значения:

    public class SomeTweener {
    	private var _obj:Object;
    	private var _paramName:String;
    	private var _endValue:Number;
    	private var _frames:Number;
    	
    	public function SomeTweener(obj:Object, paramName:String, endValue:Number, frames:Number) {
    		_obj = obj;
    		_paramName = paramName;
    		_endValue = endValue;
    		_frames = frames;
    	}
    }

    Да, самым простым способом определить, когда настало время обновить значение, будет подписка на событие Event.ENTER_FRAME у передаваемого в конструктор DisplayObject. Однако мы не ищем лёгких путей и делаем твинер универсальным. То есть таким, который меняет параметры не только DisplayObject'а. Поэтому придётся использовать ещё одну недокументированную малоизвестную особенность as3:

    Любой DisplayObject, даже не добавленный в DisplayList исправно получает событие входа на кадр.


    Меняем конструктор:

    import flash.display.Shape;
    import flash.events.Event;
    import flash.events.IEventDispatcher;
     
    public class SomeTweener {
    	private var _obj:Object;
    	private var _paramName:String;
    	private var _endValue:Number;
    	private var _frames:Number;
    	private var eventDispatcher:IEventDispatcher;
     
    	public function SomeTweener(obj:Object, paramName:String, endValue:Number, frames:Number) {
    		_obj = obj;
    		_paramName = paramName;
    		_endValue = endValue;
    		_frames = frames;
    		eventDispatcher = new Shape();
    		eventDispatcher.addEventListener(Event.ENTER_FRAME, tween);
    	}
     
    	private function tween(e:Event):void {
    	}
    }

    Можете проверить сами. Метод tween будет исправно вызываться с частотой fps.

    Идём дальше. Опять же по наипростейшему пути — каждый вызов tween мы меняем переданное значение на одну и ту же величину (твининг типа easeNone — то есть равномерный). Для этого лучше ещё в конструкторе рассчитать покадровый инкремент, исходя из разницы между конечным и стартовым значениями и продолжительностью твина, и записать инкремент в поле класса. В самом методе tween мы будем проверять, сколько кадров твининг уже длится и по достижении заданного значения — прерывать твининг:

    import flash.display.Shape;
    import flash.events.Event;
    import flash.events.IEventDispatcher;
     
    public class SomeTweener {
    	private var _obj:Object;
    	private var _paramName:String;
    	private var _endValue:Number;
    	private var _frames:Number;
    	private var eventDispatcher:IEventDispatcher;
    	private var increment:Number;
     
    	public function SomeTweener(obj:Object, paramName:String, endValue:Number, frames:Number) {
    		_obj = obj;
    		_paramName = paramName;
    		_endValue = endValue;
    		_frames = frames;
    		increment = (endValue - Number(obj[paramName])/frames;
    		eventDispatcher = new Shape();
    		eventDispatcher.addEventListener(Event.ENTER_FRAME, tween);
    	}
     
    	private function tween(e:Event):void {
    		if (_frames == 0) {
    			e.currentTarget.removeEventListener(e.type, tween);
    			return;
    		}
    		obj[paramName] += increment;
    		_frames--;
    	}
     
    }


    Нет ничего страшного в том, чтобы использовать frames как счётчик оставшихся «тиков» твининга: мы договорились не использовать секунды в качестве времени твина. Более того, кроме как в конструкторе параметр frames нигде не используется и никто (даже мы) не станет нас обвинять в том, что мы меняем переданную по значению переменную в своих целях.

    Казалось бы — всё, твинер готов. И он даже будет работать, а в большинстве случаев — ещё и правильно. Но есть один случай, когда он ни в коем случае не выдаст нормального результата. Приведу небольшой пример:

    import flash.display.Sprite;
    import flash.events.Event;
     
    public class SomeClass extends Sprite {
    	private var sprite:Sprite;
    	public function SomeClass() {
    		sprite = new Sprite();
    		sprite.x = 1;
    		sprite.addEventListener(Event.ENTER_FRAME, traceSome);
    		var tween:SomeTweener = new SomeTweener(sprite, 'x', -1, 40);
    	}
    	private function traceSome(e:Event):void {
    		trace(sprite.x);
    	}
    }


    Что нам даст трейс в результате вызова этого кода? По логике вещей — столбец из сорока чисел уменьшающихся с шагом в 0.05. На практике же у любого DisplayObject есть ещё как минимум одна недокументированная особенность: его координаты (а возможно и некоторые другие свойства) всегда кратны 0.05. Попытка присвоить им некратное значение провалится: при следующей отрисовке оно будет округлено до ближайшего к нулю кратного. В данном конкретном примере этот эффект не должен нам угрожать (на самом деле — он проявляется во всей красе), но, к примеру, увеличив значение кадров, в течение которых должен проявиться твининг, до 80, мы получим инкремент равный 0.025 и трейс «зависнет» на нуле, так никогда и не достигнув -1.

    Есть и другая особенность. В среде исполнения FlashPlayer тип Number является 64х битным числом с плавающей запятой. Из-за этого достаточно часто случаются накладки. Проще всего объяснить на примере:

    trace(String(-.35 - .05)) // 0.39(9)97


    Так ведёт себя флеш со всеми значениями типа Number (к коим относятся и поля координат DisplayObject), тут уж ничего не поделаешь. Вполне естественно, что пример работы метода traceSome нашего класса SomeClass будет сбоить даже при твининге на 40 кадров. Практика показала, что sprite.x не сможет сдвинуться именно со значения -0.35 будучи каждый кадр до него округлённым. Цикл таков:
    1. Берём значение поля объекта (-0.35)
    2. Наращиваем его на значение инкремента (-0.35 + (-0.05) = -0.39(9)97)
    3. Записываем его в поле объекта (-0.39(9)97)
    4. (Скрытый обязательный пункт) Значение поля координаты экземпляра DisplayObject округляется (-0.35)
    5. Входим на следующий кадр и берём значение поля объекта (-0.35)
    6. GOTO 2

    Избавиться от скрытого четвёртого пункта невозможно. Однако ошибка, вызванная симбиозом математики Number и ограничениями значений координат DisplayObject обходится нами ровно тремя лишними строками. Для этого необходимо изменить первый и пятый пункт цикла и вместо перманентно «портящегося» хранилища значения, завести своё:

    import flash.display.Shape;
    import flash.events.Event;
    import flash.events.IEventDispatcher;
     
    public class SomeTweener {
    	private var _obj:Object;
    	private var _paramName:String;
    	private var _endValue:Number;
    	private var _frames:Number;
    	private var eventDispatcher:IEventDispatcher;
    	private var increment:Number;
    	private var currentValue:Number;
     
    	public function SomeTweener(obj:Object, paramName:String, endValue:Number, frames:Number) {
    		_obj = obj;
    		_paramName = paramName;
    		_endValue = endValue;
    		_frames = frames;
    		currentValue = Number(obj[paramName]);
    		increment = (endValue - Number(obj[paramName])/frames;
    		eventDispatcher = new Shape();
    		eventDispatcher.addEventListener(Event.ENTER_FRAME, tween);
    	}
     
    	private function tween(e:Event):void {
    		if (_frames == 0) {
    			e.currentTarget.removeEventListener(e.type, tween);
    			return;
    		}
    		currentValue += increment;
    		obj[paramName] = currentValue;
    		_frames--;
    	}
     
    }


    Казалось бы, при чём тут MVC? :-)

    P.S.: Да, существующий код твинера можно и нужно улучшать. Добавить отложенный запуск, рассылку сообщений. Можно добавить easing и прочий мультифилд-твин. Но к данной задаче это не относится, а потому оставим в качестве домашнего задания.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 12

      +8
      Подход с хранимым значением currentValue — костыль. При достаточно большом количестве промежуточных кадров и при каждом инкременте будет накапливаться ошибка. Поэтому твинер должен рассчитывать значение параметра каждый раз через интерполятор как функцию от процента завершенности анимации. Это сразу решает все проблемы и убирает подобные костыли.
        0
        Согласен, что у автора в данном случае получился костыль, т.к. мы знаем конец и начало.
        Но мне интересно, а как быть без этого костыля (без отдельного хранения координаты), когда мы не знаем конца анимации? Например движение шарика, которым управляет игрок.
          0
          Есть такая парадигма, что данные от представления хранятся отдельно. То есть координаты объекта храниться будут не в Sprite а в отдельной переменной. И все расчеты будут вестись с этой переменной, а при обновлении кадра Sprite просто будет брать значение из переменной.
            0
            Да, я на это и намекал как раз, что без дополнительной переменной все равно не обойтись
        0
        >> Любой DisplayObject, даже не добавленный в DisplayList исправно получает событие входа на кадр.

        Разумется получает! Что значит «недокументированная особенность»? Нигде и не было сказано, что это событие не получают не добавленные в дисплейлист объекты. Я, честно говоря, удивляюсь, как можно было писать что-то на as3 и не знать этой «особенности».

        Вот цитата из документации:
        «Dispatched when the playhead is entering a new frame. If the playhead is not moving, or if there is only one frame, this event is dispatched continuously in conjunction with the frame rate. This event is a broadcast event, which means that it is dispatched by all display objects with a listener registered for this event.»
          0
          Случайно обрезал самую важную часть:
          «Note: This event has neither a „capture phase“ nor a „bubble phase“, which means that event listeners must be added directly to any potential targets, whether the target is on the display list or not
          –2
          Да уж, что возьмешь с флеш-программистов :) очевидно, что параметры объекта надо не инкрементить, а вычислять по формуле value = initial_value + change_speed * (time — start_time). Я не знаю, насколько во флеше гарантируется стабильность частоты вызова ENTER_FRAME, возможно, надо брать время, возможно, достаточно брать номер текущего кадра.

          Но, конечно, сокруглением до 0.05, флеш выкидывает интересные коленца.

          • НЛО прилетело и опубликовало эту надпись здесь
              0
              -Доктор когда я делаю ТАК, у меня там что-то щелкает.
              -А вы не делайте ТАК.
              -Как же не делать? Оно же щелкает!
                +1
                Флеш не гарантирует 100% точность частоты вызовов ENTER_FRAME, метод изначально не правильный.
                  0
                  Верно. Именно поэтому для критичных перемещений используется коррекция ENTER_FRAME эвента по таймеру.
                  +1


                  Сократил статью.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое