Pull to refresh

Солнечная система на LibCanvas

Reading time10 min
Views54K


Вчера был отличный топик, "История одного хабраспора", о создании «галактики» на HTML5 Canvas, который сам по себе да своими комментариями вдохновил меня на ответный код. Думал, до окончания документации, не писать новых вещей на Хабру, но, как видите, сорвался) Спасибо kibizoidus за это.

В топике вы увидите описание процесс создания звёздной системы на последней версии LibCanvas. Быстро, оптимизированно, кратко.

Сразу скажу, что ТЗ выполнена не полностью. Причина очень проста — придя вчера в 3 часа ночи с «Хоббита» я поставил перед собой чёткий дедлайн — не больше часа на всё. Честно говоря, я считил, добавив ещё утром за завтраком изменение курсора, но это детали) В час я уложился.

В отличии от предыдущего автора мне не пришлось создавать «Virtualhost в своем dev-окружении, git-репозиторий» и всё остальное — всё это у меня всегда в бою для libcanvas.github.com. Потому я взял стандартный шаблон и сразу рынулся в бой, установив небо в качестве фона, чтобы не смотреть на одинокую черноту:

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8" />
		<title>LibCanvas :: Solar</title>
		<link href="/files/styles.css" rel="stylesheet" />
		<style> html, body { background: url(im/sky.png) } </style>
		<script src="/files/js/atom.js"></script>
		<script src="/files/js/libcanvas.js"></script>
	</head>
	<body>
		<p><a href="/">Return to index</a></p>
		<script>
new function () {
	LibCanvas.extract();

	atom.dom(function () {
		new Solar.Controller();
	});
};
		</script>
		<script src="js/controller.js"></script>
	</body>
</html>


Первые шаги


В первую очередь нам необходимо добавить само приложение. Сразу скажу — срок был очень ограничен, так что архитектурой я не заморачивался и по всему коду разбросаны магические числа и неверные решения, но в качестве реализации идеи подойдёт. Кроме создания приложения сразу добавим предзагрузку двух картинок — изображения Солнца и нарезку с изображениями всех планет. Добавляем картинки в ресурсы приложения, чтобы всегда можно было легко до них достучаться.
/** @class Solar.Controller */
atom.declare('Solar.Controller', {
	initialize: function () {
		this.size = new Size(840, 840);

		this.app = new App({ size: this.size });

		atom.ImagePreloader.run({
			planets: 'im/planets.png',
			sun    : 'im/sun.png'
		}, this.start.bind(this));
	},
	
	start: function (images) {
		// images ready
		this.app.resources.set( 'images', images );
	}
});


Все астрономические объекты у нас будут на специальном слое. Их особенность в том, что каждый из них обновляется каждый кадр и нету смысла решать что-то особенное с перерисовкой. Просто ставим intersection: all, чтобы они все перерисовывались и invoke: true, чтобы у каждого из них вызывался метод onUpdate
/** @class Solar.Controller */
	// ..
	initialize: function () {
	// ..
		this.geoLayer = this.app.createLayer({
			name: 'geo',
			invoke: true,
			intersection: 'all',
			zIndex: 2
		});
	// ..


Самое простое, что мы можем сделать — это добавить отрисовку Солнца. Оно статическое и банально стоит в центре кадра. Создаём для него класс, который будет отрисовывать необходимую картинку в точку, наследуемся от App.Element, чтобы можно было добавить в приложение:
/** @class Solar.Sun */
atom.declare('Solar.Sun', App.Element, {

	renderTo: function (ctx, resources) {
		ctx.drawImage({
			image : resources.get('images').get('sun'),
			center: this.shape.center
		});
	}

});


После этого в нашем контроллере создаём наше Солнце:
/** @class Solar.Controller */
	// ..
	start: function (images) {
	// ..
		this.sun = new Solar.Sun(this.geoLayer, {
			shape: new Circle(this.app.rectangle.center, 50)
		});
	// ..


Теперь у нас в системе горит одинокое Солнце:


Планеты


Следующая цель — планеты. У нас 12 планет. Первая будет на расстоянии 90 пикселей от центра система, каждая следующая на 26 пикселей дальше. Первая проходит круг за 40 секунд, каждая следующая на 20 секунд дольше. И у каждой планеты есть имя

/** @class Solar.Controller */
atom.declare('Solar.Controller', {
	names : 'Selene Mimas Ares Enceladus Tethys Dione Zeus Rhea Titan Janus Hyperion Iapetus'
		.split(' '),
	
	// ..
	start: function (images) {
	// ..
		for (var i = 12; i--;) {
			var planet = new Solar.Planet(this.geoLayer, {
				sun   : this.sun,
				radius: 90 + i * 26,
				time  : 40 + i * 20,
				image : i,
				zIndex: 0,
				name  : this.names[i]
			});
		}
	// ..


Выводим планету на орбиту очень легко — сначала размещаем её в центре системы, а потом смещаем вправо на радиус орбиты.

	this.center = this.solarCenter.clone();
	this.center.move([ this.radius, 0 ]);


Скорость вращения найти не сложнее — нам необходимо пролетать 360 градусов за 'time' в секундах или 360/1000 за время в миллисекундах:

	this.rotatePerMs = (360).degree() / 1000 / this.settings.get('time');


Необходимая картинка из спрайтов вырезается очень просто — смещаемся вправо на индекс планеты:

	getImagePart: function () {
		var x = this.settings.get('image');

		return this.layer.app.resources.get('images')
			.get('planets')
			.sprite(new Rectangle([ x*this.size.width,0 ],this.size));
	},


Сам процесс вращения планеты — просто вращаем центр планеты вокруг центра системы методом LibCanvas.Point.rotate. normalizeAngle нужен, чтобы угол всегда находился в пределах 0 и 360 градусов.

	rotate: function (angle) {
		if (angle == null) angle = Number.random(0, 360).degree();

		this.angle = (this.angle + angle).normalizeAngle();
		this.center.rotate(angle, this.solarCenter);

		return this;
	},


Добавляем интерактивности — каждый вызов метода onUpdate вертим планету, не забывая делать поправку на время, которое прошло с предыдущего вызова. onUpdate, как и renderTo — встроенные в фреймворк LibCanvas методы, которые можно переопределить и изменить поведение.
	onUpdate: function (time) {
		this.rotate(time * this.rotatePerMs);
		this.redraw();
	},


Ну и самое главное — добавляем отрисовку картинки:

	renderTo: function (ctx) {
		ctx.drawImage({
			image : this.image,
			center: this.center,
			angle : this.angle
		});
	}


Скрытый текст
/** @class Solar.Planet */
atom.declare('Solar.Planet', App.Element, {

	angle: 0,

	configure: function () {
		this.size = new Size(26, 26);

		this.center = this.solarCenter.clone();
		this.center.move([ this.radius, 0 ]);

		this.rotatePerMs = (360).degree() / 1000 / this.settings.get('time');
		this.shape = new Circle(this.center, this.size.width/2);
		this.image  = this.getImagePart();
		this.rotate();
	},

	getImagePart: function () {
		var x = this.settings.get('image');

		return this.layer.app.resources.get('images')
			.get('planets')
			.sprite(new Rectangle([x*this.size.width,0],this.size));
	},

	get radius () {
		return this.settings.get('radius');
	},

	get solarCenter () {
		return this.settings.get('sun').shape.center;
	},

	rotate: function (angle) {
		if (angle == null) angle = Number.random(0, 360).degree();

		this.angle = (this.angle + angle).normalizeAngle();
		this.center.rotate(angle, this.solarCenter);

		return this;
	},

	onUpdate: function (time) {
		this.rotate(time * this.rotatePerMs);
		this.redraw();
	},

	renderTo: function (ctx) {
		ctx.drawImage({
			image : this.image,
			center: this.center,
			angle : this.angle
		});
	}

});



Теперь у нас в системе появились планеты:


Орбиты


Дальнейшая задача — добавить орбиты. У них есть существенные отличия от планет, потому мы будем использовать другой подход и отдельный слой.
Во-первых, они статичны. В отличии от планет — не меняются, или меняются очень редко. Кроме этого — они значительно больше планет и их отрисовка ресурсозатратнее, потому будем её производить как можно реже.

Создаём слой. Вызов onUpdate нам тут не нужен, а пересечениями мы будем управлять вручную. Всё-равно этих пересечений объектов между собой, как мы видим, особо и нету. Кроме этого, чтобы не возвращаться, сразу добавим метод создания орбиты у планеты
/** @class Solar.Controller */
	// ..
	initialize: function () {
	// ..
		this.orbitLayer = this.app.createLayer({
			name: 'orbit',
			intersection: 'manual',
			zIndex: 1
		});
	// ..
	
	start: function (images) {
	// ..
		for (var i = 12; i--;) {
			var planet = new Solar.Planet(this.geoLayer, {
				// ..
			});
			planet.createOrbit(this.orbitLayer, i); // <===

			// ..
		}
	// ..
	


/** @class Solar.Planet */
	// ..
	createOrbit: function (layer, z) {
		return this.orbit = new Solar.Orbit(layer, { planet: this, zIndex: z });
	},
	// ..


Изначальный код орбиты достаточно простой. Создаём Circle, который будет основой нашей отрисовки, в методе renderTo просто stroke'аем этот круг. Единственно, что теперь ещё добавился метод clearPrevious, который изменяет принцип очистки слоя от этого объекта — мы не грубо очищаем содержимое BoundingRectangle, а аккуратненько зарисовываем обводку круга при помощи инвертированного строука ctx.clear(this.shape, true):
/** @class Solar.Orbit */
atom.declare('Solar.Orbit', App.Element, {

	configure: function () {
		this.shape = new Circle(this.planet.solarCenter, this.planet.radius);
	},

	get planet () {
		return this.settings.get('planet');
	},

	clearPrevious: function (ctx) {
		ctx.clear(this.shape, true);
	},

	renderTo: function (ctx, resources) {
		ctx.stroke(this.shape, 'rgba(0,192,255,0.5)');
	}

});


Теперь наше приложение приобрело вид, близкий к окончательному и осталось добавить взаимодействие с пользователем.


Подписываемся на мышь


Создадим объект LibCanvas.Mouse, который ловит события мыши определённого dom-элемента и объект LibCanvas.App.MouseHandler, который будет обрабатывать эти события и перенаправлять соответствующему элементу приложения
/** @class Solar.Controller */

	// ..
	start: function (images) {
		var mouse, mouseHandler;

		mouse = new Mouse(this.app.container.bounds);
		mouseHandler = new App.MouseHandler({ mouse: mouse, app: this.app });

		this.app.resources.set({
			images: images,
			mouse : mouse,
			mouseHandler: mouseHandler
		});
		
		// ..
		
		for (var i = 12; i--;) {
			var planet = new Solar.Planet(this.geoLayer, {
				// ..
			});
			planet.createOrbit(this.orbitLayer, i);
			
			mouseHandler.subscribe( planet );
			mouseHandler.subscribe( planet.orbit );
		}
	}
	
	// ..


Мышь уже ловиться, но пока мы это никак не можем заметить. Начнём с орбит. Сейчас она ловиться просто если находится внутри неё. Т.Е. любое место в пределах между солнцем и планетой считается триггером для срабатывания орбиты. Мы это изменим, переопределив метод isTriggerPoint. Теперь события мыши будут срабатавать на орбиту только в пределах 13 пикселей от неё.
/** @class Solar.Orbit */

	// ..
	isTriggerPoint: function (point) {
		var distance = this.planet.solarCenter.distanceTo(point);

		return (this.planet.radius - distance).abs() < 13;
	},
	
	// ..


Сразу же, чтобы проверить, как оно работает — активируем событие hover при помощи поведения Clickable и слегка изменим метод отрисовки:

/** @class Solar.Orbit */

	// ..
	configure: function () {
		// ..
		new App.Clickable( this, this.redraw ).start();
	},
	// ..

	renderTo: function (ctx, resources) {
		if (this.hover) {
			ctx.stroke(this.shape, 'rgba(255,64,64,0.8)');
		} else {
			ctx.stroke(this.shape, 'rgba(0,192,255,0.5)');
		}
	}
	// ..




Сейчас мы видим, что орбиты меняются при наведении мыши. Но при наведении на планету мышь блокируется и до орбиты уже не достаёт. Слегка изменим это поведение, добавив hover к планете и проверяя оба этих состояния:

/** @class Solar.Orbit */

	// ..
	
	isHover: function () {
		return this.hover || this.planet.hover;
	},

	renderTo: function (ctx, resources) {
		if (this.isHover()) {
		// ..
	}
	// ..


А т.к. при наведении на планету нам всё-равно понадобится перерисовывать орбиту каждый кадр (круг вокруг планеты смещается то) — добавляем redraw в onUpdate
/** @class Solar.Planet */

	// ..
	configure: function () {
		// ..
		new App.Clickable( this, this.redraw ).start();
	},
	// ..

	onUpdate: function (time) {
		// ..
		if (this.orbit.isHover()) this.orbit.redraw();
	},
	// ..


Теперь планета не блокирует hover и мы можем занятся стилизацией орбиты. Задачу мы выполним очень легко. Сначала отрисовываем орбиту. Потом стираем shape планеты, а потом обводим shape планеты. Таким образом получится быстро и ненапряжно достичь результата:

/** @class Solar.Orbit */
	// ..
	renderTo: function (ctx, resources) {
		if (this.isHover()) {
			ctx.save();
			ctx.set({ strokeStyle: 'rgb(0,192,255)', lineWidth: 3 });
			ctx.stroke(this.shape);
			ctx.clear(this.planet.shape);
			ctx.stroke(this.planet.shape);
			ctx.restore();
		} else {
			ctx.stroke(this.shape, 'rgba(0,192,255,0.5)');
		}
	}


Но тут нас застанет неприятность — наш «чистильщик» ничего не знает ни про толщину орбиты ни, тем более, о том, что на ней ещё есть «грыжа» от планеты. Переопределяем очередной метод LibCanvas — saveCurrentBoundingShape, который будет сохранять, где именно в последний раз была грыжа, если была вообще. Он так же будет вызываться, когда необходимо автоматически:
/** @class Solar.Orbit */
	// ..

	saveCurrentBoundingShape: function () {
		if (this.isHover()) {
			this.previousBoundingShape = this.planet.shape.clone().grow(6);
		} else {
			this.previousBoundingShape = null;
		}
		return this;
	},


Теперь мы можем сообщить нашему чистильщику о новых условиях. Если грыжи не было — чистим по старому. Иначе — увеличиваем толщину, чтобы хорошо стереть, чистим грыжу и потом востанавливаем настройки холста:
/** @class Solar.Orbit */
	// ..

	clearPrevious: function (ctx) {
		if (this.previousBoundingShape) {
			ctx.save();
			ctx.set({ lineWidth: 4 });
			ctx.clear(this.previousBoundingShape);
			ctx.clear(this.shape, true);
			ctx.restore();
		} else {
			ctx.clear(this.shape, true);
		}
	},


Результат ровно ожидаемый:


Информация о планете


Последнее, что необходимо добавить — всплывающее окошко с названием планеты. Мы бы могли подписаться на события mouseover и mouseout у Solar.Planet, но они срабатывают только при движении мыши, т.е. если планета уедет из под неподвижной мыши — mouseout не сработает. Потому мы берём точку мыши и каждый кадр проверяем, не находится ли она над нашей планетой.
/** @class Solar.Planet */
	// ..

	configure: function () {
		// ..
		this.mousePoint = this.layer.app.resources.get('mouse').point;
		this.info = new Solar.Info(this.layer, { planet: this, zIndex: 1 });
	},

	checkStatus: function (visible) {
		if (this.info.isVisible() != visible) {
			this.info[visible ? 'show' : 'hide']();
		}
	},

	onUpdate: function (time) {
		// ..
		this.checkStatus(this.isTriggerPoint(this.mousePoint));
		// смещаем фигуру инфо блока за нашей планетой
		if (this.info.isVisible()) this.info.updateShape(this.shape.center);
	},
	
	// ..


settings: { hidden: true } — одна из настроек LibCanvas. Этот элемент никак не будет учавствовать в отрисовке, но всё так же ловит события мыши, если подписан. Потому мы создаём простой и логичный класс Solar.Info, используя эту возможность.

/** @class Solar.Info */
atom.declare('Solar.Info', App.Element, {

	settings: { hidden: true },

	get planet () {
		return this.settings.get('planet');
	},

	configure: function () {
		this.shape = new Rectangle(0,0,100,30);
	},

	updateShape: function (from) {
		this.shape.moveTo(from).move([20,10])
	},

	show: function () {
		this.settings.set({ hidden: false });
		this.redraw();
	},

	hide: function () {
		this.settings.set({ hidden: true });
		this.redraw();
	},

	renderTo: function (ctx) {
		ctx.fill(this.shape, '#002244');
		ctx.text({
			to   : this.shape,
			text : this.planet.settings.get('name'),
			color: '#0ff',
			align: 'center',
			optimize: false,
			padding: 3
		})
	}
});


И получаем предсказуемый результат:

Единственное, что осталось — добавить изменение курсора мыши при наведении на планету:
/** @class Solar.Planet */
	// ..

	checkStatus: function (visible) {
		if (this.info.isVisible() != visible) {
			this.info[visible ? 'show' : 'hide']();
			this.layer.dom.element.css('cursor', visible ? 'pointer' : 'default');
		}
	},
	
	// ..


Результат


Смотрим результат на GitHub, а также исходники.

Так же сравним с решением из предыдущего топика:
1. Значительно меньше кода
2. Значительно выше производительность

Проверим в профайле. Чем выше процент (program) — тем лучше. На пустом приложении он доходит до 100%.

Оригинальная версия:


Версия на LibCanvas:


Пустая вкладка:


Также я проверил на слабом ноуте фактическую производительность (фпс/загрузка одного ядра проца):

Оригинальная версия — загрузка до 100%, 44 fps:


Версия на LibCanvas — загрузка до 40%, стабильные 60 fps:


Пишите код в удовольствие, пользуйтесь хорошими инструментами, наслаждайтесь. Если есть вопросы, а на Хабре задать не имеете возможности — пишите на емейл shocksilien@gmail.com
Tags:
Hubs:
Total votes 111: ↑107 and ↓4+103
Comments147

Articles