Пр2: Сетевое взаимодействие в Unity на базе Node.js

Этап 1. Подготовка сцен, префабов и механики персонажа

На этом этапе создаются две независимые сцены (меню входа и игровая локация), настраивается передача параметров между ними, создаются префабы для локального и сетевого игрока, а также прорабатываются механики прыжка, приседания и отображения направления взгляда.

Шаг 1.1. Создание и настройка сцен

1.   В окне Project перейдите в папку Assets/Scenes (если папки нет — создайте).

2.  Создайте две новые сцены через правый клик  Create  Scene:

o  Назовите первую: LobbyScene

o  Назовите вторую: GameScene

3.  Откройте окно сборки: File  Build Settings...

4.   Перетащите обе сцены в список Scenes In Build:

o  Сцена LobbyScene должна стоять на позиции с индексом 0.

o  Сцена GameScene должна стоять на позиции с индексом 1.

5.   Закройте окно Build Settings.

Шаг 1.2. Настройка геометрии игровой сцены (GameScene)

1.   Дважды кликните по сцене GameScene в окне Project, чтобы открыть её.

2.  В окне Hierarchy создайте базовую плоскость арены: правый клик  3D Object  Plane.

3.   Назовите объект ArenaFloor.

4.   В окне Inspector настройте компонент Transform:

o  Position: (0, 0, 0)

o  Rotation: (0, 0, 0)

o  Scale: (5, 1, 5)

Геометрическая справка: Стандартный примитив Plane в Unity имеет базовый размер  метров при масштабе (1, 1, 1). При масштабе (5, 1, 5) итоговая площадь составляет ровно  метров.

5.   Убедитесь, что на объекте ArenaFloor включен компонент Mesh Collider.

6.  Создайте пустой объект-контейнер для стен лабиринта: в Hierarchy нажмите правый клик  Create Empty.

7.   Переименуйте его в [MAZE_ROOT] и сбросьте его Transform: Position (0, 0, 0), Rotation (0, 0, 0), Scale (1, 1, 1).

8.   Сохраните сцену (Ctrl + S).

Шаг 1.3. Настройка сцены авторизации (LobbyScene)

1.   Дважды кликните по сцене LobbyScene, чтобы открыть её.

2.  Создайте интерфейс пользователя: в Hierarchy нажмите правый клик  UI  Canvas.

3.   Выберите созданный Canvas и в компоненте Canvas Scaler:

o  Установите UI Scale Mode в значение Scale With Screen Size.

o  Задайте Reference Resolution: X = 1920, Y = 1080.

4.  Внутри Canvas создайте визуальную панель: правый клик по Canvas  UI  Panel. Назовите её Panel_Lobby.

5.  Внутри Panel_Lobby создайте элементы управления (через правый клик по Panel_Lobby  UI):

o  Поле ввода никнейма: создайте TextMeshPro - Input Field, назовите объект Input_Nickname. В дочернем объекте Placeholder укажите текст: «Введите никнейм...».

o  Поле ввода IP-адреса: создайте второй TextMeshPro - Input Field, назовите Input_IP. В поле Placeholder укажите: «IP сервера (например, 127.0.0.1)...».

o  Поле ввода порта: создайте третий TextMeshPro - Input Field, назовите Input_Port. В поле Placeholder укажите: «Порт (7777)...».

o  Кнопка входа: создайте Button - TextMeshPro, назовите Btn_Connect. В дочернем объекте Text (TMP) укажите: «Подключиться».

6.   Выровняйте элементы по центру экрана друг под другом с помощью инструментов позиционирования RectTransform.

Шаг 1.4. Создание управляющего объекта и скрипта LobbyManager

Чтобы сохранить введенные данные при загрузке второй сцены, требуется выделить отдельный управляющий объект и скрипт со статическими переменными.

1.  В окне Hierarchy сцены LobbyScene нажмите правый клик  Create Empty.

2.   Назовите созданный объект [LOBBY_MANAGER].

3.   Сбросьте Transform объекта в координаты (0, 0, 0).

4.   В окне Project создайте папку Assets/Scripts.

5.   Внутри папки Scripts создайте C# скрипт с именем LobbyManager.cs.

6.   Откройте скрипт и вставьте следующий код:

using UnityEngine;

using UnityEngine.SceneManagement;

using TMPro;

 

public class LobbyManager : MonoBehaviour

{

    [Header("UI Элементы ввода")]

    [SerializeField] private TMP_InputField inputNickname;

    [SerializeField] private TMP_InputField inputIP;

    [SerializeField] private TMP_InputField inputPort;

 

    // Статические поля доступны из любой сцены после загрузки

    public static string Nickname = "Player";

    public static string ServerIP = "127.0.0.1";

    public static int ServerPort = 7777;

 

    public void OnConnectButtonClicked()

    {

        // Считываем никнейм

        if (!string.IsNullOrWhiteSpace(inputNickname.text))

        {

            Nickname = inputNickname.text.Trim();

        }

 

        // Считываем IP-адрес

        if (!string.IsNullOrWhiteSpace(inputIP.text))

        {

            ServerIP = inputIP.text.Trim();

        }

 

        // Считываем порт

        if (int.TryParse(inputPort.text, out int parsedPort))

        {

            ServerPort = parsedPort;

        }

 

        Debug.Log($"[Lobby] Подключение игрока {Nickname} к {ServerIP}:{ServerPort}");

 

        // Загрузка игровой сцены

        SceneManager.LoadScene("GameScene");

    }

}

7.   Вернитесь в Unity, выделите объект [LOBBY_MANAGER] в Hierarchy и перетащите на него созданный скрипт LobbyManager.cs.

8.   В окне Inspector объекта [LOBBY_MANAGER] перетащите поля ввода из Canvas в соответствующие слоты:

o   Объект Input_Nickname  в поле Input Nickname.

o   Объект Input_IP  в поле Input IP.

o   Объект Input_Port  в поле Input Port.

9.   Выделите кнопку Btn_Connect, найдите блок On Click ():

o  Нажмите кнопку «+».

o  В поле объекта перетащите [LOBBY_MANAGER].

o  В выпадающем меню выберите: LobbyManager $\rightarrow$ OnConnectButtonClicked ().

10.   Сохраните сцену LobbyScene.

Шаг 1.5. Сборка визуальной основы персонажа

Для наглядного отслеживания взгляда сетевых игроков персонажу требуется контрастный визуальный маркер («лицо»).

1.   Откройте сцену GameScene.

2.  Создайте пустой объект в Hierarchy: правый клик  Create Empty, назовите его CharacterModel_Base.

3.  Внутри CharacterModel_Base создайте примитив для туловища: правый клик по объекту  3D Object  Capsule.

o  Назовите капсулу BodyMesh.

o  Transform: Position (0, 1, 0), Rotation (0, 0, 0), Scale (1, 1, 1).

o   Важно: Удалите компонент Capsule Collider с объекта BodyMesh (правый клик по заголовку компонента  Remove Component), чтобы он не мешал физике движения.

4.  Внутри CharacterModel_Base создайте точку поворота головы: правый клик по объекту  Create Empty.

o  Назовите объект HeadPivot.

o  Transform: Position (0, 1.6, 0), Rotation (0, 0, 0), Scale (1, 1, 1).

5.  Внутри HeadPivot создайте квадратный индикатор лица: правый клик по HeadPivot  3D Object  Cube.

o  Назовите объект FaceIndicator.

o  Transform: Position (0, 0, 0.45), Rotation (0, 0, 0), Scale (0.35, 0.25, 0.15).

o  Удалите компонент Box Collider с кубика.

6.   В папке Assets создайте папку Materials и создайте два материала:

o  Mat_Body (серый/белый цвет) — назначьте на BodyMesh.

o  Mat_Face (яркий красный, зеленый или синий цвет) — назначьте на FaceIndicator.

Шаг 1.6. Сборка префаба LocalPlayer (Локальный игрок)

1.   Переименуйте объект CharacterModel_Base в LocalPlayer.

2.  Добавьте компонент физического перемещения: выделите LocalPlayer  Add Component  Character Controller.

o  Slope Limit: 45

o  Step Offset: 0.3

o  Center: X = 0, Y = 1, Z = 0

o  Radius: 0.5

o  Height: 2

3.   Найдите в Hierarchy сцены стандартную камеру Main Camera:

o  Перетащите её в иерархии внутрь объекта HeadPivot (дочерний к LocalPlayer).

o  Сбросьте Transform камеры: Position (0, 0, 0), Rotation (0, 0, 0).

o  Убедитесь, что на камере включен компонент Audio Listener.

4.   В папке Assets/Scripts создайте скрипт LocalPlayerController.cs:

using UnityEngine;

 

[RequireComponent(typeof(CharacterController))]

public class LocalPlayerController : MonoBehaviour

{

    [Header("Параметры скорости")]

    [SerializeField] private float walkSpeed = 6f;

    [SerializeField] private float crouchSpeed = 3f;

 

    [Header("Параметры прыжка и гравитации")]

    [SerializeField] private float jumpHeight = 1.2f;

    [SerializeField] private float gravity = -19.62f;

 

    [Header("Параметры обзора")]

    [SerializeField] private float mouseSensitivity = 2f;

    [SerializeField] private Transform headPivot;

 

    [Header("Параметры приседания")]

    [SerializeField] private float standingHeight = 2f;

    [SerializeField] private float crouchingHeight = 1f;

    [SerializeField] private float crouchTransitionSpeed = 10f;

 

    private CharacterController controller;

    private Vector3 verticalVelocity;

    private float pitchAngle = 0f;

    private bool isCrouching = false;

 

    public bool IsCrouching => isCrouching;

    public float PitchAngle => pitchAngle;

 

    private void Awake()

    {

        controller = GetComponent<CharacterController>();

        Cursor.lockState = CursorLockMode.Locked;

        Cursor.visible = false;

    }

 

    private void Update()

    {

        HandleRotation();

        HandleCrouch();

        HandleMovement();

    }

 

    private void HandleRotation()

    {

        float mouseX = Input.GetAxis("Mouse X") * mouseSensitivity;

        float mouseY = Input.GetAxis("Mouse Y") * mouseSensitivity;

 

        // Вращение тела вокруг вертикальной оси Y

        transform.Rotate(Vector3.up * mouseX);

 

        // Наклон головы вверх и вниз вокруг оси X

        pitchAngle -= mouseY;

        pitchAngle = Mathf.Clamp(pitchAngle, -80f, 80f);

 

        if (headPivot != null)

        {

            headPivot.localEulerAngles = new Vector3(pitchAngle, 0f, 0f);

        }

    }

 

    private void HandleCrouch()

    {

        isCrouching = Input.GetKey(KeyCode.LeftControl);

        float targetHeight = isCrouching ? crouchingHeight : standingHeight;

 

        // Плавная смена высоты коллайдера

        controller.height = Mathf.Lerp(controller.height, targetHeight, Time.deltaTime * crouchTransitionSpeed);

        controller.center = new Vector3(0f, controller.height / 2f, 0f);

    }

 

    private void HandleMovement()

    {

        bool isGrounded = controller.isGrounded;

        if (isGrounded && verticalVelocity.y < 0)

        {

            verticalVelocity.y = -2f;

        }

 

        float inputX = Input.GetAxis("Horizontal");

        float inputZ = Input.GetAxis("Vertical");

        Vector3 moveDir = transform.right * inputX + transform.forward * inputZ;

 

        float currentSpeed = isCrouching ? crouchSpeed : walkSpeed;

        controller.Move(moveDir * currentSpeed * Time.deltaTime);

 

        // Прыжок

        if (Input.GetButtonDown("Jump") && isGrounded && !isCrouching)

        {

            verticalVelocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity);

        }

 

        verticalVelocity.y += gravity * Time.deltaTime;

        controller.Move(verticalVelocity * Time.deltaTime);

    }

}

5.   Навесьте скрипт LocalPlayerController на объект LocalPlayer.

6.   В инспекторе перетащите объект HeadPivot в поле Head Pivot.

7.   Создайте в окне Project папку Assets/Prefabs.

8.   Перетащите объект LocalPlayer из Hierarchy в папку Prefabs, чтобы создать префаб.

9.   Удалите LocalPlayer со сцены GameScene (спавн будет производиться кодом).

Шаг 1.7. Создание префаба NetworkPlayer (Сетевой игрок)

Сетевой игрок не содержит компонентов локального ввода и камеры, но имеет плашку с именем, развернутую к наблюдателю.

1.       В папке Assets/Scripts создайте скрипт Billboard.cs:

using UnityEngine;

 

public class Billboard : MonoBehaviour

{

    private Transform mainCameraTransform;

 

    private void Start()

    {

        FindCamera();

    }

 

    private void LateUpdate()

    {

        if (mainCameraTransform == null)

        {

            FindCamera();

            return;

        }

 

        // Плашка всегда строго параллельна плоскости экрана камеры

        transform.rotation = mainCameraTransform.rotation;

    }

 

    private void FindCamera()

    {

        if (Camera.main != null)

        {

            mainCameraTransform = Camera.main.transform;

        }

    }

}

2.   В папке Assets/Scripts создайте скрипт NetworkPlayer.cs:

using UnityEngine;

using TMPro;

 

public class NetworkPlayer : MonoBehaviour

{

    [Header("Ссылки на узлы")]

    [SerializeField] private Transform headPivot;

    [SerializeField] private Transform bodyMesh;

    [SerializeField] private TextMeshProUGUI nicknameText;

 

    [Header("Сглаживание сети")]

    [SerializeField] private float smoothSpeed = 15f;

 

    private Vector3 targetPosition;

    private float targetYaw;

    private float targetPitch;

    private bool targetCrouch;

 

    public void Initialize(string nickname, Vector3 startPos)

    {

        if (nicknameText != null)

        {

            nicknameText.text = nickname;

        }

 

        transform.position = startPos;

        targetPosition = startPos;

    }

 

    public void UpdateNetworkState(Vector3 pos, float yaw, float pitch, bool crouch)

    {

        targetPosition = pos;

        targetYaw = yaw;

        targetPitch = pitch;

        targetCrouch = crouch;

    }

 

    private void Update()

    {

        // Плавная интерполяция координат

        transform.position = Vector3.Lerp(transform.position, targetPosition, Time.deltaTime * smoothSpeed);

        transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.Euler(0f, targetYaw, 0f), Time.deltaTime * smoothSpeed);

 

        // Наклон головы с кубиком-лицом

        if (headPivot != null)

        {

            headPivot.localRotation = Quaternion.Slerp(headPivot.localRotation, Quaternion.Euler(targetPitch, 0f, 0f), Time.deltaTime * smoothSpeed);

        }

 

        // Отражение приседания через масштаб модели туловища

        if (bodyMesh != null)

        {

            float targetScaleY = targetCrouch ? 0.5f : 1f;

            Vector3 s = bodyMesh.localScale;

            s.y = Mathf.Lerp(s.y, targetScaleY, Time.deltaTime * smoothSpeed);

            bodyMesh.localScale = s;

        }

    }

}

3.   Соберите объект сетевого игрока на сцене GameScene:

o   Создайте пустой объект: правый клик  Create Empty, назовите NetworkPlayer.

o  Дочерними объектами добавьте BodyMesh (Capsule без коллайдера) и HeadPivot (с кубиком FaceIndicator внутри), точно так же, как в шаге 1.5.

4.   Добавьте над головой плашку с никнеймом:

o   Правый клик по NetworkPlayer  UI  Canvas. Назовите его NameCanvas.

o  В компоненте Canvas переключите Render Mode на World Space.

o  Настройте RectTransform: Pos X = 0, Pos Y = 2.3, Pos Z = 0, Width = 200, Height = 50, Scale = (0.01, 0.01, 0.01).

o  Навесьте на NameCanvas скрипт Billboard.cs.

o   Внутри NameCanvas создайте текстовый элемент: правый клик  UI  TextMeshPro - Text.

o  Настройте выравнивание по центру по вертикали и горизонтали, размер шрифта 28, цвет желтый или белый.

5.   На корневой объект NetworkPlayer добавьте скрипт NetworkPlayer.cs.

6.   В инспекторе NetworkPlayer привяжите ссылки:

o   Объект HeadPivot  в поле Head Pivot.

o   Объект BodyMesh  в поле Body Mesh.

o   Текстовый объект никнейма  в поле Nickname Text.

7.   Перетащите готовый объект NetworkPlayer из Hierarchy в папку Assets/Prefabs.

8.   Удалите NetworkPlayer со сцены GameScene.

9.   Сохраните проект.

Этап 2. Разработка базового сетевого сервера и попарное тестирование на открытой арене

На этом этапе создается минималистичный WebSocket-сервер на платформе Node.js, организуется сетевой транспорт в Unity через клиентский менеджер и проводится первый практический тест: студенты работают в парах на своих ПК, подключаясь по локальной сети к открытой плоскости  метров.

Основная задача этапа — наглядно увидеть работу сетевого взаимодействия: как аватары сокурсников синхронно бегают, прыгают, приседают и поворачивают квадрат «лица» вслед за взглядом реального игрока.

Шаг 2.1. Создание и запуск сервера на Node.js

Серверная часть пишется на нативном Node.js с легковесной библиотекой ws.

1.   Создайте на одном из рабочих компьютеров пары (или на общем сервере) папку MazeServer.

2.   Откройте в этой папке терминал и выполните команды:

npm init -y

npm install ws

3.       Создайте файл server.js со следующим кодом:

const { WebSocketServer } = require('ws');

 

const PORT = 7777;

const TICK_RATE = 20; // 20 раз в секунду (каждые 50 мс)

 

const wss = new WebSocketServer({ port: PORT });

 

// Таблица активных сессий: clientId -> { ws, state }

const players = new Map();

 

console.log(`=========================================`);

console.log(`[СЕРВЕР] Запущен на порту ${PORT}`);

console.log(`Ожидание подключений студентов...`);

console.log(`=========================================`);

 

wss.on('connection', (ws, req) => {

    // 1. Уникальный ID клиента

    const clientId = 'p_' + Math.random().toString(36).substr(2, 8);

    const clientIp = req.socket.remoteAddress;

 

    // 2. Случайные стартовые координаты на плоскости 50x50 м

    // (берем диапазон от -18 до +18, чтобы не спавнить на самом краю арены)

    const spawnX = Number(((Math.random() - 0.5) * 36).toFixed(2));

    const spawnZ = Number(((Math.random() - 0.5) * 36).toFixed(2));

 

    const initialPlayerState = {

        id: clientId,

        name: 'Player',

        x: spawnX,

        y: 0.0,

        z: spawnZ,

        yaw: 0.0,

        pitch: 0.0,

        crouch: false

    };

 

    players.set(clientId, { ws, state: initialPlayerState });

    console.log(`[ПОДКЛЮЧЕНИЕ] Клиент ${clientId} (IP: ${clientIp}) вошел в сессию`);

 

    // 3. Отправляем игроку его персональный ID и координаты спавна

    ws.send(JSON.stringify({

        type: 'INIT_PLAYER',

        id: clientId,

        spawnPos: [initialPlayerState.x, initialPlayerState.y, initialPlayerState.z]

    }));

 

    // 4. Прием пакетов от Unity-клиента

    ws.on('message', (messageRaw) => {

        try {

            const data = JSON.parse(messageRaw);

 

            // Регистрация никнейма из LobbyScene

            if (data.type === 'JOIN') {

                const session = players.get(clientId);

                if (session) {

                    session.state.name = data.name || 'Anonymous';

                    console.log(`[РЕГИСТРАЦИЯ] Игрок ${clientId} указал имя: "${session.state.name}"`);

                }

            }

 

            // Синхронизация движения и взгляда

            if (data.type === 'MOVE') {

                const session = players.get(clientId);

                if (session) {

                    session.state.x = Number(data.x);

                    session.state.y = Number(data.y);

                    session.state.z = Number(data.z);

                    session.state.yaw = Number(data.yaw);

                    session.state.pitch = Number(data.pitch);

                    session.state.crouch = Boolean(data.crouch);

                }

            }

        } catch (err) {

            console.error(`[ОШИБКА JSON] от ${clientId}:`, err.message);

        }

    });

 

    // 5. Отключение клиента

    ws.on('close', () => {

        const session = players.get(clientId);

        const playerName = session ? session.state.name : clientId;

        players.delete(clientId);

        console.log(`[ОТКЛЮЧЕНИЕ] Игрок "${playerName}" (${clientId}) отключился`);

 

        // Оповещаем остальных клиентов об удалении аватара

        broadcast({

            type: 'PLAYER_LEFT',

            id: clientId

        });

    });

 

    ws.on('error', (err) => {

        console.error(`[ОШИБКА СОКЕТА] ${clientId}:`, err.message);

    });

});

 

// Игровой цикл: каждые 50 мс рассылаем снапшот мира всем подключенным

setInterval(() => {

    if (players.size === 0) return;

 

    const snapshot = {

        type: 'SNAPSHOT',

        players: Array.from(players.values()).map(p => p.state)

    };

 

    broadcast(snapshot);

}, 1000 / TICK_RATE);

 

function broadcast(packet) {

    const rawData = JSON.stringify(packet);

    for (const { ws } of players.values()) {

        if (ws.readyState === 1) { // WebSocket.OPEN

            ws.send(rawData);

        }

    }

}

4.       Запустите сервер командой:

node server.js

Шаг 2.2. Установка WebSocket-пакета в Unity

1.  В Unity откройте окно Window  Package Manager.

2.   Нажмите на значок «+» слева вверху и выберите пункт Add package from git URL...

3.   Введите адрес репозитория:

https://github.com/endel/NativeWebSocket.git

4.   Нажмите Add и дождитесь импорта пакета.

Шаг 2.3. Создание скрипта NetworkClient.cs

Скрипт управляет жизненным циклом сетевой сессии, спавнит своего персонажа по координатам от сервера, отправляет позицию с частотой 20 Гц и обновляет удаленных участников.

1.   В папке Assets/Scripts создайте C# скрипт NetworkClient.cs:

using System;

using System.Collections.Generic;

using UnityEngine;

using NativeWebSocket;

 

[Serializable]

public class InitPacket

{

    public string type;

    public string id;

    public float[] spawnPos;

}

 

[Serializable]

public class PlayerStateData

{

    public string id;

    public string name;

    public float x, y, z;

    public float yaw, pitch;

    public bool crouch;

}

 

[Serializable]

public class SnapshotPacket

{

    public string type;

    public PlayerStateData[] players;

}

 

[Serializable]

public class PlayerLeftPacket

{

    public string type;

    public string id;

}

 

public class NetworkClient : MonoBehaviour

{

    [Header("Префабы")]

    [SerializeField] private GameObject localPlayerPrefab;

    [SerializeField] private GameObject networkPlayerPrefab;

 

    private WebSocket websocket;

    private string myClientId;

    private GameObject localPlayerInstance;

    private LocalPlayerController localController;

 

    private readonly Dictionary<string, NetworkPlayer> remotePlayers = new Dictionary<string, NetworkPlayer>();

 

    private float sendTimer = 0f;

    private const float SendInterval = 0.05f; // 20 раз в секунду

 

    private async void Start()

    {

        string serverUrl = $"ws://{LobbyManager.ServerIP}:{LobbyManager.ServerPort}";

        Debug.Log($"[NetworkClient] Подключение к {serverUrl}...");

 

        websocket = new WebSocket(serverUrl);

 

        websocket.OnOpen += async () =>

        {

            Debug.Log("[NetworkClient] Соединение открыто! Передача имени из лобби...");

            string joinJson = $"{{\"type\":\"JOIN\",\"name\":\"{LobbyManager.Nickname}\"}}";

            await websocket.SendText(joinJson);

        };

 

        websocket.OnMessage += (bytes) =>

        {

            string json = System.Text.Encoding.UTF8.GetString(bytes);

            HandleServerMessage(json);

        };

 

        websocket.OnError += (err) => Debug.LogError($"[NetworkClient Ошибка]: {err}");

        websocket.OnClose += (code) => Debug.LogWarning($"[NetworkClient] Соединение закрыто. Код: {code}");

 

        await websocket.Connect();

    }

 

    private void Update()

    {

        #if !UNITY_WEBGL || UNITY_EDITOR

        if (websocket != null)

        {

            websocket.DispatchMessageQueue();

        }

        #endif

 

        if (localPlayerInstance != null && websocket != null && websocket.State == WebSocketState.Open)

        {

            sendTimer += Time.deltaTime;

            if (sendTimer >= SendInterval)

            {

                sendTimer = 0f;

                SendMovementUpdate();

            }

        }

    }

 

    private void HandleServerMessage(string json)

    {

        // Первичная инициализация игрока

        if (json.Contains("\"INIT_PLAYER\""))

        {

            InitPacket packet = JsonUtility.FromJson<InitPacket>(json);

            myClientId = packet.id;

 

            Vector3 spawn = new Vector3(packet.spawnPos[0], packet.spawnPos[1], packet.spawnPos[2]);

            SpawnLocalPlayer(spawn);

            Debug.Log($"[NetworkClient] Заспавнен в точке: {spawn} (ID: {myClientId})");

        }

        // Получение среза мира

        else if (json.Contains("\"SNAPSHOT\""))

        {

            SnapshotPacket snapshot = JsonUtility.FromJson<SnapshotPacket>(json);

            ProcessWorldSnapshot(snapshot);

        }

        // Отключение сокурсника

        else if (json.Contains("\"PLAYER_LEFT\""))

        {

            PlayerLeftPacket packet = JsonUtility.FromJson<PlayerLeftPacket>(json);

            RemoveRemotePlayer(packet.id);

        }

    }

 

    private void SpawnLocalPlayer(Vector3 spawnPos)

    {

        if (localPlayerInstance != null) return;

 

        localPlayerInstance = Instantiate(localPlayerPrefab, spawnPos, Quaternion.identity);

        localController = localPlayerInstance.GetComponent<LocalPlayerController>();

    }

 

    private void ProcessWorldSnapshot(SnapshotPacket snapshot)

    {

        if (string.IsNullOrEmpty(myClientId)) return;

 

        foreach (var data in snapshot.players)

        {

            // Собственный аватар не дублируем

            if (data.id == myClientId) continue;

 

            Vector3 netPos = new Vector3(data.x, data.y, data.z);

 

            if (!remotePlayers.ContainsKey(data.id))

            {

                // Появление нового сетевого игрока

                GameObject remoteObj = Instantiate(networkPlayerPrefab, netPos, Quaternion.Euler(0f, data.yaw, 0f));

                NetworkPlayer np = remoteObj.GetComponent<NetworkPlayer>();

                np.Initialize(data.name, netPos);

 

                remotePlayers.Add(data.id, np);

            }

            else

            {

                // Обновление координат, углов тела/головы и приседания

                remotePlayers[data.id].UpdateNetworkState(netPos, data.yaw, data.pitch, data.crouch);

            }

        }

    }

 

    private void RemoveRemotePlayer(string id)

    {

        if (remotePlayers.TryGetValue(id, out NetworkPlayer player))

        {

            Destroy(player.gameObject);

            remotePlayers.Remove(id);

        }

    }

 

    private async void SendMovementUpdate()

    {

        Transform tr = localPlayerInstance.transform;

 

        string moveJson = $"{{\"type\":\"MOVE\"," +

                          $"\"x\":{tr.position.x.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}," +

                          $"\"y\":{tr.position.y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}," +

                          $"\"z\":{tr.position.z.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}," +

                          $"\"yaw\":{tr.eulerAngles.y.ToString("F1", System.Globalization.CultureInfo.InvariantCulture)}," +

                          $"\"pitch\":{localController.PitchAngle.ToString("F1", System.Globalization.CultureInfo.InvariantCulture)}," +

                          $"\"crouch\":{(localController.IsCrouching ? "true" : "false")}}}";

 

        await websocket.SendText(moveJson);

    }

 

    private async void OnApplicationQuit()

    {

        if (websocket != null && websocket.State == WebSocketState.Open)

        {

            await websocket.Close();

        }

    }

}

Шаг 2.4. Настройка объекта [NETWORK_MANAGER] на сцене GameScene

1.   Откройте сцену GameScene.

2.  В окне Hierarchy создайте пустой объект: правый клик  Create Empty.

3.   Назовите его [NETWORK_MANAGER] и сбросьте Transform в (0, 0, 0).

4.   Навесьте на него созданный скрипт NetworkClient.cs.

5.   В окне Inspector назначьте поля:

o  Перетащите префаб LocalPlayer из папки Assets/Prefabs в поле Local Player Prefab.

o  Перетащите префаб NetworkPlayer из папки Assets/Prefabs в поле Network Player Prefab.

6.   Сохраните сцену GameScene (Ctrl + S).

Шаг 2.5. Попарное самостоятельное тестирование на ПК

Студенты распределяются по парам за соседними компьютерами. Преподаватель диктует IP-адрес сервера (если сервер запущен централизованно) либо первый студент в паре сообщает свой локальный IP второму.

Порядок проверки:

1.   Запуск сервера:

o  Сервер server.js запускается в терминале.

2.   Вход через лобби:

o  Оба студента запускают сцену LobbyScene (в Unity Editor или собранном Standalone-приложении).

o  В поле никнейма каждый студент вводит свое имя/фамилию.

o  В поле IP вводится продиктованный адрес сервера, порт — 7777.

o  Нажимается кнопка «Подключиться».

3.   Что студенты проверяют друг у друга на экранах:

o   Появление аватаров: оба студента заспавнились на пустой плоскости  метров и видят модельки друг друга.

o  Плашка с именем: над головой напарника отображается его никнейм. При обходе персонажа вокруг плашка поворачивается следом за камерой и всегда читаема.

o  Наклон взгляда: когда напарник водит мышью вверх-вниз, кубик «лица» на его голове синхронно наклоняется вверх и вниз, точно показывая направление его взгляда.

o  Приседание: напарник нажимает LeftControl — его модель на экране второго студента плавно сжимается вдвое по высоте.

o  Прыжки: напарник нажимает Space — его модель совершает вертикальный прыжок без рывков.

o  Отключение: один из студентов закрывает клиент — его аватар мгновенно исчезает с арены у второго участника.

Этап 3. Разработка модульных префабов, генерация лабиринта с открытыми зонами и клиентская сборка уровня

На этом этапе плоскость  метров застраивается процедурным лабиринтом. Чтобы обеспечить свободу перемещения и тактические обходные пути, лабиринт генерируется на сервере по алгоритму с открытыми площадками (комнатами) и кольцевыми маршрутами, после чего передается клиентам для автоматической расстановки префабов.

Шаг 3.1. Создание библиотеки модульных префабов стен в Unity

Стены рассчитываются под модульную сетку с шагом ячейки  метра и высотой  метра.

1.   Откройте сцену GameScene.

2.   В окне Project создайте папку Assets/Prefabs/Walls.

3.   В папке Assets/Materials создайте материал для стен Mat_Wall (выберите контрастный цвет, например серый или кирпичный).

Префаб прямой стены (Wall_Straight):

1.  В Hierarchy нажмите: правый клик  Create Empty, назовите объект Wall_Straight.

2.  Внутри создайте примитив: правый клик по Wall_Straight  3D Object  Cube, назовите Wall_Mesh.

3.   Настройте Transform для Wall_Mesh:

o  Position: (0, 1.5, 0) (смещение вверх на половину высоты, чтобы основание лежало на полу).

o  Rotation: (0, 0, 0).

o  Scale: (4, 3, 0.4) (длина 4 м, высота 3 м, толщина 0.4 м).

4.   Убедитесь, что на объекте Wall_Mesh включен компонент Box Collider.

5.   Назначьте на Wall_Mesh материал Mat_Wall.

6.   Перетащите Wall_Straight из Hierarchy в папку Assets/Prefabs/Walls.

7.   Удалите объект со сцены.

Префаб колонны (Wall_Pillar):

Колонны используются на стыках стен для аккуратного перекрытия углов:

1.  В Hierarchy создайте куб: правый клик  3D Object  Cube, назовите Wall_Pillar.

2.   Настройте Transform:

o  Position: (0, 1.5, 0).

o  Rotation: (0, 0, 0).

o  Scale: (0.8, 3.2, 0.8).

3.   Назначьте материал Mat_Wall.

4.   Перетащите Wall_Pillar в папку Assets/Prefabs/Walls.

5.   Удалите объект со сцены.

Шаг 3.2. Реализация генератора лабиринта на стороне сервера (server.js)

Генератор создает сетку  ячеек (итоговый размер  метров), вырезает просторные открытые зоны в центре и по углам, а также пробивает часть тупиков для создания обходных колец.

Обновите файл server.js:

const { WebSocketServer } = require('ws');

 

const PORT = 7777;

const TICK_RATE = 20;

const wss = new WebSocketServer({ port: PORT });

const players = new Map();

 

// ==========================================

// ГЕНЕРАТОР ЛАБИРИНТА С КОМНАТАМИ И КОЛЬЦАМИ

// ==========================================

function generateArenaMaze(cols = 12, rows = 12, cellSize = 4) {

    const grid = [];

    for (let r = 0; r < rows; r++) {

        grid[r] = [];

        for (let c = 0; c < cols; c++) {

            grid[r][c] = { north: true, south: true, east: true, west: true, visited: false };

        }

    }

 

    // 1. Построение базовой связной структуры

    const stack = [];

    let current = { r: 0, c: 0 };

    grid[0][0].visited = true;

 

    while (true) {

        const neighbors = [];

        const { r, c } = current;

 

        if (r > 0 && !grid[r - 1][c].visited) neighbors.push({ r: r - 1, c, dir: 'north', opp: 'south' });

        if (r < rows - 1 && !grid[r + 1][c].visited) neighbors.push({ r: r + 1, c, dir: 'south', opp: 'north' });

        if (c < cols - 1 && !grid[r][c + 1].visited) neighbors.push({ r, c: c + 1, dir: 'east', opp: 'west' });

        if (c > 0 && !grid[r][c - 1].visited) neighbors.push({ r, c: c - 1, dir: 'west', opp: 'east' });

 

        if (neighbors.length > 0) {

            const next = neighbors[Math.floor(Math.random() * neighbors.length)];

            grid[current.r][current.c][next.dir] = false;

            grid[next.r][next.c][next.opp] = false;

            grid[next.r][next.c].visited = true;

            stack.push(current);

            current = { r: next.r, c: next.c };

        } else if (stack.length > 0) {

            current = stack.pop();

        } else {

            break;

        }

    }

 

    // 2. Создание открытых комнат (удаление внутренних перегородок)

    // Центральная арена 3x3 ячейки

    carveOpenRoom(grid, Math.floor(rows / 2) - 1, Math.floor(cols / 2) - 1, 3, 3);

    // Дополнительные открытые зоны 2x2 ячейки

    carveOpenRoom(grid, 1, 1, 2, 2);

    carveOpenRoom(grid, rows - 3, cols - 3, 2, 2);

 

    // 3. Формирование кольцевых проходов (Braid)

    for (let r = 0; r < rows; r++) {

        for (let c = 0; c < cols; c++) {

            let wallCount = 0;

            if (grid[r][c].north) wallCount++;

            if (grid[r][c].south) wallCount++;

            if (grid[r][c].east) wallCount++;

            if (grid[r][c].west) wallCount++;

 

            if (wallCount === 3 && Math.random() < 0.4) {

                const candidates = [];

                if (r > 0 && grid[r][c].north) candidates.push({ r1: r, c1: c, dir: 'north', r2: r - 1, c2: c, opp: 'south' });

                if (r < rows - 1 && grid[r][c].south) candidates.push({ r1: r, c1: c, dir: 'south', r2: r + 1, c2: c, opp: 'north' });

                if (c < cols - 1 && grid[r][c].east) candidates.push({ r1: r, c1: c, dir: 'east', r2: r, c2: c + 1, opp: 'west' });

                if (c > 0 && grid[r][c].west) candidates.push({ r1: r, c1: c, dir: 'west', r2: r, c2: c - 1, opp: 'east' });

 

                if (candidates.length > 0) {

                    const pick = candidates[Math.floor(Math.random() * candidates.length)];

                    grid[pick.r1][pick.c1][pick.dir] = false;

                    grid[pick.r2][pick.c2][pick.opp] = false;

                }

            }

        }

    }

 

    // 4. Перевод в массив мировых координат

    const walls = [];

    const offsetX = -(cols * cellSize) / 2;

    const offsetZ = -(rows * cellSize) / 2;

 

    for (let r = 0; r < rows; r++) {

        for (let c = 0; c < cols; c++) {

            const centerX = offsetX + c * cellSize + cellSize / 2;

            const centerZ = offsetZ + r * cellSize + cellSize / 2;

 

            if (grid[r][c].north) {

                walls.push({ x: Number(centerX.toFixed(2)), z: Number((centerZ + cellSize / 2).toFixed(2)), rot: 0 });

            }

            if (grid[r][c].west) {

                walls.push({ x: Number((centerX - cellSize / 2).toFixed(2)), z: Number(centerZ.toFixed(2)), rot: 90 });

            }

            if (r === rows - 1 && grid[r][c].south) {

                walls.push({ x: Number(centerX.toFixed(2)), z: Number((centerZ - cellSize / 2).toFixed(2)), rot: 0 });

            }

            if (c === cols - 1 && grid[r][c].east) {

                walls.push({ x: Number((centerX + cellSize / 2).toFixed(2)), z: Number(centerZ.toFixed(2)), rot: 90 });

            }

        }

    }

 

    return walls;

}

 

function carveOpenRoom(grid, startR, startC, h, w) {

    for (let r = startR; r < startR + h; r++) {

        for (let c = startC; c < startC + w; c++) {

            if (r + 1 < startR + h) {

                grid[r][c].south = false;

                grid[r + 1][c].north = false;

            }

            if (c + 1 < startC + w) {

                grid[r][c].east = false;

                grid[r][c + 1].west = false;

            }

        }

    }

}

 

// Генерация карты при запуске сервера

const globalWalls = generateArenaMaze(12, 12, 4);

console.log(`[СЕРВЕР] Карта создана. Сегментов стен: ${globalWalls.length}`);

 

// ==========================================

// СЕТЕВОЙ ЦИКЛ

// ==========================================

wss.on('connection', (ws) => {

    const clientId = 'p_' + Math.random().toString(36).substr(2, 8);

 

    // Точка спавна в центре открытой площадки

    const initialPlayerState = {

        id: clientId,

        name: 'Player',

        x: 0.0,

        y: 0.0,

        z: 0.0,

        yaw: 0.0,

        pitch: 0.0,

        crouch: false

    };

 

    players.set(clientId, { ws, state: initialPlayerState });

 

    // Отправка клиенту структуры лабиринта и точки спавна

    ws.send(JSON.stringify({

        type: 'INIT_WORLD',

        id: clientId,

        spawnPos: [initialPlayerState.x, initialPlayerState.y, initialPlayerState.z],

        walls: globalWalls

    }));

 

    ws.on('message', (messageRaw) => {

        try {

            const data = JSON.parse(messageRaw);

            if (data.type === 'JOIN') {

                const session = players.get(clientId);

                if (session) session.state.name = data.name || 'Anonymous';

            }

            if (data.type === 'MOVE') {

                const session = players.get(clientId);

                if (session) {

                    session.state.x = Number(data.x);

                    session.state.y = Number(data.y);

                    session.state.z = Number(data.z);

                    session.state.yaw = Number(data.yaw);

                    session.state.pitch = Number(data.pitch);

                    session.state.crouch = Boolean(data.crouch);

                }

            }

        } catch (err) {

            console.error('[ОШИБКА ДАННЫХ]', err);

        }

    });

 

    ws.on('close', () => {

        players.delete(clientId);

        broadcast({ type: 'PLAYER_LEFT', id: clientId });

    });

});

 

setInterval(() => {

    if (players.size === 0) return;

    broadcast({

        type: 'SNAPSHOT',

        players: Array.from(players.values()).map(p => p.state)

    });

}, 1000 / TICK_RATE);

 

function broadcast(packet) {

    const raw = JSON.stringify(packet);

    for (const { ws } of players.values()) {

        if (ws.readyState === 1) ws.send(raw);

    }

}

Шаг 3.3. Создание скрипта строителя лабиринта (MazeBuilder.cs)

1.   В папке Assets/Scripts создайте скрипт MazeBuilder.cs:

using System;

using UnityEngine;

 

[Serializable]

public class WallSegmentData

{

    public float x;

    public float z;

    public float rot;

}

 

[Serializable]

public class InitWorldPacket

{

    public string type;

    public string id;

    public float[] spawnPos;

    public WallSegmentData[] walls;

}

 

public class MazeBuilder : MonoBehaviour

{

    [Header("Префабы модулей")]

    [SerializeField] private GameObject straightWallPrefab;

    [SerializeField] private GameObject pillarPrefab;

 

    [Header("Иерархия")]

    [SerializeField] private Transform mazeRoot;

 

    public event Action OnMazeBuildFinished;

 

    public void BuildFromNetworkData(WallSegmentData[] walls)

    {

        if (walls == null || walls.Length == 0) return;

 

        // Очистка старых объектов перед сборкой

        for (int i = mazeRoot.childCount - 1; i >= 0; i--)

        {

            Destroy(mazeRoot.GetChild(i).gameObject);

        }

 

        // 1. Построение секций стен

        foreach (var w in walls)

        {

            Vector3 position = new Vector3(w.x, 0f, w.z);

            Quaternion rotation = Quaternion.Euler(0f, w.rot, 0f);

 

            Instantiate(straightWallPrefab, position, rotation, mazeRoot);

        }

 

        // 2. Расстановка колонн на стыках сетки

        if (pillarPrefab != null)

        {

            int cols = 12;

            int rows = 12;

            float cellSize = 4f;

            float offsetX = -(cols * cellSize) / 2f;

            float offsetZ = -(rows * cellSize) / 2f;

 

            for (int r = 0; r <= rows; r++)

            {

                for (int c = 0; c <= cols; c++)

                {

                    Vector3 pillarPos = new Vector3(offsetX + c * cellSize, 0f, offsetZ + r * cellSize);

                    Instantiate(pillarPrefab, pillarPos, Quaternion.identity, mazeRoot);

                }

            }

        }

 

        Debug.Log($"[MazeBuilder] Уровень построен. Стен: {walls.Length}");

        OnMazeBuildFinished?.Invoke();

    }

}

2.   Откройте сцену GameScene.

3.   Выберите объект [MAZE_ROOT] в Hierarchy и добавьте на него компонент MazeBuilder.

4.   В инспекторе компонента настройте связи:

o  Straight Wall Prefab: перетащите префаб Wall_Straight из Assets/Prefabs/Walls.

o  Pillar Prefab: перетащите префаб Wall_Pillar из Assets/Prefabs/Walls.

o  Maze Root: перетащите объект [MAZE_ROOT].

Шаг 3.4. Обновление NetworkClient.cs для обработки карты

1.   Откройте NetworkClient.cs и добавьте ссылку на строитель:

[SerializeField] private MazeBuilder mazeBuilder;

2.   В методе HandleServerMessage замените блок обработки инициализации:

if (json.Contains("\"INIT_WORLD\""))

{

    InitWorldPacket packet = JsonUtility.FromJson<InitWorldPacket>(json);

    myClientId = packet.id;

 

    // Постройка лабиринта по данным от сервера

    if (mazeBuilder != null)

    {

        mazeBuilder.BuildFromNetworkData(packet.walls);

    }

 

    // Спавн локального игрока в стартовой позиции

    Vector3 spawnPos = new Vector3(packet.spawnPos[0], packet.spawnPos[1], packet.spawnPos[2]);

    SpawnLocalPlayer(spawnPos);

}

3.   В Hierarchy выберите [NETWORK_MANAGER] и в инспекторе перетащите объект [MAZE_ROOT] в поле Maze Builder.

4.   Сохраните сцену (Ctrl + S).

Шаг 3.5. Практическая проверка этапа

1.   Перезапустите сервер в терминале:

node server.js

2.   Подключитесь к серверу из сцены LobbyScene.

3.       Контрольные пункты проверки:

o  Сборка карты: на арене возводятся стены, формируя лабиринт с коридорами и перекрестками.

o   Открытые арены: в центре присутствует просторная зона без внутренних перегородок ( ячейки).

o  Кольца: коридоры лабиринта закольцованы, отсутствуют глухие тупики, блокирующие проход.

o  Синхронизация: на всех подключенных устройствах геометрия уровня совпадает, а игроки могут перемещаться по коридорам и видеть друг друга.

Этап 4. Однократное запекание статической миникарты и UI-маркеры игроков

На этом этапе создается наглядная миникарта в интерфейсе.

Главное архитектурное требование — оптимизация: вместо постоянной работы второй камеры (что снижает FPS и удваивает вызовы отрисовки), вспомогательная ортографическая камера создается программно ровно на один кадр сразу после постройки лабиринта, рендерит вид сверху в статическую текстуру Texture2D и полностью уничтожается. Все маркеры участников существуют исключительно в 2D-интерфейсе Canvas поверх этой картинки.

Шаг 4.1. Подготовка UI-интерфейса миникарты на сцене GameScene

1.   Откройте сцену GameScene.

2.  Создайте интерфейсный холст (если его еще нет на игровой сцене): в Hierarchy нажмите правый клик  UI  Canvas, назовите его GameHUD.

3.   В компоненте Canvas Scaler:

o  UI Scale Mode: Scale With Screen Size.

o  Reference Resolution: 1920 x 1080.

4.   Внутри GameHUD создайте контейнер для миникарты:

o   Правый клик по GameHUD  UI  Panel, назовите MinimapContainer.

o  Настройте RectTransform: привяжите панель к правому верхнему углу (Anchor Presets: зажмите Shift + Alt и выберите правый верхний угол).

o  Задайте параметры: Pos X = -30, Pos Y = -30, Width = 260, Height = 260.

o  Назначьте темный полупрозрачный цвет фона или рамку.

5.   Внутри MinimapContainer создайте объект вывода текстуры:

o   Правый клик по MinimapContainer  UI  Raw Image, назовите MinimapDisplay.

o  Растяните его по центру контейнера с небольшим отступом: Width = 240, Height = 240, Pos X = 0, Pos Y = 0.

6.   Создайте внутри MinimapDisplay пустой объект для меток игроков:

o   Правый клик по MinimapDisplay  Create Empty, назовите MarkersLayer.

o  Растяните его на всю площадь родителя (Anchor: stretch/stretch, Left = 0, Right = 0, Top = 0, Bottom = 0).

Шаг 4.2. Создание UI-префабов маркеров игроков

Все маркеры — это легковесные 2D UI-элементы (Image), которые не нагружают физику и не видны в 3D-пространстве.

1. Маркер локального игрока (UI_Marker_Local):

1.  В Hierarchy нажмите: правый клик по MarkersLayer  UI  Image, назовите UI_Marker_Local.

2.   Настройте RectTransform:

o  Width: 16, Height: 16.

o  Pivot: (0.5, 0.5).

3.   В компоненте Image:

o   Назначьте спрайт стрелочки или треугольника (указывающего острием вверх, вдоль локальной оси ).

o  Задайте контрастный цвет (например, ярко-зеленый).

4.   Перетащите UI_Marker_Local в папку Assets/Prefabs и удалите его из Hierarchy.

2. Маркер сетевого игрока (UI_Marker_Remote):

1.  В Hierarchy нажмите: правый клик по MarkersLayer  UI  Image, назовите UI_Marker_Remote.

2.   Настройте RectTransform:

o  Width: 12, Height: 12.

3.   В компоненте Image:

o  Назначьте круглый спрайт (Knob или Circle).

o  Задайте цвет (например, красный или оранжевый).

4.   Перетащите UI_Marker_Remote в папку Assets/Prefabs и удалите его из Hierarchy.

Шаг 4.3. Скрипт однократного запекания схемы (MinimapBaker.cs)

Скрипт находит крайние границы стен построенного лабиринта, создает временную камеру на один кадр, считывает пиксели в Texture2D, передает изображение в RawImage и удаляет камеру.

1.   В папке Assets/Scripts создайте C# скрипт MinimapBaker.cs:

using UnityEngine;

using UnityEngine.UI;

 

public class MinimapBaker : MonoBehaviour

{

    [Header("UI Компоненты")]

    [SerializeField] private RawImage minimapDisplay;

 

    [Header("Параметры снимка")]

    [SerializeField] private int resolution = 512;

    [SerializeField] private Color mapBackgroundColor = new Color(0.1f, 0.1f, 0.12f, 1f);

 

    public Bounds LevelBounds { get; private set; }

 

    /// <summary>

    /// Вызывается единожды сразу после окончания постройки лабиринта

    /// </summary>

    public void Bake(Transform mazeRoot)

    {

        if (mazeRoot == null || minimapDisplay == null) return;

 

        // 1. Вычисляем точные габариты лабиринта по всем его рендерерам

        Renderer[] renderers = mazeRoot.GetComponentsInChildren<Renderer>();

        if (renderers.Length == 0)

        {

            Debug.LogWarning("[MinimapBaker] Стены лабиринта не содержат рендереров!");

            return;

        }

 

        Bounds bounds = renderers[0].bounds;

        for (int i = 1; i < renderers.Length; i++)

        {

            bounds.Encapsulate(renderers[i].bounds);

        }

        LevelBounds = bounds;

 

        // 2. Создаем временную ортографическую камеру над центром лабиринта

        GameObject camObj = new GameObject("Temp_MinimapCamera");

        Camera cam = camObj.AddComponent<Camera>();

        cam.orthographic = true;

        cam.clearFlags = CameraClearFlags.SolidColor;

        cam.backgroundColor = mapBackgroundColor;

 

        // Позиционируем строго над центром уровня и направляем вниз

        camObj.transform.position = new Vector3(bounds.center.x, bounds.max.y + 50f, bounds.center.z);

        camObj.transform.rotation = Quaternion.Euler(90f, 0f, 0f);

 

        // Рассчитываем размер охвата ортографической камеры с небольшим запасом 5%

        float maxDimension = Mathf.Max(bounds.size.x, bounds.size.z);

        cam.orthographicSize = (maxDimension / 2f) * 1.05f;

 

        // 3. Рендерим один кадр в RenderTexture

        RenderTexture rt = new RenderTexture(resolution, resolution, 16);

        cam.targetTexture = rt;

        cam.Render();

 

        // 4. Копируем результат в постоянную статическую Texture2D

        RenderTexture.active = rt;

        Texture2D staticMapTexture = new Texture2D(resolution, resolution, TextureFormat.RGB24, false);

        staticMapTexture.ReadPixels(new Rect(0, 0, resolution, resolution), 0, 0);

        staticMapTexture.Apply();

 

        // 5. Полное освобождение ресурсов: удаление камеры и буфера

        cam.targetTexture = null;

        RenderTexture.active = null;

        Destroy(rt);

        Destroy(camObj);

 

        // 6. Передаем статическую текстуру в UI

        minimapDisplay.texture = staticMapTexture;

 

        Debug.Log($"[MinimapBaker] Схема успешно запечена в 2D-текстуру ({resolution}x{resolution}). Временная камера удалена.");

    }

}

Шаг 4.4. Скрипт позиционирования маркеров (MinimapMarkersManager.cs)

Скрипт проецирует мировые координаты  игроков в двумерную плоскость RectTransform миникарты.

1.   В папке Assets/Scripts создайте C# скрипт MinimapMarkersManager.cs:

using System.Collections.Generic;

using UnityEngine;

 

public class MinimapMarkersManager : MonoBehaviour

{

    [Header("Ссылки")]

    [SerializeField] private MinimapBaker baker;

    [SerializeField] private RectTransform markersContainer; // Объект MarkersLayer в Canvas

 

    [Header("UI Префабы маркеров")]

    [SerializeField] private GameObject localMarkerPrefab;

    [SerializeField] private GameObject remoteMarkerPrefab;

 

    private RectTransform localMarkerRect;

    private Transform localPlayerTransform;

 

    // Реестр сетевых маркеров: ID -> { RectTransform маркера, Transform сетевого игрока }

    private readonly Dictionary<string, RectTransform> remoteMarkers = new Dictionary<string, RectTransform>();

    private readonly Dictionary<string, Transform> remoteTransforms = new Dictionary<string, Transform>();

 

    public void RegisterLocalPlayer(Transform playerTr)

    {

        localPlayerTransform = playerTr;

        if (localMarkerPrefab != null && markersContainer != null)

        {

            GameObject markerObj = Instantiate(localMarkerPrefab, markersContainer);

            localMarkerRect = markerObj.GetComponent<RectTransform>();

        }

    }

 

    public void RegisterRemotePlayer(string id, Transform playerTr)

    {

        if (remoteMarkers.ContainsKey(id)) return;

 

        if (remoteMarkerPrefab != null && markersContainer != null)

        {

            GameObject markerObj = Instantiate(remoteMarkerPrefab, markersContainer);

            remoteMarkers.Add(id, markerObj.GetComponent<RectTransform>());

            remoteTransforms.Add(id, playerTr);

        }

    }

 

    public void UnregisterRemotePlayer(string id)

    {

        if (remoteMarkers.TryGetValue(id, out RectTransform markerRect))

        {

            Destroy(markerRect.gameObject);

            remoteMarkers.Remove(id);

            remoteTransforms.Remove(id);

        }

    }

 

    private void Update()

    {

        // Пока схема не запечена, границы равны нулю — координаты не проецируем

        if (baker == null || baker.LevelBounds.size == Vector3.zero) return;

 

        // 1. Обновление положения и угла поворота локального игрока

        if (localPlayerTransform != null && localMarkerRect != null)

        {

            localMarkerRect.anchoredPosition = WorldToMinimapPosition(localPlayerTransform.position);

            // Поворот стрелочки (инвертируем угол по оси Z для Canvas)

            localMarkerRect.localEulerAngles = new Vector3(0f, 0f, -localPlayerTransform.eulerAngles.y);

        }

 

        // 2. Обновление точек всех сетевых участников

        foreach (var kvp in remoteTransforms)

        {

            string id = kvp.Key;

            Transform netTr = kvp.Value;

 

            if (netTr != null && remoteMarkers.TryGetValue(id, out RectTransform markerRect))

            {

                markerRect.anchoredPosition = WorldToMinimapPosition(netTr.position);

            }

        }

    }

 

    /// <summary>

    /// Перевод мировых координат (X, Z) в координаты UI RectTransform

    /// </summary>

    private Vector2 WorldToMinimapPosition(Vector3 worldPos)

    {

        Bounds b = baker.LevelBounds;

        float maxDim = Mathf.Max(b.size.x, b.size.z) * 1.05f;

 

        // Нормализация координат от 0.0 до 1.0 относительно центра лабиринта

        float normX = (worldPos.x - b.center.x) / maxDim + 0.5f;

        float normZ = (worldPos.z - b.center.z) / maxDim + 0.5f;

 

        // Преобразование в пиксельные координаты контейнера с центром в (0, 0)

        float uiX = (normX - 0.5f) * markersContainer.rect.width;

        float uiY = (normZ - 0.5f) * markersContainer.rect.height;

 

        return new Vector2(uiX, uiY);

    }

}

Шаг 4.5. Размещение менеджеров и финальная сборка на сцене GameScene

1.   В Hierarchy выберите MinimapContainer:

o  Добавьте компонент MinimapBaker.

o  Перетащите объект MinimapDisplay в поле Minimap Display.

2.   На этот же объект MinimapContainer добавьте компонент MinimapMarkersManager:

o  В поле Baker перетащите MinimapContainer (на котором висит MinimapBaker).

o  В поле Markers Container перетащите дочерний объект MarkersLayer.

o  В поле Local Marker Prefab назначьте префаб UI_Marker_Local.

o  В поле Remote Marker Prefab назначьте префаб UI_Marker_Remote.

Шаг 4.6. Связка событий в NetworkClient.cs

Финальный шаг — настроить строгую очередность:

1.   При получении пакета строится лабиринт.

2.   Сразу после постройки запекается миникарта.

3.   Спавнится локальный игрок и добавляется его стрелочка на миникарту.

4.   При спавне сетевых игроков регистрируются их маркеры, а при выходе — удаляются.

Откройте NetworkClient.cs и добавьте интеграцию с миникартой:

[Header("Миникарта")]

[SerializeField] private MinimapBaker minimapBaker;

[SerializeField] private MinimapMarkersManager markersManager;

[SerializeField] private Transform mazeRoot;

Дополните методы в NetworkClient.cs:

private void HandleServerMessage(string json)

{

    if (json.Contains("\"INIT_WORLD\""))

    {

        InitWorldPacket packet = JsonUtility.FromJson<InitWorldPacket>(json);

        myClientId = packet.id;

 

        // 1. Постройка лабиринта

        if (mazeBuilder != null)

        {

            mazeBuilder.BuildFromNetworkData(packet.walls);

        }

 

        // 2. Однократное запекание схемы лабиринта в статическую текстуру

        if (minimapBaker != null && mazeRoot != null)

        {

            minimapBaker.Bake(mazeRoot);

        }

 

        // 3. Спавн локального игрока и регистрация на миникарте

        Vector3 spawnPos = new Vector3(packet.spawnPos[0], packet.spawnPos[1], packet.spawnPos[2]);

        SpawnLocalPlayer(spawnPos);

 

        if (markersManager != null)

        {

            markersManager.RegisterLocalPlayer(localPlayerInstance.transform);

        }

    }

    else if (json.Contains("\"SNAPSHOT\""))

    {

        SnapshotPacket snapshot = JsonUtility.FromJson<SnapshotPacket>(json);

        ProcessWorldSnapshot(snapshot);

    }

    else if (json.Contains("\"PLAYER_LEFT\""))

    {

        PlayerLeftPacket packet = JsonUtility.FromJson<PlayerLeftPacket>(json);

        RemoveRemotePlayer(packet.id);

    }

}

 

private void ProcessWorldSnapshot(SnapshotPacket snapshot)

{

    if (string.IsNullOrEmpty(myClientId)) return;

 

    foreach (var data in snapshot.players)

    {

        if (data.id == myClientId) continue;

 

        Vector3 netPos = new Vector3(data.x, data.y, data.z);

 

        if (!remotePlayers.ContainsKey(data.id))

        {

            GameObject remoteObj = Instantiate(networkPlayerPrefab, netPos, Quaternion.Euler(0f, data.yaw, 0f));

            NetworkPlayer np = remoteObj.GetComponent<NetworkPlayer>();

            np.Initialize(data.name, netPos);

 

            remotePlayers.Add(data.id, np);

 

            // Регистрация маркера нового сокурсника на миникарте

            if (markersManager != null)

            {

                markersManager.RegisterRemotePlayer(data.id, remoteObj.transform);

            }

        }

        else

        {

            remotePlayers[data.id].UpdateNetworkState(netPos, data.yaw, data.pitch, data.crouch);

        }

    }

}

 

private void RemoveRemotePlayer(string id)

{

    if (remotePlayers.TryGetValue(id, out NetworkPlayer player))

    {

        Destroy(player.gameObject);

        remotePlayers.Remove(id);

 

        // Удаление маркера с миникарты

        if (markersManager != null)

        {

            markersManager.UnregisterRemotePlayer(id);

        }

    }

}

В инспекторе объекта [NETWORK_MANAGER]:

·     Перетащите MinimapContainer в поля Minimap Baker и Markers Manager.

·     Перетащите [MAZE_ROOT] в поле Maze Root.

·     Сохраните проект.

Шаг 4.7. Итоговая проверка всей лабораторной работы в парах

1.       Запустите сервер: node server.js.

2.       Оба студента входят через LobbyScene, указав никнеймы и IP-адрес.

3.       Критерии завершения лабораторной работы:

o  Генерация и вид: в правом верхнем углу экрана отображается аккуратная квадратная схема коридоров и комнат сгенерированного лабиринта.

o  Отсутствие оверхеда камер: в Hierarchy во время игры присутствует только одна камера (Main Camera локального игрока). Временная камера съемки отсутствует.

o  Стрелочка локального игрока: зеленая стрелка точно перемещается по нарисованным коридорам миникарты и поворачивается острием в сторону реального взгляда игрока.

o  Маркеры напарников: маркер напарника перемещается по соседним коридорам миникарты синхронно с его движением.

o  Отключение: при выходе напарника из игры его маркер моментально исчезает с миникарты.