@sagl/server 0.0.0-stage → 0.1.0-next.49
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/mission.js
ADDED
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
/** How often waits and fail conditions are checked. */
|
|
2
|
+
const TICK_MS = 250;
|
|
3
|
+
/** Defines a mission (it only adds types). */
|
|
4
|
+
export function defineMission(definition) {
|
|
5
|
+
return definition;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Thrown into the script's pending waits when the mission ends (a fail
|
|
9
|
+
* condition, every player leaving, ...). The helper catches it: let it go.
|
|
10
|
+
*/
|
|
11
|
+
export class MissionEnded extends Error {
|
|
12
|
+
outcome;
|
|
13
|
+
constructor(outcome) {
|
|
14
|
+
super(`mission ended: ${outcome.reason}`);
|
|
15
|
+
this.name = "MissionEnded";
|
|
16
|
+
this.outcome = outcome;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Starting or joining a mission was refused. `reason` says why. */
|
|
20
|
+
export class MissionRefused extends Error {
|
|
21
|
+
reason;
|
|
22
|
+
constructor(reason) {
|
|
23
|
+
super(reason);
|
|
24
|
+
this.name = "MissionRefused";
|
|
25
|
+
this.reason = reason;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** A blip every party member has, players who join later included. */
|
|
29
|
+
export class MissionBlip {
|
|
30
|
+
#blips = new Map();
|
|
31
|
+
#target;
|
|
32
|
+
#options;
|
|
33
|
+
#removed = false;
|
|
34
|
+
/** @internal */
|
|
35
|
+
constructor(target, options) {
|
|
36
|
+
this.#target = target;
|
|
37
|
+
this.#options = options;
|
|
38
|
+
}
|
|
39
|
+
/** Changes its options. */
|
|
40
|
+
set(options) {
|
|
41
|
+
this.#options = { ...this.#options, ...options };
|
|
42
|
+
for (const b of this.#blips.values())
|
|
43
|
+
b.set(options);
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
remove() {
|
|
47
|
+
this.#removed = true;
|
|
48
|
+
for (const b of this.#blips.values())
|
|
49
|
+
b.remove();
|
|
50
|
+
this.#blips.clear();
|
|
51
|
+
}
|
|
52
|
+
/** @internal */
|
|
53
|
+
add(player) {
|
|
54
|
+
if (!this.#removed && !this.#blips.has(player))
|
|
55
|
+
this.#blips.set(player, player.hud.blip(this.#target, this.#options));
|
|
56
|
+
}
|
|
57
|
+
/** @internal */
|
|
58
|
+
drop(player) {
|
|
59
|
+
this.#blips.get(player)?.remove();
|
|
60
|
+
this.#blips.delete(player);
|
|
61
|
+
}
|
|
62
|
+
get removed() {
|
|
63
|
+
return this.#removed;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** A HUD counter or timer every party member has. */
|
|
67
|
+
export class MissionCounter {
|
|
68
|
+
#counters = new Map();
|
|
69
|
+
#make;
|
|
70
|
+
#value;
|
|
71
|
+
#label;
|
|
72
|
+
#removed = false;
|
|
73
|
+
// Timers: when it reaches 0 (Date.now()), to start joiners at the right time.
|
|
74
|
+
#endsAt;
|
|
75
|
+
#pausedLeft = null; // paused: the time left
|
|
76
|
+
/** @internal */
|
|
77
|
+
constructor(label, value, endsAt, make) {
|
|
78
|
+
this.#label = label;
|
|
79
|
+
this.#value = value;
|
|
80
|
+
this.#endsAt = endsAt;
|
|
81
|
+
this.#make = (p) => {
|
|
82
|
+
const c = make(p, this.#label, this.value);
|
|
83
|
+
if (this.#pausedLeft !== null)
|
|
84
|
+
c.setPaused(true, this.#pausedLeft);
|
|
85
|
+
return c;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** The number (a counter), the time left (a timer, ms), or how full (a bar). */
|
|
89
|
+
get value() {
|
|
90
|
+
if (this.#pausedLeft !== null)
|
|
91
|
+
return this.#pausedLeft;
|
|
92
|
+
return this.#endsAt === null ? this.#value : Math.max(0, this.#endsAt - Date.now());
|
|
93
|
+
}
|
|
94
|
+
/** A timer: whether it's held still (`pause()`). */
|
|
95
|
+
get paused() {
|
|
96
|
+
return this.#pausedLeft !== null;
|
|
97
|
+
}
|
|
98
|
+
/** Holds a timer still (it doesn't run out meanwhile); `resume()` counts on from there. */
|
|
99
|
+
pause() {
|
|
100
|
+
if (this.#endsAt === null || this.#pausedLeft !== null || this.#removed)
|
|
101
|
+
return;
|
|
102
|
+
this.#pausedLeft = Math.max(0, this.#endsAt - Date.now());
|
|
103
|
+
for (const c of this.#counters.values())
|
|
104
|
+
c.setPaused(true, this.#pausedLeft);
|
|
105
|
+
}
|
|
106
|
+
resume() {
|
|
107
|
+
if (this.#pausedLeft === null)
|
|
108
|
+
return;
|
|
109
|
+
const left = this.#pausedLeft;
|
|
110
|
+
this.#pausedLeft = null;
|
|
111
|
+
this.#endsAt = Date.now() + left;
|
|
112
|
+
for (const c of this.#counters.values())
|
|
113
|
+
c.setPaused(false, left);
|
|
114
|
+
}
|
|
115
|
+
/** @internal Is it a timer (counting down or up)? */
|
|
116
|
+
get isTimer() {
|
|
117
|
+
return this.#endsAt !== null;
|
|
118
|
+
}
|
|
119
|
+
set value(value) {
|
|
120
|
+
if (this.#endsAt !== null)
|
|
121
|
+
this.#endsAt = Date.now() + value;
|
|
122
|
+
else
|
|
123
|
+
this.#value = value;
|
|
124
|
+
for (const c of this.#counters.values())
|
|
125
|
+
c.value = value;
|
|
126
|
+
}
|
|
127
|
+
set label(label) {
|
|
128
|
+
this.#label = label;
|
|
129
|
+
for (const c of this.#counters.values())
|
|
130
|
+
c.label = label;
|
|
131
|
+
}
|
|
132
|
+
remove() {
|
|
133
|
+
this.#removed = true;
|
|
134
|
+
for (const c of this.#counters.values())
|
|
135
|
+
c.remove();
|
|
136
|
+
this.#counters.clear();
|
|
137
|
+
}
|
|
138
|
+
get removed() {
|
|
139
|
+
return this.#removed;
|
|
140
|
+
}
|
|
141
|
+
/** @internal */
|
|
142
|
+
add(player) {
|
|
143
|
+
if (!this.#removed && !this.#counters.has(player))
|
|
144
|
+
this.#counters.set(player, this.#make(player));
|
|
145
|
+
}
|
|
146
|
+
/** @internal */
|
|
147
|
+
drop(player) {
|
|
148
|
+
this.#counters.get(player)?.remove();
|
|
149
|
+
this.#counters.delete(player);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A running mission: its party, what it created (removed when it ends), its
|
|
154
|
+
* HUD, fail conditions and waits. The script gets it as `m`.
|
|
155
|
+
*
|
|
156
|
+
* Every wait (`reach`, `until`, `wait`, `step`, ...) rejects with
|
|
157
|
+
* `MissionEnded` when the mission ends, so the script stops where it is;
|
|
158
|
+
* the helper catches it. Mission calls made after the end throw it too.
|
|
159
|
+
*/
|
|
160
|
+
export class Mission {
|
|
161
|
+
name;
|
|
162
|
+
definition;
|
|
163
|
+
/** Free-form storage for the script. */
|
|
164
|
+
data = {};
|
|
165
|
+
/** When it started (Date.now()). */
|
|
166
|
+
startedAt = Date.now();
|
|
167
|
+
/** Resolves when it ends, passed or not (never rejects). */
|
|
168
|
+
done;
|
|
169
|
+
#missions;
|
|
170
|
+
#server;
|
|
171
|
+
#players = [];
|
|
172
|
+
#outcome = null;
|
|
173
|
+
#resolveDone;
|
|
174
|
+
#peds = [];
|
|
175
|
+
#vehicles = [];
|
|
176
|
+
#blips = [];
|
|
177
|
+
#counters = [];
|
|
178
|
+
#objective = null;
|
|
179
|
+
#failWhen = [];
|
|
180
|
+
#listeners = [];
|
|
181
|
+
#intervals = new Set();
|
|
182
|
+
#speeches = new Set();
|
|
183
|
+
#restore = []; // population changes, cinematics: undone at the end
|
|
184
|
+
#startFrom = null;
|
|
185
|
+
#stage = "";
|
|
186
|
+
#waiters = new Set();
|
|
187
|
+
#timers = new Set();
|
|
188
|
+
#cooldowns = new Map();
|
|
189
|
+
#tick;
|
|
190
|
+
#spawn = null;
|
|
191
|
+
#cutscene = null;
|
|
192
|
+
#objects = [];
|
|
193
|
+
#pickups = [];
|
|
194
|
+
#warned = new Set(); // shown keepClose's warning instead of the objective
|
|
195
|
+
/** @internal */
|
|
196
|
+
constructor(missions, server, definition, players) {
|
|
197
|
+
this.#missions = missions;
|
|
198
|
+
this.#server = server;
|
|
199
|
+
this.definition = definition;
|
|
200
|
+
this.name = definition.name;
|
|
201
|
+
this.#players = [...players];
|
|
202
|
+
this.done = new Promise((resolve) => (this.#resolveDone = resolve));
|
|
203
|
+
this.#tick = setInterval(() => this.#check(), TICK_MS);
|
|
204
|
+
}
|
|
205
|
+
/** The party, leader first. */
|
|
206
|
+
get players() {
|
|
207
|
+
return this.#players;
|
|
208
|
+
}
|
|
209
|
+
/** Who started it, or the next in line once they leave. */
|
|
210
|
+
get leader() {
|
|
211
|
+
return this.#players[0];
|
|
212
|
+
}
|
|
213
|
+
/** Whether it's still running. */
|
|
214
|
+
get running() {
|
|
215
|
+
return this.#outcome === null;
|
|
216
|
+
}
|
|
217
|
+
/** How it ended, or null while it runs. */
|
|
218
|
+
get outcome() {
|
|
219
|
+
return this.#outcome;
|
|
220
|
+
}
|
|
221
|
+
// ---- The party ----------------------------------------------------------
|
|
222
|
+
/**
|
|
223
|
+
* Adds a player to the party (an invitation). They get its HUD and see its
|
|
224
|
+
* peds. Throws `MissionRefused` if they're in another mission, it's full,
|
|
225
|
+
* they don't meet `requires`, or they're cooling down.
|
|
226
|
+
*/
|
|
227
|
+
addPlayer(player) {
|
|
228
|
+
this.#live();
|
|
229
|
+
if (this.#players.includes(player))
|
|
230
|
+
return;
|
|
231
|
+
if (this.#players.length >= (this.definition.maxPlayers ?? 1))
|
|
232
|
+
this.#missions.refuse(this.definition, player, "The mission is full.");
|
|
233
|
+
this.#missions.admit(this.definition, player);
|
|
234
|
+
this.#players.push(player);
|
|
235
|
+
this.#missions.bind(player, this);
|
|
236
|
+
this.#show(player);
|
|
237
|
+
this.#updateVisibility();
|
|
238
|
+
this.#missions.event({ type: "join", name: this.name, mission: this, player, reason: "" });
|
|
239
|
+
}
|
|
240
|
+
/** Takes a player out of the party (their HUD for it goes). The last one leaving abandons it. */
|
|
241
|
+
removePlayer(player) {
|
|
242
|
+
const i = this.#players.indexOf(player);
|
|
243
|
+
if (i < 0)
|
|
244
|
+
return;
|
|
245
|
+
this.#players.splice(i, 1);
|
|
246
|
+
this.#missions.bind(player, null);
|
|
247
|
+
this.#hide(player);
|
|
248
|
+
this.#updateVisibility();
|
|
249
|
+
this.#missions.event({ type: "leave", name: this.name, mission: this, player, reason: "" });
|
|
250
|
+
if (this.running && this.#players.length === 0)
|
|
251
|
+
this.#end(false, "abandoned", "");
|
|
252
|
+
}
|
|
253
|
+
// ---- Things it creates ----------------------------------------------------
|
|
254
|
+
/**
|
|
255
|
+
* A ped for the mission. When it ends, it fades out, or if it's dead its
|
|
256
|
+
* body is released (`ped.release()`: it lies there until the cleanup
|
|
257
|
+
* takes it); with `keep` it stays for good. Seen by the party (players joining later
|
|
258
|
+
* too) unless `visibleTo` says otherwise.
|
|
259
|
+
*/
|
|
260
|
+
createPed(model, position, heading = 0, options = {}) {
|
|
261
|
+
this.#live();
|
|
262
|
+
const partyVisible = options.visibleTo === undefined;
|
|
263
|
+
const ped = this.#server.createPed(model, position, heading, {
|
|
264
|
+
...options,
|
|
265
|
+
visibleTo: options.visibleTo ?? this.#players,
|
|
266
|
+
});
|
|
267
|
+
this.#peds.push({ ped, onEnd: options.onEnd ?? (options.keep ? "keep" : "release"), partyVisible });
|
|
268
|
+
return ped;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* A vehicle for the mission, released when it ends (it stays until nobody
|
|
272
|
+
* is around, as the game's), `onEnd: "destroy"` removed at once, or
|
|
273
|
+
* `onEnd: "keep"` (or `keep`) left for good.
|
|
274
|
+
*/
|
|
275
|
+
createVehicle(model, position, heading = 0, options = {}) {
|
|
276
|
+
this.#live();
|
|
277
|
+
const vehicle = this.#server.createVehicle(model, position, heading, options);
|
|
278
|
+
this.#vehicles.push({ vehicle, onEnd: options.onEnd ?? (options.keep ? "keep" : "release") });
|
|
279
|
+
return vehicle;
|
|
280
|
+
}
|
|
281
|
+
/** An object for the mission (the party sees it), removed when it ends. See `server.createObject`. */
|
|
282
|
+
createObject(model, position, rotation, options = {}) {
|
|
283
|
+
this.#live();
|
|
284
|
+
const o = this.#server.createObject(model, position, rotation, { visibleTo: this.#players, ...options });
|
|
285
|
+
this.#objects.push(o);
|
|
286
|
+
return o;
|
|
287
|
+
}
|
|
288
|
+
/** A pickup for the mission (the party sees it), removed when it ends. See `server.createPickup`. */
|
|
289
|
+
createPickup(options) {
|
|
290
|
+
this.#live();
|
|
291
|
+
const p = this.#server.createPickup({ visibleTo: this.#players, ...options });
|
|
292
|
+
this.#pickups.push(p);
|
|
293
|
+
return p;
|
|
294
|
+
}
|
|
295
|
+
// ---- HUD ------------------------------------------------------------------
|
|
296
|
+
/** Objective text at the bottom of the party's screens, for `ms` (left out: as long as reading takes). */
|
|
297
|
+
objective(text, ms) {
|
|
298
|
+
this.#live();
|
|
299
|
+
if (this.#held) {
|
|
300
|
+
// Losing the cops first: this one after.
|
|
301
|
+
this.#held.before = { text, ms };
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.#objective = { text, until: ms ? Date.now() + ms : Date.now() + Math.max(3000, text.length * 80) };
|
|
305
|
+
// Those keepClose is warning see it once they're back.
|
|
306
|
+
for (const p of this.#players)
|
|
307
|
+
if (!this.#warned.has(p))
|
|
308
|
+
p.hud.objective(text, ms);
|
|
309
|
+
}
|
|
310
|
+
clearObjective() {
|
|
311
|
+
if (this.#held) {
|
|
312
|
+
this.#held.before = null;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
this.#objective = null;
|
|
316
|
+
for (const p of this.#players)
|
|
317
|
+
p.hud.clearObjective();
|
|
318
|
+
}
|
|
319
|
+
/** Big text in the middle of the party's screens. */
|
|
320
|
+
bigText(text, ms) {
|
|
321
|
+
this.#live();
|
|
322
|
+
for (const p of this.#players)
|
|
323
|
+
p.hud.bigText(text, ms);
|
|
324
|
+
}
|
|
325
|
+
/** A blip (and marker) on a spot, ped, vehicle or player, for the whole party. */
|
|
326
|
+
blip(target, options = {}) {
|
|
327
|
+
this.#live();
|
|
328
|
+
const blip = new MissionBlip(target, options);
|
|
329
|
+
this.#blips.push(blip);
|
|
330
|
+
for (const p of this.#players)
|
|
331
|
+
blip.add(p);
|
|
332
|
+
return blip;
|
|
333
|
+
}
|
|
334
|
+
/** A counter on the party's HUD ("Crates 2/6"). */
|
|
335
|
+
counter(label, options = {}) {
|
|
336
|
+
this.#live();
|
|
337
|
+
const c = new MissionCounter(label, options.value ?? 0, null, (p, l, v) => p.hud.counter(l, { ...options, value: v }));
|
|
338
|
+
this.#counters.push(c);
|
|
339
|
+
for (const p of this.#players)
|
|
340
|
+
c.add(p);
|
|
341
|
+
return c;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* A countdown on the party's HUD (or counting up with `countUp`). With
|
|
345
|
+
* `fail`, the mission fails with that reason when a countdown reaches 0.
|
|
346
|
+
*/
|
|
347
|
+
timer(label, ms, options = {}) {
|
|
348
|
+
this.#live();
|
|
349
|
+
const { fail, ...hud } = options;
|
|
350
|
+
const c = new MissionCounter(label, ms, options.countUp ? null : Date.now() + ms, (p, l, v) => p.hud.timer(l, v, hud));
|
|
351
|
+
this.#counters.push(c);
|
|
352
|
+
for (const p of this.#players)
|
|
353
|
+
c.add(p);
|
|
354
|
+
if (fail !== undefined && !options.countUp) {
|
|
355
|
+
const check = () => {
|
|
356
|
+
if (!this.running || c.removed)
|
|
357
|
+
return;
|
|
358
|
+
const left = c.value;
|
|
359
|
+
if (left <= 0)
|
|
360
|
+
this.fail(fail);
|
|
361
|
+
else
|
|
362
|
+
this.#later(check, left);
|
|
363
|
+
};
|
|
364
|
+
this.#later(check, ms);
|
|
365
|
+
}
|
|
366
|
+
return c;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* While anyone in the party is wanted, the mission's timers stand still
|
|
370
|
+
* and `message` stays as the objective (default "Lose the ~r~cops~w~.");
|
|
371
|
+
* once they're all clear, the timers run on and the objective is back.
|
|
372
|
+
* For a mission that shouldn't be failed by a chase it didn't mean.
|
|
373
|
+
*
|
|
374
|
+
* ```ts
|
|
375
|
+
* m.holdWhileWanted();
|
|
376
|
+
* ```
|
|
377
|
+
*/
|
|
378
|
+
holdWhileWanted(message = "Lose the ~r~cops~w~.") {
|
|
379
|
+
this.#live();
|
|
380
|
+
const check = () => {
|
|
381
|
+
if (!this.running)
|
|
382
|
+
return;
|
|
383
|
+
const wanted = this.#players.some((p) => p.connected && p.wantedLevel > 0);
|
|
384
|
+
const timers = this.#counters.filter((c) => c.isTimer && !c.removed);
|
|
385
|
+
if (wanted) {
|
|
386
|
+
for (const c of timers)
|
|
387
|
+
c.pause(); // and any started meanwhile
|
|
388
|
+
if (!this.#held) {
|
|
389
|
+
const o = this.#objective;
|
|
390
|
+
this.#held = { before: o && o.until > Date.now() ? { text: o.text, ms: o.until === Infinity ? Infinity : o.until - Date.now() } : null };
|
|
391
|
+
this.#objective = { text: message, until: Infinity };
|
|
392
|
+
for (const p of this.#players)
|
|
393
|
+
p.hud.objective(message, Infinity);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
else if (this.#held) {
|
|
397
|
+
const before = this.#held.before;
|
|
398
|
+
this.#held = null;
|
|
399
|
+
for (const c of timers)
|
|
400
|
+
c.resume();
|
|
401
|
+
if (before)
|
|
402
|
+
this.objective(before.text, before.ms);
|
|
403
|
+
else
|
|
404
|
+
this.clearObjective();
|
|
405
|
+
}
|
|
406
|
+
this.#later(check, 500);
|
|
407
|
+
};
|
|
408
|
+
check();
|
|
409
|
+
}
|
|
410
|
+
// Held while wanted: the objective the script set meanwhile, shown after.
|
|
411
|
+
#held = null;
|
|
412
|
+
failWhen(a, b, c) {
|
|
413
|
+
this.#live();
|
|
414
|
+
let entry;
|
|
415
|
+
if (typeof a === "function") {
|
|
416
|
+
entry = { check: a, reason: b };
|
|
417
|
+
}
|
|
418
|
+
else if (b === "died") {
|
|
419
|
+
const ped = a;
|
|
420
|
+
entry = { check: () => !ped.exists || ped.dead, reason: c ?? "" };
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
const vehicle = a;
|
|
424
|
+
entry = { check: () => !vehicle.exists || vehicle.wrecked, reason: c ?? "" };
|
|
425
|
+
}
|
|
426
|
+
this.#failWhen.push(entry);
|
|
427
|
+
return {
|
|
428
|
+
stop: () => {
|
|
429
|
+
const i = this.#failWhen.indexOf(entry);
|
|
430
|
+
if (i >= 0)
|
|
431
|
+
this.#failWhen.splice(i, 1);
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/** Fails it for everyone: "MISSION FAILED!" with `reason` under it. */
|
|
436
|
+
fail(reason = "") {
|
|
437
|
+
if (this.running)
|
|
438
|
+
this.#end(false, "failed", reason);
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Passes it: each player gets `money` (once) and "MISSION PASSED!" with
|
|
442
|
+
* `text` under it (default: the money, as "$2500"). Refused, with no
|
|
443
|
+
* reward, if it's sooner than `minDuration` or a player doesn't meet
|
|
444
|
+
* `requires` any more.
|
|
445
|
+
*/
|
|
446
|
+
pass(reward = {}) {
|
|
447
|
+
if (!this.running)
|
|
448
|
+
return;
|
|
449
|
+
const ms = Date.now() - this.startedAt;
|
|
450
|
+
if (this.definition.minDuration && ms < this.definition.minDuration) {
|
|
451
|
+
console.warn(`[sagl] mission "${this.name}" passed in ${ms} ms (minDuration ${this.definition.minDuration}): refused`);
|
|
452
|
+
this.#end(false, "tooFast", "");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
for (const p of this.#players) {
|
|
456
|
+
const why = this.definition.requires?.(p);
|
|
457
|
+
if (why) {
|
|
458
|
+
this.#end(false, "requirements", why);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const money = Math.max(0, Math.trunc(reward.money ?? 0));
|
|
463
|
+
if (money)
|
|
464
|
+
for (const p of this.#players)
|
|
465
|
+
p.giveMoney(money);
|
|
466
|
+
this.#end(true, "passed", reward.text ?? (money ? `$${money}` : ""));
|
|
467
|
+
}
|
|
468
|
+
// ---- Waiting --------------------------------------------------------------
|
|
469
|
+
/** Resolves after `ms`. */
|
|
470
|
+
wait(ms) {
|
|
471
|
+
this.#live();
|
|
472
|
+
return new Promise((resolve, reject) => {
|
|
473
|
+
const w = { check: () => false, resolve, reject };
|
|
474
|
+
this.#waiters.add(w);
|
|
475
|
+
this.#later(() => {
|
|
476
|
+
if (this.#waiters.delete(w))
|
|
477
|
+
resolve();
|
|
478
|
+
}, ms);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Resolves once `condition()` is true (checked four times a second), with
|
|
483
|
+
* `true`; with `timeout` (ms), with `false` if that passes first. A wait
|
|
484
|
+
* with a limit can't leave a scene stuck when something gets in the way.
|
|
485
|
+
*
|
|
486
|
+
* ```ts
|
|
487
|
+
* if (!await m.until(() => benny.vehicle === cab, { timeout: 8000 })) benny.putInVehicle(cab, 2);
|
|
488
|
+
* ```
|
|
489
|
+
*/
|
|
490
|
+
until(condition, options = {}) {
|
|
491
|
+
this.#live();
|
|
492
|
+
return new Promise((resolve, reject) => {
|
|
493
|
+
const w = { check: condition, resolve: () => resolve(true), reject };
|
|
494
|
+
this.#waiters.add(w);
|
|
495
|
+
if (options.timeout !== undefined) {
|
|
496
|
+
this.#later(() => {
|
|
497
|
+
if (this.#waiters.delete(w))
|
|
498
|
+
resolve(false);
|
|
499
|
+
}, Math.max(0, options.timeout));
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
step(promise, options = {}) {
|
|
504
|
+
this.#live();
|
|
505
|
+
return new Promise((resolve, reject) => {
|
|
506
|
+
const w = { check: () => false, resolve: () => { }, reject };
|
|
507
|
+
this.#waiters.add(w);
|
|
508
|
+
promise.then((v) => {
|
|
509
|
+
if (this.#waiters.delete(w))
|
|
510
|
+
resolve(v);
|
|
511
|
+
}, (e) => {
|
|
512
|
+
if (this.#waiters.delete(w))
|
|
513
|
+
reject(e);
|
|
514
|
+
});
|
|
515
|
+
if (options.timeout !== undefined) {
|
|
516
|
+
this.#later(() => {
|
|
517
|
+
if (this.#waiters.delete(w))
|
|
518
|
+
resolve(undefined);
|
|
519
|
+
}, Math.max(0, options.timeout));
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
// ---- Lifetime helpers ---------------------------------------------------------
|
|
524
|
+
/**
|
|
525
|
+
* Listens to a server event while the mission runs: removed when it ends
|
|
526
|
+
* (however it ends), or earlier with the function it returns.
|
|
527
|
+
*
|
|
528
|
+
* ```ts
|
|
529
|
+
* m.on("playerExitVehicle", (player, vehicle) => { if (vehicle === getaway) ... });
|
|
530
|
+
* ```
|
|
531
|
+
*/
|
|
532
|
+
on(event, listener) {
|
|
533
|
+
this.#live();
|
|
534
|
+
this.#server.on(event, listener);
|
|
535
|
+
let on = true;
|
|
536
|
+
const off = () => {
|
|
537
|
+
if (!on)
|
|
538
|
+
return;
|
|
539
|
+
on = false;
|
|
540
|
+
this.#server.off(event, listener);
|
|
541
|
+
const i = this.#listeners.indexOf(off);
|
|
542
|
+
if (i >= 0)
|
|
543
|
+
this.#listeners.splice(i, 1);
|
|
544
|
+
};
|
|
545
|
+
this.#listeners.push(off);
|
|
546
|
+
return off;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Calls `fn` every `ms` while the mission runs (stopped when it ends, or
|
|
550
|
+
* with the function it returns). An error is logged, not fatal.
|
|
551
|
+
*/
|
|
552
|
+
every(ms, fn) {
|
|
553
|
+
this.#live();
|
|
554
|
+
const t = setInterval(() => {
|
|
555
|
+
if (!this.running)
|
|
556
|
+
return;
|
|
557
|
+
try {
|
|
558
|
+
fn();
|
|
559
|
+
}
|
|
560
|
+
catch (e) {
|
|
561
|
+
console.error(`[sagl] mission "${this.name}" every() threw:`, e);
|
|
562
|
+
}
|
|
563
|
+
}, Math.max(10, ms));
|
|
564
|
+
this.#intervals.add(t);
|
|
565
|
+
return () => {
|
|
566
|
+
clearInterval(t);
|
|
567
|
+
this.#intervals.delete(t);
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Changes the population (peds, traffic, police, fire...) of the leader's
|
|
572
|
+
* world until the mission ends (or the function it returns is called):
|
|
573
|
+
* then the settings it changed are as they were.
|
|
574
|
+
*
|
|
575
|
+
* ```ts
|
|
576
|
+
* const fireBack = m.population({ fire: false }); // no fire engines to the car we torch
|
|
577
|
+
* ```
|
|
578
|
+
*/
|
|
579
|
+
population(settings) {
|
|
580
|
+
this.#live();
|
|
581
|
+
const world = this.#server.world(this.leader.world);
|
|
582
|
+
const before = world.population;
|
|
583
|
+
const was = {};
|
|
584
|
+
for (const k of Object.keys(settings))
|
|
585
|
+
was[k] = before[k];
|
|
586
|
+
world.population = settings;
|
|
587
|
+
let done = false;
|
|
588
|
+
const restore = () => {
|
|
589
|
+
if (done)
|
|
590
|
+
return;
|
|
591
|
+
done = true;
|
|
592
|
+
world.population = was;
|
|
593
|
+
const i = this.#restore.indexOf(restore);
|
|
594
|
+
if (i >= 0)
|
|
595
|
+
this.#restore.splice(i, 1);
|
|
596
|
+
};
|
|
597
|
+
this.#restore.push(restore);
|
|
598
|
+
return restore;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* A recorded line with its subtitles timed to it (as speech: the radio
|
|
602
|
+
* ducks under it), shown to the party. Resolves when it's been said;
|
|
603
|
+
* stopped if the mission ends. `at` a ped (or vehicle): from them; left
|
|
604
|
+
* out, at the party's ears (`effects: "phone"` for a caller).
|
|
605
|
+
*
|
|
606
|
+
* ```ts
|
|
607
|
+
* await m.say("audio/voice-call2.mp3", { effects: "phone", subtitle: VOICE.call2, prefix: "~y~" });
|
|
608
|
+
* await m.say("audio/benny-my-car.mp3", { at: benny, subtitle: "My car! That's my car!" });
|
|
609
|
+
* ```
|
|
610
|
+
*/
|
|
611
|
+
async say(file, options = {}) {
|
|
612
|
+
this.#live();
|
|
613
|
+
const speech = this.#server.audio.speak(file, {
|
|
614
|
+
...options,
|
|
615
|
+
to: options.to ?? (options.at ? undefined : this.#players),
|
|
616
|
+
subtitleTo: options.subtitleTo ?? this.#players,
|
|
617
|
+
});
|
|
618
|
+
this.#speeches.add(speech);
|
|
619
|
+
try {
|
|
620
|
+
await this.step(speech.done);
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
this.#speeches.delete(speech);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* A cinematic shot for the party (or `players`): black bars, the camera
|
|
628
|
+
* on `shot`, everyone frozen and their weapons put away, while `scene`
|
|
629
|
+
* runs; then the camera's back behind them, they can move and their
|
|
630
|
+
* weapons are back, however the scene ends (the mission ending too).
|
|
631
|
+
* `cut(shot)` changes the shot during it; `cut(null)` back to the first.
|
|
632
|
+
*
|
|
633
|
+
* ```ts
|
|
634
|
+
* await m.cinematic({ position: ACROSS_STREET, lookAt: car.position }, async (cut) => {
|
|
635
|
+
* await m.wait(3000);
|
|
636
|
+
* cut({ position: car.offset({ right: 3 }), lookAt: { vehicle: car } });
|
|
637
|
+
* await m.wait(2000);
|
|
638
|
+
* });
|
|
639
|
+
* ```
|
|
640
|
+
*/
|
|
641
|
+
async cinematic(shot, scene, options = {}) {
|
|
642
|
+
this.#live();
|
|
643
|
+
const players = (options.players ?? this.#players).filter((p) => p.connected);
|
|
644
|
+
const giveBack = [];
|
|
645
|
+
for (const p of players) {
|
|
646
|
+
if (options.freeze !== false)
|
|
647
|
+
p.frozen = true;
|
|
648
|
+
if (options.holster !== false)
|
|
649
|
+
giveBack.push(p.holster());
|
|
650
|
+
}
|
|
651
|
+
const cut = (to) => {
|
|
652
|
+
for (const p of players)
|
|
653
|
+
if (p.connected)
|
|
654
|
+
void p.camera.set({ ...(to ?? shot), widescreen: true });
|
|
655
|
+
};
|
|
656
|
+
cut(null);
|
|
657
|
+
let restored = false;
|
|
658
|
+
const restore = () => {
|
|
659
|
+
if (restored)
|
|
660
|
+
return;
|
|
661
|
+
restored = true;
|
|
662
|
+
for (const p of players) {
|
|
663
|
+
if (!p.connected)
|
|
664
|
+
continue;
|
|
665
|
+
p.camera.reset();
|
|
666
|
+
if (options.freeze !== false)
|
|
667
|
+
p.frozen = false;
|
|
668
|
+
}
|
|
669
|
+
for (const g of giveBack)
|
|
670
|
+
g();
|
|
671
|
+
const i = this.#restore.indexOf(restore);
|
|
672
|
+
if (i >= 0)
|
|
673
|
+
this.#restore.splice(i, 1);
|
|
674
|
+
};
|
|
675
|
+
this.#restore.push(restore);
|
|
676
|
+
try {
|
|
677
|
+
await scene(cut);
|
|
678
|
+
}
|
|
679
|
+
finally {
|
|
680
|
+
restore();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Marks where the script is (logged, and a `missionEvent` of type
|
|
685
|
+
* `"stage"`). Steps (`steps`) do it themselves.
|
|
686
|
+
*/
|
|
687
|
+
stage(name) {
|
|
688
|
+
this.#stage = name;
|
|
689
|
+
console.log(`[mission] ${this.name} (${this.leader?.name ?? "?"}): ${name}`);
|
|
690
|
+
this.#missions.event({ type: "stage", name: this.name, mission: this, player: null, reason: name });
|
|
691
|
+
}
|
|
692
|
+
/** The stage (or step) it's at. */
|
|
693
|
+
get currentStage() {
|
|
694
|
+
return this.#stage;
|
|
695
|
+
}
|
|
696
|
+
/** @internal Starts the steps from this one (`missions.start(..., { from })`). */
|
|
697
|
+
startFrom(step) {
|
|
698
|
+
this.#startFrom = step;
|
|
699
|
+
}
|
|
700
|
+
/** Resolves once every one of these peds is dead or gone. */
|
|
701
|
+
allDead(peds) {
|
|
702
|
+
return this.until(() => peds.every((p) => !p.exists || p.dead)).then(() => { });
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Waits for the party to reach a spot: the first player (the default), or
|
|
706
|
+
* all of them (`allPlayers`, a waiting point: each counts once they've
|
|
707
|
+
* been there, and the rest are waited for). Shows it with a blip and the
|
|
708
|
+
* game's marker meanwhile, for those who haven't reached it. Resolves
|
|
709
|
+
* with the player who completed it (the last, for `allPlayers`).
|
|
710
|
+
*/
|
|
711
|
+
reach(spot, options = {}) {
|
|
712
|
+
this.#live();
|
|
713
|
+
const radius = options.radius ?? 3;
|
|
714
|
+
const reached = new Set();
|
|
715
|
+
const at = (p) => {
|
|
716
|
+
if (reached.has(p))
|
|
717
|
+
return true;
|
|
718
|
+
if (!p.alive || dist(p.position, spot) > radius)
|
|
719
|
+
return false;
|
|
720
|
+
if (options.vehicle && p.vehicle !== options.vehicle)
|
|
721
|
+
return false;
|
|
722
|
+
reached.add(p);
|
|
723
|
+
return true;
|
|
724
|
+
};
|
|
725
|
+
const markerOptions = options.marker === false ? null : options.marker === true || !options.marker ? {} : options.marker;
|
|
726
|
+
const blip = markerOptions ? this.blip(spot, markerOptions) : null;
|
|
727
|
+
return this.#party(at, options, (done) => {
|
|
728
|
+
if (blip)
|
|
729
|
+
for (const p of done)
|
|
730
|
+
blip.drop(p);
|
|
731
|
+
}).finally(() => blip?.remove());
|
|
732
|
+
}
|
|
733
|
+
/** Waits for the party to get in a vehicle: the first player (the default), or all of them at once. */
|
|
734
|
+
enter(vehicle, options = {}) {
|
|
735
|
+
this.#live();
|
|
736
|
+
return this.#party((p) => p.vehicle === vehicle && (!options.driver || vehicle.driver === p), options);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Waits until `condition(player)` holds for the first player (the default),
|
|
740
|
+
* or for every player at once (`allPlayers`). Resolves with that player.
|
|
741
|
+
*/
|
|
742
|
+
untilPlayer(condition, options = {}) {
|
|
743
|
+
this.#live();
|
|
744
|
+
return this.#party(condition, options);
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Keeps the party near someone (a ped to escort or follow, a vehicle):
|
|
748
|
+
* a player further than `warnAt` (m) gets `warning` instead of the
|
|
749
|
+
* objective until they're back in range (then the objective returns);
|
|
750
|
+
* with `failAt`, the mission fails (`fail`) when every player is further
|
|
751
|
+
* than that. Lasts until the mission ends or `stop()`.
|
|
752
|
+
*/
|
|
753
|
+
keepClose(target, options = {}) {
|
|
754
|
+
this.#live();
|
|
755
|
+
const warnAt = options.warnAt ?? 25;
|
|
756
|
+
const warning = options.warning ?? "You're too far away. Go back.";
|
|
757
|
+
const warned = new Set();
|
|
758
|
+
let stopped = false;
|
|
759
|
+
const distance = (p) => {
|
|
760
|
+
const t = target.position, q = p.position;
|
|
761
|
+
return Math.hypot(t.x - q.x, t.y - q.y, t.z - q.z);
|
|
762
|
+
};
|
|
763
|
+
const check = () => {
|
|
764
|
+
if (stopped || !this.running)
|
|
765
|
+
return;
|
|
766
|
+
for (const p of this.#players) {
|
|
767
|
+
const far = distance(p) > warnAt;
|
|
768
|
+
if (far && !warned.has(p)) {
|
|
769
|
+
warned.add(p);
|
|
770
|
+
this.#warned.add(p);
|
|
771
|
+
p.hud.objective(warning, 60_000);
|
|
772
|
+
}
|
|
773
|
+
else if (!far && warned.has(p)) {
|
|
774
|
+
warned.delete(p);
|
|
775
|
+
this.#warned.delete(p);
|
|
776
|
+
if (this.#objective)
|
|
777
|
+
p.hud.objective(this.#objective.text, Math.max(1, this.#objective.until - Date.now()));
|
|
778
|
+
else
|
|
779
|
+
p.hud.clearObjective();
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
for (const p of [...warned]) {
|
|
783
|
+
if (!this.#players.includes(p)) {
|
|
784
|
+
warned.delete(p);
|
|
785
|
+
this.#warned.delete(p);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
this.#later(check, 500);
|
|
789
|
+
};
|
|
790
|
+
if (options.failAt !== undefined) {
|
|
791
|
+
const failAt = options.failAt;
|
|
792
|
+
this.failWhen(() => !stopped && this.#players.length > 0 && this.#players.every((p) => distance(p) > failAt), options.fail ?? "You left them behind.");
|
|
793
|
+
}
|
|
794
|
+
check();
|
|
795
|
+
return {
|
|
796
|
+
stop: () => {
|
|
797
|
+
stopped = true;
|
|
798
|
+
for (const p of warned) {
|
|
799
|
+
this.#warned.delete(p);
|
|
800
|
+
if (this.#objective)
|
|
801
|
+
p.hud.objective(this.#objective.text, Math.max(1, this.#objective.until - Date.now()));
|
|
802
|
+
else
|
|
803
|
+
p.hud.clearObjective();
|
|
804
|
+
}
|
|
805
|
+
warned.clear();
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
/** True if `key` was used in the last `ms` (and starts it again if not): throttles repeated actions. */
|
|
810
|
+
cooldown(key, ms) {
|
|
811
|
+
const now = Date.now();
|
|
812
|
+
const until = this.#cooldowns.get(key) ?? 0;
|
|
813
|
+
if (now < until)
|
|
814
|
+
return true;
|
|
815
|
+
this.#cooldowns.set(key, now + ms);
|
|
816
|
+
return false;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Plays one of the game's cutscenes (e.g. `"ryder2a"`) for the party
|
|
820
|
+
* together; resolves when everyone's has finished. The leader's skip ends
|
|
821
|
+
* it for everyone (see `CutsceneOptions`). Ending the mission stops it.
|
|
822
|
+
*/
|
|
823
|
+
cutscene(name, options = {}) {
|
|
824
|
+
this.#live();
|
|
825
|
+
this.#cutscene?.stop();
|
|
826
|
+
const play = this.#server.playCutscene(this.#players, name, options);
|
|
827
|
+
this.#cutscene = play;
|
|
828
|
+
return this.step(play.done).finally(() => {
|
|
829
|
+
if (this.#cutscene === play)
|
|
830
|
+
this.#cutscene = null;
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
// ---- Sections -------------------------------------------------------------
|
|
834
|
+
/**
|
|
835
|
+
* Starts a section: where the party respawns if they die, and where
|
|
836
|
+
* anyone far behind (more than `catchUpDistance`, or in another world)
|
|
837
|
+
* is brought, faded out and back in. Resolves once they're there.
|
|
838
|
+
*/
|
|
839
|
+
async section(name, options) {
|
|
840
|
+
this.#live();
|
|
841
|
+
const spots = Array.isArray(options.at) ? options.at : [options.at];
|
|
842
|
+
if (spots.length === 0)
|
|
843
|
+
throw new RangeError(`section "${name}": no spots`);
|
|
844
|
+
this.#spawn = { at: spots, heading: options.heading ?? 0 };
|
|
845
|
+
const far = options.catchUpDistance ?? this.definition.catchUpDistance ?? 150;
|
|
846
|
+
const world = this.leader.world;
|
|
847
|
+
const behind = this.#players.filter((p, i) => p.alive && (p.world !== world || dist(p.position, spots[i % spots.length]) > far));
|
|
848
|
+
await Promise.all(behind.map((p) => this.#bring(p, 500)));
|
|
849
|
+
}
|
|
850
|
+
// ---- Internals ------------------------------------------------------------
|
|
851
|
+
/** @internal Runs the script. */
|
|
852
|
+
async run() {
|
|
853
|
+
for (const p of this.#players)
|
|
854
|
+
this.#missions.bind(p, this);
|
|
855
|
+
try {
|
|
856
|
+
const steps = this.definition.steps;
|
|
857
|
+
if (steps?.length) {
|
|
858
|
+
const state = this.definition.state ? this.definition.state() : {};
|
|
859
|
+
let i = this.#startFrom ? steps.findIndex((s) => s.name === this.#startFrom) : 0;
|
|
860
|
+
if (i < 0)
|
|
861
|
+
throw new RangeError(`mission "${this.name}" has no step "${this.#startFrom}"`);
|
|
862
|
+
if (i > 0)
|
|
863
|
+
await steps[i].setup?.(this, state);
|
|
864
|
+
for (; i < steps.length && this.running; ++i) {
|
|
865
|
+
this.stage(steps[i].name);
|
|
866
|
+
await steps[i].run(this, state);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
else {
|
|
870
|
+
await this.definition.run?.(this);
|
|
871
|
+
}
|
|
872
|
+
if (this.running)
|
|
873
|
+
this.pass();
|
|
874
|
+
}
|
|
875
|
+
catch (e) {
|
|
876
|
+
if (e instanceof MissionEnded)
|
|
877
|
+
return;
|
|
878
|
+
console.error(`[sagl] mission "${this.name}" threw:`, e);
|
|
879
|
+
if (this.running)
|
|
880
|
+
this.#end(false, "error", "");
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/** @internal A party member respawned: back to the section start. */
|
|
884
|
+
respawned(player) {
|
|
885
|
+
// After the gamemode's own playerSpawn handlers, which may place them.
|
|
886
|
+
if (this.running && this.#spawn)
|
|
887
|
+
this.#later(() => void this.#bring(player, 0), 100);
|
|
888
|
+
}
|
|
889
|
+
/** @internal A party member died. */
|
|
890
|
+
died(player) {
|
|
891
|
+
if (!this.running)
|
|
892
|
+
return;
|
|
893
|
+
if (this.definition.deathFails) {
|
|
894
|
+
this.#end(false, "failed", `${player.name} died.`);
|
|
895
|
+
}
|
|
896
|
+
else if (this.#players.every((p) => p === player || !p.alive)) {
|
|
897
|
+
this.#end(false, "failed", "Everyone died.");
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// Faded out, moved to their section spot, faded in.
|
|
901
|
+
async #bring(player, fadeMs) {
|
|
902
|
+
if (!this.#spawn)
|
|
903
|
+
return;
|
|
904
|
+
const i = Math.max(0, this.#players.indexOf(player));
|
|
905
|
+
const spot = this.#spawn.at[i % this.#spawn.at.length];
|
|
906
|
+
if (fadeMs)
|
|
907
|
+
await player.fadeOut(fadeMs);
|
|
908
|
+
if (!player.connected)
|
|
909
|
+
return;
|
|
910
|
+
player.world = this.leader?.world ?? player.world;
|
|
911
|
+
player.setPosition(spot, this.#spawn.heading);
|
|
912
|
+
await new Promise((r) => setTimeout(r, 300)); // the world streams in
|
|
913
|
+
await player.fadeIn(fadeMs || 300);
|
|
914
|
+
}
|
|
915
|
+
#party(condition, options, progress) {
|
|
916
|
+
const all = options.allPlayers === true;
|
|
917
|
+
let last = null;
|
|
918
|
+
return new Promise((resolve, reject) => {
|
|
919
|
+
const w = {
|
|
920
|
+
check: () => {
|
|
921
|
+
const done = this.#players.filter(condition);
|
|
922
|
+
progress?.(done);
|
|
923
|
+
if (done.length === 0)
|
|
924
|
+
return false;
|
|
925
|
+
if (all && done.length < this.#players.length)
|
|
926
|
+
return false;
|
|
927
|
+
last = done[done.length - 1];
|
|
928
|
+
return true;
|
|
929
|
+
},
|
|
930
|
+
resolve: () => resolve(last),
|
|
931
|
+
reject,
|
|
932
|
+
};
|
|
933
|
+
this.#waiters.add(w);
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
#wait(check) {
|
|
937
|
+
return new Promise((resolve, reject) => {
|
|
938
|
+
this.#waiters.add({ check, resolve, reject });
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
#check() {
|
|
942
|
+
if (!this.running)
|
|
943
|
+
return;
|
|
944
|
+
for (const f of this.#failWhen) {
|
|
945
|
+
let hit = false;
|
|
946
|
+
try {
|
|
947
|
+
hit = f.check();
|
|
948
|
+
}
|
|
949
|
+
catch (e) {
|
|
950
|
+
console.error(`[sagl] mission "${this.name}" fail condition threw:`, e);
|
|
951
|
+
}
|
|
952
|
+
if (hit) {
|
|
953
|
+
this.#end(false, "failed", f.reason);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
for (const w of [...this.#waiters]) {
|
|
958
|
+
let ok = false;
|
|
959
|
+
try {
|
|
960
|
+
ok = w.check();
|
|
961
|
+
}
|
|
962
|
+
catch (e) {
|
|
963
|
+
this.#waiters.delete(w);
|
|
964
|
+
w.reject(e);
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (ok && this.#waiters.delete(w))
|
|
968
|
+
w.resolve();
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
#later(fn, ms) {
|
|
972
|
+
const t = setTimeout(() => {
|
|
973
|
+
this.#timers.delete(t);
|
|
974
|
+
fn();
|
|
975
|
+
}, ms);
|
|
976
|
+
this.#timers.add(t);
|
|
977
|
+
}
|
|
978
|
+
#live() {
|
|
979
|
+
if (this.#outcome)
|
|
980
|
+
throw new MissionEnded(this.#outcome);
|
|
981
|
+
}
|
|
982
|
+
// A joining player gets the HUD as it is.
|
|
983
|
+
#show(player) {
|
|
984
|
+
for (const b of this.#blips)
|
|
985
|
+
b.add(player);
|
|
986
|
+
for (const c of this.#counters)
|
|
987
|
+
c.add(player);
|
|
988
|
+
if (this.#objective && this.#objective.until > Date.now()) {
|
|
989
|
+
player.hud.objective(this.#objective.text, this.#objective.until - Date.now());
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
#hide(player) {
|
|
993
|
+
if (!player.connected)
|
|
994
|
+
return;
|
|
995
|
+
for (const b of this.#blips)
|
|
996
|
+
b.drop(player);
|
|
997
|
+
for (const c of this.#counters)
|
|
998
|
+
c.drop(player);
|
|
999
|
+
player.hud.clearObjective();
|
|
1000
|
+
}
|
|
1001
|
+
#updateVisibility() {
|
|
1002
|
+
for (const { ped, partyVisible } of this.#peds) {
|
|
1003
|
+
if (partyVisible && ped.exists)
|
|
1004
|
+
ped.visibleTo = this.#players;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
#end(passed, reason, text) {
|
|
1008
|
+
if (this.#outcome)
|
|
1009
|
+
return;
|
|
1010
|
+
const outcome = { passed, reason, text, players: [...this.#players], ms: Date.now() - this.startedAt };
|
|
1011
|
+
this.#outcome = outcome;
|
|
1012
|
+
clearInterval(this.#tick);
|
|
1013
|
+
this.#cutscene?.stop();
|
|
1014
|
+
for (const off of [...this.#listeners])
|
|
1015
|
+
off();
|
|
1016
|
+
for (const t of this.#intervals)
|
|
1017
|
+
clearInterval(t);
|
|
1018
|
+
this.#intervals.clear();
|
|
1019
|
+
for (const sp of this.#speeches)
|
|
1020
|
+
sp.stop();
|
|
1021
|
+
this.#speeches.clear();
|
|
1022
|
+
for (const r of [...this.#restore].reverse()) {
|
|
1023
|
+
try {
|
|
1024
|
+
r();
|
|
1025
|
+
}
|
|
1026
|
+
catch (e) {
|
|
1027
|
+
console.error(`[sagl] mission "${this.name}" cleanup threw:`, e);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
for (const t of this.#timers)
|
|
1031
|
+
clearTimeout(t);
|
|
1032
|
+
this.#timers.clear();
|
|
1033
|
+
const ended = new MissionEnded(outcome);
|
|
1034
|
+
for (const w of this.#waiters)
|
|
1035
|
+
w.reject(ended);
|
|
1036
|
+
this.#waiters.clear();
|
|
1037
|
+
// Cleanup: its HUD, and what it made.
|
|
1038
|
+
for (const b of this.#blips)
|
|
1039
|
+
b.remove();
|
|
1040
|
+
for (const c of this.#counters)
|
|
1041
|
+
c.remove();
|
|
1042
|
+
// Its characters go (fading out); bodies lie where they fell until
|
|
1043
|
+
// the cleanup takes them, and vehicles stay until nobody is around, as
|
|
1044
|
+
// in the game. `keep` ones stay for good.
|
|
1045
|
+
for (const { ped, onEnd } of this.#peds) {
|
|
1046
|
+
if (!ped.exists)
|
|
1047
|
+
continue;
|
|
1048
|
+
if (onEnd === "keep")
|
|
1049
|
+
ped.visibleTo = "world";
|
|
1050
|
+
else if (ped.dead && onEnd === "release")
|
|
1051
|
+
ped.release();
|
|
1052
|
+
else
|
|
1053
|
+
ped.destroy();
|
|
1054
|
+
}
|
|
1055
|
+
for (const { vehicle, onEnd } of this.#vehicles) {
|
|
1056
|
+
if (!vehicle.exists || onEnd === "keep")
|
|
1057
|
+
continue;
|
|
1058
|
+
if (onEnd === "destroy")
|
|
1059
|
+
vehicle.destroy();
|
|
1060
|
+
else
|
|
1061
|
+
vehicle.release();
|
|
1062
|
+
}
|
|
1063
|
+
for (const o of this.#objects)
|
|
1064
|
+
o.destroy();
|
|
1065
|
+
for (const p of this.#pickups)
|
|
1066
|
+
p.remove();
|
|
1067
|
+
for (const p of this.#players) {
|
|
1068
|
+
if (!p.connected)
|
|
1069
|
+
continue;
|
|
1070
|
+
p.hud.clearObjective();
|
|
1071
|
+
if (passed)
|
|
1072
|
+
p.hud.missionPassed(text);
|
|
1073
|
+
else if (reason !== "abandoned")
|
|
1074
|
+
p.hud.missionFailed(text);
|
|
1075
|
+
this.#missions.bind(p, null);
|
|
1076
|
+
}
|
|
1077
|
+
this.#missions.finished(this, outcome);
|
|
1078
|
+
this.#resolveDone(outcome);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
/** A spot that starts a mission when a player walks into it (`server.missions.marker()`). */
|
|
1082
|
+
export class MissionMarker {
|
|
1083
|
+
definition;
|
|
1084
|
+
position;
|
|
1085
|
+
#remove;
|
|
1086
|
+
/** @internal */
|
|
1087
|
+
constructor(definition, position, remove) {
|
|
1088
|
+
this.definition = definition;
|
|
1089
|
+
this.position = position;
|
|
1090
|
+
this.#remove = remove;
|
|
1091
|
+
}
|
|
1092
|
+
remove() {
|
|
1093
|
+
this.#remove();
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Starts and tracks missions (`server.missions`). A player is in one
|
|
1098
|
+
* mission at a time.
|
|
1099
|
+
*/
|
|
1100
|
+
export class Missions {
|
|
1101
|
+
#server;
|
|
1102
|
+
#emit;
|
|
1103
|
+
#of = new Map();
|
|
1104
|
+
#running = new Set();
|
|
1105
|
+
// Cooldowns by mission name, then player name (names are the closest thing
|
|
1106
|
+
// to an identity until players have accounts).
|
|
1107
|
+
#passedAt = new Map();
|
|
1108
|
+
/** @internal */
|
|
1109
|
+
constructor(server, emit) {
|
|
1110
|
+
this.#server = server;
|
|
1111
|
+
this.#emit = emit;
|
|
1112
|
+
server.on("playerQuit", (p) => this.#of.get(p)?.removePlayer(p));
|
|
1113
|
+
server.on("playerDeath", (p) => this.#of.get(p)?.died(p));
|
|
1114
|
+
server.on("playerSpawn", (p) => this.#of.get(p)?.respawned(p));
|
|
1115
|
+
}
|
|
1116
|
+
/** The missions running now. */
|
|
1117
|
+
get running() {
|
|
1118
|
+
return [...this.#running];
|
|
1119
|
+
}
|
|
1120
|
+
/** The mission a player is in, or null. */
|
|
1121
|
+
of(player) {
|
|
1122
|
+
return this.#of.get(player) ?? null;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Starts a mission for these players (the first is the leader). Throws
|
|
1126
|
+
* `MissionRefused` if any of them is in a mission, cooling down or
|
|
1127
|
+
* doesn't meet `requires`, or there are too many.
|
|
1128
|
+
*/
|
|
1129
|
+
start(definition, players, options = {}) {
|
|
1130
|
+
if (options.from !== undefined && !definition.steps?.some((st) => st.name === options.from)) {
|
|
1131
|
+
throw new RangeError(`mission "${definition.name}" has no step "${options.from}"`);
|
|
1132
|
+
}
|
|
1133
|
+
const party = Array.isArray(players) ? [...players] : [players];
|
|
1134
|
+
if (party.length === 0)
|
|
1135
|
+
throw new RangeError("a mission needs a player");
|
|
1136
|
+
if (party.length > (definition.maxPlayers ?? 1)) {
|
|
1137
|
+
this.refuse(definition, party[0], `At most ${definition.maxPlayers ?? 1} players.`);
|
|
1138
|
+
}
|
|
1139
|
+
for (const p of party)
|
|
1140
|
+
this.admit(definition, p);
|
|
1141
|
+
const m = new Mission(this, this.#server, definition, party);
|
|
1142
|
+
m.startFrom(options.from ?? null);
|
|
1143
|
+
this.#running.add(m);
|
|
1144
|
+
for (const p of party)
|
|
1145
|
+
this.#of.set(p, m);
|
|
1146
|
+
this.event({ type: "start", name: definition.name, mission: m, player: party[0], reason: "" });
|
|
1147
|
+
void m.run();
|
|
1148
|
+
return m;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* A start marker: a blip and the game's yellow marker for everyone not in
|
|
1152
|
+
* a mission. Walking into it on foot starts the mission with everyone
|
|
1153
|
+
* else standing in it (up to `maxPlayers`).
|
|
1154
|
+
*/
|
|
1155
|
+
marker(definition, at, options = {}) {
|
|
1156
|
+
const radius = options.radius ?? 1.5;
|
|
1157
|
+
const world = options.world ?? 0;
|
|
1158
|
+
const blips = new Map();
|
|
1159
|
+
const inside = new Set();
|
|
1160
|
+
const refused = options.onRefused ?? ((p, why) => p.hud.objective(why, 3000));
|
|
1161
|
+
const tick = setInterval(() => {
|
|
1162
|
+
for (const p of this.#server.players) {
|
|
1163
|
+
const free = !this.#of.has(p) && p.world === world;
|
|
1164
|
+
const blip = blips.get(p);
|
|
1165
|
+
if (options.blip !== false && free && !blip)
|
|
1166
|
+
blips.set(p, p.hud.blip(at, options.blip || {}));
|
|
1167
|
+
if (blip && !free) {
|
|
1168
|
+
blip.remove();
|
|
1169
|
+
blips.delete(p);
|
|
1170
|
+
}
|
|
1171
|
+
// Only walking in starts it: whoever is still standing in it when a
|
|
1172
|
+
// mission ends (or after being refused) has to step out first.
|
|
1173
|
+
if (p.world !== world || dist(p.position, at) > radius) {
|
|
1174
|
+
inside.delete(p);
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
if (inside.has(p))
|
|
1178
|
+
continue;
|
|
1179
|
+
if (!p.alive || p.vehicle)
|
|
1180
|
+
continue; // getting out in it counts as walking in
|
|
1181
|
+
inside.add(p);
|
|
1182
|
+
if (!free)
|
|
1183
|
+
continue;
|
|
1184
|
+
const others = this.#server.players.filter((o) => o !== p && !this.#of.has(o) && o.world === world &&
|
|
1185
|
+
o.alive && !o.vehicle && dist(o.position, at) <= radius);
|
|
1186
|
+
const party = [p, ...others].slice(0, definition.maxPlayers ?? 1);
|
|
1187
|
+
for (const o of party)
|
|
1188
|
+
inside.add(o);
|
|
1189
|
+
try {
|
|
1190
|
+
this.start(definition, party);
|
|
1191
|
+
}
|
|
1192
|
+
catch (e) {
|
|
1193
|
+
if (e instanceof MissionRefused)
|
|
1194
|
+
refused(p, e.reason);
|
|
1195
|
+
else
|
|
1196
|
+
throw e;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
for (const p of [...blips.keys()])
|
|
1200
|
+
if (!p.connected)
|
|
1201
|
+
blips.delete(p);
|
|
1202
|
+
for (const p of [...inside])
|
|
1203
|
+
if (!p.connected)
|
|
1204
|
+
inside.delete(p);
|
|
1205
|
+
}, TICK_MS);
|
|
1206
|
+
return new MissionMarker(definition, at, () => {
|
|
1207
|
+
clearInterval(tick);
|
|
1208
|
+
for (const b of blips.values())
|
|
1209
|
+
b.remove();
|
|
1210
|
+
blips.clear();
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
/** @internal Throws MissionRefused if the player can't start or join it. */
|
|
1214
|
+
admit(definition, player) {
|
|
1215
|
+
if (this.#of.has(player))
|
|
1216
|
+
this.refuse(definition, player, "You're already on a mission.");
|
|
1217
|
+
const passed = this.#passedAt.get(definition.name)?.get(player.name);
|
|
1218
|
+
if (definition.cooldown && passed !== undefined && Date.now() - passed < definition.cooldown) {
|
|
1219
|
+
const minutes = Math.ceil((definition.cooldown - (Date.now() - passed)) / 60000);
|
|
1220
|
+
this.refuse(definition, player, `Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`);
|
|
1221
|
+
}
|
|
1222
|
+
const why = definition.requires?.(player);
|
|
1223
|
+
if (why)
|
|
1224
|
+
this.refuse(definition, player, why);
|
|
1225
|
+
}
|
|
1226
|
+
/** @internal */
|
|
1227
|
+
refuse(definition, player, reason) {
|
|
1228
|
+
this.event({ type: "refused", name: definition.name, mission: null, player, reason });
|
|
1229
|
+
throw new MissionRefused(reason);
|
|
1230
|
+
}
|
|
1231
|
+
/** @internal */
|
|
1232
|
+
bind(player, mission) {
|
|
1233
|
+
if (mission)
|
|
1234
|
+
this.#of.set(player, mission);
|
|
1235
|
+
else
|
|
1236
|
+
this.#of.delete(player);
|
|
1237
|
+
}
|
|
1238
|
+
/** @internal */
|
|
1239
|
+
finished(mission, outcome) {
|
|
1240
|
+
this.#running.delete(mission);
|
|
1241
|
+
if (outcome.passed) {
|
|
1242
|
+
const byName = this.#passedAt.get(mission.name) ?? new Map();
|
|
1243
|
+
for (const p of outcome.players)
|
|
1244
|
+
byName.set(p.name, Date.now());
|
|
1245
|
+
this.#passedAt.set(mission.name, byName);
|
|
1246
|
+
}
|
|
1247
|
+
this.event({ type: outcome.passed ? "pass" : "fail", name: mission.name, mission, player: null,
|
|
1248
|
+
reason: outcome.passed ? "" : outcome.reason === "failed" ? outcome.text || "failed" : outcome.reason });
|
|
1249
|
+
}
|
|
1250
|
+
/** @internal */
|
|
1251
|
+
event(e) {
|
|
1252
|
+
this.#emit(e);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
function dist(a, b) {
|
|
1256
|
+
return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
|
1257
|
+
}
|
|
1258
|
+
//# sourceMappingURL=mission.js.map
|