@sagl/server 0.0.0-stage → 0.1.0-next.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/dist/actions.d.ts +46 -0
- package/dist/actions.js +80 -0
- package/dist/actions.js.map +1 -0
- package/dist/animations.d.ts +32 -0
- package/dist/animations.js +95 -0
- package/dist/animations.js.map +1 -0
- package/dist/audio.d.ts +180 -0
- package/dist/audio.js +353 -0
- package/dist/audio.js.map +1 -0
- package/dist/audio_effects.d.ts +219 -0
- package/dist/audio_effects.js +84 -0
- package/dist/audio_effects.js.map +1 -0
- package/dist/camera.d.ts +188 -0
- package/dist/camera.js +247 -0
- package/dist/camera.js.map +1 -0
- package/dist/claims.d.ts +27 -0
- package/dist/claims.js +48 -0
- package/dist/claims.js.map +1 -0
- package/dist/commands.d.ts +135 -0
- package/dist/commands.js +373 -0
- package/dist/commands.js.map +1 -0
- package/dist/console.d.ts +21 -0
- package/dist/console.js +100 -0
- package/dist/console.js.map +1 -0
- package/dist/constants.d.ts +654 -0
- package/dist/constants.js +657 -0
- package/dist/constants.js.map +1 -0
- package/dist/cutscene.d.ts +47 -0
- package/dist/cutscene.js +190 -0
- package/dist/cutscene.js.map +1 -0
- package/dist/draw.d.ts +106 -0
- package/dist/draw.js +132 -0
- package/dist/draw.js.map +1 -0
- package/dist/effects.d.ts +91 -0
- package/dist/effects.js +116 -0
- package/dist/effects.js.map +1 -0
- package/dist/gangzones.d.ts +47 -0
- package/dist/gangzones.js +122 -0
- package/dist/gangzones.js.map +1 -0
- package/dist/geo.d.ts +11 -0
- package/dist/geo.js +25 -0
- package/dist/geo.js.map +1 -0
- package/dist/hud.d.ts +159 -0
- package/dist/hud.js +247 -0
- package/dist/hud.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/interaction.d.ts +81 -0
- package/dist/interaction.js +143 -0
- package/dist/interaction.js.map +1 -0
- package/dist/junctions.d.ts +1 -0
- package/dist/junctions.js +330 -0
- package/dist/junctions.js.map +1 -0
- package/dist/map.d.ts +55 -0
- package/dist/map.js +71 -0
- package/dist/map.js.map +1 -0
- package/dist/menu.d.ts +96 -0
- package/dist/menu.js +129 -0
- package/dist/menu.js.map +1 -0
- package/dist/mission.d.ts +482 -0
- package/dist/mission.js +1258 -0
- package/dist/mission.js.map +1 -0
- package/dist/models.d.ts +56 -0
- package/dist/models.js +71 -0
- package/dist/models.js.map +1 -0
- package/dist/modshop.d.ts +229 -0
- package/dist/modshop.js +904 -0
- package/dist/modshop.js.map +1 -0
- package/dist/modshop_data.d.ts +44 -0
- package/dist/modshop_data.js +287 -0
- package/dist/modshop_data.js.map +1 -0
- package/dist/native.d.ts +809 -0
- package/dist/native.js +27 -0
- package/dist/native.js.map +1 -0
- package/dist/objects.d.ts +249 -0
- package/dist/objects.js +455 -0
- package/dist/objects.js.map +1 -0
- package/dist/paint.d.ts +124 -0
- package/dist/paint.js +84 -0
- package/dist/paint.js.map +1 -0
- package/dist/paynspray.d.ts +44 -0
- package/dist/paynspray.js +177 -0
- package/dist/paynspray.js.map +1 -0
- package/dist/ped.d.ts +335 -0
- package/dist/ped.js +536 -0
- package/dist/ped.js.map +1 -0
- package/dist/player.d.ts +685 -0
- package/dist/player.js +1163 -0
- package/dist/player.js.map +1 -0
- package/dist/screeneffects.d.ts +99 -0
- package/dist/screeneffects.js +257 -0
- package/dist/screeneffects.js.map +1 -0
- package/dist/scripts.d.ts +69 -0
- package/dist/scripts.js +125 -0
- package/dist/scripts.js.map +1 -0
- package/dist/server.d.ts +713 -0
- package/dist/server.js +1080 -0
- package/dist/server.js.map +1 -0
- package/dist/shopblips.d.ts +9 -0
- package/dist/shopblips.js +30 -0
- package/dist/shopblips.js.map +1 -0
- package/dist/special.d.ts +33 -0
- package/dist/special.js +26 -0
- package/dist/special.js.map +1 -0
- package/dist/splash.d.ts +42 -0
- package/dist/splash.js +55 -0
- package/dist/splash.js.map +1 -0
- package/dist/text.d.ts +19 -0
- package/dist/text.js +25 -0
- package/dist/text.js.map +1 -0
- package/dist/ui.d.ts +141 -0
- package/dist/ui.js +243 -0
- package/dist/ui.js.map +1 -0
- package/dist/vehicle.d.ts +306 -0
- package/dist/vehicle.js +612 -0
- package/dist/vehicle.js.map +1 -0
- package/dist/vehiclecolors.d.ts +26 -0
- package/dist/vehiclecolors.js +49 -0
- package/dist/vehiclecolors.js.map +1 -0
- package/dist/world.d.ts +222 -0
- package/dist/world.js +227 -0
- package/dist/world.js.map +1 -0
- package/dist/zone.d.ts +89 -0
- package/dist/zone.js +140 -0
- package/dist/zone.js.map +1 -0
- package/package.json +33 -4
package/dist/server.js
ADDED
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
import { native } from "./native.js";
|
|
2
|
+
import { Player } from "./player.js";
|
|
3
|
+
import { Vehicle } from "./vehicle.js";
|
|
4
|
+
import { Ped, setPedSpeaker } from "./ped.js";
|
|
5
|
+
import { Garage, Zone } from "./zone.js";
|
|
6
|
+
import { Actions } from "./actions.js";
|
|
7
|
+
import { CullZone, World } from "./world.js";
|
|
8
|
+
import { Console } from "./console.js";
|
|
9
|
+
import { GameMap, mapColor } from "./map.js";
|
|
10
|
+
import { GangZone } from "./gangzones.js";
|
|
11
|
+
import { ServerDraw } from "./draw.js";
|
|
12
|
+
import { Missions } from "./mission.js";
|
|
13
|
+
import { PayNSpray } from "./paynspray.js";
|
|
14
|
+
import { ScreenEffects } from "./screeneffects.js";
|
|
15
|
+
import { VehicleColors } from "./vehiclecolors.js";
|
|
16
|
+
import { ModShops } from "./modshop.js";
|
|
17
|
+
import { CutscenePlay } from "./cutscene.js";
|
|
18
|
+
import { Interactions } from "./interaction.js";
|
|
19
|
+
import { ServerAudio } from "./audio.js";
|
|
20
|
+
import { ServerEffects } from "./effects.js";
|
|
21
|
+
import { Claims } from "./claims.js";
|
|
22
|
+
import { Commands, setLookup as setCommandLookup } from "./commands.js";
|
|
23
|
+
import { ServerAnimations } from "./animations.js";
|
|
24
|
+
import { ServerModels } from "./models.js";
|
|
25
|
+
import { WorldThings } from "./objects.js";
|
|
26
|
+
import { Channel, ClientScripts, checkChannel, decode } from "./scripts.js";
|
|
27
|
+
/**
|
|
28
|
+
* A SAGL server.
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* const server = new Server({ port: 7777, name: "My server" });
|
|
32
|
+
* server.on("playerJoin", (p) => server.broadcast(`${p.name} joined`));
|
|
33
|
+
* server.listen();
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export class Server {
|
|
37
|
+
options;
|
|
38
|
+
/** Operator console on stdin; add commands with `server.console.command()`. */
|
|
39
|
+
console;
|
|
40
|
+
/** JavaScript run (sandboxed) in every player's game: see `ClientScripts`. */
|
|
41
|
+
clientScripts;
|
|
42
|
+
/** The pause menu's map, everyone's: see `GameMap`. */
|
|
43
|
+
map;
|
|
44
|
+
/** Drawing on everyone's screen (players joining later too), SA-MP textdraw style: see `ScreenDraw`. */
|
|
45
|
+
draw;
|
|
46
|
+
/** Missions: starting them, start markers, who's in which. See `Missions`. */
|
|
47
|
+
missions;
|
|
48
|
+
/** Car colours: the game's, and your own (ids 128 up). See `VehicleColors`. */
|
|
49
|
+
vehicleColors;
|
|
50
|
+
/** Full-screen effects (your shaders) on everyone's screen, players joining later too. See `ScreenEffects`. */
|
|
51
|
+
screenEffects;
|
|
52
|
+
/** The game's Pay 'n' Sprays, on by default. See `PayNSpray`. */
|
|
53
|
+
payNSpray;
|
|
54
|
+
/** The game's mod shops (TransFender, Loco Low Co, Wheel Arch Angels), on by default. See `ModShops`. */
|
|
55
|
+
modShops;
|
|
56
|
+
/** What players can walk up to and use: vending machines, payphones... See `Interactions`. */
|
|
57
|
+
interactions;
|
|
58
|
+
/** Things players do with a key they choose (Settings, Controls): see `Actions`. */
|
|
59
|
+
actions;
|
|
60
|
+
/** Sounds from client files, positioned or not: see `ServerAudio`. */
|
|
61
|
+
audio;
|
|
62
|
+
/** The game's particle effects and coloured dust: see `ServerEffects`. */
|
|
63
|
+
effects;
|
|
64
|
+
/** First come, first served: one player at a time holds a thing. See `Claims`. */
|
|
65
|
+
claims = new Claims();
|
|
66
|
+
/** Chat commands, built as Minecraft's (`command(...)`): see `Commands`. */
|
|
67
|
+
commands;
|
|
68
|
+
/** How chat lines go out, and its flood limit: see `ChatOptions`. */
|
|
69
|
+
chat = {
|
|
70
|
+
format: (player, text) => `{${(player.color & 0xffffff).toString(16).padStart(6, "0")}}${player.name}{FFFFFF}: ${text}`,
|
|
71
|
+
floodLines: 5,
|
|
72
|
+
floodPerSecond: 1,
|
|
73
|
+
};
|
|
74
|
+
/** The server's own animation sets (IFP client files): see `ServerAnimations`. */
|
|
75
|
+
animations;
|
|
76
|
+
/** The server's own models (DFF/TXD client files), as SA-MP's AddSimpleModel: see `ServerModels`. */
|
|
77
|
+
models;
|
|
78
|
+
/** Floating text in the world: `server.labels.add(text, at, options)`. See `Label`. */
|
|
79
|
+
labels;
|
|
80
|
+
#things;
|
|
81
|
+
#native;
|
|
82
|
+
#cutscenes = new Map();
|
|
83
|
+
#nextCutscene = 1;
|
|
84
|
+
#players = new Map();
|
|
85
|
+
#vehicles = new Map();
|
|
86
|
+
#peds = new Map();
|
|
87
|
+
#zones = new Map();
|
|
88
|
+
#garages = [];
|
|
89
|
+
#cullZones = new Map();
|
|
90
|
+
#listeners = {};
|
|
91
|
+
#timer = null;
|
|
92
|
+
#warnedAsyncVeto = false;
|
|
93
|
+
#channels = new Map();
|
|
94
|
+
#branding = {};
|
|
95
|
+
constructor(options = {}) {
|
|
96
|
+
this.options = options;
|
|
97
|
+
this.#native = new native.NativeServer({
|
|
98
|
+
port: options.port,
|
|
99
|
+
name: options.name,
|
|
100
|
+
maxPlayers: options.maxPlayers,
|
|
101
|
+
timeHour: options.time?.hour,
|
|
102
|
+
timeMinute: options.time?.minute,
|
|
103
|
+
timeFrozen: options.time?.frozen ?? true,
|
|
104
|
+
trustedProxies: options.trustedProxies,
|
|
105
|
+
startMoney: options.startMoney,
|
|
106
|
+
weather: options.weather,
|
|
107
|
+
skinAnimations: options.skinAnimations,
|
|
108
|
+
gameEntrances: options.gameEntrances,
|
|
109
|
+
splash: !!options.splash,
|
|
110
|
+
carjacking: options.carjacking ?? true,
|
|
111
|
+
indicatorKeys: options.indicators?.keys ?? true,
|
|
112
|
+
indicatorCancel: options.indicators?.autoCancel ?? true,
|
|
113
|
+
indicatorSound: options.indicators?.sound === false ? "-" : (options.indicators?.sound ?? ""),
|
|
114
|
+
clientFiles: options.clientFiles,
|
|
115
|
+
});
|
|
116
|
+
this.#native.setHooks({
|
|
117
|
+
join: (id) => this.#onJoin(id),
|
|
118
|
+
quit: (id, reason) => this.#onQuit(id, reason),
|
|
119
|
+
damage: (victimId, attackerId, hit, attackerPedId) => this.#onDamage(victimId, this.#attacker(attackerId, attackerPedId), hit),
|
|
120
|
+
death: (id, killerId, weapon, killerPedId) => {
|
|
121
|
+
const player = this.#players.get(id);
|
|
122
|
+
if (!player)
|
|
123
|
+
return;
|
|
124
|
+
player.specialEnds(false);
|
|
125
|
+
this.#emit("playerDeath", player, this.#attacker(killerId, killerPedId) ?? null, weapon);
|
|
126
|
+
},
|
|
127
|
+
spawn: (id) => {
|
|
128
|
+
const player = this.#players.get(id);
|
|
129
|
+
if (player)
|
|
130
|
+
this.#emit("playerSpawn", player);
|
|
131
|
+
},
|
|
132
|
+
pause: (id, paused, focused) => {
|
|
133
|
+
const player = this.#players.get(id);
|
|
134
|
+
if (player)
|
|
135
|
+
this.#emit("playerPause", player, paused, focused);
|
|
136
|
+
},
|
|
137
|
+
enterVehicle: (playerId, vehicleId) => {
|
|
138
|
+
const player = this.#players.get(playerId);
|
|
139
|
+
const vehicle = this.#vehicleById(vehicleId);
|
|
140
|
+
if (!player || !vehicle)
|
|
141
|
+
return;
|
|
142
|
+
player.specialEnds(true);
|
|
143
|
+
this.#emit("playerEnterVehicle", player, vehicle);
|
|
144
|
+
},
|
|
145
|
+
radio: (playerId, vehicleId, station) => {
|
|
146
|
+
const player = this.#players.get(playerId);
|
|
147
|
+
const vehicle = this.#vehicleById(vehicleId);
|
|
148
|
+
if (!player || !vehicle)
|
|
149
|
+
return undefined;
|
|
150
|
+
let result;
|
|
151
|
+
for (const r of this.#emit("vehicleRadio", player, vehicle, station)) {
|
|
152
|
+
if (r === false)
|
|
153
|
+
return false;
|
|
154
|
+
if (typeof r === "number")
|
|
155
|
+
result = r;
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
},
|
|
159
|
+
shoot: (playerId, shot) => {
|
|
160
|
+
const player = this.#players.get(playerId);
|
|
161
|
+
if (!player)
|
|
162
|
+
return undefined;
|
|
163
|
+
for (const r of this.#emit("playerShoot", player, shot)) {
|
|
164
|
+
if (r === false)
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
},
|
|
169
|
+
scriptMessage: (playerId, channel, text) => {
|
|
170
|
+
const player = this.#players.get(playerId);
|
|
171
|
+
if (!player)
|
|
172
|
+
return;
|
|
173
|
+
const data = decode(text);
|
|
174
|
+
if (channel === "sagl:error") {
|
|
175
|
+
const e = (data ?? {});
|
|
176
|
+
this.#emit("clientScriptError", player, { where: String(e.where ?? "?"), message: String(e.message ?? data) });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
this.#channels.get(channel)?.dispatch(player, data);
|
|
180
|
+
this.#emit("clientMessage", player, channel, data);
|
|
181
|
+
},
|
|
182
|
+
uiEvent: (playerId, event) => {
|
|
183
|
+
this.#players.get(playerId)?.ui.dispatch(event);
|
|
184
|
+
},
|
|
185
|
+
objectHit: (playerId, id, damage, weapon) => {
|
|
186
|
+
const player = this.#players.get(playerId);
|
|
187
|
+
if (player)
|
|
188
|
+
this.#things.objects.get(id)?.hit(player, damage, weapon);
|
|
189
|
+
},
|
|
190
|
+
pickupTouch: (playerId, id) => {
|
|
191
|
+
const player = this.#players.get(playerId);
|
|
192
|
+
if (player)
|
|
193
|
+
this.#things.pickups.get(id)?.touch(player);
|
|
194
|
+
},
|
|
195
|
+
carjack: (jackerId, victimId, vehicleId) => {
|
|
196
|
+
const jacker = this.#players.get(jackerId), victim = this.#players.get(victimId), vehicle = this.#vehicleById(vehicleId);
|
|
197
|
+
if (jacker && victim && vehicle)
|
|
198
|
+
this.#emit("playerCarjack", jacker, victim, vehicle);
|
|
199
|
+
},
|
|
200
|
+
playerAttach: (playerId, spec) => {
|
|
201
|
+
const player = this.#players.get(playerId);
|
|
202
|
+
if (!player)
|
|
203
|
+
return;
|
|
204
|
+
const model = spec.remove ? null : (spec.model ?? 0);
|
|
205
|
+
if (this.#emit("playerAttachObject", player, spec.slot, model).includes(false))
|
|
206
|
+
return;
|
|
207
|
+
player.attachedByScript(spec);
|
|
208
|
+
},
|
|
209
|
+
gameMenuEvent: (playerId, id, kind, row) => {
|
|
210
|
+
this.#players.get(playerId)?.menuHeard(id, kind, row);
|
|
211
|
+
},
|
|
212
|
+
documentResult: (playerId, id, button) => {
|
|
213
|
+
this.#players.get(playerId)?.ui.documentAnswered(id, button);
|
|
214
|
+
},
|
|
215
|
+
cameraEvent: (playerId, id, step, kind) => {
|
|
216
|
+
this.#players.get(playerId)?.camera.heard(id, step, kind);
|
|
217
|
+
},
|
|
218
|
+
dialogResponse: (playerId, id, response, listItem, input) => {
|
|
219
|
+
this.#players.get(playerId)?.dialogAnswered(id, { response, listItem, input });
|
|
220
|
+
},
|
|
221
|
+
chat: (playerId, text) => {
|
|
222
|
+
const player = this.#players.get(playerId);
|
|
223
|
+
if (player)
|
|
224
|
+
void this.#onChat(player, text);
|
|
225
|
+
},
|
|
226
|
+
suggestRequest: (playerId, id, text) => {
|
|
227
|
+
const player = this.#players.get(playerId);
|
|
228
|
+
if (!player)
|
|
229
|
+
return;
|
|
230
|
+
void this.commands.suggest(player, text).then((r) => {
|
|
231
|
+
if (r && player.connected)
|
|
232
|
+
this.#native.suggestions(player.id, id, r[0], r[1]);
|
|
233
|
+
}).catch((err) => console.error("[sagl] command suggestions failed:", err));
|
|
234
|
+
},
|
|
235
|
+
playerKeys: (playerId, keys, old) => {
|
|
236
|
+
const player = this.#players.get(playerId);
|
|
237
|
+
if (!player)
|
|
238
|
+
return;
|
|
239
|
+
player.specialKeys(keys, old);
|
|
240
|
+
this.#emit("playerKeys", player, keys, old);
|
|
241
|
+
},
|
|
242
|
+
playerInterior: (playerId, interior, old) => {
|
|
243
|
+
const player = this.#players.get(playerId);
|
|
244
|
+
if (player)
|
|
245
|
+
this.#emit("playerInterior", player, interior, old);
|
|
246
|
+
},
|
|
247
|
+
playerAction: (playerId, id) => {
|
|
248
|
+
const player = this.#players.get(playerId);
|
|
249
|
+
if (!player)
|
|
250
|
+
return;
|
|
251
|
+
this.actions.pressed(player, id);
|
|
252
|
+
this.#emit("playerAction", player, id);
|
|
253
|
+
},
|
|
254
|
+
interactionUse: (playerId, id, where) => {
|
|
255
|
+
const player = this.#players.get(playerId);
|
|
256
|
+
if (player)
|
|
257
|
+
this.interactions.use(player, id, where);
|
|
258
|
+
},
|
|
259
|
+
cutsceneState: (playerId, id, state, skipped) => {
|
|
260
|
+
const player = this.#players.get(playerId);
|
|
261
|
+
if (player)
|
|
262
|
+
this.#cutscenes.get(id)?.state(player, state, skipped);
|
|
263
|
+
},
|
|
264
|
+
wantedLevel: (playerId, level, old) => {
|
|
265
|
+
const player = this.#players.get(playerId);
|
|
266
|
+
if (!player)
|
|
267
|
+
return undefined;
|
|
268
|
+
let out;
|
|
269
|
+
for (const r of this.#emit("playerWantedLevel", player, level, old)) {
|
|
270
|
+
if (r === false)
|
|
271
|
+
return false;
|
|
272
|
+
if (typeof r === "number")
|
|
273
|
+
out = r;
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
},
|
|
277
|
+
weaponViolation: (playerId, reason, weapon) => {
|
|
278
|
+
const player = this.#players.get(playerId);
|
|
279
|
+
if (player)
|
|
280
|
+
this.#emit("playerWeaponViolation", player, reason, weapon);
|
|
281
|
+
},
|
|
282
|
+
pedDamage: (pedId, attackerId, hit, attackerPedId) => {
|
|
283
|
+
const ped = this.#pedById(pedId);
|
|
284
|
+
const attacker = this.#attacker(attackerId, attackerPedId);
|
|
285
|
+
if (!ped || !attacker)
|
|
286
|
+
return undefined;
|
|
287
|
+
let damage;
|
|
288
|
+
const own = ped.damagedBy(attacker, { ...hit });
|
|
289
|
+
if (own === false)
|
|
290
|
+
return false;
|
|
291
|
+
if (typeof own === "number")
|
|
292
|
+
damage = own;
|
|
293
|
+
for (const result of this.#emit("pedDamage", ped, attacker, { ...hit })) {
|
|
294
|
+
if (result === false)
|
|
295
|
+
return false;
|
|
296
|
+
if (typeof result === "number")
|
|
297
|
+
damage = result;
|
|
298
|
+
}
|
|
299
|
+
return damage;
|
|
300
|
+
},
|
|
301
|
+
pedOrder: (pedId, orderId, step, state, reason) => {
|
|
302
|
+
const ped = this.#pedById(pedId);
|
|
303
|
+
if (!ped)
|
|
304
|
+
return;
|
|
305
|
+
ped.handleOrder(orderId, step, state, reason);
|
|
306
|
+
this.#emit("pedOrder", ped, { orderId, step, state, reason: reason || undefined });
|
|
307
|
+
},
|
|
308
|
+
pedDeath: (pedId, killerId, weapon, killerPedId) => {
|
|
309
|
+
const ped = this.#pedById(pedId);
|
|
310
|
+
if (ped)
|
|
311
|
+
this.#emit("pedDeath", ped, this.#attacker(killerId, killerPedId) ?? null, weapon);
|
|
312
|
+
},
|
|
313
|
+
populationSpawn: (playerId, spawn) => {
|
|
314
|
+
const player = this.#players.get(playerId);
|
|
315
|
+
if (!player)
|
|
316
|
+
return undefined;
|
|
317
|
+
const info = {
|
|
318
|
+
vehicle: spawn.vehicle ? spawn.vehicleModel : null,
|
|
319
|
+
position: spawn.position,
|
|
320
|
+
heading: (spawn.heading * 180) / Math.PI,
|
|
321
|
+
peds: spawn.peds,
|
|
322
|
+
};
|
|
323
|
+
for (const r of this.#emit("populationSpawn", info, player)) {
|
|
324
|
+
if (r === false)
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
return undefined;
|
|
328
|
+
},
|
|
329
|
+
pedViolation: (playerId, pedId, reason) => {
|
|
330
|
+
const player = this.#players.get(playerId);
|
|
331
|
+
const ped = this.#pedById(pedId);
|
|
332
|
+
if (player && ped)
|
|
333
|
+
this.#emit("pedViolation", player, ped, reason);
|
|
334
|
+
},
|
|
335
|
+
vehicleRespawn: (vehicleId) => {
|
|
336
|
+
const vehicle = this.#vehicleById(vehicleId);
|
|
337
|
+
if (vehicle)
|
|
338
|
+
this.#emit("vehicleRespawn", vehicle);
|
|
339
|
+
},
|
|
340
|
+
vehicleHit: (vehicleId, h) => {
|
|
341
|
+
const vehicle = this.#vehicleById(vehicleId);
|
|
342
|
+
if (!vehicle)
|
|
343
|
+
return undefined;
|
|
344
|
+
const hit = {
|
|
345
|
+
cause: h.cause,
|
|
346
|
+
with: h.with,
|
|
347
|
+
weapon: h.weapon,
|
|
348
|
+
vehicle: h.otherVehicleId === null ? null : (this.#vehicleById(h.otherVehicleId) ?? null),
|
|
349
|
+
player: h.playerId === null ? null : (this.#players.get(h.playerId) ?? null),
|
|
350
|
+
ped: h.pedId === null ? null : (this.#pedById(h.pedId) ?? null),
|
|
351
|
+
healthBefore: h.healthBefore,
|
|
352
|
+
health: h.health,
|
|
353
|
+
loss: h.loss,
|
|
354
|
+
intensity: h.intensity,
|
|
355
|
+
position: h.position,
|
|
356
|
+
};
|
|
357
|
+
let out;
|
|
358
|
+
const own = vehicle.hitBy(hit);
|
|
359
|
+
if (own === false)
|
|
360
|
+
return false;
|
|
361
|
+
if (typeof own === "number")
|
|
362
|
+
out = own;
|
|
363
|
+
for (const r of this.#emit("vehicleHit", vehicle, hit)) {
|
|
364
|
+
if (r === false)
|
|
365
|
+
return false;
|
|
366
|
+
if (typeof r === "number")
|
|
367
|
+
out = r;
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
},
|
|
371
|
+
vehicleDeath: (vehicleId, killerId) => {
|
|
372
|
+
const vehicle = this.#vehicleById(vehicleId);
|
|
373
|
+
if (!vehicle)
|
|
374
|
+
return undefined;
|
|
375
|
+
const killer = killerId === null ? null : (this.#players.get(killerId) ?? null);
|
|
376
|
+
for (const r of this.#emit("vehicleDeath", vehicle, killer)) {
|
|
377
|
+
if (r === false)
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
},
|
|
382
|
+
explosion: (playerId, e) => {
|
|
383
|
+
const player = this.#players.get(playerId);
|
|
384
|
+
if (!player)
|
|
385
|
+
return undefined;
|
|
386
|
+
const explosion = {
|
|
387
|
+
type: e.type,
|
|
388
|
+
weapon: e.weapon,
|
|
389
|
+
vehicle: e.vehicleId ? (this.#vehicleById(e.vehicleId) ?? null) : null,
|
|
390
|
+
position: e.position,
|
|
391
|
+
};
|
|
392
|
+
for (const v of this.#vehicles.values())
|
|
393
|
+
v.explosionNear(player, explosion);
|
|
394
|
+
for (const r of this.#emit("playerExplosion", player, explosion)) {
|
|
395
|
+
if (r === false)
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
return undefined;
|
|
399
|
+
},
|
|
400
|
+
zoneEnter: (playerId, zoneId) => this.#zoneEvent("zoneEnter", playerId, zoneId),
|
|
401
|
+
zoneLeave: (playerId, zoneId) => this.#zoneEvent("zoneLeave", playerId, zoneId),
|
|
402
|
+
zoneMove: (playerId, zoneId) => this.#zoneEvent("zoneMove", playerId, zoneId),
|
|
403
|
+
exitVehicle: (playerId, vehicleId) => {
|
|
404
|
+
const player = this.#players.get(playerId);
|
|
405
|
+
const vehicle = this.#vehicleById(vehicleId);
|
|
406
|
+
if (player && vehicle)
|
|
407
|
+
this.#emit("playerExitVehicle", player, vehicle);
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
this.console = new Console(this);
|
|
411
|
+
this.clientScripts = new ClientScripts(this.#native);
|
|
412
|
+
if (options.branding)
|
|
413
|
+
this.branding = options.branding;
|
|
414
|
+
this.map = new GameMap(this.#native, -1);
|
|
415
|
+
let nextDraw = 1_000_000; // apart from players' own ids
|
|
416
|
+
this.draw = new ServerDraw(this.#native, () => this.players, () => nextDraw++);
|
|
417
|
+
this.missions = new Missions(this, (e) => this.#emit("missionEvent", e));
|
|
418
|
+
this.interactions = new Interactions(this.#native, () => this.players);
|
|
419
|
+
this.actions = new Actions(this.#native);
|
|
420
|
+
this.audio = new ServerAudio(this.#native, () => this.players, options.clientFiles);
|
|
421
|
+
setPedSpeaker((file, options) => this.audio.speak(file, options));
|
|
422
|
+
this.commands = new Commands((p, tree) => this.#native.commandTree(p.id, tree), () => this.players, (p, text) => p.send(text, 0xff6060));
|
|
423
|
+
setCommandLookup({ players: () => this.players, vehicle: (id) => this.#vehicleById(id) });
|
|
424
|
+
this.effects = new ServerEffects(this.#native, () => this.players);
|
|
425
|
+
this.animations = new ServerAnimations(this.#native, () => this.players, options.clientFiles);
|
|
426
|
+
this.models = new ServerModels(this.#native, () => this.players);
|
|
427
|
+
this.#things = new WorldThings(this.#native, () => this.players);
|
|
428
|
+
this.labels = { add: (text, at, opts) => this.#things.addLabel(text, at, opts) };
|
|
429
|
+
for (const state of this.#native.garages()) {
|
|
430
|
+
const garage = new Garage(this.#native, state);
|
|
431
|
+
garage.zone = new Zone(state.zoneId, garage, (z) => this.destroyZone(z));
|
|
432
|
+
this.#zones.set(state.zoneId, garage.zone);
|
|
433
|
+
this.#garages.push(garage);
|
|
434
|
+
}
|
|
435
|
+
this.screenEffects = new ScreenEffects(this.#native, null, () => this.players);
|
|
436
|
+
this.vehicleColors = new VehicleColors(this.#native, () => this.players);
|
|
437
|
+
this.payNSpray = new PayNSpray(this, (p, v, colors) => this.#emit("vehicleRespray", p, v, colors));
|
|
438
|
+
this.modShops = new ModShops(this, {
|
|
439
|
+
mod: (p, v, part, price) => this.#emit("vehicleMod", p, v, part, price),
|
|
440
|
+
paintjob: (p, v, paintjob, price) => this.#emit("vehiclePaintjob", p, v, paintjob, price),
|
|
441
|
+
respray: (p, v, colors) => this.#emit("vehicleRespray", p, v, colors),
|
|
442
|
+
shop: (p, v, shop, entered, site) => this.#emit("modShop", p, v, shop, entered, site),
|
|
443
|
+
livery: (p, v, livery, price) => this.#emit("vehicleLivery", p, v, livery, price),
|
|
444
|
+
plate: (p, v, plate, price) => this.#emit("vehiclePlate", p, v, plate, price),
|
|
445
|
+
finish: (p, v, finish, price) => this.#emit("vehicleFinish", p, v, finish, price),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
#zoneEvent(event, playerId, zoneId) {
|
|
449
|
+
const player = this.#players.get(playerId);
|
|
450
|
+
const zone = this.#zones.get(zoneId);
|
|
451
|
+
if (player && zone) {
|
|
452
|
+
if (event !== "zoneMove")
|
|
453
|
+
this.audio.zoneEvent(player, zone, event === "zoneEnter");
|
|
454
|
+
this.#emit(event, player, zone);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/** Protocol version clients must speak. */
|
|
458
|
+
static get protocolVersion() {
|
|
459
|
+
return native.protocolVersion;
|
|
460
|
+
}
|
|
461
|
+
/** SAGL's version this server is (as the client and the SDK: one version for all of SAGL). */
|
|
462
|
+
static get version() {
|
|
463
|
+
return native.version;
|
|
464
|
+
}
|
|
465
|
+
// ---- Lifecycle --------------------------------------------------------------
|
|
466
|
+
/**
|
|
467
|
+
* Starts accepting players. Also starts the operator console unless
|
|
468
|
+
* `{ console: false }`, and stops cleanly on SIGINT/SIGTERM.
|
|
469
|
+
*/
|
|
470
|
+
listen({ console: withConsole = true } = {}) {
|
|
471
|
+
if (this.#timer)
|
|
472
|
+
return this;
|
|
473
|
+
if (!this.#native.start()) {
|
|
474
|
+
throw new Error(`could not start the server on UDP port ${this.options.port ?? 7777}`);
|
|
475
|
+
}
|
|
476
|
+
this.#timer = setInterval(() => this.#native.service(0), this.options.tickMs ?? 5);
|
|
477
|
+
this.#checkpointTimer = setInterval(() => {
|
|
478
|
+
this.#checkCheckpoints();
|
|
479
|
+
if (++this.#aimTick % 3 === 0)
|
|
480
|
+
this.#checkAims();
|
|
481
|
+
}, 100);
|
|
482
|
+
if (withConsole)
|
|
483
|
+
this.console.start();
|
|
484
|
+
const stop = () => {
|
|
485
|
+
this.close();
|
|
486
|
+
process.exit(0);
|
|
487
|
+
};
|
|
488
|
+
process.once("SIGINT", stop);
|
|
489
|
+
process.once("SIGTERM", stop);
|
|
490
|
+
return this;
|
|
491
|
+
}
|
|
492
|
+
/** Disconnects everyone and stops the network. */
|
|
493
|
+
close() {
|
|
494
|
+
if (this.#timer) {
|
|
495
|
+
clearInterval(this.#timer);
|
|
496
|
+
this.#timer = null;
|
|
497
|
+
}
|
|
498
|
+
if (this.#checkpointTimer) {
|
|
499
|
+
clearInterval(this.#checkpointTimer);
|
|
500
|
+
this.#checkpointTimer = null;
|
|
501
|
+
}
|
|
502
|
+
this.console.stop();
|
|
503
|
+
this.#native.shutdown();
|
|
504
|
+
}
|
|
505
|
+
// ---- Events -----------------------------------------------------------------
|
|
506
|
+
/** The server's branding, for players joining from now. */
|
|
507
|
+
get branding() {
|
|
508
|
+
return { ...this.#branding };
|
|
509
|
+
}
|
|
510
|
+
set branding(b) {
|
|
511
|
+
const error = this.#native.setBranding(b.logo ?? "", mapColor(b.primary, 0), mapColor(b.secondary, 0), b.background ?? "", b.showName ?? false);
|
|
512
|
+
if (error)
|
|
513
|
+
throw new Error(`branding: ${error}`);
|
|
514
|
+
this.#branding = { ...b };
|
|
515
|
+
}
|
|
516
|
+
/** What players download from the `clientFiles` directory: path, size, SHA-256 (hex). */
|
|
517
|
+
get clientFiles() {
|
|
518
|
+
return this.#native.clientFiles();
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Reads the `clientFiles` directory again (after changing it); players
|
|
522
|
+
* joining from now get it as it is. Returns how many files there are.
|
|
523
|
+
*/
|
|
524
|
+
rescanClientFiles() {
|
|
525
|
+
const n = this.#native.rescanClientFiles();
|
|
526
|
+
this.animations.rescan();
|
|
527
|
+
return n;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* The named channel between the gamemode and its client scripts (made on
|
|
531
|
+
* first use): `.on((player, data) => ...)` for what scripts send,
|
|
532
|
+
* `.send(player, data)` / `.broadcast(data)` to them. Data is JSON.
|
|
533
|
+
*/
|
|
534
|
+
channel(name) {
|
|
535
|
+
let ch = this.#channels.get(name);
|
|
536
|
+
if (!ch) {
|
|
537
|
+
checkChannel(name);
|
|
538
|
+
ch = new Channel(this.#native, name);
|
|
539
|
+
this.#channels.set(name, ch);
|
|
540
|
+
}
|
|
541
|
+
return ch;
|
|
542
|
+
}
|
|
543
|
+
on(event, listener) {
|
|
544
|
+
(this.#listeners[event] ??= []).push(listener);
|
|
545
|
+
return this;
|
|
546
|
+
}
|
|
547
|
+
off(event, listener) {
|
|
548
|
+
const list = this.#listeners[event];
|
|
549
|
+
const index = list?.indexOf(listener) ?? -1;
|
|
550
|
+
if (list && index >= 0)
|
|
551
|
+
list.splice(index, 1);
|
|
552
|
+
return this;
|
|
553
|
+
}
|
|
554
|
+
#emit(event, ...args) {
|
|
555
|
+
const results = [];
|
|
556
|
+
for (const listener of [...(this.#listeners[event] ?? [])]) {
|
|
557
|
+
try {
|
|
558
|
+
const result = listener(...args);
|
|
559
|
+
if (result instanceof Promise) {
|
|
560
|
+
result.catch((err) => console.error(`[sagl] ${event} handler failed:`, err));
|
|
561
|
+
}
|
|
562
|
+
results.push(result);
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
console.error(`[sagl] ${event} handler threw:`, err);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return results;
|
|
569
|
+
}
|
|
570
|
+
#flood = new Map();
|
|
571
|
+
#checkpointTimer = null;
|
|
572
|
+
#aimTick = 0;
|
|
573
|
+
// Peds with onThreatened: is any player aiming at them? (a few times a second)
|
|
574
|
+
#checkAims() {
|
|
575
|
+
for (const ped of this.#peds.values()) {
|
|
576
|
+
if (!ped.watchesThreats || !ped.exists)
|
|
577
|
+
continue;
|
|
578
|
+
for (const p of this.#players.values())
|
|
579
|
+
if (p.connected && !p.vehicle)
|
|
580
|
+
ped.checkAim(p);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// Checkpoints: in one when within its radius on the ground (their position
|
|
584
|
+
// is their middle, or their vehicle's), checked ten times a second.
|
|
585
|
+
#checkCheckpoints() {
|
|
586
|
+
const inside = (p, c) => {
|
|
587
|
+
const at = p.position;
|
|
588
|
+
return Math.hypot(at.x - c.position.x, at.y - c.position.y) <= c.size &&
|
|
589
|
+
at.z >= c.position.z - 2 && at.z <= c.position.z + Math.max(4, c.size);
|
|
590
|
+
};
|
|
591
|
+
for (const p of this.#players.values()) {
|
|
592
|
+
if (p.checkpoint) {
|
|
593
|
+
const now = p.alive && inside(p, p.checkpoint);
|
|
594
|
+
if (now !== p.inCheckpoint) {
|
|
595
|
+
p.inCheckpoint = now;
|
|
596
|
+
this.#emit(now ? "playerEnterCheckpoint" : "playerLeaveCheckpoint", p);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (p.raceCheckpoint) {
|
|
600
|
+
const now = p.alive && inside(p, p.raceCheckpoint);
|
|
601
|
+
if (now !== p.inRaceCheckpoint) {
|
|
602
|
+
p.inRaceCheckpoint = now;
|
|
603
|
+
this.#emit(now ? "playerEnterRaceCheckpoint" : "playerLeaveRaceCheckpoint", p);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
async #onChat(player, raw) {
|
|
609
|
+
const text = raw.replace(/[\x00-\x1f]/g, "").trim().slice(0, 256);
|
|
610
|
+
if (!text)
|
|
611
|
+
return;
|
|
612
|
+
// A burst of floodLines, then floodPerSecond.
|
|
613
|
+
const now = Date.now();
|
|
614
|
+
const f = this.#flood.get(player) ?? { tokens: this.chat.floodLines, at: now };
|
|
615
|
+
f.tokens = Math.min(this.chat.floodLines, f.tokens + ((now - f.at) / 1000) * this.chat.floodPerSecond);
|
|
616
|
+
f.at = now;
|
|
617
|
+
this.#flood.set(player, f);
|
|
618
|
+
if (f.tokens < 1) {
|
|
619
|
+
player.send("You're sending messages too fast.", 0xff6060);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
f.tokens -= 1;
|
|
623
|
+
if (text.startsWith("/")) {
|
|
624
|
+
if (await this.commands.handle(player, text))
|
|
625
|
+
return;
|
|
626
|
+
if (this.#emit("playerCommandText", player, text).some((r) => r === true))
|
|
627
|
+
return;
|
|
628
|
+
player.send(`Unknown command: ${text.split(/\s+/)[0]}`, 0xff6060);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
let line = text;
|
|
632
|
+
for (const r of this.#emit("playerChat", player, text)) {
|
|
633
|
+
if (r === false)
|
|
634
|
+
return;
|
|
635
|
+
if (typeof r === "string")
|
|
636
|
+
line = r;
|
|
637
|
+
}
|
|
638
|
+
this.broadcast(this.chat.format(player, line));
|
|
639
|
+
}
|
|
640
|
+
#onJoin(id) {
|
|
641
|
+
const player = new Player(this.#native, id, (vid) => this.#vehicleById(vid));
|
|
642
|
+
this.#players.set(id, player);
|
|
643
|
+
if (this.options.splash) {
|
|
644
|
+
// Up until the gamemode hides it (or the timeout does).
|
|
645
|
+
const splash = player.holdSplash();
|
|
646
|
+
const ms = typeof this.options.splash === "object" ? (this.options.splash.timeoutMs ?? 30000) : 30000;
|
|
647
|
+
setTimeout(() => {
|
|
648
|
+
if (!player.connected || splash.everHidden)
|
|
649
|
+
return;
|
|
650
|
+
console.warn(`[sagl] ${player.name}'s splash screen was still up after ${ms} ms: hidden (call player.splash.hide() when they're ready)`);
|
|
651
|
+
splash.hide();
|
|
652
|
+
}, ms).unref?.();
|
|
653
|
+
}
|
|
654
|
+
this.draw.replay(player);
|
|
655
|
+
this.interactions.replay(player);
|
|
656
|
+
this.audio.replay(player);
|
|
657
|
+
this.effects.replay(player);
|
|
658
|
+
this.animations.replay(player);
|
|
659
|
+
this.models.replay(player); // before the objects that use them
|
|
660
|
+
this.#things.replay(player);
|
|
661
|
+
this.commands.refresh(player);
|
|
662
|
+
// What everyone holds, to them; what they hold (nothing yet), to everyone.
|
|
663
|
+
// Chat bubbles: a label on them, above their head (a little higher than a name).
|
|
664
|
+
player.setBubbleMaker((text, color, distance) => this.labels.add(text, player, { color, distance, offset: { x: 0, y: 0, z: 1.3 } }));
|
|
665
|
+
player.setAttachmentSender((spec) => {
|
|
666
|
+
for (const p of this.players)
|
|
667
|
+
if (p.connected)
|
|
668
|
+
this.#native.playerAttachment(p.id, spec);
|
|
669
|
+
});
|
|
670
|
+
for (const p of this.players) {
|
|
671
|
+
for (const spec of p.attachments.values())
|
|
672
|
+
this.#native.playerAttachment(player.id, spec);
|
|
673
|
+
}
|
|
674
|
+
player.setRespawner((p) => this.#emit("playerSpawn", p));
|
|
675
|
+
this.screenEffects.joined(player);
|
|
676
|
+
this.vehicleColors.joined(player);
|
|
677
|
+
// Name tags: how they show, and everyone's that isn't as normal.
|
|
678
|
+
player.setTagSender((target, viewer) => {
|
|
679
|
+
for (const v of viewer ? [viewer] : this.players) {
|
|
680
|
+
if (v !== target && v.connected && target.connected)
|
|
681
|
+
this.#native.nameTag(v.id, target.id, ...target.tagFor(v));
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
this.#sendNameTagOptions(player);
|
|
685
|
+
if (!this.#stuntBonus)
|
|
686
|
+
player.stuntBonus = false;
|
|
687
|
+
for (const z of this.#gangZones.values())
|
|
688
|
+
z.replay(player);
|
|
689
|
+
for (const p of this.players) {
|
|
690
|
+
if (p === player)
|
|
691
|
+
continue;
|
|
692
|
+
const [hidden, text, color] = p.tagFor(player);
|
|
693
|
+
if (hidden || text || color !== null)
|
|
694
|
+
this.#native.nameTag(player.id, p.id, hidden, text, color);
|
|
695
|
+
}
|
|
696
|
+
this.#emit("playerJoin", player);
|
|
697
|
+
}
|
|
698
|
+
#onQuit(id, reason) {
|
|
699
|
+
const player = this.#players.get(id);
|
|
700
|
+
if (!player)
|
|
701
|
+
return;
|
|
702
|
+
this.#emit("playerQuit", player, reason);
|
|
703
|
+
this.#players.delete(id);
|
|
704
|
+
for (const p of this.#players.values())
|
|
705
|
+
p.forgetViewer(player);
|
|
706
|
+
for (const z of this.#gangZones.values())
|
|
707
|
+
z.forget(player);
|
|
708
|
+
this.#things.forgetTarget(2, id);
|
|
709
|
+
this.audio.forget(player);
|
|
710
|
+
this.claims.forget(player);
|
|
711
|
+
this.#flood.delete(player);
|
|
712
|
+
player.hideDialog();
|
|
713
|
+
player.forgetMenus();
|
|
714
|
+
for (const ped of this.#peds.values())
|
|
715
|
+
ped.forgetPlayer(player);
|
|
716
|
+
for (const play of [...this.#cutscenes.values()])
|
|
717
|
+
play.left(player);
|
|
718
|
+
player.markDisconnected();
|
|
719
|
+
}
|
|
720
|
+
// A player or (attackerId null) a server ped.
|
|
721
|
+
#attacker(playerId, pedId) {
|
|
722
|
+
if (playerId !== null && playerId !== undefined)
|
|
723
|
+
return this.#players.get(playerId);
|
|
724
|
+
return pedId === null || pedId === undefined ? undefined : this.#pedById(pedId);
|
|
725
|
+
}
|
|
726
|
+
#onDamage(victimId, attacker, hit) {
|
|
727
|
+
const victim = this.#players.get(victimId);
|
|
728
|
+
if (!victim || !attacker)
|
|
729
|
+
return undefined;
|
|
730
|
+
let damage;
|
|
731
|
+
for (const result of this.#emit("playerDamage", victim, attacker, { ...hit })) {
|
|
732
|
+
if (result === false)
|
|
733
|
+
return false;
|
|
734
|
+
if (typeof result === "number")
|
|
735
|
+
damage = result;
|
|
736
|
+
if (result instanceof Promise && !this.#warnedAsyncVeto) {
|
|
737
|
+
this.#warnedAsyncVeto = true;
|
|
738
|
+
console.warn("[sagl] playerDamage handlers must return synchronously; a Promise can't veto a hit");
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return damage;
|
|
742
|
+
}
|
|
743
|
+
// ---- Players ----------------------------------------------------------------
|
|
744
|
+
/**
|
|
745
|
+
* How name tags show, for everyone: within `distance` m (default 70),
|
|
746
|
+
* hidden behind buildings and objects (`lineOfSight`, default true), with
|
|
747
|
+
* their id (`ids`, default true), with health and armour bars under them
|
|
748
|
+
* (`bars`, default false). Setting it sets what's given. Each player's own:
|
|
749
|
+
* `player.nameTag`.
|
|
750
|
+
*/
|
|
751
|
+
get nameTags() {
|
|
752
|
+
return { ...this.#nameTags };
|
|
753
|
+
}
|
|
754
|
+
set nameTags(options) {
|
|
755
|
+
this.#nameTags = { ...this.#nameTags, ...options };
|
|
756
|
+
for (const p of this.players)
|
|
757
|
+
this.#sendNameTagOptions(p);
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* A gang zone: a coloured rectangle on the radar and maps (SA-MP's
|
|
761
|
+
* GangZoneCreate), shown to no one until `show()`. See `GangZone`.
|
|
762
|
+
*/
|
|
763
|
+
createGangZone(area, color) {
|
|
764
|
+
const id = this.#nextGangZone++;
|
|
765
|
+
const zone = new GangZone(this.#native, () => this.players, id, area, color, () => this.#gangZones.delete(id));
|
|
766
|
+
this.#gangZones.set(id, zone);
|
|
767
|
+
return zone;
|
|
768
|
+
}
|
|
769
|
+
/** Every gang zone not destroyed. */
|
|
770
|
+
get gangZones() {
|
|
771
|
+
return [...this.#gangZones.values()];
|
|
772
|
+
}
|
|
773
|
+
#gangZones = new Map();
|
|
774
|
+
#nextGangZone = 1;
|
|
775
|
+
/**
|
|
776
|
+
* The game's stunt bonuses for everyone (EnableStuntBonusForAll), players
|
|
777
|
+
* joining later too; `player.stuntBonus` changes one player's.
|
|
778
|
+
*/
|
|
779
|
+
get stuntBonus() {
|
|
780
|
+
return this.#stuntBonus;
|
|
781
|
+
}
|
|
782
|
+
set stuntBonus(on) {
|
|
783
|
+
this.#stuntBonus = on;
|
|
784
|
+
for (const p of this.players)
|
|
785
|
+
p.stuntBonus = on;
|
|
786
|
+
}
|
|
787
|
+
#stuntBonus = true;
|
|
788
|
+
#nameTags = { distance: 70, lineOfSight: true, ids: true, bars: false };
|
|
789
|
+
#sendNameTagOptions(player) {
|
|
790
|
+
const o = this.#nameTags;
|
|
791
|
+
if (player.connected)
|
|
792
|
+
this.#native.nameTagOptions(player.id, Math.max(0, o.distance), o.lineOfSight, o.ids, o.bars);
|
|
793
|
+
}
|
|
794
|
+
/** Connected players. */
|
|
795
|
+
get players() {
|
|
796
|
+
return [...this.#players.values()];
|
|
797
|
+
}
|
|
798
|
+
player(id) {
|
|
799
|
+
return this.#players.get(id);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Shows a message to every player. `color` is 0xRRGGBB. `markdown`: the
|
|
803
|
+
* text is markdown (bold, italic, code, links). `{key:ID}` shows an
|
|
804
|
+
* action's key either way.
|
|
805
|
+
*/
|
|
806
|
+
broadcast(text, color, options = {}) {
|
|
807
|
+
this.#native.sendMessage(-1, text, color ?? 0, options.markdown ?? false);
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* An explosion at a spot, as SA-MP's CreateExplosion: the game's own
|
|
811
|
+
* (`ExplosionType`: grenade, molotov, rocket, car, small, tiny...), its
|
|
812
|
+
* blast, fire and damage, in the games of `to` (default everyone). It's
|
|
813
|
+
* no one's: a player it hurts is hurt as by a fall (no attacker), and a
|
|
814
|
+
* vehicle it wrecks is wrecked by nobody.
|
|
815
|
+
*/
|
|
816
|
+
createExplosion(position, type = 0, options = {}) {
|
|
817
|
+
if (!Number.isInteger(type) || type < 0 || type > 12)
|
|
818
|
+
throw new RangeError(`not an explosion type: ${type} (0-12)`);
|
|
819
|
+
for (const p of options.to ?? this.players) {
|
|
820
|
+
if (p.connected)
|
|
821
|
+
this.#native.explosion(p.id, position.x, position.y, position.z, type);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/** Empties everyone's chat log. */
|
|
825
|
+
clearChat() {
|
|
826
|
+
for (const p of this.players)
|
|
827
|
+
p.clearChat();
|
|
828
|
+
}
|
|
829
|
+
/** A random valid ped model. */
|
|
830
|
+
randomSkin() {
|
|
831
|
+
return this.#native.randomSkin();
|
|
832
|
+
}
|
|
833
|
+
// ---- Vehicles ---------------------------------------------------------------
|
|
834
|
+
/**
|
|
835
|
+
* Creates a vehicle for everyone, standing on the ground at `position`.
|
|
836
|
+
* `heading` in degrees (0 = north, anticlockwise).
|
|
837
|
+
*/
|
|
838
|
+
createVehicle(model, position, heading = 0, options = {}) {
|
|
839
|
+
const id = this.#native.createVehicle(model, position.x, position.y, position.z, (heading * Math.PI) / 180, options.colors?.[0] ?? -1, options.colors?.[1] ?? -1, options.radio ?? -1, options.world ?? 0, options.ambient ?? false, Math.max(0, (options.respawnDelay ?? 0) * 1000), options.plate ?? "");
|
|
840
|
+
if (!id)
|
|
841
|
+
throw new RangeError(`could not create vehicle model ${model}`);
|
|
842
|
+
const vehicle = new Vehicle(this.#native, id, (pid) => this.#players.get(pid), (v) => this.destroyVehicle(v), (pedId) => this.#pedById(pedId), (vid) => this.#vehicleById(vid));
|
|
843
|
+
this.#vehicles.set(id, vehicle);
|
|
844
|
+
return vehicle;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Creates a ped standing at `position` (feet) facing `heading` degrees:
|
|
848
|
+
* a ped model (skin), or one of the story's characters by name ("ryder",
|
|
849
|
+
* "sweet", "smoke", "cesar"...), which each player's game loads (up to 10
|
|
850
|
+
* different ones near a player at once).
|
|
851
|
+
* The nearest player's game runs its AI.
|
|
852
|
+
*/
|
|
853
|
+
createPed(model, position, heading = 0, options = {}) {
|
|
854
|
+
if (typeof model === "string" && !/^[A-Za-z0-9_]{1,20}$/.test(model)) {
|
|
855
|
+
throw new RangeError(`not a character name: "${model}"`);
|
|
856
|
+
}
|
|
857
|
+
const id = this.#native.createPed(model, position.x, position.y, position.z, (heading * Math.PI) / 180, options.world ?? 0, options.weapon ?? 0, options.health ?? 100, options.behaviour === "wander" ? 1 : 0, options.visibleTo && options.visibleTo !== "world" ? options.visibleTo.filter((p) => p.connected).map((p) => p.id) : null, options.controller ? options.controller.id : null);
|
|
858
|
+
if (!id)
|
|
859
|
+
throw new RangeError(`could not create ped ${typeof model === "string" ? `character "${model}"` : `model ${model}`}`);
|
|
860
|
+
const ped = new Ped(this.#native, id, (pid) => this.#players.get(pid), (vid) => this.#vehicleById(vid), (p) => this.destroyPed(p));
|
|
861
|
+
this.#peds.set(id, ped);
|
|
862
|
+
ped.initVisibility(options.visibleTo ?? "world", options.controller ?? null);
|
|
863
|
+
if (options.mission) {
|
|
864
|
+
ped.set({ reactions: "self-defence", keepTask: true, ...(options.mission === true ? {} : options.mission) });
|
|
865
|
+
}
|
|
866
|
+
return ped;
|
|
867
|
+
}
|
|
868
|
+
/** Removes a ped for everyone (same as `ped.destroy()`). */
|
|
869
|
+
destroyPed(ped) {
|
|
870
|
+
if (this.#native.destroyPed(ped.id)) {
|
|
871
|
+
this.#peds.delete(ped.id);
|
|
872
|
+
this.#things.forgetTarget(1, ped.id);
|
|
873
|
+
ped.markDestroyed();
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/** Every ped, the ambient population's included. */
|
|
877
|
+
get peds() {
|
|
878
|
+
return this.#native.pedIds().map((id) => this.#pedById(id)).filter((p) => !!p);
|
|
879
|
+
}
|
|
880
|
+
/** A ped by id. */
|
|
881
|
+
ped(id) {
|
|
882
|
+
return this.#pedById(id);
|
|
883
|
+
}
|
|
884
|
+
// Wrappers for peds and vehicles the server made itself (the ambient
|
|
885
|
+
// population) are made when first needed, and dropped once they're gone.
|
|
886
|
+
#pedById(id) {
|
|
887
|
+
let ped = this.#peds.get(id);
|
|
888
|
+
const exists = this.#native.ped(id) !== null;
|
|
889
|
+
if (!ped && exists) {
|
|
890
|
+
ped = new Ped(this.#native, id, (pid) => this.#players.get(pid), (vid) => this.#vehicleById(vid), (p) => this.destroyPed(p));
|
|
891
|
+
this.#peds.set(id, ped);
|
|
892
|
+
}
|
|
893
|
+
else if (ped && !exists) {
|
|
894
|
+
this.#peds.delete(id);
|
|
895
|
+
this.#things.forgetTarget(1, id);
|
|
896
|
+
ped.markDestroyed();
|
|
897
|
+
return undefined;
|
|
898
|
+
}
|
|
899
|
+
return ped;
|
|
900
|
+
}
|
|
901
|
+
#vehicleById(id) {
|
|
902
|
+
let vehicle = this.#vehicles.get(id);
|
|
903
|
+
const exists = this.#native.vehicle(id) !== null;
|
|
904
|
+
if (!vehicle && exists) {
|
|
905
|
+
vehicle = new Vehicle(this.#native, id, (pid) => this.#players.get(pid), (v) => this.destroyVehicle(v), (pedId) => this.#pedById(pedId), (vid) => this.#vehicleById(vid));
|
|
906
|
+
this.#vehicles.set(id, vehicle);
|
|
907
|
+
}
|
|
908
|
+
else if (vehicle && !exists) {
|
|
909
|
+
this.#vehicles.delete(id);
|
|
910
|
+
this.#things.forgetTarget(3, id);
|
|
911
|
+
vehicle.markDestroyed();
|
|
912
|
+
return undefined;
|
|
913
|
+
}
|
|
914
|
+
return vehicle;
|
|
915
|
+
}
|
|
916
|
+
/** Removes a vehicle for everyone (same as `vehicle.destroy()`). */
|
|
917
|
+
destroyVehicle(vehicle) {
|
|
918
|
+
if (this.#native.destroyVehicle(vehicle.id)) {
|
|
919
|
+
this.#vehicles.delete(vehicle.id);
|
|
920
|
+
this.#things.forgetTarget(3, vehicle.id);
|
|
921
|
+
vehicle.markDestroyed();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/** Every vehicle, the ambient traffic's included. */
|
|
925
|
+
get vehicles() {
|
|
926
|
+
return this.#native.vehicleIds().map((id) => this.#vehicleById(id)).filter((v) => !!v);
|
|
927
|
+
}
|
|
928
|
+
vehicle(id) {
|
|
929
|
+
return this.#vehicleById(id);
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* World 0's ambient population (and every world's without its own): see
|
|
933
|
+
* `world.population`. Off by default.
|
|
934
|
+
*/
|
|
935
|
+
get population() {
|
|
936
|
+
return this.world(0).population;
|
|
937
|
+
}
|
|
938
|
+
set population(settings) {
|
|
939
|
+
this.world(0).population = settings;
|
|
940
|
+
}
|
|
941
|
+
// ---- Zones and garages ------------------------------------------------------
|
|
942
|
+
/**
|
|
943
|
+
* Watches an area: a sphere (`{ center, radius }`) or an axis-aligned box
|
|
944
|
+
* between two corners (`{ min, max }`).
|
|
945
|
+
*/
|
|
946
|
+
createZone(shape) {
|
|
947
|
+
const world = shape.world ?? -1;
|
|
948
|
+
const id = "radius" in shape
|
|
949
|
+
? this.#native.createSphereZone(shape.center.x, shape.center.y, shape.center.z, shape.radius, world)
|
|
950
|
+
: this.#native.createBoxZone(shape.min.x, shape.min.y, shape.min.z, shape.max.x, shape.max.y, shape.max.z, world);
|
|
951
|
+
const zone = new Zone(id, null, (z) => this.destroyZone(z));
|
|
952
|
+
this.#zones.set(id, zone);
|
|
953
|
+
return zone;
|
|
954
|
+
}
|
|
955
|
+
destroyZone(zone) {
|
|
956
|
+
if (this.#native.destroyZone(zone.id)) {
|
|
957
|
+
this.#zones.delete(zone.id);
|
|
958
|
+
zone.markDestroyed();
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
get zones() {
|
|
962
|
+
return [...this.#zones.values()];
|
|
963
|
+
}
|
|
964
|
+
/** The game's garages, by id. */
|
|
965
|
+
get garages() {
|
|
966
|
+
return this.#garages;
|
|
967
|
+
}
|
|
968
|
+
garage(id) {
|
|
969
|
+
return this.#garages[id];
|
|
970
|
+
}
|
|
971
|
+
// ---- Virtual worlds ---------------------------------------------------------
|
|
972
|
+
/**
|
|
973
|
+
* Lets players in `viewer` world see (not meet) players and vehicles in
|
|
974
|
+
* `target` world, one way: a safehouse world viewing world 0 sees the
|
|
975
|
+
* street; world 0 still doesn't see in.
|
|
976
|
+
*/
|
|
977
|
+
/**
|
|
978
|
+
* One of the game's objects (any object model: crates, gates, barriers,
|
|
979
|
+
* switches...) where you put it, for everyone (or `visibleTo`). It stays
|
|
980
|
+
* put: players' games never push or break it. With `health` it's
|
|
981
|
+
* breakable: players' shots, blows and cars wear it down until it's
|
|
982
|
+
* destroyed (`onDestroyed`). `move()` glides it (gates, shutters).
|
|
983
|
+
*/
|
|
984
|
+
createObject(model, position, rotation, options) {
|
|
985
|
+
return this.#things.createObject(model, position, rotation, options);
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* A pickup, spinning and glowing as the game's: a weapon (`weapon`,
|
|
989
|
+
* `ammo`), `health`, `armour` or `money`, given when a player walks into
|
|
990
|
+
* it (checked by the server), or any `model` with `onPickup` doing the
|
|
991
|
+
* rest. Taken once, or back after `respawn` ms.
|
|
992
|
+
*/
|
|
993
|
+
createPickup(options) {
|
|
994
|
+
return this.#things.createPickup(options);
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Plays one of the game's cutscenes (from `anim/cuts.img`, e.g.
|
|
998
|
+
* `"ryder2a"`) for these players together: each game loads it (their
|
|
999
|
+
* screens fade out), it starts for everyone once all have it, and `done`
|
|
1000
|
+
* resolves when everyone's has finished. The first player is the leader:
|
|
1001
|
+
* their skip ends it for everyone (see `CutsceneOptions`).
|
|
1002
|
+
*/
|
|
1003
|
+
playCutscene(players, name, options = {}) {
|
|
1004
|
+
if (!/^[A-Za-z0-9_]{1,8}$/.test(name))
|
|
1005
|
+
throw new RangeError(`not a cutscene name: "${name}"`);
|
|
1006
|
+
const group = Array.isArray(players) ? [...players] : [players];
|
|
1007
|
+
const id = this.#nextCutscene++;
|
|
1008
|
+
const play = new CutscenePlay(this.#native, id, group, name.toLowerCase(), options, (p) => this.#cutscenes.delete(p.id));
|
|
1009
|
+
if (!play.over)
|
|
1010
|
+
this.#cutscenes.set(id, play); // (nobody to play it for: over already)
|
|
1011
|
+
return play;
|
|
1012
|
+
}
|
|
1013
|
+
setWorldView(viewer, target, visible = true) {
|
|
1014
|
+
this.#native.setWorldView(viewer, target, visible);
|
|
1015
|
+
}
|
|
1016
|
+
/** Whether `viewer` world sees `target` world. */
|
|
1017
|
+
worldSees(viewer, target) {
|
|
1018
|
+
return this.#native.worldSees(viewer, target);
|
|
1019
|
+
}
|
|
1020
|
+
/** A virtual world's settings: its time and weather (world 0's by default). */
|
|
1021
|
+
world(id) {
|
|
1022
|
+
return new World(this.#native, id);
|
|
1023
|
+
}
|
|
1024
|
+
// ---- Cull zones ---------------------------------------------------------------
|
|
1025
|
+
/**
|
|
1026
|
+
* An area with effects like the game's own cull and audio zones, applied by
|
|
1027
|
+
* the games of players in `world` (default: every world): interior music
|
|
1028
|
+
* (`audio`, an `AudioZone`), `attributes` (`ZoneAttribute` bits, e.g. no
|
|
1029
|
+
* rain) and/or a `mirror` (the plane at `coord` across `axis` 0 x / 1 y /
|
|
1030
|
+
* 2 z, facing `sign`). An axis-aligned box between `min` and `max`.
|
|
1031
|
+
*/
|
|
1032
|
+
createCullZone(zone) {
|
|
1033
|
+
const id = this.#native.createCullZone({
|
|
1034
|
+
world: zone.world ?? -1,
|
|
1035
|
+
min: [zone.min.x, zone.min.y, zone.min.z],
|
|
1036
|
+
max: [zone.max.x, zone.max.y, zone.max.z],
|
|
1037
|
+
audio: zone.audio ?? -1,
|
|
1038
|
+
attributes: zone.attributes ?? 0,
|
|
1039
|
+
mirror: zone.mirror,
|
|
1040
|
+
});
|
|
1041
|
+
const cz = new CullZone(id, (z) => this.destroyCullZone(z));
|
|
1042
|
+
this.#cullZones.set(id, cz);
|
|
1043
|
+
return cz;
|
|
1044
|
+
}
|
|
1045
|
+
destroyCullZone(zone) {
|
|
1046
|
+
if (this.#native.destroyCullZone(zone.id)) {
|
|
1047
|
+
this.#cullZones.delete(zone.id);
|
|
1048
|
+
zone.markDestroyed();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
// ---- World ------------------------------------------------------------------
|
|
1052
|
+
/** World 0's weather (`Weather`); other worlds follow it unless set apart. */
|
|
1053
|
+
get weather() {
|
|
1054
|
+
return this.#native.getWeather(0);
|
|
1055
|
+
}
|
|
1056
|
+
set weather(weather) {
|
|
1057
|
+
this.#native.setWeather(weather, 0);
|
|
1058
|
+
}
|
|
1059
|
+
/** World 0's traffic lights (every world's without its own): see `TrafficLights`. */
|
|
1060
|
+
get trafficLights() {
|
|
1061
|
+
return this.world(0).trafficLights;
|
|
1062
|
+
}
|
|
1063
|
+
/** In-game time (world 0's). Setting it keeps the current frozen/running state. */
|
|
1064
|
+
get time() {
|
|
1065
|
+
return this.#native.getTime();
|
|
1066
|
+
}
|
|
1067
|
+
set time(value) {
|
|
1068
|
+
if (value.hour < 0 || value.hour > 23 || value.minute < 0 || value.minute > 59) {
|
|
1069
|
+
throw new RangeError("time must be 00:00-23:59");
|
|
1070
|
+
}
|
|
1071
|
+
this.#native.setTime(value.hour, value.minute);
|
|
1072
|
+
}
|
|
1073
|
+
freezeTime() {
|
|
1074
|
+
this.#native.freezeTime(true);
|
|
1075
|
+
}
|
|
1076
|
+
unfreezeTime() {
|
|
1077
|
+
this.#native.freezeTime(false);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
//# sourceMappingURL=server.js.map
|