С примером рендеринга игровой сцены
Возможность TypeScript определять поведение, используя несколько интерфейсов - очень мощная штука. Эта возможность предоставляет абстракцию, взаимодействие только через интерфейсы без использования классов.
Способность реализовывать несколько интерфесов решает некоторые сложности наследования, которые возникали с использованием обычных классов.
Интерфейсы также определяют полиморфизм, дают возможность различным классам определять поведение, не связанное с реализацией интерфеса. Классы, которые реализуют один и тот же интерфейс, могут заменять друг друга.
Able интерфейсы
Условности .NET Framework'а об able интерфейсах всегда ассоциируются с вещами по типу IEquatable
или IComparable
, которые обозначают действие интерфейса прилагательным с суффиксом "мый".
Например, у вас есть приложение для рисования, состоящее из некоторых сущностей, которые реализуют собственные операции рендера.
Эта функция render()
будет существовать в IRenderable
интерфейсе:
export interface IRenderable {
render(): void;
}
Несколько классов, например Circle
и Rectangle
, могут реализовать этот интерфейс:
export class Circle implements IRenderable {
render(): void {
console.log("Circle rendering code here...")
}
}
export class Rectangle implements IRenderable {
render(): void {
console.log("Rectangle rendering code here...")
}
}
Удобство состоит в том, чтобы не использовать в качестве типа конкретные классы, а использовать интерфейс. Например, вы хотите создать какую-нибудь фигуру:
const shape: IRenderable = new Circle();
shape.render();
Или изменить тип фигуры на другой класс, который реализует тот же интерфейс:
let shape: IRenderable;
shape = new Circle();
shape.render();
shape = new Rectangle();
shape.render();
Практический пример - добавление фигур в коллекцию, которая потом будет обработана для вывода на канвас.
const shapes: IRenderable[] = [
new Circle(),
new Rectangle()
];
for (let shape of shapes) {
shape.render();
}
Полный пример с использованием библиотеки PIXI:
export interface IRenderable {
render(graphics: PIXI.Graphics): void;
}
export class Circle implements IRenderable {
public radius: number = 100;
render(graphics: PIXI.Graphics): void {
graphics.drawCircle(0, 0, this.radius);
}
}
export class Rectangle implements IRenderable {
public width: number = 100;
public height: number = 100;
render(graphics: PIXI.Graphics): void {
graphics.drawRect(0, 0, this.width, this.height);
}
}
// Определение фигур и контекста для графики
const shapes: IRenderable[] = [new Circle(), new Rectangle()];
const graphics = new PIXI.Graphics();
for (let shape of shapes) {
shape.render(graphics);
}
Создание интерфейсов
Допустим, вы хотите создать игровую сцену, используя движок для рендера - давайте посмотрим на встроенный в PIXI движок и на собственный.
Наш интерфейс для игровой сцены будет содержать движок обощенного типа (generic type):
export interface IGameScene<T> {
engine: T;
}
В интерфейсе движка будет функция start()
, которая будет запускать процесс рендера:
export interface IEngine {
start(): void;
}
Первый движок будет использовать обычный Ticker
движок, который есть в PIXI. Назовем этот движок TickerEngine
, он будет реализовывать IEngine
интерфейс:
export class TickerEngine implements IEngine {
start(): void {
console.log("Starting ticker engine...");
const renderer = new PIXI.Renderer();
const scene = new PIXI.Container();
const ticker = new PIXI.Ticker();
ticker.add(() => {
console.log("ticker frame handler");
renderer.render(scene);
}, PIXI.UPDATE_PRIORITY.LOW);
ticker.start();
}
}
Второй движок будет использовать пользовательский обработчик. Назовем этот движок LoopEngine
, который также будет реализовывать IEngine
интерфейс:
export class LoopEngine implements IEngine {
renderer = new PIXI.Renderer();
scene = new PIXI.Container();
start(): void {
console.log("Starting loop engine...");
requestAnimationFrame(this.frameHandler);
}
private frameHandler = () => {
console.log("loop frame handler");
this.renderer.render(this.scene);
requestAnimationFrame(this.frameHandler);
};
}
Класс игровой сцены будет реализовывать IGameScene
интерфейс типа IEngine
. Это позволит нам указывать конкретный движок, который мы будем использовать и автоматически запускать в конструкторе:
export class Scene implements IGameScene<IEngine> {
engine: IEngine;
constructor(engine: IEngine) {
this.engine = engine;
this.engine.start();
}
}
Теперь создадим экзепляр игровой сцены, используя TickerEngine
:
Сделаем тоже самое, но теперь с LoopEngine
:
Этот подход позволяет нам создать похожее поведение для разных сущностей.
Дальнейшая инкапсуляция
Допустим, вы оцениваете два физических движка для игры и хотите менять их. В обработчике анимаций в вашем приложении вы хотите использовать функцию step()
, чтобы вызывать вычисления движка по одному. Начнем с определения интерфейса:
export interface IPhysicsEngine {
step(): void;
}
Скажем, вы нашли библиотеку физического движка Box 2D, который взаимодействует с вашим графическим фреймворком. Создадим вокруг него обертку, вызывающую следующее вычисление:
export class Box2DPhysicsEngine implements IPhysicsEngine {
step(): void {
// Какая-то реализация движка Box 2D
}
}
Возможно, вы хотите сравнить Box 2D с Planck. Создадим обертку вокруг этого движка:
export class PlanckPhysicsEngine implements IPhysicsEngine {
step(): void {
// Какая-то реализация движка Planck
}
}
В классе игры создадим экземпляр физического движка. В обработчике будем ссылаться на экземпляр и вызывать метод step()
:
export class GameScene {
private physicsEngine: IPhysicsEngine;
constructor(physicsEngine: IPhysicsEngine) {
this.physicsEngine = physicsEngine;
}
private frameHandler = () => {
this.physicsEngine.step();
requestAnimationFrame(this.frameHandler);
};
}
Попробуем создать игру с помощью движка Box 2D:
const game = new GameScene(new Box2DPhysicsEngine());
А теперь с помощью Planck:
const game = new GameScene(new PlanckPhysicsEngine());
Конечно, смена основной функциональности может быть и сложной, и невозможной¹, это зависит от архитектуры и компонентов. Может быть интересно исследовать различные паттерны, используя обобщенные типы, интерфейсы и инкапсуляцию.
Упрощение
Описание связей с помощью типов упрощает ваш код, требует лишь один интерфейс, который будет определять что-то общее.
Рассмотрим игру в космическом стиле с многоугольниками, летающими в открытом пространстве, у которых есть свои координаты и вращение. Также, вероятно, количество сторон многоугольника уменьшается после попадания в него.
Будем использовать тот же интерфейс IRenderable
:
export interface IRenderable {
render(graphics: PIXI.Graphics): void;
}
Наши фигуры будут иметь две координаты, угол вращения, а также количество сторон и радиус. Определим для этого три разных интерфейса:
export interface IPosition {
x: number;
y: number;
}
export interface IRotation {
angle: number;
}
export interface IPolygon {
sides: number;
radius: number;
}
Класс фигуры будет реализовывать IPosition
и IRotation
интерфейсы без знания того, как потом эту фигуру обработают:
export class Shape implements IPosition, IRotation {
x: number = 0;
y: number = 0;
angle: number = 0;
}
Конкретный класс многоугольника унаследует класс фигуры, реализует интерфейсы, который реализует класс фигуры, а помимо этого еще и интерфейсы IPolygon
и IRenderable
:
export class Polygon extends Shape implements IPolygon,
IRenderable {
sides: number;
radius: number;
constructor(sides: number, radius: number) {
super();
this.sides = sides;
this.radius = radius;
}
render(graphics: PIXI.Graphics): void {
let step = (Math.PI * 2) / this.sides;
let start = (this.angle / 180) * Math.PI;
let n, dx, dy;
graphics.moveTo(
this.x + Math.cos(start) * this.radius,
this.y - Math.sin(start) * this.radius
);
for (n = 1; n <= this.sides; ++n) {
dx = this.x + Math.cos(start + step * n) * this.radius;
dy = this.y - Math.sin(start + step * n) * this.radius;
graphics.lineTo(dx, dy);
}
}
}
Все многоугольники могут быть созданы как:
const triangle = new Polygon(3, 100);
const square = new Polygon(4, 100);
const pentagon = new Polygon(5, 100);
const hexagon = new Polygon(6, 100);
Или определены как отдельные классы:
export class Triangle extends Polygon {
constructor(radius: number) {
super(3, radius);
}
}
export class Square extends Polygon {
constructor(radius: number) {
super(4, radius);
}
}
export class Pentagon extends Polygon {
constructor(radius: number) {
super(5, radius);
}
}
Сейчас, когда мы определили все эти интерфейсы, мы может забыть обо всех сложностях и сфокусироваться на других задачах.
Для нашего рендерного обработчика важно только то, чтобы все фигуры реализовывали интерфейс IRenderable
:
const shapes: IRenderable[] = [triangle, square, pentagon, hexagon];
const graphics = new PIXI.Graphics();
for (let shape of shapes) {
shape.render(graphics);
}
Возможно, в тестах на попадание в многоугольники, нам нужно будет считать площадь этого многоугольника. Для этого мы может использовать интерфейс IPolygon
, чтобы получить доступ к необходимым полям raduis
и sides
:
/** Подсчет площади многоульника */
export const area = (polygon: IPolygon): number => {
const r = polygon.radius;
const n = polygon.sides;
return (n * Math.pow(r, 2)) / (4 * Math.tan(Math.PI / n));
};
const polygons: IPolygon[] = [
new Triangle(100),
new Square(100),
new Pentagon(100)
];
for (let polygon of polygons) {
console.log(`Area: ${area(polygon)}`);
}
Возможно, каждый раз, когда в многоугольник попадают, количество сторон будет уменьшаться до того момента, пока фигура не будет уничтожена:
const hit = (shape: IPolygon) => {
shape.sides -= 1;
if (shape.sides < 3) {
console.log("Enemy destroyed!");
}
};
Возможно, при клике вам нужно будет получить доступ к координатам фигуры, для этого можно использовать IPosition
интерфейс:
export const onMouseDown = (position: IPosition) => {
console.log(position.x, position.y);
};
Координаты мыши имеют схожую с IPosition
интерфейсом структуру и этим можно пользоваться.
Интерфейсы помогают разделить логику и сделать код более понятным.
¹ нет ничего невозможного, но есть то, что не стоит своих вложений.
Все проблемы в сфере компьютерных наук могут быть решены другим уровнем абстракции. - Дэвид Уиллер
Комментарии (5)
AjnaGame
06.02.2022 17:30Интерфейсы помогают разделить логику и сделать код более понятным.
Точно также и наоборот
Напомнило https://habr.com/ru/post/153225/
atomic1989
07.02.2022 08:05Мне кажется лучше использовать базовый класс(возможно и абстрактный). Ими можно управлять в рантайме. В ts интерфейсы пустышки, в рантайме недоступны, например в отличии c#. Интерфейсы ts годны как некий контрак между сервером и клиентом, где кроме структуры данных не передать(скажем методы)
m0tral
08.02.2022 09:48Мужчина дорвался до базовых вещей ООП в виде интерфейсов и теперь нарадоваться не может)) следующая статья видимо будет наследование от абстрактного класса) ожидал большего от статьи
whalemare
Вся статья - объяснение полиморфизма через сложные конструкции, хотя заголовок очень интригующий.
Ожидал увидеть здесь более интересные техники применения типов
ReDev1L
Да, хотел написать что эту статью осилит только тот, кто уже это знает)