rock-mod
Version:
Rock-Mod is a powerful framework designed for creating and managing mods for Grand Theft Auto (GTA) games.
349 lines (348 loc) • 15.8 kB
JavaScript
import { ClientInternalEventName } from "../../../net/common/events/types";
import { CCMPPlayer } from "./CCMPPlayer";
/**
* Реализация `IPlayersManager` под CCMP — single source of truth для
* `IPlayer`-инстансов и `rm::playerReady`/`rm::playerConnected`-эмиссий
* с настоящими `CCMPPlayer`-инстансами.
*
* ### Хранилище
*
* `Map<id, CCMPPlayer>`. For CCMP players only, `id` and `remoteId` still
* mean the same server/network id. Non-player CCMP wrappers have a separate
* client-local `id` and nullable server `remoteId`.
* `findByID`/`findByRemoteID`/`findByRemoteId` map to one lookup for players.
*
* ### Жизненный цикл local player'а
*
* `ccmp.players.local` (backed by `op_get_local_player_id`) валиден сразу
* после handshake'а — `PlayerManager::set_local_player_id` срабатывает до
* того, как scripts загружаются. Мы используем его, чтобы синхронно
* выпустить `rm::playerReady` как sticky-event (см. ниже):
*
* 1. **На конструкторе** — fast-path. Если local op возвращает игрока,
* эмитим `rm::playerReady` через `emitInternalSticky` с реальным
* `CCMPPlayer`-инстансом. Sticky-кэш гарантирует, что поздние
* подписчики из async DI-bootstrap'а геймода тоже получат сигнал
* при подписке.
* 2. **На `connectionStateChanged`** — fallback / handler реконнекта.
* Если на конструкторе local ещё не был валиден (handshake race),
* `connected = true` запустит retry fast-path. На `connected = false`
* чистим _localPlayer и sticky-кэш, чтобы при следующем connect'е
* эмитнуть с актуальным remoteId.
*
* `CCMPPlayer`-инстанс важен (а не структурный stub) потому что
* `RockModStateAdapter` в геймоде хранит `rockModPlayer`-ссылку и через
* неё вызывает методы (`setNoCollision`, etc). На структурном stub'е
* без методов это падало бы с `TypeError`.
*
* ### Remote-игроки
*
* Источники:
* - `ccmp.players.all` — prepopulation для уже-существующих на момент
* подключения (заполнен Rust'ом в `ClientScriptingService::init`).
* - `rm::playerConnected`/`rm::playerDisconnected` internal events
* (эмитит `CCMPEventsBridge` из нативных `ccmp.on('playerConnected'/...)`).
*/
export class CCMPPlayersManager {
constructor(events) {
this._players = new Map();
this._localPlayer = null;
// Iterator реализован inline — у CCMP нет per-dimension/per-range
// фильтрации игроков на JS-уровне.
this._iterator = {
all: () => this._players.values(),
dimension: (value) => this._iterateDimension(value),
range2D: (center, range) => this._iterateRange2D(center, range),
range3D: (center, range) => this._iterateRange3D(center, range),
};
this._events = events;
// 1) Subscribe ПЕРВЫМ — чтобы между подпиской и prepopulation'ом не
// пропустить параллельный `rm::playerConnected`.
this._events.onInternal({
[ClientInternalEventName.PlayerReady]: (player) => {
// Если кто-то ещё эмитнул rm::playerReady (например, cooperation
// fallback в будущем) — синхронизируем своё состояние.
const remoteId = player.remoteId;
if (remoteId === null) {
return;
}
this._ensurePlayer(remoteId, player.name, /* isLocal */ true);
},
[ClientInternalEventName.PlayerConnected]: (player) => {
const remoteId = player.remoteId;
if (remoteId === null) {
return;
}
this._ensurePlayer(remoteId, player.name, /* isLocal */ false);
},
[ClientInternalEventName.PlayerDisconnected]: (player) => {
const remoteId = player.remoteId;
if (remoteId === null) {
return;
}
const existing = this._players.get(remoteId);
if (existing) {
existing.markRemoved();
this._players.delete(remoteId);
}
},
});
this._registerNativePlayerEvents();
// 2) Prepopulate уже-существующих remote-игроков из CCMP-runtime'а.
// `ccmp.players.all` исключает local (см. js_runtime.rs::setup_globals).
this._syncRemotePlayersFromRuntime();
// 3) Fast-path: попытка эмитнуть rm::playerReady сразу. Если local не
// готов (handshake ещё не отработал) — пропускаем, дальше connectionStateChanged
// подберёт.
this._tryEmitLocalPlayerReady();
// 4) Retry-on-reconnect + reset-on-disconnect. Listener регистрируем
// через `ccmp.on` напрямую (не через bridge), чтобы наша логика была
// независимой от состояния других менеджеров.
ccmp.on("connectionStateChanged", (state) => {
if (state === null || state === void 0 ? void 0 : state.connected) {
this._tryEmitLocalPlayerReady();
}
else {
this._handleDisconnect();
}
});
}
// -- IPlayersManager ------------------------------------------------------
findLocalPlayer() {
// Кэшированный _localPlayer проставлен из rm::playerReady-эмиссии
// (либо нашей собственной из конструктора, либо чьей-то ещё).
// Если кэш пуст, lazy-retry: возможно local op теперь валиден.
if (this._localPlayer) {
return this._localPlayer;
}
this._tryEmitLocalPlayerReady();
return this._localPlayer;
}
getLocalPlayer() {
const player = this.findLocalPlayer();
if (!player) {
throw new Error("CCMPPlayersManager.getLocalPlayer: local player ещё не известен — handshake не завершён");
}
return player;
}
findByRemoteId(remoteId) {
var _a;
return (_a = this._players.get(remoteId)) !== null && _a !== void 0 ? _a : null;
}
getByRemoteId(remoteId) {
const player = this.findByRemoteId(remoteId);
if (!player) {
throw new Error(`CCMPPlayersManager.getByRemoteId: player remoteId=${remoteId} не найден`);
}
return player;
}
findByName(name) {
for (const player of this._players.values()) {
if (player.name === name) {
return player;
}
}
return null;
}
getByName(name) {
const player = this.findByName(name);
if (!player) {
throw new Error(`CCMPPlayersManager.getByName: player name="${name}" не найден`);
}
return player;
}
// -- IEntitiesManager -----------------------------------------------------
syncWithMpPool() {
this._syncRemotePlayersFromRuntime();
this._syncLocalPlayerFromRuntime();
this._tryEmitLocalPlayerReady();
}
registerById(id) {
// Под CCMP мы не можем "достать" игрока по id из глобального пула без
// дополнительных данных (name, isLocal). Возвращаем существующего или
// создаём минимальный stub. Корректный путь регистрации — через события.
return this._ensurePlayer(id, /* name */ "", /* isLocal */ false);
}
unregisterById(id) {
return this.deleteById(id);
}
// -- IBaseObjectsManager / IWorldObjectsManager ---------------------------
findByID(id) {
return this.findByRemoteId(id);
}
getByID(id) {
return this.getByRemoteId(id);
}
findByRemoteID(remoteId) {
return this.findByRemoteId(remoteId);
}
getByRemoteID(remoteId) {
return this.getByRemoteId(remoteId);
}
deleteById(id) {
const player = this.getByRemoteId(id);
player.markRemoved();
this._players.delete(id);
if (this._localPlayer === player) {
this._localPlayer = null;
this._events.clearInternalSticky(ClientInternalEventName.PlayerReady);
}
return player;
}
get iterator() {
return this._iterator;
}
// -- Внутреннее -----------------------------------------------------------
_registerNativePlayerEvents() {
ccmp.on("playerConnected", (ccmpPlayer) => {
var _a;
const player = this._ensurePlayer(ccmpPlayer.id, (_a = ccmpPlayer.name) !== null && _a !== void 0 ? _a : "", /* isLocal */ false);
this._events.emitInternal(ClientInternalEventName.PlayerConnected, player);
});
ccmp.on("playerDisconnected", (ccmpPlayer) => {
var _a, _b;
const player = (_a = this._players.get(ccmpPlayer.id)) !== null && _a !== void 0 ? _a : this._ensurePlayer(ccmpPlayer.id, (_b = ccmpPlayer.name) !== null && _b !== void 0 ? _b : "", /* isLocal */ false);
player.markRemoved();
this._players.delete(ccmpPlayer.id);
if (this._localPlayer === player) {
this._localPlayer = null;
this._events.clearInternalSticky(ClientInternalEventName.PlayerReady);
}
this._events.emitInternal(ClientInternalEventName.PlayerDisconnected, player);
});
ccmp.on("playerStreamIn", (ccmpPlayer) => {
var _a;
const player = this._ensurePlayer(ccmpPlayer.id, (_a = ccmpPlayer.name) !== null && _a !== void 0 ? _a : "", /* isLocal */ false);
this._events.emitInternal(ClientInternalEventName.EntityStreamIn, player);
});
ccmp.on("playerStreamOut", (ccmpPlayer) => {
var _a, _b;
const player = (_a = this._players.get(ccmpPlayer.id)) !== null && _a !== void 0 ? _a : this._ensurePlayer(ccmpPlayer.id, (_b = ccmpPlayer.name) !== null && _b !== void 0 ? _b : "", /* isLocal */ false);
this._events.emitInternal(ClientInternalEventName.EntityStreamOut, player);
});
}
_syncRemotePlayersFromRuntime() {
var _a;
try {
for (const ccmpPlayer of ccmp.players.all) {
this._ensurePlayer(ccmpPlayer.id, (_a = ccmpPlayer.name) !== null && _a !== void 0 ? _a : "", /* isLocal */ false);
}
}
catch (error) {
console.warn("[CCMPPlayersManager] failed to sync from ccmp.players.all:", error);
}
}
_syncLocalPlayerFromRuntime() {
var _a;
let nativeLocal;
try {
nativeLocal = ccmp.players.local;
}
catch (_b) {
return;
}
if (nativeLocal) {
this._ensurePlayer(nativeLocal.id, (_a = nativeLocal.name) !== null && _a !== void 0 ? _a : "", /* isLocal */ true);
}
}
/**
* Идемпотентная точка создания/обновления CCMPPlayer. Все источники
* (events, prepopulation, fast-path) проходят сюда.
*/
_ensurePlayer(id, name, isLocal) {
const existing = this._players.get(id);
if (existing) {
if (name) {
existing.setNameInternal(name);
}
if (isLocal && !this._localPlayer) {
this._localPlayer = existing;
}
return existing;
}
const player = new CCMPPlayer({
id,
name,
isLocal,
});
this._players.set(id, player);
if (isLocal) {
this._localPlayer = player;
}
return player;
}
/**
* Пытается синхронно достать local player из CCMP, создать CCMPPlayer
* и эмитнуть rm::playerReady как sticky-event. Идемпотентна: повторный
* вызов после успешной эмиссии — no-op (`_localPlayer !== null`).
*/
_tryEmitLocalPlayerReady() {
var _a;
if (this._localPlayer) {
this._syncLocalPlayerFromRuntime();
return;
}
let nativeLocal;
try {
nativeLocal = ccmp.players.local;
}
catch (error) {
console.warn("[CCMPPlayersManager] ccmp.players.local read failed:", error);
return;
}
if (!nativeLocal) {
return;
}
const localPlayer = this._ensurePlayer(nativeLocal.id, (_a = nativeLocal.name) !== null && _a !== void 0 ? _a : "", /* isLocal */ true);
// Sticky: поздние подписчики из async DI-bootstrap'а геймода
// (например `RockModMultiplayerEventsAdapter` из NetworkModule.init)
// получат событие сразу при подписке.
this._events.emitInternalSticky(ClientInternalEventName.PlayerReady, localPlayer);
}
_handleDisconnect() {
if (this._localPlayer) {
this._localPlayer.markRemoved();
this._players.delete(this._localPlayer.id);
this._localPlayer = null;
}
// Чистим sticky-кэш — иначе новые подписчики после reconnect'а получат
// устаревший local-player stub с прошлым remoteId.
this._events.clearInternalSticky(ClientInternalEventName.PlayerReady);
}
*_iterateDimension(value) {
for (const player of this._players.values()) {
if (player.dimension === value) {
yield player;
}
}
}
*_iterateRange2D(center, range) {
const squaredRange = range * range;
for (const player of this._players.values()) {
if (!player.isExists || player.handle === 0) {
continue;
}
const position = player.position;
const dx = position.x - center.x;
const dy = position.y - center.y;
if (dx * dx + dy * dy <= squaredRange) {
yield player;
}
}
}
*_iterateRange3D(center, range) {
const squaredRange = range * range;
for (const player of this._players.values()) {
if (!player.isExists || player.handle === 0) {
continue;
}
const position = player.position;
const dx = position.x - center.x;
const dy = position.y - center.y;
const dz = position.z - center.z;
if (dx * dx + dy * dy + dz * dz <= squaredRange) {
yield player;
}
}
}
}