Игра

Игроки: команда, действия, списки

Player — это игрок на сервере. Его даёт событие или команда, к которой он относится (event.player, player в обработчике команды), а server.players перечисляет всех. Игровые данные игрока — gravity, origin, hideHud и остальные — это свойства, они описаны на странице Игроки и сущности. Эта страница — о том, что с игроком можно делать: найти, перевести в команду, выдать оружие, показать текст и поделиться его состоянием с другими плагинами.

server
.
addCommand
("/rearm", rearm, {
access
: "slay" });
function rearm() { for (const
player
of
server
.
players
.
filter
(
other
=>
other
.
isAlive
&&
other
.
team
=== "CT")) {
player
.
removeAllItems
();
player
.
give
("weapon_knife");
player
.
give
("weapon_m4a1");
player
.
setAmmo
("weapon_m4a1", 90);
player
.
showHud
("Перевооружены!", {
color
: [0, 200, 0],
hold
: 2 });
} }

Список игроков с v0.2

for (const 
player
of
server
.
players
)
console
.
log
(
player
.
name
); // все
const
aliveCts
=
server
.
players
.
filter
(
player
=>
player
.
isAlive
&&
player
.
team
=== "CT");
const
people
=
server
.
players
.
filter
(
player
=> !
player
.
isBot
); // без ботов
const
bots
=
server
.
players
.
filter
(
player
=>
player
.
isBot
);
const
dead
=
server
.
players
.
filter
(
player
=> !
player
.
isAlive
);

server.players — все подключённые, и читается заново при каждом обращении: держите список в переменной, только пока игроки в нём не могут измениться. Это массив, поэтому часть списка — filter, а один игрок — find, по свойствам самого игрока: isAlive, isBot, team ("TERRORIST", "CT", "SPECTATOR" или "UNASSIGNED"). HLTV-прокси в списке не бывает никогда.

Команда

if (
player
.
team
== "CT")
console
.
log
("спецназовец");
player
.
team
= "TERRORIST";

player.team — это "TERRORIST", "CT", "SPECTATOR" или "UNASSIGNED" (тип Team), и сразу после смены команды он уже верный. Присваивание переводит игрока: модель меняется на модель новой команды, таблица счёта показывает новую команду. Условия победы в раунде при этом не проверяются — если перевод должен закончить раунд, закончите его сами через game.endRound (События игры).

server
.
addEventListener
("putInServer", async (
event
) => {
await
sleep
(200, {
signal
:
event
.
player
.
signal
}); // игра ещё ставит его в мир
event
.
player
.
joinTeam
("CT"); // false, если игра отказала
});

player.joinTeam(team) вводит игрока в сторону так, как игра вводит того, кто выбрал её в меню команд, а внешность выбирается за него. У только что пришедшего игрока стороны нет, и в игре его ещё нет; присваивание player.team даёт ему сторону, а joinTeam ещё и вводит его в игру, так что он может появиться. Живой игрок, отправленный в зрители, тихо умирает: без смерти и без фрага.

mp_limitteams
Без ReAPI игра отказывает в стороне, которую mp_limitteams перегрузил бы, как и в меню команд, и мёртвому игроку, который уже сменил сторону в этом раунде; joinTeam возвращает false. С ReAPI вызов эти проверки пропускает и ставит игрока на ту сторону, которую ему дали.

Голос

player
.
muted
= true; // в голосовом чате его никто не слышит
if (
player
.
muted
)
console
.
log
(`${
player
.
name
} без голоса`);
player
.
muted
= false;
player
.
heardByEveryone
= true; // его слышат обе стороны
player
.
hearsEveryone
= true; // он слышит обе стороны — например, зритель

Кто кого слышит, игра решает по сторонам и sv_alltalk; эти три свойства меняют это для одного игрока, каждое само по себе. muted работает и с включённым sv_alltalk, и с выключенным и сильнее heardByEveryone. Кто кого слышит среди игроков на сторонах, можно решать и в каждом случае отдельно — событием игры canPlayerHearPlayer (События игры).

Квары его игры

server
.
addCommand
("/fps", async ({
player
}) => {
const
limit
= await
player
.
queryCvar
("fps_max"); // "100" или null
print
(
player
, `fps_max ${
limit
?? "неизвестно"}`);
});

player.queryCvar(name) спрашивает у игры игрока один из её кваров и отдаёт промис ответа: значение текстом или null, если такого квара в его игре нет или она его не называет. Ответ — то, что говорит его игра, заявление, которое чит может подменить. У бота игры нет, и он сразу отвечает null. Если игрок уходит раньше ответа, промис отклоняется с "AbortError", а его асинхронная команда тихо останавливается (асинхронность).

Как его игра подтвердила, кто он с v0.2

server
.
addCommand
("/game", ({
player
}) => {
if (
player
.
authType
=== "steam")
print
(
player
, "Ваша игра из Steam");
else
print
(
player
, `Ваша игра: ${
player
.
authType
}, протокол ${
player
.
protocol
}`); // "revEmu2013, протокол 48"
});

Reunion — плагин ReHLDS, который пускает на сервер игроков без Steam. С ним player.authType говорит, как игра игрока подтвердила, кто он (тип AuthType): "steam" — игра из Steam; у игры без Steam — эмулятор, которым она себя подтвердила: "steamEmu", "revEmu", "revEmu2013", "oldRevEmu", "sc2009", "avsmp", "sxei", "sse3"; "dproto"; "hltv" — HLTV-прокси. player.protocol — сетевой протокол его игры: 48 у нынешней игры, 47 у старой, которую пускает Reunion. player.authKey — ключ, которым его игра себя подтвердила, из него сделан его SteamID (player.steamId): "STEAM_..." или "VALVE_...", как скажут настройки Reunion на сервере.

Без Reunion
Всё это знает только Reunion, а ReAPI читает это у него. На сервере без Reunion или без ReAPI authType — "unknown", protocol — 0, а authKey — "".

Действия

player
.
give
("weapon_flashbang"); // или "item_kevlar", "item_assaultsuit", "item_thighpack"
player
.
setAmmo
("weapon_flashbang", 2); // патроны к нему с собой
player
.
getAmmo
("weapon_flashbang"); // 2
player
.
switchWeapon
("weapon_knife"); // false, если ножа нет
player
.
removeAllItems
(); // всё оружие
player
.
resetMaxSpeed
(); // скорость снова по оружию
player
.
respawn
();
player
.
kill
(); // kill({ keepFrags: true }) - без штрафа во фрагах
player
.
command
("stop"); // в его собственной консоли, как будто он набрал сам
player
.
deaths
= 0; // таблица счёта тоже обновится
МетодЧто делает
give(item)выдаёт оружие или предмет; false, если игра не выдала
setAmmo(weapon, amount)задаёт патроны, которые игрок носит к своему оружию, помимо обоймы
getAmmo(weapon)патроны, которые игрок носит к своему оружию; для гранаты — сколько их; 0 для оружия, которого у него нет
switchWeapon(weapon)берёт в руки оружие, которое у него есть; false, если такого нет
removeAllItems(removeSuit?)забирает всё оружие; костюм остаётся, если removeSuit не true
resetMaxSpeed()возвращает скорость, которую позволяет оружие в руках, — например, после замедления
respawn()возрождает в текущем раунде, на точке, которую выберет игра
kill(options?)убивает, как команда kill; { keepFrags: true } не отнимает фраг
command(text)выполняет команду в консоли игрока: её выполняет его игра, а не сервер

Имена оружия — тип WeaponName ("weapon_knife", "weapon_awp", …), а предметы, которые принимает give, — ItemName; редактор их подсказывает и не пропускает опечатку. У оружия, которое игрок несёт, имя — weapon.classname (оружие): other.give(weapon.classname).

name, health, armor, frags и deaths — свойства: их можно читать, а health, armor, frags и deaths — ещё и присваивать. health, равный 0 или меньше, убивает игрока.

Действия работают на любом сервере. С ReAPI они идут через него, без него — через стандартные модули AMX Mod X fun, cstrike и hamsandwich. Плагин пишется одинаково в обоих случаях.

Фейковые клиенты с v0.2

Фейковый клиент — бот — это игрок, которого ведёт сам сервер: он занимает слот, есть в server.players и играет по правилам игры, но игры за ним нет. server.addBot(name) добавляет такого:

const 
bot
=
server
.
addBot
("Dummy"); // null, если свободного слота нет
if (
bot
) {
bot
.
joinTeam
("CT");
bot
.
respawn
();
}

Он приходит, как любой игрок, — для него срабатывают "connect" и "putInServer", — bot.isBot равно true, а bot.kick() убирает его, с "disconnected".

Своего разума у бота нет: он стоит, где появился, и ничего не делает, пока плагин его не двинет. bot.move(options) — это один кадр его клавиш и мыши, поэтому его вызывают каждый кадр, в событии "frame", пока бот должен двигаться:

server
.
addEventListener
("frame", () => {
bot?.move({
forward
: 250,
buttons
: ["jump"],
angles
: [0, 90, 0] });
});
ПараметрЧто это
forwardскорость вперёд, единиц в секунду; назад, если отрицательная (250 — бег с ножом)
sideвправо; влево, если отрицательная
upвверх, в воде и на лестнице; вниз, если отрицательная
buttonsкнопки, зажатые на время шага: "attack", "attack2", "jump", "duck", "use", "reload", … (тип Button)
anglesкуда он смотрит, [pitch, yaw, roll] или Vector; если не задано — куда смотрит сейчас
msecсколько длится шаг, от 1 до 255 миллисекунд; если не задано — время кадра

move для игрока, который не бот, бросает Error: плагин двигает только своих ботов.

ИИ бота — это плагин
addBot даёт тело, а не игрока: сам он не ходит, не целится, не стреляет и не покупает. Всё, что он делает, — это вызовы move плагина, кадр за кадром. Шаг, вызванный реже — из таймера раз в 0,1 секунды, — двигает его только на свой msec, а не до следующего вызова.

Есть ли модуль на сервере

if (!
hasModule
("reapi"))
console
.
warn
("myplugin: событиям раунда нужен ReAPI");

hasModule возвращает true, если на сервере работает этот модуль AMX Mod X: "reapi", "cstrike", "fun", "hamsandwich", "engine" или "fakemeta". Спрашивайте его перед нативом, который есть только в одном модуле.

HUD

player
.
showHud
("-35 HP");
player
.
showHud
("Раунд 3", {
color
: [255, 40, 40],
x
: 0.02,
y
: 0.88,
hold
: 2 });
player
.
showHud
("печатается по буквам", {
effect
: "typewriter",
channel
: 2 });
player
.
showHud
("ТЕРРОРИСТЫ ПОБЕДИЛИ", {
large
: true,
y
: 0.3,
hold
: 5 }); // крупные буквы
server
.
showHud
("Для всех", {
y
: 0.6 });

HUD-сообщение — это текст на экране вне чата. Любую настройку можно не писать:

НастройкаПо умолчаниюЗначение
color[200, 100, 0]красный, зелёный, синий, от 0 до 255
x, y-1, 0.35позиция, от 0 до 1 от левого верхнего угла; -1 — по центру
hold12сколько секунд держится на экране
effect"fade""fade", "flicker" или "typewriter" (по буквам)
fadeIn, fadeOut0.1, 0.2секунды на появление и на исчезновение
effectTime6сколько секунд идут эффекты "flicker" и "typewriter"
channel-1канал, от 1 до 4; -1 берёт свободный. Новое сообщение на канале заменяет старое
largefalseкрупные буквы — для итога или заголовка; каналов у них нет

Строка, которая обновляется, — HudLine

const 
countdown
= new
HudLine
();
function tick(
player
: Player,
left
: number) {
countdown
.
show
(
player
, `Бомба: ${
left
}`, {
color
: [255, 50, 50],
hold
: 1.1 });
}

HudLine — одно место на экране: каждый show заменяет то, что оно показывало, а обычный showHud занял бы другой канал, и прошлая цифра угасала бы под новой. countdown.clear(player) убирает строку с экрана одного игрока раньше времени, countdown.clearAll() — у всех.

Эффекты экрана — player.screen

player
.
screen
.
fade
({
color
: [200, 0, 0, 100],
duration
: 0.5 }); // красная вспышка, которая проходит
player
.
screen
.
fade
({
color
: [0, 0, 0, 255],
duration
: 0.1,
hold
: 1,
stay
: true }); // чёрный экран, и он остаётся
player
.
screen
.
fade
({
color
: [0, 0, 0, 0],
duration
: 0.2 }); // снова ясно
player
.
screen
.
shake
({
amplitude
: 8,
duration
: 1,
frequency
: 5 });
player
.
screen
.
statusIcon
("dmg_cold", "show", [0, 200, 255]); // "hide", "show", "flash"
player
.
screen
.
roundTime
(90); // часы раунда, в секундах
player
.
screen
.
hideHud
(["money", "timer"]); // сразу
player
.
screen
.
crosshair
(false);
player
.
screen
.
flashlight
(false); // значок фонарика
player
.
screen
.
progressBar
(5); // полоса заполнится за 5 секунд; 0 её убирает
player
.
screen
.
progressBar
(4, {
startPercent
: 50 }); // наполовину полна, заполнится за 2 секунды

То, что один игрок видит поверх мира. Время — в секундах. Обработчик сообщений слышит то, что шлёт player.screen, как слышит сообщения игры.

Настройка fadeПо умолчаниюЗначение
color[0, 0, 0, 255]красный, зелёный, синий и непрозрачность, от 0 до 255
duration1сколько секунд идёт переход
hold0сколько секунд держится полный цвет
direction"in""in" — от цвета к ясной картинке, "out" — от ясной картинки к цвету
stayfalseцвет остаётся до следующего fade
modulatefalseподкрашивает картинку, а не закрашивает её

Переход или удержание дольше 16 секунд обрезается до 16.

  • shake: amplitude — насколько сильно трясёт, до 16 (по умолчанию 4); duration в секундах (1); frequency — толчков в секунду (5).
  • statusIcon(sprite, state, color): спрайт из игрового sprites/hud.txt ("dmg_cold", "buyzone", "c4", …) — показан, мигает или скрыт.
  • roundTime(seconds): часы раунда вверху его HUD.
  • hideHud(parts): сразу скрывает эти части его HUD. Свойство player.hideHud (Флаги) делает то же со следующего кадра игры.
  • crosshair(shown): собственный прицел игры.
  • flashlight(on, battery?): значок фонарика и заряд батареи в процентах.
  • progressBar(seconds, { startPercent }): полоса посреди его экрана, заполненная через seconds секунд; 0 её убирает. С startPercent с v0.2 она начинается заполненной настолько и заполняет остаток seconds: при 50 — за половину.

Общие поля игрока

Плагины могут добавлять в Player поля друг для друга: одно значение на игрока, которое читают и пишут все плагины сервера — и на TypeScript, и на Pawn. Так один плагин сообщает остальным, что игрок защищён после появления, заморожен или получил бонус.

Плагин, которому принадлежат поля, объявляет их в своём маленьком файле, в папке внутри папки плагинов:

// plugins/myplugin/player.ts
import "@amxts/core";

declare module "@amxts/core" {
    interface Player {
        
spawnProtected
: boolean;
kills
: number;
bonus
: number;
tag
: string;
glow
: {
enabled
: true | false | "default";
seenBy
: Player[];
}; } }

Каждый плагин, который ими пользуется, — и сам владелец тоже — импортирует этот файл, и поля становятся свойствами каждого игрока:

// plugins/shop.ts
import "~/myplugin/player";

function onKill(
killer
: Player,
victim
: Player) {
if (
victim
.spawnProtected) return;
killer
.kills =
killer
.kills + 1;
killer
.tag = "охотник";
killer
.glow.enabled = true; // один член, записывается сразу
killer
.glow.seenBy.push(
victim
); // push записывает обратно
killer
.glow = {
enabled
: "default",
seenBy
: [] };
}

Файл в папке внутри папки плагинов — не отдельный плагин, а код, который плагины импортируют по его пути после ~/.

  • Типы. Поле — boolean, number, string, юнион строковых литералов ("red" | "blue", а true | false | "default" — одно значение: да, нет или слово), Player[] или объект из них. Необязательных полей не бывает, и члены объекта — не объекты.
  • Поле, в которое ещё не писали, читается как false, 0, "" или [], а юнион — как его первый строковый литерал (выше это "default").
  • Поле-объект пишется прямо в объявлении, как glow выше, или интерфейсом из того же файла. player.glow.enabled = true сразу записывает этот член; присваивание всего объекта записывает все члены.
  • Player[] — массив, чей push записывает обратно, как список флагов: чтобы убрать игрока, присвойте отфильтрованный массив. includes и indexOf находят игрока по его номеру. Ушедший с сервера игрок убирается из списков у всех.
  • Проверяется. Опечатка в поле (player.gost) или значение не того типа — ошибка и в редакторе, и в сборке. Так же и одно поле, объявленное с двумя типами, и поле с именем, которое у Player уже есть (solid, health): дайте ему другое имя.
  • Только то, что плагин импортирует. Сборка знает поля файлов, которые плагин импортирует, а редактор — всех файлов папки плагинов. Если редактор поле принимает, а сборка говорит, что его нет, не хватает import "~/myplugin/player".
  • Когда сбрасываются. Поля игрока очищаются, когда он уходит, — после того как отработают обработчики "disconnected" всех плагинов, так что обработчик ещё может их прочитать. Поля всех игроков очищаются при смене карты. amxts_reload их сохраняет.

Как узнать об изменении

Когда у игрока меняется поле, сервер поднимает событие "playerChange". Каждая запись, которая меняет значение, — любого плагина, на TypeScript или Pawn, — сразу доходит до всех плагинов, которые слушают это поле. Какое именно, говорит field в третьем аргументе:

import "~/myplugin/player";

server
.
addEventListener
("playerChange", (
event
) => {
print
(
event
.
player
,
event
.
value
? "Вы под защитой" : "Защита после появления закончилась");
}, {
field
: "spawnProtected" });

event.value и event.previous — значение поля после изменения и до него, типа этого поля: здесь boolean, для glow — объект. Плагин, который слушает spawnProtected, не вызывается, когда меняется kills. Поле пишется явно, как и имя события, и сборка проверяет, что это поле, которое плагин импортирует.

Без field слышно любое поле, а какое — говорит event.field; значение читается у игрока:

server
.
addEventListener
("playerChange", (
event
) => {
console
.
log
(`${
event
.
player
.
name
}: ${
event
.
field
} changed`);
});

Обработчик, написанный отдельной функцией, принимает событие своего поля:

server
.
addEventListener
("playerChange", onProtection, {
field
: "spawnProtected" });
function onProtection(
event
: PlayerChangeEvent<"spawnProtected">) {
event
.
player
.
renderMode
=
event
.
value
? "additive" : "normal";
}
  • Поле-объект меняется по одному члену, и event.field — имя члена через точку, "glow.enabled". { field: "glow.enabled" } слушает этот член; { field: "glow" } — каждый из них, и в event.value весь объект. Присваивание всего объекта — по изменению на каждый член, чьё значение другое.
  • Player[] меняется, когда меняется список: push или присвоенный список.
  • Не изменение: запись значения, которое у поля уже есть; уход игрока — его поля и его место в списках других уходят без события, для этого есть "disconnected"; смена карты.
  • Сразу. Обработчик выполняется внутри записи, до следующей строки плагина, который писал. Обработчик, который пишет поле, снова поднимает событие — для этого поля.

Из Pawn

Плагин на Pawn читает и пишет те же поля через amxts.inc. amxts build --deploy и amxts dev кладут его в addons/amxmodx/scripting/include сервера. Ключ — имя поля, как его объявляет TypeScript:

#include <amxts>

if (amxts_get_player_data(id, "spawnProtected")) { ... }
amxts_set_player_data(id, "spawnProtected", 1);

new Float:bonus = amxts_get_player_data_float(id, "bonus");
amxts_set_player_data_float(id, "bonus", 1.5);

new tag[32];
amxts_get_player_data_string(id, "tag", tag, charsmax(tag));
amxts_set_player_data_string(id, "tag", "VIP");

// член поля-объекта: его ключ через точку
new enabled[16];
amxts_get_player_data_string(id, "glow.enabled", enabled, charsmax(enabled));
amxts_set_player_data_string(id, "glow.enabled", "true");
НативЧто делает
amxts_get_player_data(id, const key[])числовое поле, целым; логическое — 1 или 0
amxts_set_player_data(id, const key[], value)записывает числовое или логическое поле
Float:amxts_get_player_data_float(id, const key[])числовое поле как Float
amxts_set_player_data_float(id, const key[], Float:value)записывает числовое поле
amxts_get_player_data_string(id, const key[], out[], len)текстовое поле; возвращает число записанных байт, обрезает перед буквой, которая не влезла
amxts_set_player_data_string(id, const key[], const value[])записывает текстовое поле

Поле, прочитанное не тем видом (текстовое через amxts_get_player_data), читается как 0 или "". Юнион литералов — текст: true | false | "default" — это "true", "false" или "default", а пока в поле не писали — "" (TypeScript читает это как первый литерал, "default"). Player[] — тоже текст, номера через запятую: "3,5".