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

в 6:32, , рубрики: as3, tricks, метки: ,

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

Автор: bukt

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js