Однажды меня попросили разобраться с багом: при смене 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 и прочий мультифилд-твин. Но к данной задаче это не относится, а потому оставим в качестве домашнего задания.