Перейти к содержимому

Архитектурные паттерны

Хорошая архитектура модуля в основном отвечает на два вопроса:

Компонент — самостоятельная возможность модуля со своим состоянием и обработчиками: например HUD, свободная камера или маркер позиции.

Хорошая архитектура отвечает на два вопроса:

  1. какому компоненту принадлежит это состояние или обработчик?
  2. когда принадлежащий ему ресурс должен перестать существовать?

Компилятор уже объединяет файлы и изолирует имена. Тяжёлый фреймворк, контейнер зависимостей или один огромный main.ts не нужны.

Небольшой всегда активный компонент регистрируется на верхнем уровне:

client/features/damage-hint.ts
import { logger } from "@killscript/sdk/client";
const log = logger("DamageHint");
Agents.OnLocalPlayerReceivedDamage((event) => {
NotificationController.ShowHint(
`Received ${event.Damage} damage`,
1.5,
);
log.debug(`Damage tick: ${event.DamageTick}`);
});

client/main.ts только импортирует файл. Состояние, нужное одному компоненту, остаётся в этом же файле и не экспортируется.

Всё созданное при включении должно исчезнуть при выключении. Scope делает владение явным:

client/features/position-marker.ts
import {
Keyboard,
type ClientScope,
defineControls,
scheduler,
scope,
visuals,
} from "@killscript/sdk/client";
const controls = defineControls("positionMarker", {
toggle: Keyboard.F6,
});
let active: ClientScope | undefined;
controls.toggle.onPressed(() => {
if (active !== undefined) {
active.dispose();
active = undefined;
return;
}
const local = Agents.GetLocalAgent();
if (local === undefined) return;
const next = scope();
const marker = next.world(visuals.overlay({
position: local.Movement.Position,
size: new Vector3(0.6, 0.02, 0.6),
color: new Color(0, 1, 1, 0.5),
}));
if (marker === undefined) {
next.dispose();
return;
}
next.using(scheduler.every(0.25, () => {
const current = Agents.GetLocalAgent();
if (current !== undefined) {
marker.SetPosition(current.Movement.Position);
}
}));
active = next;
});

Переменная отвечает, включён ли компонент. Scope отвечает, что нужно очистить при выключении. Порядок очистки и серверный вариант описаны в разделе Scope и жизненный цикл компонента.

Разделяйте контракты, а не владение ресурсами

Заголовок раздела «Разделяйте контракты, а не владение ресурсами»

В shared-файлах находятся данные, имеющие одинаковый смысл в обеих средах:

shared/messages.ts
export interface MarkerState {
readonly enabled: boolean;
readonly x: number;
readonly y: number;
readonly z: number;
}

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

Большинству функций достаточно таких схем:

управление → состояние компонента → клиентское отображение
control → сетевое намерение → Reflex → сетевой результат → отображение
игровое событие → состояние компонента → отображение или лог

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

  • точка входа остаётся списком импортов включённых компонентов;
  • настройки и управление верхнего уровня находятся рядом со своим владельцем;
  • переменная остаётся локальной, пока она действительно не нужна другому файлу;
  • динамические подписки, таймеры и визуальные объекты принадлежат scope;
  • владелец — клиент или Reflex — виден по папке и импорту SDK;
  • если вспомогательный метод SDK не улучшает вызов, используется типизированный API игры;
  • компонент удаляется вместе с одним импортом из точки входа и принадлежащими ему файлами.

Если файл уже обслуживает несколько несвязанных controls, независимых состояний или разных циклов очистки, разделите его по поведению. Категории вроде «все обработчики» и «все переменные» обычно только прячут владельца.