@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/modshop.js
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
// The game's mod shops: TransFender (three of them), Loco Low Co and Wheel
|
|
2
|
+
// Arch Angels. Players' games run none of their logic (it was the game's
|
|
3
|
+
// mission script), so the server does it, the way the game did:
|
|
4
|
+
//
|
|
5
|
+
// - Drive in. A car the shop doesn't work on is turned away ("You cannot
|
|
6
|
+
// mod this vehicle here."): Loco Low Co does lowriders, Wheel Arch Angels
|
|
7
|
+
// street racers, TransFender the other cars.
|
|
8
|
+
// - At the script's spot inside the door, the door shuts behind it, the
|
|
9
|
+
// screen fades, and the car is on the shop's own floor (the game's
|
|
10
|
+
// interior for it), in a world of its own: anyone else can use the shop
|
|
11
|
+
// meanwhile.
|
|
12
|
+
// - A menu as the game's: the areas (Spoilers, Hood, Exhausts, ... as the
|
|
13
|
+
// car has parts for them, and Colors and Paint Jobs), then each one's
|
|
14
|
+
// parts and prices, shown on the car as they're chosen, the script's
|
|
15
|
+
// camera for that part of it. Enter buys, Backspace (or F, as the game's)
|
|
16
|
+
// goes back; out of the areas, the car is back at the spot, facing in, as
|
|
17
|
+
// the game leaves it.
|
|
18
|
+
//
|
|
19
|
+
// Names, prices, spots, cameras and headings are the game's (shopping.dat,
|
|
20
|
+
// its GXT text, its mod shop script carmod1.scm: see modshop_data.ts).
|
|
21
|
+
var _a;
|
|
22
|
+
import { MOD_SHOP_COLORS, MOD_SHOP_PARTS, MOD_SHOP_SITES } from "./modshop_data.js";
|
|
23
|
+
import { gxt } from "./text.js";
|
|
24
|
+
import { componentFits, componentSlot } from "./vehicle.js";
|
|
25
|
+
import { ShopBlips } from "./shopblips.js";
|
|
26
|
+
/** The game's mod shops (each sells its own parts; `modShop` events say which). */
|
|
27
|
+
export const ModShop = {
|
|
28
|
+
TransFender: 1,
|
|
29
|
+
LocoLowCo: 2,
|
|
30
|
+
WheelArchAngels: 4,
|
|
31
|
+
};
|
|
32
|
+
/** The Paint Finish area's default list. */
|
|
33
|
+
export const DEFAULT_MOD_SHOP_FINISHES = [
|
|
34
|
+
{ name: "Standard", finish: null },
|
|
35
|
+
{ name: "Metallic", finish: { type: "metallic" } },
|
|
36
|
+
{ name: "Pearl", finish: { type: "pearl" } },
|
|
37
|
+
{ name: "Satin", finish: { type: "satin" } },
|
|
38
|
+
{ name: "Matte", finish: { type: "matte" } },
|
|
39
|
+
{ name: "Colour Shift", finish: { type: "colorShift" } },
|
|
40
|
+
];
|
|
41
|
+
const AREA_NAMES = {
|
|
42
|
+
colors: 1, finishes: 101, liveries: 102, paintjobs: 11, plate: 100,
|
|
43
|
+
spoilers: 2, hood: 3, vents: 17, sideSkirts: 4, exhausts: 5, nitro: 6, frontBumper: 7, rearBumper: 8, lights: 9,
|
|
44
|
+
roof: 10, wheels: 12, frontBullbars: 13, rearBullbars: 14, misc: 15, stereo: 16, hydraulics: 18,
|
|
45
|
+
};
|
|
46
|
+
const AREA_KEY = Object.fromEntries(Object.entries(AREA_NAMES).map(([k, v]) => [v, k]));
|
|
47
|
+
/**
|
|
48
|
+
* One mod shop (`server.modShops.shop(id)`): the game's ("transfender-ls",
|
|
49
|
+
* "transfender-sf", "transfender-lv", "locolow", "waa") or one you made.
|
|
50
|
+
* Its own options go over everyone's.
|
|
51
|
+
*/
|
|
52
|
+
export class ModShopSite {
|
|
53
|
+
id;
|
|
54
|
+
/** Whose shop floor (`ModShop`): its interior, cameras, and (the game's) its parts. */
|
|
55
|
+
floor;
|
|
56
|
+
/** The game's shop it is, or 0 (one you made). */
|
|
57
|
+
shop;
|
|
58
|
+
garage;
|
|
59
|
+
/** @internal */
|
|
60
|
+
entry;
|
|
61
|
+
/** @internal */
|
|
62
|
+
exitHeading;
|
|
63
|
+
/** @internal */
|
|
64
|
+
options = {};
|
|
65
|
+
#changed;
|
|
66
|
+
#remove;
|
|
67
|
+
/** @internal */
|
|
68
|
+
constructor(init) {
|
|
69
|
+
this.id = init.id;
|
|
70
|
+
this.floor = init.floor;
|
|
71
|
+
this.shop = init.shop;
|
|
72
|
+
this.garage = init.garage;
|
|
73
|
+
this.entry = init.entry;
|
|
74
|
+
this.exitHeading = init.exitHeading;
|
|
75
|
+
this.#changed = init.changed;
|
|
76
|
+
this.#remove = init.remove;
|
|
77
|
+
}
|
|
78
|
+
/** Changes how this shop works (only the options given, over everyone's). */
|
|
79
|
+
configure(options) {
|
|
80
|
+
Object.assign(this.options, options);
|
|
81
|
+
this.#changed();
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
/** Where a car starts it. */
|
|
85
|
+
get position() {
|
|
86
|
+
return { x: this.entry.x, y: this.entry.y, z: this.entry.z };
|
|
87
|
+
}
|
|
88
|
+
/** Takes away a shop you made (the game's: `configure({ enabled: false })`). */
|
|
89
|
+
remove() {
|
|
90
|
+
this.#remove?.();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Each shop's sites, floor, cameras and exit heading: its mission script's
|
|
94
|
+
// (MOD_SHOP_SITES). Its name: CARMOD1-3.
|
|
95
|
+
const TITLES = {
|
|
96
|
+
[ModShop.TransFender]: "CARMOD1", [ModShop.LocoLowCo]: "CARMOD2", [ModShop.WheelArchAngels]: "CARMOD3",
|
|
97
|
+
};
|
|
98
|
+
// Their signs, from the game's own textures, over the menu (Loco Low Co has
|
|
99
|
+
// none of its own: its name).
|
|
100
|
+
const LOGOS = {
|
|
101
|
+
[ModShop.TransFender]: "game:garag3_lawn/ws_Transfender_dirty",
|
|
102
|
+
[ModShop.WheelArchAngels]: "game:subshops_sfs/ws_archangels_dirty",
|
|
103
|
+
};
|
|
104
|
+
// The cars Loco Low Co and Wheel Arch Angels work on (and TransFender doesn't).
|
|
105
|
+
const LOWRIDERS = new Set([534, 535, 536, 567, 575, 576]);
|
|
106
|
+
const RACERS = new Set([558, 559, 560, 561, 562, 565]);
|
|
107
|
+
// Models with paint jobs, and how many.
|
|
108
|
+
const PAINTJOBS = {
|
|
109
|
+
534: 3, 535: 3, 536: 3, 558: 3, 559: 3, 560: 3, 561: 3, 562: 3, 565: 3, 567: 3, 575: 2, 576: 3,
|
|
110
|
+
};
|
|
111
|
+
// The game's areas (CARMn), in its order. 1 colours and 11 paint jobs are
|
|
112
|
+
// ours to sell; the rest are parts.
|
|
113
|
+
const AREAS = [1, 2, 3, 17, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18];
|
|
114
|
+
// Ours, last: the licence plate (shot from behind); finishes and liveries
|
|
115
|
+
// after the colours.
|
|
116
|
+
const PLATE = 100;
|
|
117
|
+
const FINISH = 101;
|
|
118
|
+
const LIVERY = 102;
|
|
119
|
+
const COLORS = 1;
|
|
120
|
+
const PAINT = 11;
|
|
121
|
+
// Each area's camera: one of the script's 15 shots (by what each looks at;
|
|
122
|
+
// 0 is the whole car from its front right, the areas' own).
|
|
123
|
+
const SHOT_OF = {
|
|
124
|
+
[PLATE]: 8,
|
|
125
|
+
[FINISH]: 0,
|
|
126
|
+
[LIVERY]: 0,
|
|
127
|
+
1: 0, 2: 2, 3: 4, 4: 3, 5: 6, 6: 0, 7: 7, 8: 8, 9: 9, 10: 12, 11: 0, 12: 1, 13: 10, 14: 11, 15: 0, 16: 13, 17: 5, 18: 14,
|
|
128
|
+
};
|
|
129
|
+
// The script's sounds (mission audio events, at the car): a part fitted,
|
|
130
|
+
// paint, and not enough money.
|
|
131
|
+
const SOUND_PART = 1133;
|
|
132
|
+
const SOUND_PAINT = 1134;
|
|
133
|
+
const SOUND_NO = 1055;
|
|
134
|
+
// The cameras' heights over the floor, against the script's.
|
|
135
|
+
const CAMERA_HEIGHT = {
|
|
136
|
+
[ModShop.TransFender]: 0.75, [ModShop.LocoLowCo]: 0.75, [ModShop.WheelArchAngels]: 0.875,
|
|
137
|
+
};
|
|
138
|
+
// Shots moved sideways (m, to the view's right; by shot): Loco Low Co's
|
|
139
|
+
// whole-car shot had the car's nose off the right of the screen.
|
|
140
|
+
const CAMERA_SIDE = {
|
|
141
|
+
[ModShop.LocoLowCo]: { 0: 0.8 },
|
|
142
|
+
};
|
|
143
|
+
const MOD_SPRITE = 27; // RADAR_SPRITE_MOD_GARAGE
|
|
144
|
+
const DOOR_MS = 1500;
|
|
145
|
+
const FADE_MS = 500;
|
|
146
|
+
const WORLD_BASE = 0x7fff0000; // + the player's id: their own shop floor
|
|
147
|
+
// The buttons' hints, bottom left (the first leftmost).
|
|
148
|
+
const HINTS_AREAS = [{ key: "select", label: "Select" }, { key: "back", label: "Leave" }, { key: "updown", label: "Browse" }];
|
|
149
|
+
const HINTS_SLOTS = [{ key: "select", label: "Select" }, { key: "back", label: "Back" }, { key: "updown", label: "Browse" }];
|
|
150
|
+
const HINTS_PARTS = [{ key: "select", label: "Buy" }, { key: "back", label: "Back" }, { key: "updown", label: "Browse" }];
|
|
151
|
+
const HINTS_GRID = [{ key: "select", label: "Buy" }, { key: "back", label: "Back" }, { key: "arrows", label: "Browse" }];
|
|
152
|
+
/**
|
|
153
|
+
* The game's mod shops (`server.modShops`), on by default: drive into
|
|
154
|
+
* TransFender, Loco Low Co or Wheel Arch Angels, and buy parts, colours,
|
|
155
|
+
* paint finishes and paint jobs from the game's menu, each shown on the car
|
|
156
|
+
* first. Configure every shop (`configure`), one (`shop(id).configure`), or
|
|
157
|
+
* per player (`forPlayer`); make your own (`create`). Fires `vehicleMod`,
|
|
158
|
+
* `vehiclePaintjob`, `vehicleRespray`, `vehicleFinish`, `vehicleLivery`,
|
|
159
|
+
* `vehiclePlate` and `modShop`.
|
|
160
|
+
*/
|
|
161
|
+
export class ModShops {
|
|
162
|
+
#server;
|
|
163
|
+
#events;
|
|
164
|
+
#enabled = true;
|
|
165
|
+
#o = {};
|
|
166
|
+
#sites = [];
|
|
167
|
+
#sessions = new Map();
|
|
168
|
+
#busy = new Set();
|
|
169
|
+
// "player:site": turned away, or just out, until they're off the spot.
|
|
170
|
+
#untilTheyLeave = new Set();
|
|
171
|
+
#blips;
|
|
172
|
+
#nextCustom = 1;
|
|
173
|
+
/** @internal */
|
|
174
|
+
constructor(server, events) {
|
|
175
|
+
this.#server = server;
|
|
176
|
+
this.#events = events;
|
|
177
|
+
this.#gameSites();
|
|
178
|
+
// Not the garages' zones: they hear a car's middle, which never gets
|
|
179
|
+
// past these shops' doorways (behind them is a wall).
|
|
180
|
+
setInterval(() => this.#tick(), 200).unref?.();
|
|
181
|
+
this.#blips = new ShopBlips(server, MOD_SPRITE, () => this.#sites.filter((site) => this.#open(site) && (this.#siteOptions(site).blips ?? true)).map((site) => site.entry));
|
|
182
|
+
this.#doors();
|
|
183
|
+
server.on("playerQuit", (p) => {
|
|
184
|
+
const s = this.#sessions.get(p);
|
|
185
|
+
if (s)
|
|
186
|
+
void this.#leave(s, false);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Whether they work (default true). On, the game's shops' doors are open
|
|
191
|
+
* (its script opened them); off, shut, as the game starts them.
|
|
192
|
+
*/
|
|
193
|
+
get enabled() {
|
|
194
|
+
return this.#enabled;
|
|
195
|
+
}
|
|
196
|
+
set enabled(on) {
|
|
197
|
+
this.#enabled = on;
|
|
198
|
+
this.#doors();
|
|
199
|
+
}
|
|
200
|
+
/** Changes how every shop works (only the options given). */
|
|
201
|
+
configure(options) {
|
|
202
|
+
Object.assign(this.#o, options);
|
|
203
|
+
this.#doors();
|
|
204
|
+
return this;
|
|
205
|
+
}
|
|
206
|
+
/** Every shop: the game's five and yours. */
|
|
207
|
+
get shops() {
|
|
208
|
+
return [...this.#sites];
|
|
209
|
+
}
|
|
210
|
+
/** A shop by id: "transfender-ls", "transfender-sf", "transfender-lv", "locolow", "waa", or yours. */
|
|
211
|
+
shop(id) {
|
|
212
|
+
return this.#sites.find((site) => site.id === id);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Makes a mod shop of your own: a spot a car drives onto, taking it to
|
|
216
|
+
* one of the game's shop floors, with its own options.
|
|
217
|
+
*
|
|
218
|
+
* ```ts
|
|
219
|
+
* server.modShops.create({ id: "docks", at: { x: 2770, y: -2400, z: 13.6 }, title: "Docks Tuning", logo: "ui/docks.png" });
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
create(spec) {
|
|
223
|
+
const id = spec.id ?? `custom-${this.#nextCustom++}`;
|
|
224
|
+
if (this.shop(id))
|
|
225
|
+
throw new RangeError(`there's already a mod shop "${id}"`);
|
|
226
|
+
const floor = spec.floor ?? ModShop.TransFender;
|
|
227
|
+
if (!MOD_SHOP_SITES[floor])
|
|
228
|
+
throw new RangeError(`not a shop floor: ${floor} (ModShop.TransFender, LocoLowCo or WheelArchAngels)`);
|
|
229
|
+
const size = spec.size ?? { x: 4, y: 4, z: 2 };
|
|
230
|
+
const site = new ModShopSite({
|
|
231
|
+
id, floor, shop: 0, garage: spec.garage ?? null,
|
|
232
|
+
entry: { x: spec.at.x, y: spec.at.y, z: spec.at.z, size: { x: size.x, y: size.y, z: size.z } },
|
|
233
|
+
exitHeading: spec.exitHeading ?? null,
|
|
234
|
+
changed: () => this.#doors(),
|
|
235
|
+
remove: () => {
|
|
236
|
+
this.#sites = this.#sites.filter((x) => x !== site);
|
|
237
|
+
this.#doors();
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
const { id: _id, at: _at, size: _size, exitHeading: _h, floor: _f, garage: _g, ...options } = spec;
|
|
241
|
+
site.options = options;
|
|
242
|
+
this.#sites.push(site);
|
|
243
|
+
this.#doors();
|
|
244
|
+
return site;
|
|
245
|
+
}
|
|
246
|
+
// The game's: each of its spots (TransFender has three), and its garage.
|
|
247
|
+
#gameSites() {
|
|
248
|
+
const names = {
|
|
249
|
+
[ModShop.TransFender]: ["transfender-ls", "transfender-sf", "transfender-lv"],
|
|
250
|
+
[ModShop.LocoLowCo]: ["locolow"], [ModShop.WheelArchAngels]: ["waa"],
|
|
251
|
+
};
|
|
252
|
+
for (const [shop, info] of Object.entries(MOD_SHOP_SITES)) {
|
|
253
|
+
info.entries.forEach((entry, k) => {
|
|
254
|
+
const garage = this.#server.garages.find((g) => g.contains(entry, -1)) ?? null;
|
|
255
|
+
this.#sites.push(new ModShopSite({
|
|
256
|
+
id: names[Number(shop)]?.[k] ?? `shop-${shop}-${k}`, floor: Number(shop), shop: Number(shop), garage,
|
|
257
|
+
entry: { x: entry.x, y: entry.y, z: entry.z, size: { ...entry.size } }, exitHeading: info.exitHeading,
|
|
258
|
+
changed: () => this.#doors(), remove: null,
|
|
259
|
+
}));
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Open shops' doors open, the others' shut; their blips.
|
|
264
|
+
#doors() {
|
|
265
|
+
this.#blips?.refresh();
|
|
266
|
+
for (const site of this.#sites) {
|
|
267
|
+
const g = site.garage;
|
|
268
|
+
if (!g || this.#busy.has(site))
|
|
269
|
+
continue;
|
|
270
|
+
const open = this.#open(site);
|
|
271
|
+
if (g.isOpen !== open) {
|
|
272
|
+
if (open)
|
|
273
|
+
g.open();
|
|
274
|
+
else
|
|
275
|
+
g.close();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** The garages that are mod shops, and which shop each is. */
|
|
280
|
+
get garages() {
|
|
281
|
+
return this.#sites.filter((x) => x.garage && x.shop).map((x) => ({ garage: x.garage, shop: x.shop }));
|
|
282
|
+
}
|
|
283
|
+
/** Which of the game's shops works on a model (0 none): Loco Low Co lowriders, Wheel Arch Angels street racers, TransFender other cars. */
|
|
284
|
+
static shopFor(model) {
|
|
285
|
+
if (LOWRIDERS.has(model))
|
|
286
|
+
return ModShop.LocoLowCo;
|
|
287
|
+
if (RACERS.has(model))
|
|
288
|
+
return ModShop.WheelArchAngels;
|
|
289
|
+
return MOD_SHOP_PARTS.some((p) => p.shops & ModShop.TransFender && componentFits(model, p.part)) ? ModShop.TransFender : 0;
|
|
290
|
+
}
|
|
291
|
+
/** Whether a player is in a shop now. */
|
|
292
|
+
isShopping(player) {
|
|
293
|
+
return this.#sessions.has(player);
|
|
294
|
+
}
|
|
295
|
+
/** Sends them out of the shop they're in (as if they'd left). */
|
|
296
|
+
async kickOut(player) {
|
|
297
|
+
const s = this.#sessions.get(player);
|
|
298
|
+
if (s)
|
|
299
|
+
await this.#leave(s, true);
|
|
300
|
+
}
|
|
301
|
+
// Everyone's options with the shop's over them (not a player's).
|
|
302
|
+
#siteOptions(site) {
|
|
303
|
+
return merge(this.#o, site.options);
|
|
304
|
+
}
|
|
305
|
+
#open(site) {
|
|
306
|
+
if (!this.#enabled)
|
|
307
|
+
return false;
|
|
308
|
+
const o = this.#siteOptions(site);
|
|
309
|
+
if (o.enabled === false)
|
|
310
|
+
return false;
|
|
311
|
+
const open = o.shops ?? {};
|
|
312
|
+
if (site.shop === ModShop.TransFender && open.transFender === false)
|
|
313
|
+
return false;
|
|
314
|
+
if (site.shop === ModShop.LocoLowCo && open.locoLowCo === false)
|
|
315
|
+
return false;
|
|
316
|
+
if (site.shop === ModShop.WheelArchAngels && open.wheelArchAngels === false)
|
|
317
|
+
return false;
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
// Drivers at a shop's spot (the script's box, inside its door): in, or
|
|
321
|
+
// turned away.
|
|
322
|
+
#tick() {
|
|
323
|
+
if (!this.#enabled)
|
|
324
|
+
return;
|
|
325
|
+
const sites = this.#sites.filter((site) => this.#open(site));
|
|
326
|
+
for (const player of this.#server.players) {
|
|
327
|
+
const vehicle = player.vehicle;
|
|
328
|
+
if (!vehicle || vehicle.driver !== player || this.#sessions.has(player))
|
|
329
|
+
continue;
|
|
330
|
+
const at = vehicle.position;
|
|
331
|
+
for (const site of sites) {
|
|
332
|
+
const { entry } = site;
|
|
333
|
+
const key = `${player.id}:${site.id}`;
|
|
334
|
+
const there = Math.abs(at.x - entry.x) <= entry.size.x && Math.abs(at.y - entry.y) <= entry.size.y &&
|
|
335
|
+
Math.abs(at.z - entry.z) <= entry.size.z;
|
|
336
|
+
if (!there) {
|
|
337
|
+
this.#untilTheyLeave.delete(key);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (this.#busy.has(site) || this.#untilTheyLeave.has(key))
|
|
341
|
+
continue;
|
|
342
|
+
this.#untilTheyLeave.add(key);
|
|
343
|
+
const base = this.#siteOptions(site);
|
|
344
|
+
const o = merge(base, base.forPlayer?.(player, vehicle, site) ?? {}, site.options.forPlayer?.(player, vehicle, site) ?? {});
|
|
345
|
+
const works = _a.shopFor(vehicle.model);
|
|
346
|
+
const accepts = o.accepts ? o.accepts(vehicle.model) : site.shop ? works === site.shop : works !== 0;
|
|
347
|
+
if (!accepts) {
|
|
348
|
+
player.hud.notice(gxt(works ? "MODNO" : "MODNO2"), 4000);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const said = o.onEnter?.(player, vehicle, site.shop, site.garage);
|
|
352
|
+
if (said === false)
|
|
353
|
+
continue;
|
|
354
|
+
if (typeof said === "string") {
|
|
355
|
+
player.hud.notice(said, 4000);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
void this.#start(player, vehicle, site, o, vehicle.heading);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async #start(player, vehicle, site, o, heading) {
|
|
363
|
+
const floor = MOD_SHOP_SITES[site.floor].floor;
|
|
364
|
+
const garage = site.garage;
|
|
365
|
+
const s = {
|
|
366
|
+
player, vehicle, site, garage, shop: site.floor, o, entry: site.entry, world: vehicle.world, interior: vehicle.interior,
|
|
367
|
+
owned: { parts: [...vehicle.components], colors: vehicle.colors, paintjob: vehicle.paintjob, finish: vehicle.paintFinish },
|
|
368
|
+
livery: vehicle.livery, exitHeading: site.exitHeading ?? heading,
|
|
369
|
+
menu: null, watch: null, leaving: false, hud: { radar: player.hud.parts.radar, chat: player.hud.parts.chat },
|
|
370
|
+
};
|
|
371
|
+
this.#sessions.set(player, s);
|
|
372
|
+
this.#busy.add(site);
|
|
373
|
+
player.frozen = true;
|
|
374
|
+
if (garage) {
|
|
375
|
+
garage.close();
|
|
376
|
+
await wait(DOOR_MS);
|
|
377
|
+
}
|
|
378
|
+
if (!this.#here(s))
|
|
379
|
+
return this.#leave(s, true);
|
|
380
|
+
await player.fadeOut(FADE_MS);
|
|
381
|
+
if (!this.#here(s))
|
|
382
|
+
return this.#leave(s, true);
|
|
383
|
+
player.hud.set({ radar: false, chat: false });
|
|
384
|
+
vehicle.world = WORLD_BASE + player.id;
|
|
385
|
+
vehicle.interior = floor.interior;
|
|
386
|
+
for (const p of this.#occupants(vehicle))
|
|
387
|
+
p.interior = floor.interior;
|
|
388
|
+
vehicle.setPosition(floor, floor.heading);
|
|
389
|
+
garage?.open();
|
|
390
|
+
this.#busy.delete(site);
|
|
391
|
+
this.#events.shop(player, vehicle, site.shop, true, site.id);
|
|
392
|
+
await this.#shoot(s, 0, 0);
|
|
393
|
+
await wait(400);
|
|
394
|
+
if (!this.#here(s))
|
|
395
|
+
return this.#leave(s, true);
|
|
396
|
+
await player.fadeIn(FADE_MS);
|
|
397
|
+
// Gone (left, died, the car destroyed): out, as best we can.
|
|
398
|
+
s.watch = setInterval(() => {
|
|
399
|
+
if (!this.#here(s))
|
|
400
|
+
void this.#leave(s, true);
|
|
401
|
+
}, 500);
|
|
402
|
+
this.#areas(s);
|
|
403
|
+
}
|
|
404
|
+
#here(s) {
|
|
405
|
+
return !s.leaving && s.player.connected && s.vehicle.exists && s.player.vehicle === s.vehicle && s.player.alive;
|
|
406
|
+
}
|
|
407
|
+
#occupants(vehicle) {
|
|
408
|
+
return this.#server.players.filter((p) => p.vehicle === vehicle);
|
|
409
|
+
}
|
|
410
|
+
// One of the script's shots of its shop's floor, lower (theirs look down
|
|
411
|
+
// from too high): its height over the floor scaled, so a low one (the
|
|
412
|
+
// wheels') hardly moves.
|
|
413
|
+
#shoot(s, shot, ms = 600) {
|
|
414
|
+
const site = MOD_SHOP_SITES[s.shop];
|
|
415
|
+
const [x, y, z, lx, ly, lz] = site.cameras[shot] ?? site.cameras[0];
|
|
416
|
+
const down = (z - site.floor.z) * (1 - (CAMERA_HEIGHT[s.shop] ?? 1));
|
|
417
|
+
// Moved sideways (m, to the view's right), camera and what it looks at.
|
|
418
|
+
const side = CAMERA_SIDE[s.shop]?.[shot] ?? 0;
|
|
419
|
+
const fx = lx - x, fy = ly - y, len = Math.hypot(fx, fy) || 1;
|
|
420
|
+
const rx = (fy / len) * side, ry = (-fx / len) * side;
|
|
421
|
+
return s.player.camera.set({
|
|
422
|
+
position: { x: x + rx, y: y + ry, z: z - down }, lookAt: { x: lx + rx, y: ly + ry, z: lz - down }, ms,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// ---- The menus ----------------------------------------------------------------
|
|
426
|
+
#scale(s, price) {
|
|
427
|
+
return Math.max(0, Math.round(price * (s.o.priceScale ?? 1)));
|
|
428
|
+
}
|
|
429
|
+
#priceOf(s, part, base) {
|
|
430
|
+
const own = s.o.parts?.[part];
|
|
431
|
+
if (own === false)
|
|
432
|
+
return null;
|
|
433
|
+
const price = typeof own === "number" ? Math.max(0, Math.round(own)) : this.#scale(s, base);
|
|
434
|
+
const said = s.o.price?.(part, price, s.vehicle, s.player);
|
|
435
|
+
if (said === null)
|
|
436
|
+
return null;
|
|
437
|
+
return typeof said === "number" ? Math.max(0, Math.round(said)) : price;
|
|
438
|
+
}
|
|
439
|
+
// The parts this shop has for this car, in an area, with prices.
|
|
440
|
+
#partsIn(s, area) {
|
|
441
|
+
const sells = s.o.sells ?? (s.site.shop || ModShop.TransFender | ModShop.LocoLowCo | ModShop.WheelArchAngels);
|
|
442
|
+
const out = [];
|
|
443
|
+
for (const p of MOD_SHOP_PARTS) {
|
|
444
|
+
if (p.cat !== area || !(p.shops & sells) || !componentFits(s.vehicle.model, p.part))
|
|
445
|
+
continue;
|
|
446
|
+
const price = this.#priceOf(s, p.part, p.price);
|
|
447
|
+
if (price !== null)
|
|
448
|
+
out.push({ part: p.part, gxt: p.gxt, price });
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
// An area on or off (its option, else `dflt`).
|
|
453
|
+
#areaOn(s, area, dflt) {
|
|
454
|
+
const own = s.o.areas?.[AREA_KEY[area]];
|
|
455
|
+
if (own !== undefined)
|
|
456
|
+
return own;
|
|
457
|
+
if (area === PLATE && s.o.plates === false)
|
|
458
|
+
return false;
|
|
459
|
+
return dflt;
|
|
460
|
+
}
|
|
461
|
+
// An item's availability: [shown, locked label or ""].
|
|
462
|
+
#availability(s, f) {
|
|
463
|
+
const said = f?.(s.player, s.vehicle);
|
|
464
|
+
if (said === false)
|
|
465
|
+
return [false, ""];
|
|
466
|
+
return [true, typeof said === "string" ? said : ""];
|
|
467
|
+
}
|
|
468
|
+
#finishList(s) {
|
|
469
|
+
const list = Array.isArray(s.o.finishes) ? s.o.finishes : s.o.finishes === false ? [] : DEFAULT_MOD_SHOP_FINISHES;
|
|
470
|
+
return list.flatMap((f) => {
|
|
471
|
+
const [shown, lock] = this.#availability(s, f.available);
|
|
472
|
+
return shown ? [{ ...f, lock }] : [];
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
#liveryList(s) {
|
|
476
|
+
return (s.o.liveries ?? []).flatMap((l) => {
|
|
477
|
+
if (l.models && !l.models.includes(s.vehicle.model))
|
|
478
|
+
return [];
|
|
479
|
+
const [shown, lock] = this.#availability(s, l.available);
|
|
480
|
+
return shown ? [{ ...l, lock }] : [];
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
#areaList(s) {
|
|
484
|
+
const paintjobs = (PAINTJOBS[s.vehicle.model] ?? 0) > 0;
|
|
485
|
+
const areas = AREAS.filter((a) => a === COLORS ? this.#areaOn(s, a, true) && this.#colorIds(s).length > 0
|
|
486
|
+
: a === PAINT ? paintjobs && this.#areaOn(s, a, s.site.shop !== ModShop.TransFender)
|
|
487
|
+
: this.#areaOn(s, a, true) && this.#partsIn(s, a).length > 0);
|
|
488
|
+
let at = areas.indexOf(COLORS) + 1;
|
|
489
|
+
if (this.#areaOn(s, FINISH, true) && this.#finishList(s).length)
|
|
490
|
+
areas.splice(at++, 0, FINISH);
|
|
491
|
+
if (this.#areaOn(s, LIVERY, true) && this.#liveryList(s).length)
|
|
492
|
+
areas.splice(at, 0, LIVERY);
|
|
493
|
+
return this.#areaOn(s, PLATE, true) ? [...areas, PLATE] : areas;
|
|
494
|
+
}
|
|
495
|
+
#logo(s) {
|
|
496
|
+
if (s.o.logo === null)
|
|
497
|
+
return undefined;
|
|
498
|
+
return s.o.logo ?? LOGOS[s.site.shop];
|
|
499
|
+
}
|
|
500
|
+
#title(s) {
|
|
501
|
+
if (s.o.title !== undefined)
|
|
502
|
+
return s.o.title;
|
|
503
|
+
if (this.#logo(s))
|
|
504
|
+
return "";
|
|
505
|
+
return s.site.shop ? gxt(TITLES[s.site.shop]) : "Mod Shop";
|
|
506
|
+
}
|
|
507
|
+
#areas(s, row = 0) {
|
|
508
|
+
const areas = this.#areaList(s);
|
|
509
|
+
void this.#shoot(s, 0);
|
|
510
|
+
s.menu = s.player.menu({
|
|
511
|
+
title: this.#title(s),
|
|
512
|
+
image: this.#logo(s),
|
|
513
|
+
hints: HINTS_AREAS,
|
|
514
|
+
rows: areas.map((a) => (a === PLATE ? "Licence Plate" : a === FINISH ? "Paint Finish" : a === LIVERY ? "Liveries" : gxt(`CARM${a}`))),
|
|
515
|
+
row,
|
|
516
|
+
onSelect: (r) => {
|
|
517
|
+
const area = areas[r];
|
|
518
|
+
if (area === PLATE)
|
|
519
|
+
void this.#plate(s, r);
|
|
520
|
+
else if (area === FINISH)
|
|
521
|
+
this.#finishes(s, r);
|
|
522
|
+
else if (area === LIVERY)
|
|
523
|
+
this.#liveries(s, r);
|
|
524
|
+
else if (area === COLORS)
|
|
525
|
+
this.#colorSlots(s, r);
|
|
526
|
+
else if (area === PAINT)
|
|
527
|
+
this.#paintjobs(s, r);
|
|
528
|
+
else
|
|
529
|
+
this.#parts(s, area, r);
|
|
530
|
+
},
|
|
531
|
+
onBack: () => void this.#leave(s, true),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
// The finishes, shown on the car as they're chosen.
|
|
535
|
+
#finishes(s, areaRow) {
|
|
536
|
+
const finishes = this.#finishList(s);
|
|
537
|
+
const priceOf = (f) => this.#scale(s, f.price ?? s.o.finishPrice ?? 1000);
|
|
538
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
539
|
+
const current = () => finishes.findIndex((f) => same(f.finish, this.#owned(s).finish));
|
|
540
|
+
const rows = () => finishes.map((f, i) => [f.name, i === current() ? "~g~Fitted" : f.lock || `$${priceOf(f)}`]);
|
|
541
|
+
const preview = (r) => {
|
|
542
|
+
s.vehicle.paintFinish = finishes[r]?.finish ?? null;
|
|
543
|
+
};
|
|
544
|
+
void this.#shoot(s, 0);
|
|
545
|
+
s.menu = s.player.menu({
|
|
546
|
+
title: "Paint Finish",
|
|
547
|
+
image: this.#logo(s),
|
|
548
|
+
hints: HINTS_PARTS,
|
|
549
|
+
rows: rows(),
|
|
550
|
+
row: Math.max(0, current()),
|
|
551
|
+
onChoose: preview,
|
|
552
|
+
onSelect: (r, menu) => {
|
|
553
|
+
const f = finishes[r];
|
|
554
|
+
if (!f)
|
|
555
|
+
return;
|
|
556
|
+
if (r === current())
|
|
557
|
+
return this.#already(s);
|
|
558
|
+
if (f.lock)
|
|
559
|
+
return this.#locked(s, f.lock);
|
|
560
|
+
const price = priceOf(f);
|
|
561
|
+
if (!this.#pay(s, price, SOUND_PAINT))
|
|
562
|
+
return;
|
|
563
|
+
this.#owned(s).finish = f.finish;
|
|
564
|
+
this.#restore(s);
|
|
565
|
+
s.vehicle.modded = true;
|
|
566
|
+
this.#events.finish(s.player, s.vehicle, f.finish, price);
|
|
567
|
+
menu.update({ rows: rows() });
|
|
568
|
+
},
|
|
569
|
+
onBack: () => {
|
|
570
|
+
this.#restore(s);
|
|
571
|
+
this.#areas(s, areaRow);
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
preview(Math.max(0, current()));
|
|
575
|
+
}
|
|
576
|
+
// Liveries (paint jobs of yours), shown on the car as they're chosen.
|
|
577
|
+
#liveries(s, areaRow) {
|
|
578
|
+
const liveries = this.#liveryList(s);
|
|
579
|
+
const priceOf = (l) => this.#scale(s, l.price ?? s.o.liveryPrice ?? 2000);
|
|
580
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
581
|
+
const current = () => liveries.findIndex((l) => same(l.livery, s.livery));
|
|
582
|
+
const rows = () => liveries.map((l, i) => [l.name, i === current() ? "~g~Fitted" : l.lock || `$${priceOf(l)}`]);
|
|
583
|
+
const preview = (r) => {
|
|
584
|
+
s.vehicle.livery = liveries[r]?.livery ?? null;
|
|
585
|
+
};
|
|
586
|
+
void this.#shoot(s, 0);
|
|
587
|
+
s.menu = s.player.menu({
|
|
588
|
+
title: "Liveries",
|
|
589
|
+
image: this.#logo(s),
|
|
590
|
+
hints: HINTS_PARTS,
|
|
591
|
+
rows: rows(),
|
|
592
|
+
row: Math.max(0, current()),
|
|
593
|
+
onChoose: preview,
|
|
594
|
+
onSelect: (r, menu) => {
|
|
595
|
+
const l = liveries[r];
|
|
596
|
+
if (!l)
|
|
597
|
+
return;
|
|
598
|
+
if (r === current())
|
|
599
|
+
return this.#already(s);
|
|
600
|
+
if (l.lock)
|
|
601
|
+
return this.#locked(s, l.lock);
|
|
602
|
+
const price = priceOf(l);
|
|
603
|
+
if (!this.#pay(s, price, SOUND_PAINT))
|
|
604
|
+
return;
|
|
605
|
+
s.livery = l.livery;
|
|
606
|
+
this.#restore(s);
|
|
607
|
+
s.vehicle.modded = true;
|
|
608
|
+
this.#events.livery(s.player, s.vehicle, l.livery, price);
|
|
609
|
+
menu.update({ rows: rows() });
|
|
610
|
+
},
|
|
611
|
+
onBack: () => {
|
|
612
|
+
this.#restore(s);
|
|
613
|
+
this.#areas(s, areaRow);
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
preview(Math.max(0, current()));
|
|
617
|
+
}
|
|
618
|
+
// The colour grid's colours: the option's, else the game's (unless off)
|
|
619
|
+
// then the server's, less those hidden.
|
|
620
|
+
#colorIds(s) {
|
|
621
|
+
const all = s.o.colors ?? [...(s.o.gameColors === false ? [] : MOD_SHOP_COLORS), ...this.#server.vehicleColors.custom];
|
|
622
|
+
const hide = new Set(s.o.hideColors ?? []);
|
|
623
|
+
return all.filter((id) => !hide.has(id));
|
|
624
|
+
}
|
|
625
|
+
// Not for them: the label, and the game's "no" sound.
|
|
626
|
+
#locked(s, label) {
|
|
627
|
+
s.player.hud.notice(label, 3000);
|
|
628
|
+
this.#sound(s, SOUND_NO);
|
|
629
|
+
}
|
|
630
|
+
async #plate(s, areaRow) {
|
|
631
|
+
const price = this.#scale(s, s.o.platePrice ?? 250);
|
|
632
|
+
void this.#shoot(s, SHOT_OF[PLATE]);
|
|
633
|
+
s.menu?.close();
|
|
634
|
+
const { response, input } = await s.player.dialog({
|
|
635
|
+
style: "input", title: "Licence Plate",
|
|
636
|
+
body: `Now: ${s.vehicle.plate.trim() || "(none)"}\nA new one, up to 8 letters, numbers and spaces: $${price}`,
|
|
637
|
+
button1: "Buy", button2: "Back",
|
|
638
|
+
});
|
|
639
|
+
if (!this.#here(s))
|
|
640
|
+
return;
|
|
641
|
+
const plate = input.trim().toUpperCase();
|
|
642
|
+
if (response) {
|
|
643
|
+
if (!/^[A-Z0-9 ]{1,8}$/.test(plate)) {
|
|
644
|
+
s.player.hud.notice("~r~A plate is up to 8 letters, numbers and spaces.", 3000);
|
|
645
|
+
this.#sound(s, SOUND_NO);
|
|
646
|
+
}
|
|
647
|
+
else if (plate === s.vehicle.plate.trim()) {
|
|
648
|
+
this.#already(s);
|
|
649
|
+
}
|
|
650
|
+
else if (this.#pay(s, price, SOUND_PART)) {
|
|
651
|
+
s.vehicle.plate = plate;
|
|
652
|
+
this.#events.plate(s.player, s.vehicle, plate, price);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
this.#areas(s, areaRow);
|
|
656
|
+
}
|
|
657
|
+
#parts(s, area, areaRow) {
|
|
658
|
+
const parts = this.#partsIn(s, area);
|
|
659
|
+
const fitted = (p) => this.#owned(s).parts.includes(p);
|
|
660
|
+
const rows = () => parts.map((p) => [gxt(p.gxt), fitted(p.part) ? "~g~Fitted" : `$${p.price}`]);
|
|
661
|
+
void this.#shoot(s, SHOT_OF[area] ?? 0);
|
|
662
|
+
const preview = (r) => {
|
|
663
|
+
const p = parts[r];
|
|
664
|
+
this.#restore(s, p && !fitted(p.part) ? componentSlot(p.part) : undefined);
|
|
665
|
+
if (p && !fitted(p.part))
|
|
666
|
+
s.vehicle.addComponent(p.part); // a pair's other half: the game fits it
|
|
667
|
+
};
|
|
668
|
+
s.menu = s.player.menu({
|
|
669
|
+
title: gxt(`CARM${area}`),
|
|
670
|
+
image: this.#logo(s),
|
|
671
|
+
hints: HINTS_PARTS,
|
|
672
|
+
rows: rows(),
|
|
673
|
+
onChoose: preview,
|
|
674
|
+
onSelect: (r, menu) => {
|
|
675
|
+
const p = parts[r];
|
|
676
|
+
if (!p)
|
|
677
|
+
return;
|
|
678
|
+
if (fitted(p.part))
|
|
679
|
+
return this.#already(s);
|
|
680
|
+
if (!this.#pay(s, p.price, SOUND_PART))
|
|
681
|
+
return;
|
|
682
|
+
const o = this.#owned(s);
|
|
683
|
+
const slot = componentSlot(p.part);
|
|
684
|
+
o.parts = o.parts.filter((x) => componentSlot(x) !== slot);
|
|
685
|
+
o.parts.push(p.part);
|
|
686
|
+
this.#restore(s);
|
|
687
|
+
s.vehicle.modded = true;
|
|
688
|
+
this.#events.mod(s.player, s.vehicle, p.part, p.price);
|
|
689
|
+
menu.update({ rows: rows() });
|
|
690
|
+
},
|
|
691
|
+
onBack: () => {
|
|
692
|
+
this.#restore(s);
|
|
693
|
+
this.#areas(s, areaRow);
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
preview(0);
|
|
697
|
+
}
|
|
698
|
+
#colorSlots(s, areaRow, row = 0) {
|
|
699
|
+
s.menu = s.player.menu({
|
|
700
|
+
title: gxt(`CARM${COLORS}`),
|
|
701
|
+
image: this.#logo(s),
|
|
702
|
+
hints: HINTS_SLOTS,
|
|
703
|
+
rows: [gxt("CARCOL1"), gxt("CARCOL2")],
|
|
704
|
+
row,
|
|
705
|
+
onSelect: (r) => this.#colors(s, areaRow, r),
|
|
706
|
+
onBack: () => this.#areas(s, areaRow),
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
#colors(s, areaRow, which) {
|
|
710
|
+
const price = this.#scale(s, s.o.colorPrice ?? 150);
|
|
711
|
+
const current = () => this.#owned(s).colors[which];
|
|
712
|
+
// The game's grid: its 64 colours, 8 across.
|
|
713
|
+
const ids = this.#colorIds(s);
|
|
714
|
+
const rgb = (id) => this.#server.vehicleColors.rgb(id) ?? 0;
|
|
715
|
+
const rows = () => ids.map((id) => ({
|
|
716
|
+
text: "", value: id === current() ? "~g~Fitted" : `$${price}`, swatch: rgb(id),
|
|
717
|
+
}));
|
|
718
|
+
// Under the grid: the colour it has now, and the chosen one's price.
|
|
719
|
+
const footer = () => ({ text: "Current", swatch: rgb(current()) });
|
|
720
|
+
const preview = (r) => {
|
|
721
|
+
const colors = [...this.#owned(s).colors];
|
|
722
|
+
colors[which] = ids[r];
|
|
723
|
+
s.vehicle.colors = colors;
|
|
724
|
+
};
|
|
725
|
+
s.menu = s.player.menu({
|
|
726
|
+
title: gxt(which === 0 ? "CARCOL1" : "CARCOL2"),
|
|
727
|
+
image: this.#logo(s),
|
|
728
|
+
hints: HINTS_GRID,
|
|
729
|
+
rows: rows(),
|
|
730
|
+
columns: 8,
|
|
731
|
+
footer: footer(),
|
|
732
|
+
row: 0,
|
|
733
|
+
onChoose: preview,
|
|
734
|
+
onSelect: (r, menu) => {
|
|
735
|
+
const id = ids[r];
|
|
736
|
+
if (id === current())
|
|
737
|
+
return this.#already(s, true);
|
|
738
|
+
if (!this.#pay(s, price, SOUND_PAINT))
|
|
739
|
+
return;
|
|
740
|
+
const o = this.#owned(s);
|
|
741
|
+
o.colors = [...o.colors];
|
|
742
|
+
o.colors[which] = id;
|
|
743
|
+
this.#restore(s);
|
|
744
|
+
s.vehicle.modded = true;
|
|
745
|
+
s.vehicle.repair(); // as the game's (its script fixes the car with new paint)
|
|
746
|
+
s.vehicle.dirt = 0;
|
|
747
|
+
this.#events.respray(s.player, s.vehicle, o.colors);
|
|
748
|
+
menu.update({ rows: rows(), footer: footer() });
|
|
749
|
+
},
|
|
750
|
+
onBack: () => {
|
|
751
|
+
this.#restore(s);
|
|
752
|
+
this.#colorSlots(s, areaRow, which);
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
preview(0); // the first, chosen
|
|
756
|
+
}
|
|
757
|
+
#paintjobs(s, areaRow) {
|
|
758
|
+
const price = this.#scale(s, s.o.paintjobPrice ?? 500);
|
|
759
|
+
const count = PAINTJOBS[s.vehicle.model] ?? 0;
|
|
760
|
+
const rows = () => Array.from({ length: count }, (_, i) => [gxt("PJOB", { numbers: [i + 1] }), this.#owned(s).paintjob === i ? "~g~Fitted" : `$${price}`]);
|
|
761
|
+
const preview = (r) => {
|
|
762
|
+
s.vehicle.paintjob = r;
|
|
763
|
+
};
|
|
764
|
+
s.menu = s.player.menu({
|
|
765
|
+
title: gxt(`CARM${PAINT}`),
|
|
766
|
+
image: this.#logo(s),
|
|
767
|
+
hints: HINTS_PARTS,
|
|
768
|
+
rows: rows(),
|
|
769
|
+
onChoose: preview,
|
|
770
|
+
onSelect: (r, menu) => {
|
|
771
|
+
if (this.#owned(s).paintjob === r)
|
|
772
|
+
return this.#already(s);
|
|
773
|
+
if (!this.#pay(s, price, SOUND_PAINT))
|
|
774
|
+
return;
|
|
775
|
+
this.#owned(s).paintjob = r;
|
|
776
|
+
this.#restore(s);
|
|
777
|
+
s.vehicle.modded = true;
|
|
778
|
+
s.vehicle.repair();
|
|
779
|
+
s.vehicle.dirt = 0;
|
|
780
|
+
this.#events.paintjob(s.player, s.vehicle, r, price);
|
|
781
|
+
menu.update({ rows: rows() });
|
|
782
|
+
},
|
|
783
|
+
onBack: () => {
|
|
784
|
+
this.#restore(s);
|
|
785
|
+
this.#areas(s, areaRow);
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
preview(0);
|
|
789
|
+
}
|
|
790
|
+
#owned(s) {
|
|
791
|
+
return s.owned;
|
|
792
|
+
}
|
|
793
|
+
// Paid for (the game's sound for it: a part's, or paint's), or not
|
|
794
|
+
// enough money (the game's notice and sound).
|
|
795
|
+
#pay(s, price, sound) {
|
|
796
|
+
if (s.player.money < price) {
|
|
797
|
+
s.player.hud.notice(gxt("SHOPNO"), 3000);
|
|
798
|
+
this.#sound(s, SOUND_NO);
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
s.player.giveMoney(-price);
|
|
802
|
+
this.#sound(s, sound);
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
// Already on the car: the game's notice.
|
|
806
|
+
#already(s, color = false) {
|
|
807
|
+
s.player.hud.notice(gxt(color ? "COLORNO" : "CLTHNO2"), 3000);
|
|
808
|
+
this.#sound(s, SOUND_NO);
|
|
809
|
+
}
|
|
810
|
+
#sound(s, sound) {
|
|
811
|
+
const { x, y, z } = MOD_SHOP_SITES[s.shop].floor;
|
|
812
|
+
this.#server.audio.playGame(sound, { at: { x, y, z }, to: [s.player] });
|
|
813
|
+
}
|
|
814
|
+
// The car as bought: previews taken off. A slot about to be previewed in
|
|
815
|
+
// (`keep`) isn't emptied first: the new part replaces it, with no frame of
|
|
816
|
+
// the car without it.
|
|
817
|
+
#restore(s, keep) {
|
|
818
|
+
const v = s.vehicle;
|
|
819
|
+
if (!v.exists)
|
|
820
|
+
return;
|
|
821
|
+
const o = s.owned;
|
|
822
|
+
for (const p of v.components)
|
|
823
|
+
if (!o.parts.includes(p) && componentSlot(p) !== keep)
|
|
824
|
+
v.removeComponent(p);
|
|
825
|
+
for (const p of o.parts)
|
|
826
|
+
if (!v.components.includes(p))
|
|
827
|
+
v.addComponent(p);
|
|
828
|
+
const [a, b] = v.colors;
|
|
829
|
+
if (a !== o.colors[0] || b !== o.colors[1])
|
|
830
|
+
v.colors = o.colors;
|
|
831
|
+
if (v.paintjob !== o.paintjob)
|
|
832
|
+
v.paintjob = o.paintjob;
|
|
833
|
+
if (JSON.stringify(v.paintFinish) !== JSON.stringify(o.finish))
|
|
834
|
+
v.paintFinish = o.finish;
|
|
835
|
+
if (JSON.stringify(v.livery) !== JSON.stringify(s.livery))
|
|
836
|
+
v.livery = s.livery;
|
|
837
|
+
}
|
|
838
|
+
async #leave(s, fade) {
|
|
839
|
+
if (s.leaving)
|
|
840
|
+
return;
|
|
841
|
+
s.leaving = true;
|
|
842
|
+
if (s.watch)
|
|
843
|
+
clearInterval(s.watch);
|
|
844
|
+
s.menu?.close();
|
|
845
|
+
const { player, vehicle } = s;
|
|
846
|
+
this.#busy.delete(s.site);
|
|
847
|
+
this.#restore(s);
|
|
848
|
+
if (fade && player.connected)
|
|
849
|
+
await player.fadeOut(FADE_MS);
|
|
850
|
+
// Back where they started it, facing in, as the game's (they back out).
|
|
851
|
+
const { x, y, z } = s.entry;
|
|
852
|
+
const out = { position: { x, y, z }, heading: s.exitHeading };
|
|
853
|
+
if (vehicle.exists) {
|
|
854
|
+
const inside = this.#occupants(vehicle);
|
|
855
|
+
vehicle.world = s.world;
|
|
856
|
+
vehicle.interior = s.interior;
|
|
857
|
+
for (const p of inside)
|
|
858
|
+
p.interior = s.interior;
|
|
859
|
+
vehicle.setPosition(out.position, out.heading);
|
|
860
|
+
}
|
|
861
|
+
if (player.connected) {
|
|
862
|
+
if (player.vehicle !== vehicle) {
|
|
863
|
+
// Not in it (it's gone, or they got out): them alone, at the spot.
|
|
864
|
+
player.world = s.world;
|
|
865
|
+
player.interior = s.interior;
|
|
866
|
+
player.setPosition(out.position, out.heading);
|
|
867
|
+
}
|
|
868
|
+
player.frozen = false;
|
|
869
|
+
player.hud.set(s.hud);
|
|
870
|
+
this.#untilTheyLeave.add(`${player.id}:${s.site.id}`);
|
|
871
|
+
}
|
|
872
|
+
this.#sessions.delete(player);
|
|
873
|
+
if (vehicle.exists)
|
|
874
|
+
this.#events.shop(player, vehicle, s.site.shop, false, s.site.id);
|
|
875
|
+
// Behind them once their game has the car there, turned (before, the
|
|
876
|
+
// camera goes behind it as it was on the shop floor: into the walls).
|
|
877
|
+
// The server has it there at once; their game a moment later.
|
|
878
|
+
await wait(800);
|
|
879
|
+
if (player.connected)
|
|
880
|
+
player.camera.reset();
|
|
881
|
+
if (fade && player.connected) {
|
|
882
|
+
await wait(300);
|
|
883
|
+
await player.fadeIn(FADE_MS);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
_a = ModShops;
|
|
888
|
+
// Options over options: later ones win; areas and parts merged per key.
|
|
889
|
+
function merge(...all) {
|
|
890
|
+
const out = {};
|
|
891
|
+
for (const o of all) {
|
|
892
|
+
const { areas, parts, ...rest } = o;
|
|
893
|
+
Object.assign(out, rest);
|
|
894
|
+
if (areas)
|
|
895
|
+
out.areas = { ...out.areas, ...areas };
|
|
896
|
+
if (parts)
|
|
897
|
+
out.parts = { ...out.parts, ...parts };
|
|
898
|
+
}
|
|
899
|
+
return out;
|
|
900
|
+
}
|
|
901
|
+
function wait(ms) {
|
|
902
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
903
|
+
}
|
|
904
|
+
//# sourceMappingURL=modshop.js.map
|