Содержание блога
Box2D позволяет легко создать главного героя, который не проходит сквозь стены и перемещается с заданным трением о препятствия, которые можно располагать под углом, как верхнее препятствие на этом скриншоте:

Финальная демка этой инструкции. Итоговый код: finish-player-movement-sdl3-c.zip
В физическом мире Box2D перемещать главного героя (ГГ) можно с помощью: установки линейной скорости, импульса или приложения силы. В данной инструкции используем установку линейной скорости с помощью функции b2Body_SetLinearVelocity(). Коллайдером для ГГ будет круг, которому запретим вращение. На Desktop управлять движением будем с помощью клавиш WASD (и клавиш-стрелок). Для браузеров на мобильных устройствах добавим четыре квадрата-кнопки для движения ГГ при касании этих квадратов-кнопок.
Подключение библиотек Box2D и SDL3 к стартовому примеру
- Установите Emscripten 4.0.15 и CMake по инструкции: Установка Emscripten SDK (emsdk) и CMake
- Скачайте стартовый пример: start-player-movement-sdl3-c.zip
- Примечание. Этот стартовый пример был получен в результате выполнения пошаговой инструкции: Подключение Box2D v3, физика и отрисовка коллайдеров
- Так выглядит код стартового примера:
src/main.c
| C | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
| #define SDL_MAIN_USE_CALLBACKS 1 // Use the callbacks instead of main()
#include "debug-drawer.h"
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <box2d/box2d.h>
#include <stdio.h>
#define TIME_STEP (1.0f / 60.0f)
#define MAX_FRAME_TIME 0.25f
static SDL_Window *window = NULL;
SDL_Renderer *renderer = NULL;
static b2WorldId worldId;
static b2DebugDraw debugDrawer;
const SDL_PixelFormatDetails *format = NULL;
float ppm = 30.f; // Pixels per meter
static float accumulator = 0.0f;
static Uint64 last_ticks = 0;
SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
{
if (!SDL_Init(SDL_INIT_VIDEO))
{
SDL_Log("Couldn't initialize SDL: %s", SDL_GetError());
return SDL_APP_FAILURE;
}
if (!SDL_CreateWindowAndRenderer("Example", 400, 400, 0, &window, &renderer))
{
SDL_Log("Couldn't create window/renderer: %s", SDL_GetError());
return SDL_APP_FAILURE;
}
SDL_SetRenderVSync(renderer, 1);
// Создаём физический мир Box2D с гравитацией
b2Vec2 gravity = { 0.f, 9.8f };
b2WorldDef worldDef = b2DefaultWorldDef();
worldDef.gravity = gravity;
worldId = b2CreateWorld(&worldDef);
// Выводим значение гравитации в консоль
// Получаем текущее значение гравитации
b2Vec2 currentGravity = b2World_GetGravity(worldId);
// Выводим компоненты x и y
printf("Текущее значение гравитации: x = %.2f, y = %.2f\n", currentGravity.x, currentGravity.y);
debugDrawer = b2DefaultDebugDraw();
debugDrawer.drawShapes = true;
debugDrawer.DrawSolidPolygonFcn = drawSolidPolygon;
debugDrawer.DrawSolidCircleFcn = drawSolidCircle;
// Floor
b2BodyDef floorBodyDef = b2DefaultBodyDef();
floorBodyDef.type = b2_staticBody;
floorBodyDef.position = (b2Vec2) { 200.f / ppm, 300.f / ppm };
b2BodyId floorBodyId = b2CreateBody(worldId, &floorBodyDef);
b2Polygon floorShape = b2MakeBox((300.f / 2.f) / ppm, (20.f / 2.f) / ppm);
b2ShapeDef floorShapeDef = b2DefaultShapeDef();
b2ShapeId floorShapeId = b2CreatePolygonShape(floorBodyId, &floorShapeDef, &floorShape);
// Square
b2BodyDef squareBodyDef = b2DefaultBodyDef();
squareBodyDef.type = b2_dynamicBody;
squareBodyDef.position = (b2Vec2) { 201.f / ppm, 100.f / ppm };
squareBodyDef.enableSleep = false;
b2BodyId squareBodyId = b2CreateBody(worldId, &squareBodyDef);
b2Polygon squareShape = b2MakeBox((30.f / 2.f) / ppm, (30.f / 2.f) / ppm);
b2ShapeDef squareShapeDef = b2DefaultShapeDef();
b2ShapeId squareShapeId = b2CreatePolygonShape(squareBodyId, &squareShapeDef, &squareShape);
// Circle
b2BodyDef circleBodyDef = b2DefaultBodyDef();
circleBodyDef.type = b2_dynamicBody;
circleBodyDef.position = (b2Vec2) { 200.f / ppm, 150.f / ppm };
circleBodyDef.enableSleep = false;
b2BodyId circleBodyId = b2CreateBody(worldId, &circleBodyDef);
b2Circle circleCircle = {
.center = { 0.0f, 0.0f }, // <--- ВАЖНО: 0,0 означает, что центр круга совпадает с центром тела
.radius = 20.f / ppm // Радиус в метрах
};
b2ShapeDef circleShapeDef = b2DefaultShapeDef();
circleShapeDef.density = 1.0f;
b2ShapeId circleShapeId = b2CreateCircleShape(circleBodyId, &circleShapeDef, &circleCircle);
// Get the pixel format
SDL_Surface *surface = SDL_GetWindowSurface(window);
format = SDL_GetPixelFormatDetails(surface->format);
last_ticks = SDL_GetTicks();
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event)
{
if (event->type == SDL_EVENT_QUIT)
{
return SDL_APP_SUCCESS;
}
// --- DESKTOP ---
if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN)
{
if (event->button.which != SDL_TOUCH_MOUSEID)
{
const char *btnName = (event->button.button == SDL_BUTTON_LEFT) ? "Левая" : "Правая";
printf("%s кнопка (Мышь): x=%.0f, y=%.0f\n", btnName, event->button.x, event->button.y);
}
}
// --- MOBILE / WASM (Touch) ---
if (event->type == SDL_EVENT_FINGER_DOWN)
{
int w, h;
SDL_GetWindowSize(window, &w, &h);
float pixelX = event->tfinger.x * w;
float pixelY = event->tfinger.y * h;
// Проверяем количество пальцев на устройстве (touchId берем из события)
int numFingers = 0;
SDL_GetTouchFingers(event->tfinger.touchID, &numFingers);
if (numFingers <= 1)
{
printf("Левая кнопка (Тап 1 пальцем): x=%.2f, y=%.2f\n", pixelX, pixelY);
}
else
{
printf("Правая кнопка (Тап несколькими): x=%.2f, y=%.2f\n", pixelX, pixelY);
}
}
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppIterate(void *appstate)
{
Uint64 current_ticks = SDL_GetTicks();
float frameTime = (float)(current_ticks - last_ticks) / 1000.0f;
last_ticks = current_ticks;
if (frameTime > MAX_FRAME_TIME)
{
frameTime = MAX_FRAME_TIME;
}
accumulator += frameTime;
while (accumulator >= TIME_STEP)
{
// Step physics
b2World_Step(worldId, TIME_STEP, 3);
accumulator -= TIME_STEP;
}
// Clear the screen
SDL_SetRenderDrawColor(renderer, 100, 100, 100, 255);
SDL_RenderClear(renderer);
b2World_Draw(worldId, &debugDrawer);
// Update the screen
SDL_RenderPresent(renderer);
return SDL_APP_CONTINUE;
}
void SDL_AppQuit(void *appstate, SDL_AppResult result)
{
// Удаляем физический мир Box2D
if (b2World_IsValid(worldId))
{
b2DestroyWorld(worldId);
}
// SDL will clean up the window/renderer for us
} |
|
src/debug-drawer.h
| C | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
| #ifndef DEBUG_DRAWER_H
#define DEBUG_DRAWER_H
#include <SDL3/SDL.h>
#include <box2d/box2d.h>
extern SDL_Renderer *renderer;
extern const SDL_PixelFormatDetails *format;
extern float ppm;
static void drawSolidPolygon(b2Transform transform,
const b2Vec2 *vertices, int vertexCount,
float radius, b2HexColor color, void *context)
{
(void)radius;
(void)context;
// Extract RGB
Uint8 r, g, b;
SDL_GetRGB(color, format, NULL, &r, &g, &b);
// Draw a collider rectangle with lines
SDL_SetRenderDrawColor(renderer, r, g, b, SDL_ALPHA_OPAQUE);
for (int i = 0; i < vertexCount; ++i)
{
int next_index = (i + 1 == vertexCount) ? 0 : i + 1;
b2Vec2 p0 = b2TransformPoint(transform, vertices[i]);
b2Vec2 p1 = b2TransformPoint(transform, vertices[next_index]);
float x0 = p0.x * ppm;
float y0 = p0.y * ppm;
float x1 = p1.x * ppm;
float y1 = p1.y * ppm;
SDL_RenderLine(renderer, x0, y0, x1, y1);
}
// --- Tip: Draw orientation line ---
// Compute polygon center
float cx = 0.f, cy = 0.f;
for (int i = 0; i < vertexCount; ++i)
{
b2Vec2 p = b2TransformPoint(transform, vertices[i]);
cx += p.x;
cy += p.y;
}
cx = (cx / vertexCount) * ppm;
cy = (cy / vertexCount) * ppm;
// Midpoint of second edge (vertex[1] → vertex[2])
b2Vec2 p1 = b2TransformPoint(transform, vertices[1]);
b2Vec2 p2 = b2TransformPoint(transform, vertices[2]);
float mx = (p1.x + p2.x) * 0.5f * ppm;
float my = (p1.y + p2.y) * 0.5f * ppm;
// Draw center → edge midpoint line (acts like "direction tip")
SDL_RenderLine(renderer, cx, cy, mx, my);
}
static void drawSolidCircle(b2Transform transform, float radius,
b2HexColor color, void *context)
{
(void)radius;
(void)context;
float angle = 0.f;
const int numberOfSegments = 20;
const float angleStep = 360.f / numberOfSegments;
// Extract RGB
Uint8 r, g, b;
SDL_GetRGB(color, format, NULL, &r, &g, &b);
// Draw a collider rectangle with lines
SDL_SetRenderDrawColor(renderer, r, g, b, SDL_ALPHA_OPAQUE);
float x = radius * cos(angle * SDL_PI_F / 180.f);
float y = radius * sin(angle * SDL_PI_F / 180.f);
b2Vec2 p0 = b2TransformPoint(transform, (b2Vec2) { x, y });
float x0 = p0.x * ppm;
float y0 = p0.y * ppm;
angle += angleStep;
for (int i = 0; i < numberOfSegments; ++i)
{
float x = radius * cos(angle * SDL_PI_F / 180.f);
float y = radius * sin(angle * SDL_PI_F / 180.f);
b2Vec2 p1 = b2TransformPoint(transform, (b2Vec2) { x, y });
float x1 = p1.x * ppm;
float y1 = p1.y * ppm;
SDL_RenderLine(renderer, x0, y0, x1, y1);
x0 = x1;
y0 = y1;
angle += angleStep;
if (angle >= 360.f)
{
angle = 0.f;
}
}
// --- Tip: Draw orientation line ---
// Circle center
b2Vec2 c = b2TransformPoint(transform, (b2Vec2) { 0.f, 0.f });
float cx = c.x * ppm;
float cy = c.y * ppm;
// Direction = transform applied to (radius, 0)
b2Vec2 orient = b2TransformPoint(transform, (b2Vec2) { radius, 0.f });
float ox = orient.x * ppm;
float oy = orient.y * ppm;
// Draw center → orientation marker
SDL_RenderLine(renderer, cx, cy, ox, oy);
}
#endif // DEBUG_DRAWER_H |
|
CMakeLists.txt
| Bash | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| cmake_minimum_required(VERSION 3.21)
project(start-player-movement-sdl3-c)
# Задаем название будущего приложения (в Windows это был бы app.exe, а в вебе будет app.js / app.wasm)
add_executable(app)
# Устанавливаем стандарт C
set(CMAKE_C_STANDARD 11)
# Указываем точное местоположение конфигурационных файлов библиотек
# Имя переменной должно строго соответствовать: НазваниеБиблиотеки_DIR
set(SDL3_DIR "C:/libs/SDL3-devel-3.4.0-wasm/lib/cmake/SDL3")
set(box2d_DIR "C:/libs/box2d-3.1.1-wasm/lib/cmake/box2d")
# Проверяем наличие библиотек в системе
# Если она не будет найдены, CMake прервет настройку с ошибкой
# REQUIRED - переводится, как «обязательно» или «требуется»
find_package(SDL3 REQUIRED)
find_package(box2d REQUIRED)
# Привязываем библиотеки к нашему приложению (настройка линковки и путей include)
target_link_libraries(app PRIVATE box2d::box2d SDL3::SDL3)
# Добавляем исходный код к проекту
target_sources(app
PRIVATE
src/main.c
) |
|
- Напомню, что результатом этого стартового примера являются падающие квадрат и круг на статичный коллайдер:

- Откройте папку со стартовым примером в какой-нибудь редакторе кода, например, в Notepad++ или в Sublime Text 4 (ST4): https://www.sublimetext.com/download
- Скачайте библиотеку SDL3 для Wasm: SDL3-devel-3.4.0-wasm.zip
- Скачайте библиотеку Box2D v3 для Wasm: box2d-3.1.1-wasm.zip
- Извлеките содержимое архив выше в какую-нибудь новую общую папку, например, с именем "libs" на какой-нибудь диск, например, на C. Таким образом, создайте на диске C папку "lib", скопируйте в неё архивы и извлеките их в текущую папку:

- Откройте папку "SDL3-devel-3.4.0-wasm", перейдите в папку "lib/cmake/SDL3" и скопируйте абсолютный путь:

- В файле CMakeLists.txt стартового примера замените в переменной SDL3_DIR путь на свой:
| Bash | 1
| set(SDL3_DIR "C:/libs/SDL3-devel-3.4.0-wasm/lib/cmake/SDL3") |
|
- Откройте папку "box2d-3.1.1-wasm", перейдите в папку "lib/cmake/box2d" и скопируйте абсолютный путь:

- Замените в переменной box2d_DIR путь на свой:
| Bash | 1
| set(SDL3_DIR "C:/libs/box2d-3.1.1-wasm/lib/cmake/box2d") |
|
Тестовая сборка и тестовый запуск стартового примера в браузере на локальном хостинге
- Откройте консоль (CMD) в корне стартового примера. Для этого можете просто в адресной строке папки (там где путь) написать "cmd" (без кавычек) и нажать Enter
- В CMD введите команду для конфигурирования:
- Примечание. "config-web" - это имя bash-файла (батника), который находится в корне папки стартового примера
- В CMD введите команду сборки проекта:
- Собранные файлы (app.js и app.wasm) будут скопированы в папку "public/js" (папка "public" лежит в корне проекта)
- В корне проекта запустите локальный сервер командой:
- Примечание 1. Будут выведены адреса:
| Bash | 1
2
3
4
| Available on:
http://192.168.1.65:8080
http://127.0.0.1:8080
Hit CTRL-C to stop the server |
|
- Примечание 2. Адрес 192.168.1.65:8080 можно использовать для запуска приложения на мобильном устройстве, если у вас есть Wi-Fi - просто вводите этот адрес в браузере мобильного устройства и приложение запустится. Если интересен запуск на мобильном устройстве, то пройдите пошаговую инструкцию: Основы отладки веб-приложений на SDL3 по USB и Wi-Fi
- Откройте новую вкладку в браузере. Если у вас Chrome или Edge, то нажмите Ctrl+Shift+J, чтобы открыть консоль браузера для контроля вывода информации. Если у вас FireFox, то нажмите Ctrl+Shift+K
- Перейдите по адресу:
- Примечание. Обновлять страницу после повторной сборки проекта лучше с очисткой кэша браузера. Для этого (обязательно с открытой консолью браузера) в браузере Chrome нужно найти в левом верхнем углу браузера кнопку обновления страницы (круговая стрелка), нажать по ней правой кнопкой мыши и выбрать "Empty Cache and Hard Reload"
- Мы убедились, что тестовый пример запускается нормально. Если вы что-то меняете в коде (в файлах main.c или CMakeLists.txt), то достаточно ввести команду для сборки build-web (в CMD нажать стрелку-вверх и Enter) и обновить вкладку в браузере. Удобнее всего, чтобы локальный хостинг был запущен в одном окне CMD, а сборку делать в другом окне CMD
Добавляем обработку нажатий клавиш
Управлять героем будет с помощью клавиш WASD и клавиш-стрелок. Цель, на данном этапе, выводить слова в консоль: "вверх", "вниз", "влево", "вправо" при нажатии соответствующих клавиш.
- В начале файла src/main.c наберите вручную код, для создания глобальной переменной:
| C | 1
2
3
4
5
6
7
8
9
| typedef struct
{
bool up;
bool down;
bool left;
bool right;
} Keys;
static Keys keys = { 0 }; |
|
- Наберите код следующий код для обработки нажатий клавиш WASD и клавиш-стрелок. Здесь мы меняет значения переменных (членов структуры keys) на true и false в зависимости от того, нажата ли клавиша или не нажата:
| C | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event)
{
if (event->type == SDL_EVENT_QUIT)
{
return SDL_APP_SUCCESS;
}
if (event->type == SDL_EVENT_KEY_DOWN)
{
SDL_Scancode sc = event->key.scancode;
if (sc == SDL_SCANCODE_W || sc == SDL_SCANCODE_UP)
keys.up = true;
if (sc == SDL_SCANCODE_S || sc == SDL_SCANCODE_DOWN)
keys.down = true;
if (sc == SDL_SCANCODE_A || sc == SDL_SCANCODE_LEFT)
keys.left = true;
if (sc == SDL_SCANCODE_D || sc == SDL_SCANCODE_RIGHT)
keys.right = true;
}
if (event->type == SDL_EVENT_KEY_UP)
{
SDL_Scancode sc = event->key.scancode;
if (sc == SDL_SCANCODE_W || sc == SDL_SCANCODE_UP)
keys.up = false;
if (sc == SDL_SCANCODE_S || sc == SDL_SCANCODE_DOWN)
keys.down = false;
if (sc == SDL_SCANCODE_A || sc == SDL_SCANCODE_LEFT)
keys.left = false;
if (sc == SDL_SCANCODE_D || sc == SDL_SCANCODE_RIGHT)
keys.right = false;
} |
|
- Примечание. Код обработки клика мышки можно удалить, но обработку касания экрана не удаляйте, потому что он нужен для управления героем на мобильных браузерах, чтобы отобразить кнопки управления (виртуальный джойстик), которые мы создадим позже
- Перед функций SDL_AppIterate() наберите код функции с именем keyboard() для обработки нажатий клавиш:
| C | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| void keyboard(void)
{
if (keys.up)
{
printf("вверх\n");
}
else if (keys.down)
{
printf("вниз\n");
}
else if (keys.left)
{
printf("влево\n");
}
else if (keys.right)
{
printf("вправо\n");
}
} |
|
- В начале функции SDL_AppIterate() добавьте вызов функции keyboard():
| C | 1
2
3
| SDL_AppResult SDL_AppIterate(void *appstate)
{
keyboard(); |
|
- Соберите приложение с помощью команды "build-web" и обновите вкладку браузера. Нажимайте клавиши WASD и клавиши-стрелки и вы увидите вывод в консоль:

Добавляем управление героем
Нам нужно будет удалить гравитацию, потому что у нас вид сверху на игровое поле. Цель: управлять героем путём установления скорости. Если кнопки управления не нажаты, то линейная скорость героя должна быть обнулена. Мы будем управлять круговым коллайдером - это коллайдер главного героя.
- Найдите строку кода задания гравитации и обнулите её:
| C | 1
| b2Vec2 gravity = { 0.f, 0.f }; |
|
- Найдите строку, где мы создаём идентификатор кругового коллайдера:
| C | 1
| b2BodyId circleBodyId = b2CreateBody(worldId, &circleBodyDef); |
|
- Идентификатор коллайдера ГГ должен быть глобальным, чтобы он был доступен в функции keyboard() для задания линейной скорости ГГ. Скопируйте "b2BodyId circleBodyId" и удалите объявление перед circleBodyId в указанной выше строке кода.
- Скопированное объявление идентификатора ("b2BodyId circleBodyId") скопируйте выше функции SDL_AppInit(), то есть сделайте объявление circleBodyId глобальным
- Добавьте глобальную константу playerSpeed с значениме 5:
| C | 1
| static const float playerSpeed = 5.f; |
|
- В функции keyboard() добавьте задание скорости ГГ:
| C | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| void keyboard(void)
{
b2Vec2 velocity = { 0.0f, 0.0f };
if (keys.up)
{
velocity.y = -playerSpeed;
}
else if (keys.down)
{
velocity.y = playerSpeed;
}
else if (keys.left)
{
velocity.x = -playerSpeed;
}
else if (keys.right)
{
velocity.x = playerSpeed;
}
b2Body_SetLinearVelocity(circleBodyId, velocity);
} |
|
- В первой консоли соберите приложение командой "build-web" и обновите вкладку браузера. Вы можете теперь управлять ГГ клавишами WASD и клавишам-стрелками
- Обратите внимание, что при управлении клавишами коллайдер ГГ поворачивается. Нужно убрать это ненужное вращение. Добавьте код в функции SDL_AppInit() запрещающий вращение:
| C | 1
| circleBodyDef.fixedRotation = true; |
|
- После пересборки приложения вы видите, что коллайдер не поворачивает при движении и ГГ не проходит через препятствие
Определяем в коде запущено ли приложение в Desktop-браузере или в браузере мобильного устройства
Цель: при запуске приложения вывести в консоль информацию на каком устройстве было запущено веб-приложение - из браузера ПК или из браузера мобильного устройства.
- Создайте глобальную переменную is_mobile:
| C | 1
| static bool is_mobile = false; |
|
- Создайте функцию is_mobile_browser():
| C | 1
2
3
4
5
6
7
8
9
10
| #ifdef __EMSCRIPTEN__
#include <emscripten.h>
// This JavaScript function returns true if the user agent matches mobile devices
EM_JS(bool, is_mobile_browser, (),
{
// We use the RegExp constructor to avoid using / / slashes which confuse Clang
var filter = new RegExp('iPhone|iPad|iPod|Android', 'i');
return filter.test(navigator.userAgent);
});
#endif |
|
- В функции SDL_AppInit() после функции создания окна и рисовальщика SDL_CreateWindowAndRenderer() добавляем код проверки на какой платформе был запуск веб-приложения (на Desktop или Mobile):
| C | 1
2
3
4
5
6
7
8
| // Platform detection
#ifdef __EMSCRIPTEN__
is_mobile = is_mobile_browser();
#elif defined(__ANDROID__) || defined(__IPHONEOS__)
is_mobile = true;
#endif
printf("Is Mobile: %s\n", is_mobile ? "Yes" : "No"); |
|
- Примечание. Для того, чтобы увидеть вывод в консоль с мобильного браузера через USB-кабель или Wi-Fi выполните инструкцию: Основы отладки веб-приложений на SDL3 по USB и Wi-Fi, запущенных в браузере мобильных устройств
- При запуске в браузере на Desktop вы увидите в консоле браузера:
- При запуске в браузере мобильного устройства вы увидите в консоле браузера:
Запрет реакции смартфона на долгое удержание пальца на холсте
Если удерживать палец на холсте в мобильном браузере, то смартфон реагирует выделением текста, вызовом контекстного меню и т.д. Запрещаем это. Добавьте в public/index.html в тег <head> следующий код:
| PHP/HTML | 1
2
3
4
5
6
7
8
| <style>
canvas {
touch-action: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
</style> |
|
Добавляем джойстик для управления главным героем на мобильном браузере
Добавим четыре квадрата, чтобы управлять ГГ на мобильном браузере.
- Создайте глобальные константы для размера кнопок (BTN_SIZE), расстояния между кнопками (BTN_GAP) и структуры SDL_FRect для хранения координат и размеров кнопок:
| C | 1
2
3
4
| static bool is_mobile = false;
static const int BTN_SIZE = 60;
static const int BTN_GAP = 5;
static SDL_FRect btnUp, btnDown, btnLeft, btnRight; |
|
- Добавьте функцию updateButtonLayout() для расчёта позиций и размеров кнопок перед функций SDL_AppInit():
| C | 1
2
3
4
5
6
7
8
9
10
11
| void updateButtonLayout(int w, int h)
{
// Positioning squares in a cross pattern at the bottom-left
float startX = 20.0f;
float startY = h - (BTN_SIZE * 3) - 20.0f;
btnUp = (SDL_FRect) { startX + BTN_SIZE + BTN_GAP, startY, BTN_SIZE, BTN_SIZE };
btnLeft = (SDL_FRect) { startX, startY + BTN_SIZE + BTN_GAP, BTN_SIZE, BTN_SIZE };
btnRight = (SDL_FRect) { startX + (BTN_SIZE + BTN_GAP) * 2, startY + BTN_SIZE + BTN_GAP, BTN_SIZE, BTN_SIZE };
btnDown = (SDL_FRect) { startX + BTN_SIZE + BTN_GAP, startY + (BTN_SIZE + BTN_GAP) * 2, BTN_SIZE, BTN_SIZE };
} |
|
- Добавьте вызов функции updateButtonLayout() в функции SDL_AppInit() после строки включения вертикальной синхронизации:
| C | 1
2
3
4
5
6
| SDL_SetRenderVSync(renderer, 1);
// Initial Layout
int w, h;
SDL_GetWindowSize(window, &w, &h);
updateButtonLayout(w, h); |
|
- В конце функции SDL_AppEvent() добавьте код обработки касаний клавиш джойстика:
| C | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event)
{
// ...
// Touch handling
if (event->type == SDL_EVENT_FINGER_DOWN || event->type == SDL_EVENT_FINGER_UP || event->type == SDL_EVENT_FINGER_MOTION)
{
int w, h;
SDL_GetWindowSize(window, &w, &h);
float tx = event->tfinger.x * w;
float ty = event->tfinger.y * h;
bool pressed = (event->type != SDL_EVENT_FINGER_UP);
SDL_Point pt = { (int)tx, (int)ty };
// Check rectangles (Manual check since FRect is float)
if (tx >= btnUp.x && tx <= btnUp.x + btnUp.w && ty >= btnUp.y && ty <= btnUp.y + btnUp.h)
keys.up = pressed;
if (tx >= btnDown.x && tx <= btnDown.x + btnDown.w && ty >= btnDown.y && ty <= btnDown.y + btnDown.h)
keys.down = pressed;
if (tx >= btnLeft.x && tx <= btnLeft.x + btnLeft.w && ty >= btnLeft.y && ty <= btnLeft.y + btnLeft.h)
keys.left = pressed;
if (tx >= btnRight.x && tx <= btnRight.x + btnRight.w && ty >= btnRight.y && ty <= btnRight.y + btnRight.h)
keys.right = pressed;
}
return SDL_APP_CONTINUE;
} |
|
- В функцию SDL_AppIterate() добавьте код рисования кнопок джойстика после вызова функции b2World_Draw():
| C | 1
2
3
4
5
6
7
8
9
10
11
| b2World_Draw(worldId, &debugDrawer);
// Render Touch UI only if mobile/web
if (is_mobile) {
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 100); // Semi-transparent white
SDL_RenderFillRect(renderer, &btnUp);
SDL_RenderFillRect(renderer, &btnDown);
SDL_RenderFillRect(renderer, &btnLeft);
SDL_RenderFillRect(renderer, &btnRight);
} |
|
- После сборки "build-web" и обновления вкладки в Desktop-браузере вы не увидите джойстика, так как он появляется только в браузере мобильных устройств и вы можете управлять героем с клавиатуры клавишами WASD и клавишами-стрелками
- На мобильном браузере вы появляется джойстик из четырёх кнопок с помощью которых вы можете управлять ГГ
- Текущий код:
src/main.c
| C | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
| #define SDL_MAIN_USE_CALLBACKS 1 // Use the callbacks instead of main()
#include "debug-drawer.h"
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <box2d/box2d.h>
#include <stdio.h>
#define TIME_STEP (1.0f / 60.0f)
#define MAX_FRAME_TIME 0.25f
static SDL_Window *window = NULL;
SDL_Renderer *renderer = NULL;
static b2WorldId worldId;
static b2DebugDraw debugDrawer;
const SDL_PixelFormatDetails *format = NULL;
float ppm = 30.f; // Pixels per meter
static float accumulator = 0.0f;
static Uint64 last_ticks = 0;
typedef struct
{
bool up;
bool down;
bool left;
bool right;
} Keys;
static Keys keys = { 0 };
b2BodyId circleBodyId;
static const float playerSpeed = 5.f;
static bool is_mobile = false;
static const int BTN_SIZE = 60;
static const int BTN_GAP = 5;
static SDL_FRect btnUp, btnDown, btnLeft, btnRight;
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
// This JavaScript function returns true if the user agent matches mobile devices
EM_JS(bool, is_mobile_browser, (),
{
// We use the RegExp constructor to avoid using / / slashes which confuse Clang
var filter = new RegExp('iPhone|iPad|iPod|Android', 'i');
return filter.test(navigator.userAgent);
});
#endif
void updateButtonLayout(int w, int h)
{
// Positioning squares in a cross pattern at the bottom-left
float startX = 20.0f;
float startY = h - (BTN_SIZE * 3) - 20.0f;
btnUp = (SDL_FRect) { startX + BTN_SIZE + BTN_GAP, startY, BTN_SIZE, BTN_SIZE };
btnLeft = (SDL_FRect) { startX, startY + BTN_SIZE + BTN_GAP, BTN_SIZE, BTN_SIZE };
btnRight = (SDL_FRect) { startX + (BTN_SIZE + BTN_GAP) * 2, startY + BTN_SIZE + BTN_GAP, BTN_SIZE, BTN_SIZE };
btnDown = (SDL_FRect) { startX + BTN_SIZE + BTN_GAP, startY + (BTN_SIZE + BTN_GAP) * 2, BTN_SIZE, BTN_SIZE };
}
SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
{
if (!SDL_Init(SDL_INIT_VIDEO))
{
SDL_Log("Couldn't initialize SDL: %s", SDL_GetError());
return SDL_APP_FAILURE;
}
if (!SDL_CreateWindowAndRenderer("Example", 400, 400, 0, &window, &renderer))
{
SDL_Log("Couldn't create window/renderer: %s", SDL_GetError());
return SDL_APP_FAILURE;
}
// Platform detection
#ifdef __EMSCRIPTEN__
is_mobile = is_mobile_browser();
#elif defined(__ANDROID__) || defined(__IPHONEOS__)
is_mobile = true;
#endif
printf("Is Mobile: %s\n", is_mobile ? "Yes" : "No");
SDL_SetRenderVSync(renderer, 1);
// Initial Layout
int w, h;
SDL_GetWindowSize(window, &w, &h);
updateButtonLayout(w, h);
// Создаём физический мир Box2D с гравитацией
b2Vec2 gravity = { 0.f, 0.f };
b2WorldDef worldDef = b2DefaultWorldDef();
worldDef.gravity = gravity;
worldId = b2CreateWorld(&worldDef);
// Выводим значение гравитации в консоль
// Получаем текущее значение гравитации
b2Vec2 currentGravity = b2World_GetGravity(worldId);
// Выводим компоненты x и y
printf("Текущее значение гравитации: x = %.2f, y = %.2f\n", currentGravity.x, currentGravity.y);
debugDrawer = b2DefaultDebugDraw();
debugDrawer.drawShapes = true;
debugDrawer.DrawSolidPolygonFcn = drawSolidPolygon;
debugDrawer.DrawSolidCircleFcn = drawSolidCircle;
// Floor
b2BodyDef floorBodyDef = b2DefaultBodyDef();
floorBodyDef.type = b2_staticBody;
floorBodyDef.position = (b2Vec2) { 200.f / ppm, 300.f / ppm };
b2BodyId floorBodyId = b2CreateBody(worldId, &floorBodyDef);
b2Polygon floorShape = b2MakeBox((300.f / 2.f) / ppm, (20.f / 2.f) / ppm);
b2ShapeDef floorShapeDef = b2DefaultShapeDef();
b2ShapeId floorShapeId = b2CreatePolygonShape(floorBodyId, &floorShapeDef, &floorShape);
// Square
b2BodyDef squareBodyDef = b2DefaultBodyDef();
squareBodyDef.type = b2_dynamicBody;
squareBodyDef.position = (b2Vec2) { 201.f / ppm, 100.f / ppm };
squareBodyDef.enableSleep = false;
b2BodyId squareBodyId = b2CreateBody(worldId, &squareBodyDef);
b2Polygon squareShape = b2MakeBox((30.f / 2.f) / ppm, (30.f / 2.f) / ppm);
b2ShapeDef squareShapeDef = b2DefaultShapeDef();
b2ShapeId squareShapeId = b2CreatePolygonShape(squareBodyId, &squareShapeDef, &squareShape);
// Circle
b2BodyDef circleBodyDef = b2DefaultBodyDef();
circleBodyDef.type = b2_dynamicBody;
circleBodyDef.position = (b2Vec2) { 200.f / ppm, 150.f / ppm };
circleBodyDef.enableSleep = false;
circleBodyDef.fixedRotation = true;
circleBodyId = b2CreateBody(worldId, &circleBodyDef);
b2Circle circleCircle = {
.center = { 0.0f, 0.0f }, // <--- ВАЖНО: 0,0 означает, что центр круга совпадает с центром тела
.radius = 20.f / ppm // Радиус в метрах
};
b2ShapeDef circleShapeDef = b2DefaultShapeDef();
circleShapeDef.density = 1.0f;
b2ShapeId circleShapeId = b2CreateCircleShape(circleBodyId, &circleShapeDef, &circleCircle);
// Get the pixel format
SDL_Surface *surface = SDL_GetWindowSurface(window);
format = SDL_GetPixelFormatDetails(surface->format);
last_ticks = SDL_GetTicks();
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event)
{
if (event->type == SDL_EVENT_QUIT)
{
return SDL_APP_SUCCESS;
}
if (event->type == SDL_EVENT_KEY_DOWN)
{
SDL_Scancode sc = event->key.scancode;
if (sc == SDL_SCANCODE_W || sc == SDL_SCANCODE_UP)
keys.up = true;
if (sc == SDL_SCANCODE_S || sc == SDL_SCANCODE_DOWN)
keys.down = true;
if (sc == SDL_SCANCODE_A || sc == SDL_SCANCODE_LEFT)
keys.left = true;
if (sc == SDL_SCANCODE_D || sc == SDL_SCANCODE_RIGHT)
keys.right = true;
}
if (event->type == SDL_EVENT_KEY_UP)
{
SDL_Scancode sc = event->key.scancode;
if (sc == SDL_SCANCODE_W || sc == SDL_SCANCODE_UP)
keys.up = false;
if (sc == SDL_SCANCODE_S || sc == SDL_SCANCODE_DOWN)
keys.down = false;
if (sc == SDL_SCANCODE_A || sc == SDL_SCANCODE_LEFT)
keys.left = false;
if (sc == SDL_SCANCODE_D || sc == SDL_SCANCODE_RIGHT)
keys.right = false;
}
// Touch handling
if (event->type == SDL_EVENT_FINGER_DOWN || event->type == SDL_EVENT_FINGER_UP || event->type == SDL_EVENT_FINGER_MOTION)
{
int w, h;
SDL_GetWindowSize(window, &w, &h);
float tx = event->tfinger.x * w;
float ty = event->tfinger.y * h;
bool pressed = (event->type != SDL_EVENT_FINGER_UP);
SDL_Point pt = { (int)tx, (int)ty };
// Check rectangles (Manual check since FRect is float)
if (tx >= btnUp.x && tx <= btnUp.x + btnUp.w && ty >= btnUp.y && ty <= btnUp.y + btnUp.h)
keys.up = pressed;
if (tx >= btnDown.x && tx <= btnDown.x + btnDown.w && ty >= btnDown.y && ty <= btnDown.y + btnDown.h)
keys.down = pressed;
if (tx >= btnLeft.x && tx <= btnLeft.x + btnLeft.w && ty >= btnLeft.y && ty <= btnLeft.y + btnLeft.h)
keys.left = pressed;
if (tx >= btnRight.x && tx <= btnRight.x + btnRight.w && ty >= btnRight.y && ty <= btnRight.y + btnRight.h)
keys.right = pressed;
}
return SDL_APP_CONTINUE;
}
void keyboard(void)
{
b2Vec2 velocity = { 0.0f, 0.0f };
if (keys.up)
{
velocity.y = -playerSpeed;
}
else if (keys.down)
{
velocity.y = playerSpeed;
}
else if (keys.left)
{
velocity.x = -playerSpeed;
}
else if (keys.right)
{
velocity.x = playerSpeed;
}
b2Body_SetLinearVelocity(circleBodyId, velocity);
}
SDL_AppResult SDL_AppIterate(void *appstate)
{
keyboard();
Uint64 current_ticks = SDL_GetTicks();
float frameTime = (float)(current_ticks - last_ticks) / 1000.0f;
last_ticks = current_ticks;
if (frameTime > MAX_FRAME_TIME)
{
frameTime = MAX_FRAME_TIME;
}
accumulator += frameTime;
while (accumulator >= TIME_STEP)
{
// Step physics
b2World_Step(worldId, TIME_STEP, 3);
accumulator -= TIME_STEP;
}
// Clear the screen
SDL_SetRenderDrawColor(renderer, 100, 100, 100, 255);
SDL_RenderClear(renderer);
b2World_Draw(worldId, &debugDrawer);
// Render Touch UI only if mobile/web
if (is_mobile)
{
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 100); // Semi-transparent white
SDL_RenderFillRect(renderer, &btnUp);
SDL_RenderFillRect(renderer, &btnDown);
SDL_RenderFillRect(renderer, &btnLeft);
SDL_RenderFillRect(renderer, &btnRight);
}
// Update the screen
SDL_RenderPresent(renderer);
return SDL_APP_CONTINUE;
}
void SDL_AppQuit(void *appstate, SDL_AppResult result)
{
// Удаляем физический мир Box2D
if (b2World_IsValid(worldId))
{
b2DestroyWorld(worldId);
}
// SDL will clean up the window/renderer for us
} |
|
public/index.html
| PHP/HTML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| <!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
canvas {
touch-action: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script async src="./js/app.js"></script>
</body> |
|
Добавляет стены, трение для стен и поворачиваем верхнюю стену
- Удалите код создания пола и квадрата:
| C | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // Floor
b2BodyDef floorBodyDef = b2DefaultBodyDef();
floorBodyDef.type = b2_staticBody;
floorBodyDef.position = (b2Vec2) { 200.f / ppm, 300.f / ppm };
b2BodyId floorBodyId = b2CreateBody(worldId, &floorBodyDef);
b2Polygon floorShape = b2MakeBox((300.f / 2.f) / ppm, (20.f / 2.f) / ppm);
b2ShapeDef floorShapeDef = b2DefaultShapeDef();
b2ShapeId floorShapeId = b2CreatePolygonShape(floorBodyId, &floorShapeDef, &floorShape);
// Square
b2BodyDef squareBodyDef = b2DefaultBodyDef();
squareBodyDef.type = b2_dynamicBody;
squareBodyDef.position = (b2Vec2) { 201.f / ppm, 100.f / ppm };
squareBodyDef.enableSleep = false;
b2BodyId squareBodyId = b2CreateBody(worldId, &squareBodyDef);
b2Polygon squareShape = b2MakeBox((30.f / 2.f) / ppm, (30.f / 2.f) / ppm);
b2ShapeDef squareShapeDef = b2DefaultShapeDef();
b2ShapeId squareShapeId = b2CreatePolygonShape(squareBodyId, &squareShapeDef, &squareShape); |
|
- Вместо этого удалённого кода выше добавьте код создания трёх стен. Здесь фигурные скобки используются для группировки для удобства чтения кода:
| C | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| const float friction = 0.1f;
// Top wall
{
b2BodyDef topWallBodyDef = b2DefaultBodyDef();
topWallBodyDef.type = b2_staticBody;
topWallBodyDef.position = (b2Vec2) { 200.f / ppm, 150.f / ppm };
topWallBodyDef.rotation = b2MakeRot(20.0f * (3.14f / 180.0f));
b2BodyId topWallBodyId = b2CreateBody(worldId, &topWallBodyDef);
b2Polygon topWallShape = b2MakeBox((200.f / 2.f) / ppm, (20.f / 2.f) / ppm);
b2ShapeDef topWallShapeDef = b2DefaultShapeDef();
topWallShapeDef.material.friction = friction;
topWallShapeDef.enableContactEvents = true;
b2ShapeId topWallShapeId = b2CreatePolygonShape(topWallBodyId, &topWallShapeDef, &topWallShape);
}
// Left wall
{
b2BodyDef leftWallBodyDef = b2DefaultBodyDef();
leftWallBodyDef.type = b2_staticBody;
leftWallBodyDef.position = (b2Vec2) { 120.f / ppm, 230.f / ppm };
leftWallBodyDef.rotation = b2MakeRot(90.0f * (3.14f / 180.0f));
b2BodyId leftWallBodyId = b2CreateBody(worldId, &leftWallBodyDef);
b2Polygon leftWallShape = b2MakeBox((200.f / 2.f) / ppm, (20.f / 2.f) / ppm);
b2ShapeDef leftWallShapeDef = b2DefaultShapeDef();
leftWallShapeDef.material.friction = friction;
leftWallShapeDef.enableContactEvents = true;
b2ShapeId leftWallShapeId = b2CreatePolygonShape(leftWallBodyId, &leftWallShapeDef, &leftWallShape);
}
// Right wall
{
b2BodyDef rightWallBodyDef = b2DefaultBodyDef();
rightWallBodyDef.type = b2_staticBody;
rightWallBodyDef.position = (b2Vec2) { 280.f / ppm, 285.f / ppm };
rightWallBodyDef.rotation = b2MakeRot(90.0f * (3.14f / 180.0f));
b2BodyId rightWallBodyId = b2CreateBody(worldId, &rightWallBodyDef);
b2Polygon rightWallShape = b2MakeBox((200.f / 2.f) / ppm, (20.f / 2.f) / ppm);
b2ShapeDef rightWallShapeDef = b2DefaultShapeDef();
rightWallShapeDef.material.friction = friction;
rightWallShapeDef.enableContactEvents = true;
b2ShapeId rightWallShapeId = b2CreatePolygonShape(rightWallBodyId, &rightWallShapeDef, &rightWallShape);
} |
|
- Зададим новые координаты главному герою:
| C | 1
| circleBodyDef.position = (b2Vec2) { 200.f / ppm, 250.f / ppm }; |
|
- Установим трение для ГГ:
| C | 1
| circleShapeDef.material.friction = friction; |
|
- Проведите сборку "build-web" и обновите страницы на Desktop и мобильном браузере, чтобы увидеть результат. Главный герой теперь движется вдоль стен с заданным трением
Сборка в релиз
- Для сборки в релиз нужно открыть файл "config-web.bat" в редакторе кода и исправить Debug на Release:
config-web.bat
| Bash | 1
| emcmake cmake -S . -B dist -DCMAKE_BUILD_TYPE=Release |
|
- Выполните команды конфигурирования и сборки:
- Примечание. Вес файлов после сборки в Debug:
| Code | 1
2
| app.js - 376 KB
app.wasm - 2.05 MB |
|
Вес файлов после сборки в Release:
| Code | 1
2
| app.js - 183 KB
app.wasm - 1.03 MB |
|
Развёртывание на бесплатном хостинге Vercel
- Скачайте установщик Node.js и установите: https://nodejs.org/en/download
- Установите Vercel следующей командой из консоли глобально:
- Зарегистрируйтесь на Vercel: https://vercel.com/
- Примечание. В стартовом прмер, который скачали в начале, уже есть файл vercel.json для решения проблемы с загрузкой wasm-файла
- В консоле в корне проекта выполните команду:
- Выполните команду:
- Примечание. Будут заданы вопросы в консоли - просто нажимайте всегда Enter
- В консоль будет выведен адрес вашего приложения: https://start-player-movement-sdl3-c.vercel.app
- Примечание. Иногда при первом запуске Vercel может долго загружать приложение - бывает около минуты, поэтому лучше использовать для хостинга бесплатный GitHub Pages: ссылка
- Примечание. Когда вы что-то измените в проекте и захотите загрузить изменённое приложение на сервер, то введите команду:
|