Skip to content

Commit 945fa9a

Browse files
v1.3.3
1 parent a07272f commit 945fa9a

24 files changed

Lines changed: 232 additions & 133 deletions

CHANGELOG.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,21 @@
88
## [Unreleased]
99

1010
### Планируется
11-
- Система инвентаря
1211
- Продвинутые UI компоненты
13-
- Система частиц
1412
- Мобильная поддержка
1513

14+
## [1.3.3]
15+
16+
### Added
17+
- **docs/networking.md**: раздел «Лучшие практики» — примитивы JSON (что можно отправлять), позиция списком `list(pos)` и приём через `data.get("pos", ...)`, значения по умолчанию, троттлинг.
18+
- **docs/networking.md**: таблица полей контекста, «Кто такой этот процесс», «Отображаемое имя», «Как отличить своего игрока от чужих».
19+
20+
### Changed
21+
- **Позиция по сети**: во всех примерах и курсе — отправка `{"pos": list(pos)}`, приём `remote_pos[:] = data.get("pos", ...)`; убраны лишние `float()` и поля `x`/`y`.
22+
- **Лобби (урок 4)**: при отправке `join` добавляем себя в `players` локально (`players.add(name)`), чтобы хост и все клиенты видели полный список (реле не отдаёт сообщение отправителю).
23+
- **Демо и курс**: упрощена обработка `sender_id` (без лишних try/except и int()), компактное получение `other_id = data.get("sender_id")` где уместно.
24+
- Версия библиотеки 1.3.3 (патч).
25+
1626
## [1.3.2]
1727

1828
### Added

docs/networking.md

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,53 @@ python your_game.py --server --tick_rate 20
213213
- `run()` не работает в интерактивной консоли — нужен файл.
214214
- Если `poll()` не вызывается, сообщения копятся в очереди.
215215

216+
## Лучшие практики
217+
218+
### Что можно отправлять по сети (примитивы JSON)
219+
220+
Сообщения сериализуются в **JSON**. В `data` можно передавать только то, что умеет JSON:
221+
222+
- **Числа**`int`, `float`
223+
- **Строки**`str`
224+
- **Списки и словари** — вложенные структуры из перечисленного
225+
- **Булевы**`True` / `False`
226+
- **Пусто**`None` (в JSON будет `null`)
227+
228+
Объекты вроде `Vector2`, спрайтов, классов — **нельзя** отправлять как есть. Конвертируйте в примитивы: позиция → `list(pos)` или `[pos.x, pos.y]`, состояние → `dict` с полями. На приёме вы получаете уже числа/списки/словари — приводить к `float()` не нужно.
229+
230+
### Позиция удалённого игрока: один список, обновление на месте
231+
232+
Один список на удалённую позицию и обновление через срез — не пересоздаём объект и не теряем ссылку для `set_position()`. Позицию удобно отправлять списком: на приёме одно присваивание. `get_world_position()` возвращает `Vector2`; он итерируем, поэтому подойдёт `list(pos)`:
233+
234+
```python
235+
# Отправка: pos — Vector2 из get_world_position(), list(pos) → [x, y]
236+
ctx.send_every("pos", {"pos": list(pos)}, 0.05)
237+
238+
# Приём: один буфер, обновляем на месте
239+
remote_pos = [x0, y0]
240+
for msg in ctx.poll():
241+
if msg.get("event") == "pos":
242+
data = msg.get("data", {})
243+
remote_pos[:] = data.get("pos", [0, 0])
244+
other.set_position(remote_pos)
245+
```
246+
247+
По умолчанию при отсутствии ключа можно подставить предыдущее значение: `data.get("pos", remote_pos)` — тогда при пропуске пакета позиция не дёрнется. Вариант с полями `{"x": pos.x, "y": pos.y}` тоже допустим; на приёме тогда `remote_pos[:] = [data.get("x", 0), data.get("y", 0)]`.
248+
249+
### Данные из JSON — уже нужные типы
250+
251+
После `json.loads` поля приходят как числа, строки, списки, словари. Приводить к `float()` или `int()` вручную не нужно — используйте значения как есть: `data.get("x", 0)`, `data.get("sender_id")`.
252+
253+
### Значения по умолчанию при разборе payload
254+
255+
- `data.get("key", default)` — если ключа нет или событие пришло без поля, подставится `default`. Для позиции удобны `0` или текущее значение (`remote_pos[0]`).
256+
- Для вложенных структур: `data.get("scores", self.scores)` — не перезаписываем локальное состояние, если в сообщении пусто.
257+
- Если вы сами всегда отправляете полный payload (например, `pos` с `x`, `y`, `sender_id`), на приёме можно не проверять на `None` — присваивайте `other_id = data.get("sender_id")` и при отображении обработайте `other_id is None` (например, показать «?»).
258+
259+
### Троттлинг позиции
260+
261+
Используйте `ctx.send_every("pos", {"pos": list(pos)}, interval)` вместо `ctx.send("pos", ...)` в каждом кадре — так вы ограничите частоту отправки (например, 20 раз/сек при `interval=0.05`) и снизите нагрузку на сеть.
262+
216263
## Debug‑режим мультиплеера (консоль)
217264

218265
Включите вывод отправки/получения сообщений:
@@ -247,17 +294,49 @@ def multiplayer_main(net: s.NetClient, role: str):
247294
s.networking.run()
248295
```
249296

250-
Контекст хранит:
251-
252-
- `client_id` (0 для хоста, для клиентов назначается сервером по порядку);
253-
- `role` и `is_host`:
254-
- `role="host"` — процесс, который поднимает сервер (host‑режим), `is_host=True`,
255-
- `role="client"` — обычный клиент,
256-
- `role="server"` — сервер без клиента (в этом режиме контекст не создаётся).
257-
Значение роли задаётся `run()` через параметры запуска (`--host_mode`, `--quick`, `--server`).
258-
- `state` для глобальных значений;
259-
- `seed` и `random` для детерминированного случайного генератора;
260-
- методы `send()`, `poll()`, `send_every()`.
297+
### Кто такой «этот процесс» (наш компьютер)
298+
299+
Код `multiplayer_main` выполняется **в одном из процессов** — в том же окне, что открылось при запуске. Этот процесс и есть «наш компьютер»: мы не «получаем» его с сервера, мы в нём уже находимся. Чтобы понять, хост мы или клиент и какой у нас номер, используйте поля контекста после `init_context()`.
300+
301+
### Поля контекста и возможные значения
302+
303+
| Поле / метод | За что отвечает | Возможные значения |
304+
|--------------|-----------------|--------------------|
305+
| `ctx.client_id` | Числовой ID этого участника. Уникален в сессии. | `0` — хост; `1`, `2`, … — клиенты (назначаются сервером по порядку подключения). |
306+
| `ctx.role` | Роль процесса в сети. | `"host"` — процесс с поднятым сервером (одно окно); `"client"` — обычный клиент; `"server"` — только сервер без игры (контекст не создаётся). |
307+
| `ctx.is_host` | Является ли этот процесс хостом. | `True` — мы хост, `False` — мы клиент. Удобно для ветвления логики («если хост — рассылаю roster»). |
308+
| `ctx.state` | Произвольный словарь для глобального состояния. | Любой dict, общий на весь процесс. |
309+
| `ctx.seed` / `ctx.random` | Детерминированный рандом для мультиплеера. | См. раздел «Детерминированный рандом». |
310+
| `ctx.send(event, data)` | Отправка сообщения в сеть. ||
311+
| `ctx.poll()` | Получение входящих сообщений. | Список dict с полями `event`, `data`. |
312+
| `ctx.send_every(event, data, interval)` | Отправка не чаще чем раз в `interval` секунд. ||
313+
314+
Роль задаётся при запуске: `run()` передаёт в `multiplayer_main` аргумент `role` в зависимости от режима (`--host_mode`, `--quick`, `--server`). Контекст только отражает уже выбранную роль.
315+
316+
### Отображаемое имя участника (name)
317+
318+
В примерах часто встречается переменная вроде `name = "host" if ctx.is_host else f"client_{client_index + 1}"`. Это **не** «получение сервера» и не данные с сети: это **локально выбранное отображаемое имя** для этого процесса (чтобы в лобби показывать «host», «client_1», «client_2»). Номер клиента для имени можно взять из `os.environ.get("SPRITEPRO_NET_INDEX", "0")` (индекс окна клиента) или строить логику по `ctx.client_id` (0 у хоста, 1 и выше у клиентов). Сервер имён не выдаёт — имя задаётся в коде под текущую роль и ID.
319+
320+
### Как отличить своего игрока от чужих (цвет, камера)
321+
322+
«Наш» экземпляр игры — это **этот процесс**: мы не получаем его по сети, мы в нём. В этом процессе один спрайт обновляется от **локального ввода** (клавиатура, мышь) и его позиция **отправляется** в сеть — это «я». Остальные спрайты обновляются из `ctx.poll()` — это «другие». Цвет задаём в коде: свой — один (например синий), чужие — другой (красный).
323+
324+
```python
325+
ctx = s.multiplayer_ctx
326+
me = s.Sprite("", (40, 40), (200, 300)) # двигаем мы, шлём позицию в сеть
327+
other = s.Sprite("", (40, 40), (600, 300)) # позицию получаем из ctx.poll()
328+
329+
# В этом процессе «я» всегда один и тот же — тот, кого мы контролируем.
330+
MY_COLOR = (70, 120, 220) # синий
331+
OTHER_COLOR = (220, 70, 70) # красный
332+
me.set_color(MY_COLOR)
333+
other.set_color(OTHER_COLOR)
334+
335+
# В игровом цикле: движение me от s.input, отправка ctx.send("pos", ...);
336+
# other.set_position(...) из данных, пришедших в ctx.poll().
337+
```
338+
339+
У каждого участника в своём окне «я» синий, «другие» красные — так и задумано: в своём экземпляре игры свой персонаж выделен. Для нескольких чужих игроков храните словарь `others[client_id]` и задайте им один цвет «чужой» или разные по `client_id`.
261340

262341
### Детерминированный рандом
263342

multiplayer_course/2/example_sync_positions.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,13 @@ def multiplayer_main(net: s.NetClient, role: str) -> None:
3939
me.set_position(pos)
4040

4141
# Отправляем pos не чаще чем раз в 0.2 сек — меньше нагрузки на сеть.
42-
ctx.send_every("pos", {"x": pos.x, "y": pos.y}, 0.2)
42+
ctx.send_every("pos", {"pos": list(pos)}, 0.2)
4343

4444
# Читаем входящие сообщения; при pos обновляем remote_pos и двигаем other.
4545
for m in ctx.poll():
4646
if m.get("event") == "pos":
4747
d = m.get("data", {})
48-
remote_pos[:] = [
49-
float(d.get("x", remote_pos[0])),
50-
float(d.get("y", remote_pos[1])),
51-
]
48+
remote_pos[:] = d.get("pos", [0, 0])
5249
other.set_position(remote_pos)
5350

5451

multiplayer_course/2/practice_sync_positions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ def multiplayer_main(net: s.NetClient, role: str) -> None:
4343
me.set_position(pos)
4444

4545
# Отправка позиции с лимитом.
46-
# TODO: используйте ctx.send_every("pos", {"x": pos.x, "y": pos.y}, 0.05).
46+
# TODO: используйте ctx.send_every("pos", {"pos": list(pos)}, 0.05).
4747
pass
4848

4949
# Прием сетевых сообщений.
5050
for msg in ctx.poll():
5151
if msg.get("event") == "pos":
52-
# TODO: прочитайте координаты и обновите remote_pos.
52+
# TODO: прочитайте координаты из data и обновите remote_pos (например remote_pos[:] = data.get("pos", [0, 0])).
5353
pass
5454

5555
# Применяем позицию удаленного игрока.

multiplayer_course/2/solution_sync_positions.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,13 @@ def multiplayer_main(net: s.NetClient, role: str) -> None:
3939
me.set_position(pos)
4040

4141
# Отправка позиции 20 раз/сек.
42-
ctx.send_every("pos", {"x": pos.x, "y": pos.y}, 0.05)
42+
ctx.send_every("pos", {"pos": list(pos)}, 0.05)
4343

4444
# Прием позиции удаленного игрока.
4545
for msg in ctx.poll():
4646
if msg.get("event") == "pos":
4747
d = msg.get("data", {})
48-
remote_pos[:] = [
49-
float(d.get("x", remote_pos[0])),
50-
float(d.get("y", remote_pos[1])),
51-
]
48+
remote_pos[:] = d.get("pos", [0, 0])
5249

5350
# Применяем удаленную позицию.
5451
other.set_position(remote_pos)

multiplayer_course/4/example_lobby.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,60 @@
44
и рассылает roster; все обновляют отображаемый список игроков.
55
"""
66

7+
import os
8+
79
import spritePro as s
810

911

1012
def multiplayer_main(net: s.NetClient, role: str) -> None:
11-
s.get_screen((800, 600), "Lesson 4 - Lobby")
13+
client_index = int(os.environ.get("SPRITEPRO_NET_INDEX", "0"))
14+
window_tag = "HOST" if role == "host" else f"CLIENT {client_index + 1}"
15+
s.get_screen((800, 600), f"Lesson 4 - Lobby [{window_tag}]")
1216
ctx = s.multiplayer.init_context(net, role)
1317

14-
# Имя по роли; players — множество имён (у хоста), roster — итоговый список для отображения.
15-
name = "host" if ctx.is_host else "client"
18+
# Отображаемое имя этого участника (не «получение сервера»).
19+
# «Это наш компьютер» = этот процесс; кто мы — задаётся ctx.is_host и ctx.client_id.
20+
# Имя задаём сами, чтобы в roster были видны host, client_1, client_2.
21+
name = "host" if ctx.is_host else f"client_{client_index + 1}"
1622
players = set()
1723
roster = []
1824
joined = False
1925

2026
title = s.TextSprite("Lobby", 34, (240, 240, 240), (20, 20), anchor=s.Anchor.TOP_LEFT)
27+
me_text = s.TextSprite("", 20, (170, 220, 255), (20, 58), anchor=s.Anchor.TOP_LEFT)
2128
roster_text = s.TextSprite("", 24, (240, 240, 240), (20, 80), anchor=s.Anchor.TOP_LEFT)
2229
hint = s.TextSprite(
23-
"Ожидание игроков...", 20, (180, 180, 180), (20, 520), anchor=s.Anchor.TOP_LEFT
30+
"Host + 2 clients: client_2 появляется через 6 сек.",
31+
20,
32+
(180, 180, 180),
33+
(20, 520),
34+
anchor=s.Anchor.TOP_LEFT,
2435
)
2536

2637
while True:
2738
s.update(fill_color=(18, 18, 24))
39+
me_text.set_text(
40+
f"Я: {name} | role={ctx.role} | id={ctx.client_id} | window={window_tag}"
41+
)
2842

29-
# Один раз при входе шлём join, чтобы все (и хост) знали о нас.
43+
# Один раз при входе шлём join. Реле не отдаёт сообщение обратно отправителю,
44+
# поэтому добавляем себя в players локально — иначе хост не увидит себя в roster.
3045
if not joined:
3146
joined = True
3247
ctx.send("join", {"name": name})
48+
players.add(name)
3349

3450
for msg in ctx.poll():
3551
event = msg.get("event")
3652
data = msg.get("data", {})
3753
if event == "join":
38-
# Хост добавляет имя и рассылает обновлённый roster.
3954
player_name = data.get("name")
40-
if player_name:
41-
players.add(player_name)
42-
if ctx.is_host:
43-
roster = sorted(players)
44-
ctx.send("roster", {"players": roster})
55+
if not player_name:
56+
continue
57+
players.add(player_name)
58+
if ctx.is_host:
59+
roster = sorted(players)
60+
ctx.send("roster", {"players": roster})
4561
elif event == "roster":
4662
# Все клиенты (и хост при получении своего же roster) обновляют список.
4763
roster = list(data.get("players", []))
@@ -50,5 +66,5 @@ def multiplayer_main(net: s.NetClient, role: str) -> None:
5066

5167

5268
if __name__ == "__main__":
53-
# Запуск примера.
54-
s.networking.run()
69+
# clients=3 => host + client_1 через 3 сек + client_2 через 6 сек.
70+
s.networking.run(clients=3, client_spawn_delay=3, net_debug=True)

0 commit comments

Comments
 (0)