Pull to refresh

Мультиплеер в играх: взгляд изнутри

Reading time 4 min
Views 29K
Привет.

Недавно я создал мобильную игру для Android, в которой потенциально мог бы быть мультиплеер, чего и затребовали пользователи.
Мультиплеер не предусматривался, так как не соблюдал разделения модели и представления.
В этой статье я рассмотрю простую реализацию сетевого режима игры и расскажу об ошибках, допущенных на этапе продумывания архитектуры игры.
Воодушевившись статьей goblin wars II структура игры была разделена на независимые блоки, что в конечном итоге позволило пользователям играть по сети.

Базовый класс для всех игровых объектов содержал всю логику модели MVC внутри себя — он умел и рисовать себя, и менять свое состояние.
public abstract class BaseObject {

	public byte type;
	public byte state;
	public Rectangle body;
	
	public abstract void update(float delta);
	public abstract void draw(float delta);
}

Разделим модель от представления.
Любой класс, который будет рисовать игровые объекты, будет реализовывать интерфейс.
public  interface Drawer {
	void draw(BaseObject obj, float delta);
}

Продумывая архитектуру для мультиплеера, а именно отдельно реализацию клиента и реализацию сервера, вынесем поля, необходимые для рисования объекта в отдельный класс.
public abstract class State {
	public byte type;
	public float x, y;
}

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

Когда объектов в игре стало много, сопровождать такой код и добавлять новые возможности стало слишком сложно.
Переход на компоненты существенно облегчил создание новых игровых объектов, так как по сути создание новой сущности представляло собой конструктор.
Все объекты в игре наследуются от базового класса Entity, и отличие лишь в возвращаемом методом getState() состоянии и различным набором компонентов.
Пример такого класса
public class StoneBox extends Entity {

	private StoneBoxState stoneBoxState = new StoneBoxState();

	private SolidBodyComponent solidBody;
	private MapBodyComponent mapBody;

	public StoneBox(SorterEntityManager entityManager, float x, float y) {
		super(entityManager);
		type = EntityType.BRICK_STONE;
		solidBody = new SolidBodyComponent(this);
		solidBody.isStatic = true;
		solidBody.rectangle.x = x;
		solidBody.rectangle.y = y;
		mapBody = new MapBodyComponent(this);
		SorterComponent sorterComponent = new SorterComponent(this, entityManager);

		addComponent(solidBody);
		addComponent(mapBody);
		addComponent(sorterComponent);
	}

	@Override
	public State getState() {
		stoneBoxState.x = solidBody.rectangle.x;
		stoneBoxState.y = solidBody.rectangle.y;
		return stoneBoxState;
	}
}


Структура игры существенно изменилась, и разделение сущностей позволило создать такую связку:
Server -> ServerImplementation <-> ClientImplementation <- Client
Все отличие сетевой игры от локальной сводится к разным иплементациям.

Действия класса сервера каждый кадр — он отдает актуальный массив состояний для ServerImplementation и передает на клиент.
выглядит это так:
public class LocalServerImpl {
	...
	public void update(float delta){
		clientImpl.states = server.getStates();
		...
	}
}

Класс Client каждый кадр забирает актуальное состояние у ClientImplementation, и затем отображает полученные данные на экран.
Разумеется, передаются не только состояния, но и события о начале игры, команды пользователя и другие. Их логика не отличается от передачи состояний.

Теперь для реализации сетевого режима нам нужно изменить реализацию ServerImplementation <-> ClientImplementation, а также для классов состояний реализовать интерфейс для сериализации и десериализации:
public interface BinaryParser {
	void parseBinary(DataInputStream stream);
	void fillBinary(DataOutputStream stream);
}

Пример такого класса
public class StoneBoxState extends State {

	public StoneBoxState() {
		super(StateType.STONE_BOX);
	}

	@Override
	public void parseBinary(DataInputStream stream) {
		x = StreamUtils.readFloat(stream);
		y = StreamUtils.readFloat(stream);
	}

	@Override
	public void fillBinary(DataOutputStream stream) {
		StreamUtils.writeByte(stream, type);
		StreamUtils.writeFloat(stream, x);
		StreamUtils.writeFloat(stream, y);
	}
}


Почему при парсинге мы не читаем байт, отвечающий за тип? Мы читаем его при определении типа объекта, чтобы создать нужную нам сущность из входного массива байт.
Парсинг состояний
public static State parseState(DataInputStream stream) {
	byte type = StreamUtils.readByte(stream);
	State state = null;
	switch (type) {
		case STONE_BOX:
			state = new StoneBoxState();
			break;
		...
	}
	state.parseBinary(stream);
	return state;
}


Для работы с сетью я использовал библиотеку Kryonet, она очень удобна, если вы не хотите знать, как передаются пакеты данных, и вам важен только результат.
LocalServerImpl заменим на NetworkServerImpl, данные передавать не сложнее:
Отправка состояния игры
	Array<State> states = entityManager.getStates();
	ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
	DataOutputStream dataOutputStream = new DataOutputStream(byteArrayOutputStream);
	StreamUtils.writeByte(dataOutputStream, GameMessages.MESSAGE_SNAPSHOT);
	for (int i = 0; i < states.size; i++) {
		states.get(i).fillBinary(dataOutputStream);
	}
	byte[] array = byteArrayOutputStream.toByteArray();
	byte[] compressedData = CompressionUtils.compress(array);
	sendToAllUDP(compressedData);


Получаем актуальные состояния, записываем в массив байт, архивируем, отсылаем клиентам.
На клиенте отличия также минимальны, он также будет получать состояния, но приходить они уже будут по сети:
Получение состояния игры
private void onReceivedData(byte[] data) {
	byte[] result = CompressionUtils.decompress(data);
	ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(result);
	DataInputStream dataInputStream = new DataInputStream(byteArrayInputStream);
	byte messageType = StreamUtils.readByte(dataInputStream);

	switch (messageType) {
	...
	case GameMessages.MESSAGE_SNAPSHOT:
		snapshot.clear();
		try {
			while (dataInputStream.available() > 0) {
				snapshot.add(StateType.parseState(dataInputStream));
			}
		} catch (IOException e) {
			e.printStackTrace();
		}
		break;
	}
}


Типы сообщений между клиентом и сервером
	public static final byte MESSAGE_PLAYER_ACTION = 0;
	public static final byte MESSAGE_SNAPSHOT = 1;
	public static final byte MESSAGE_ADD_PLAYER = 2;
	public static final byte MESSAGE_GAME_OVER = 3;
	public static final byte MESSAGE_LEVEL_COMPLETE = 4;
	public static final byte MESSAGE_LOADED_NEW_LEVEL = 5;
	public static final byte MESSAGE_CHANGE_ZOOM_LEVEL = 6;



Любые действия на стороне клиента сперва отправляются на сервер, на сервере меняется состояние, и эти данные возвращаются назад к клиенту.
Записал небольшое видео, которое демонстрирует сетевой режим (движок libgdx позволяет запускать приложения и на ПК)
Tags:
Hubs:
+6
Comments 6
Comments Comments 6

Articles