@adatechnology/game-weapons-three 0.7.1 → 0.8.0

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/dist/index.js CHANGED
@@ -219,7 +219,40 @@ var ACCESSORY_TEXT = {
219
219
  metrics: { mass: "Weapon mass", aim: "Time to the sights", fitted: "Accessories on the weapon", suppressed: "Suppressed" },
220
220
  yes: "yes",
221
221
  no: "no",
222
- attachments: {},
222
+ attachments: {
223
+ suppressor: "Suppressor",
224
+ oil_filter_suppressor: "Improvised oil filter suppressor",
225
+ integral_suppressor: "Integral suppressor",
226
+ muzzle_brake: "Muzzle brake",
227
+ muzzle_brake_heavy: "Heavy muzzle brake",
228
+ compensator: "Compensator",
229
+ red_dot: "Red dot sight",
230
+ holographic_sight: "Holographic sight",
231
+ acog_4x: "ACOG 4x",
232
+ scope_3_9x: "Rifle scope 3-9x",
233
+ scope_6_24x: "Rifle scope 6-24x",
234
+ night_vision_scope: "Night vision scope",
235
+ thermal_scope: "Thermal scope",
236
+ integral_optic_1_5x: "Integral 1.5x optic of the AUG",
237
+ integral_optic_3x: "Integral 3x optic of the G36",
238
+ pso_1: "PSO-1 scope",
239
+ fixed_scope_10x: "Fixed 10x scope of the M24",
240
+ vertical_grip: "Vertical foregrip",
241
+ bipod: "Bipod",
242
+ m203: "M203 grenade launcher",
243
+ gp_25: "GP-25 grenade launcher",
244
+ ag36: "AG36 grenade launcher",
245
+ weapon_light: "Weapon light",
246
+ laser_sight: "Laser sight",
247
+ extended_magazine: "Extended magazine",
248
+ drum_magazine: "Drum magazine",
249
+ dual_coupler: "Dual magazine coupler",
250
+ sawn_off_barrel: "Sawn-off barrel",
251
+ long_barrel: "Long barrel",
252
+ sling: "Sling",
253
+ ammo_backpack: "Ammunition backpack",
254
+ tripod: "Tripod mount"
255
+ },
223
256
  fitRefused: "Could not change {mount} of {weapon}: {reason}",
224
257
  fitting: "Fitting {attachment} on {weapon} ({seconds} s)",
225
258
  removing: "Taking {attachment} off {weapon} ({seconds} s)",
@@ -598,6 +631,731 @@ function weaponRecipes(curve) {
598
631
  ];
599
632
  }
600
633
 
634
+ // src/data/reload-sounds.json
635
+ var reload_sounds_default = {
636
+ source: "Authored in the kit. The art scene delivered sfx for the magazine, the bolt and the shell (cena L1339-L1343, used here for mag_seat, charging_handle_release and shell_insert); the gallery draws no sound, so the other cues combine the same primitives (metal click, thud, filtered noise, tones) of game-audio. Each cue is played at the second the reload clip says, so it follows the animation and scales with the weapon's reload time.",
637
+ profiles: {
638
+ weight: {
639
+ light: {
640
+ pitch: 1.3,
641
+ gain: 0.85
642
+ },
643
+ medium: {
644
+ pitch: 1,
645
+ gain: 1
646
+ },
647
+ heavy: {
648
+ pitch: 0.78,
649
+ gain: 1.2
650
+ }
651
+ },
652
+ material: {
653
+ steel: {
654
+ pitch: 1,
655
+ gain: 1
656
+ },
657
+ polymer: {
658
+ pitch: 0.82,
659
+ gain: 0.8
660
+ }
661
+ }
662
+ },
663
+ comment: "Light: pistols and revolvers. Medium: rifles, SMGs, bolt guns. Heavy: shotguns, belt-fed guns, launchers. Steel is the default; the polymer frames are listed in `polymer`.",
664
+ weights: {
665
+ pistol: "light",
666
+ revolver: "light",
667
+ rifle: "medium",
668
+ semiint: "medium",
669
+ semi12: "heavy",
670
+ p90: "medium",
671
+ belt: "heavy",
672
+ pump: "heavy",
673
+ lever: "medium",
674
+ bolt: "medium",
675
+ boltmag: "medium",
676
+ break: "heavy",
677
+ rocket: "heavy",
678
+ flame: "heavy"
679
+ },
680
+ polymer: [
681
+ "glock_17",
682
+ "glock_18",
683
+ "glock_19",
684
+ "taurus_g2c",
685
+ "taurus_ts9",
686
+ "fn_five_seven",
687
+ "pistol_9mm",
688
+ "fn_p90",
689
+ "hk_ump_45"
690
+ ],
691
+ cues: {
692
+ mag_release: [
693
+ {
694
+ layer: "scene_tone",
695
+ at: 0,
696
+ wave: "square",
697
+ f: 2400,
698
+ f2: 1680,
699
+ d: 0.02,
700
+ vol: 0.3
701
+ },
702
+ {
703
+ layer: "scene_noise",
704
+ at: 0,
705
+ type: "highpass",
706
+ f: 3e3,
707
+ q: 0.7,
708
+ d: 0.03,
709
+ vol: 0.24
710
+ },
711
+ {
712
+ layer: "thud",
713
+ at: 0.02,
714
+ gain: 0.12,
715
+ frequency: 700
716
+ }
717
+ ],
718
+ mag_out: [
719
+ {
720
+ layer: "scene_noise",
721
+ at: 0,
722
+ type: "bandpass",
723
+ f: 900,
724
+ q: 2,
725
+ d: 0.1,
726
+ vol: 0.3,
727
+ f2: 600
728
+ },
729
+ {
730
+ layer: "metal",
731
+ at: 0.08,
732
+ frequency: 1200,
733
+ gain: 0.2
734
+ }
735
+ ],
736
+ mag_insert: [
737
+ {
738
+ layer: "scene_noise",
739
+ at: 0,
740
+ type: "bandpass",
741
+ f: 700,
742
+ q: 2,
743
+ d: 0.12,
744
+ vol: 0.3,
745
+ f2: 1100
746
+ }
747
+ ],
748
+ mag_seat: [
749
+ {
750
+ layer: "scene_tone",
751
+ at: 0,
752
+ wave: "square",
753
+ f: 1100,
754
+ f2: 770,
755
+ d: 0.02,
756
+ vol: 0.5
757
+ },
758
+ {
759
+ layer: "scene_noise",
760
+ at: 0,
761
+ type: "highpass",
762
+ f: 3e3,
763
+ q: 0.7,
764
+ d: 0.03,
765
+ vol: 0.4
766
+ },
767
+ {
768
+ layer: "scene_tone",
769
+ at: 0,
770
+ wave: "sine",
771
+ f: 320,
772
+ f2: 200,
773
+ d: 0.05,
774
+ vol: 0.35
775
+ },
776
+ {
777
+ layer: "thud",
778
+ at: 0.01,
779
+ gain: 0.18,
780
+ frequency: 600
781
+ }
782
+ ],
783
+ slide_release: [
784
+ {
785
+ layer: "metal",
786
+ at: 0,
787
+ frequency: 900,
788
+ gain: 0.35
789
+ },
790
+ {
791
+ layer: "scene_tone",
792
+ at: 0.02,
793
+ wave: "square",
794
+ f: 2e3,
795
+ f2: 1400,
796
+ d: 0.02,
797
+ vol: 0.4
798
+ },
799
+ {
800
+ layer: "scene_noise",
801
+ at: 0.02,
802
+ type: "highpass",
803
+ f: 3e3,
804
+ q: 0.7,
805
+ d: 0.03,
806
+ vol: 0.32
807
+ },
808
+ {
809
+ layer: "thud",
810
+ at: 0.03,
811
+ gain: 0.25,
812
+ frequency: 500
813
+ }
814
+ ],
815
+ charging_handle_pull: [
816
+ {
817
+ layer: "scene_noise",
818
+ at: 0,
819
+ type: "bandpass",
820
+ f: 1500,
821
+ q: 3,
822
+ d: 0.09,
823
+ vol: 0.3,
824
+ f2: 900
825
+ },
826
+ {
827
+ layer: "metal",
828
+ at: 0.06,
829
+ frequency: 1300,
830
+ gain: 0.25
831
+ }
832
+ ],
833
+ charging_handle_release: [
834
+ {
835
+ layer: "scene_tone",
836
+ at: 0,
837
+ wave: "square",
838
+ f: 2200,
839
+ f2: 1540,
840
+ d: 0.02,
841
+ vol: 0.4
842
+ },
843
+ {
844
+ layer: "scene_noise",
845
+ at: 0,
846
+ type: "highpass",
847
+ f: 3e3,
848
+ q: 0.7,
849
+ d: 0.03,
850
+ vol: 0.32
851
+ },
852
+ {
853
+ layer: "scene_tone",
854
+ at: 0.02,
855
+ wave: "square",
856
+ f: 2700,
857
+ f2: 1890,
858
+ d: 0.02,
859
+ vol: 0.4
860
+ },
861
+ {
862
+ layer: "scene_noise",
863
+ at: 0.02,
864
+ type: "highpass",
865
+ f: 3e3,
866
+ q: 0.7,
867
+ d: 0.03,
868
+ vol: 0.32
869
+ },
870
+ {
871
+ layer: "thud",
872
+ at: 0.03,
873
+ gain: 0.2,
874
+ frequency: 600
875
+ }
876
+ ],
877
+ bolt_lift: [
878
+ {
879
+ layer: "metal",
880
+ at: 0,
881
+ frequency: 1500,
882
+ gain: 0.3
883
+ },
884
+ {
885
+ layer: "scene_noise",
886
+ at: 0,
887
+ type: "bandpass",
888
+ f: 2e3,
889
+ q: 3,
890
+ d: 0.04,
891
+ vol: 0.15,
892
+ f2: 1500
893
+ }
894
+ ],
895
+ bolt_pull: [
896
+ {
897
+ layer: "scene_noise",
898
+ at: 0,
899
+ type: "bandpass",
900
+ f: 1400,
901
+ q: 3,
902
+ d: 0.12,
903
+ vol: 0.3,
904
+ f2: 800
905
+ },
906
+ {
907
+ layer: "metal",
908
+ at: 0.1,
909
+ frequency: 900,
910
+ gain: 0.3
911
+ }
912
+ ],
913
+ bolt_push: [
914
+ {
915
+ layer: "scene_noise",
916
+ at: 0,
917
+ type: "bandpass",
918
+ f: 800,
919
+ q: 3,
920
+ d: 0.1,
921
+ vol: 0.3,
922
+ f2: 1400
923
+ },
924
+ {
925
+ layer: "metal",
926
+ at: 0.08,
927
+ frequency: 1100,
928
+ gain: 0.3
929
+ }
930
+ ],
931
+ bolt_lock: [
932
+ {
933
+ layer: "metal",
934
+ at: 0,
935
+ frequency: 1800,
936
+ gain: 0.4
937
+ },
938
+ {
939
+ layer: "thud",
940
+ at: 0.02,
941
+ gain: 0.2,
942
+ frequency: 700
943
+ }
944
+ ],
945
+ shell_insert: [
946
+ {
947
+ layer: "scene_tone",
948
+ at: 0,
949
+ wave: "square",
950
+ f: 1400,
951
+ f2: 980,
952
+ d: 0.02,
953
+ vol: 0.35
954
+ },
955
+ {
956
+ layer: "scene_noise",
957
+ at: 0,
958
+ type: "highpass",
959
+ f: 3e3,
960
+ q: 0.7,
961
+ d: 0.03,
962
+ vol: 0.28
963
+ },
964
+ {
965
+ layer: "scene_noise",
966
+ at: 0.02,
967
+ type: "bandpass",
968
+ f: 1200,
969
+ q: 3,
970
+ d: 0.05,
971
+ vol: 0.25
972
+ }
973
+ ],
974
+ pump_back: [
975
+ {
976
+ layer: "scene_noise",
977
+ at: 0,
978
+ type: "bandpass",
979
+ f: 1e3,
980
+ q: 2,
981
+ d: 0.12,
982
+ vol: 0.3,
983
+ f2: 700
984
+ },
985
+ {
986
+ layer: "metal",
987
+ at: 0.1,
988
+ frequency: 900,
989
+ gain: 0.35
990
+ }
991
+ ],
992
+ pump_forward: [
993
+ {
994
+ layer: "scene_noise",
995
+ at: 0,
996
+ type: "bandpass",
997
+ f: 700,
998
+ q: 2,
999
+ d: 0.1,
1000
+ vol: 0.3,
1001
+ f2: 1e3
1002
+ },
1003
+ {
1004
+ layer: "metal",
1005
+ at: 0.08,
1006
+ frequency: 1300,
1007
+ gain: 0.4
1008
+ },
1009
+ {
1010
+ layer: "thud",
1011
+ at: 0.1,
1012
+ gain: 0.25,
1013
+ frequency: 500
1014
+ }
1015
+ ],
1016
+ lever_open: [
1017
+ {
1018
+ layer: "metal",
1019
+ at: 0,
1020
+ frequency: 1e3,
1021
+ gain: 0.35
1022
+ },
1023
+ {
1024
+ layer: "scene_noise",
1025
+ at: 0,
1026
+ type: "bandpass",
1027
+ f: 1200,
1028
+ q: 3,
1029
+ d: 0.08,
1030
+ vol: 0.2,
1031
+ f2: 800
1032
+ }
1033
+ ],
1034
+ lever_close: [
1035
+ {
1036
+ layer: "metal",
1037
+ at: 0,
1038
+ frequency: 1500,
1039
+ gain: 0.4
1040
+ },
1041
+ {
1042
+ layer: "thud",
1043
+ at: 0.02,
1044
+ gain: 0.2,
1045
+ frequency: 550
1046
+ }
1047
+ ],
1048
+ cylinder_open: [
1049
+ {
1050
+ layer: "metal",
1051
+ at: 0,
1052
+ frequency: 1500,
1053
+ gain: 0.3
1054
+ },
1055
+ {
1056
+ layer: "scene_noise",
1057
+ at: 0.02,
1058
+ type: "bandpass",
1059
+ f: 1800,
1060
+ q: 3,
1061
+ d: 0.08,
1062
+ vol: 0.2,
1063
+ f2: 1200
1064
+ }
1065
+ ],
1066
+ cylinder_eject: [
1067
+ {
1068
+ layer: "metal",
1069
+ at: 0,
1070
+ frequency: 2400,
1071
+ gain: 0.25
1072
+ },
1073
+ {
1074
+ layer: "metal",
1075
+ at: 0.05,
1076
+ frequency: 2800,
1077
+ gain: 0.2
1078
+ },
1079
+ {
1080
+ layer: "metal",
1081
+ at: 0.11,
1082
+ frequency: 3200,
1083
+ gain: 0.15
1084
+ }
1085
+ ],
1086
+ speedloader: [
1087
+ {
1088
+ layer: "scene_noise",
1089
+ at: 0,
1090
+ type: "bandpass",
1091
+ f: 1500,
1092
+ q: 3,
1093
+ d: 0.08,
1094
+ vol: 0.25,
1095
+ f2: 2e3
1096
+ },
1097
+ {
1098
+ layer: "metal",
1099
+ at: 0,
1100
+ frequency: 2e3,
1101
+ gain: 0.25
1102
+ },
1103
+ {
1104
+ layer: "metal",
1105
+ at: 0.05,
1106
+ frequency: 2400,
1107
+ gain: 0.2
1108
+ },
1109
+ {
1110
+ layer: "thud",
1111
+ at: 0.07,
1112
+ gain: 0.15,
1113
+ frequency: 800
1114
+ }
1115
+ ],
1116
+ cylinder_close: [
1117
+ {
1118
+ layer: "metal",
1119
+ at: 0,
1120
+ frequency: 1100,
1121
+ gain: 0.45
1122
+ },
1123
+ {
1124
+ layer: "thud",
1125
+ at: 0.01,
1126
+ gain: 0.2,
1127
+ frequency: 500
1128
+ }
1129
+ ],
1130
+ barrels_open: [
1131
+ {
1132
+ layer: "metal",
1133
+ at: 0,
1134
+ frequency: 1e3,
1135
+ gain: 0.4
1136
+ },
1137
+ {
1138
+ layer: "thud",
1139
+ at: 0.03,
1140
+ gain: 0.2,
1141
+ frequency: 450
1142
+ }
1143
+ ],
1144
+ casings_eject: [
1145
+ {
1146
+ layer: "metal",
1147
+ at: 0,
1148
+ frequency: 2600,
1149
+ gain: 0.25
1150
+ },
1151
+ {
1152
+ layer: "metal",
1153
+ at: 0.08,
1154
+ frequency: 3e3,
1155
+ gain: 0.2
1156
+ }
1157
+ ],
1158
+ barrels_close: [
1159
+ {
1160
+ layer: "metal",
1161
+ at: 0,
1162
+ frequency: 1100,
1163
+ gain: 0.5
1164
+ },
1165
+ {
1166
+ layer: "thud",
1167
+ at: 0.02,
1168
+ gain: 0.3,
1169
+ frequency: 450
1170
+ }
1171
+ ],
1172
+ belt_cover_open: [
1173
+ {
1174
+ layer: "metal",
1175
+ at: 0,
1176
+ frequency: 800,
1177
+ gain: 0.4
1178
+ },
1179
+ {
1180
+ layer: "thud",
1181
+ at: 0.03,
1182
+ gain: 0.2,
1183
+ frequency: 500
1184
+ }
1185
+ ],
1186
+ belt_insert: [
1187
+ {
1188
+ layer: "metal",
1189
+ at: 0,
1190
+ frequency: 1400,
1191
+ gain: 0.3
1192
+ },
1193
+ {
1194
+ layer: "metal",
1195
+ at: 0.08,
1196
+ frequency: 1800,
1197
+ gain: 0.25
1198
+ },
1199
+ {
1200
+ layer: "thud",
1201
+ at: 0.1,
1202
+ gain: 0.25,
1203
+ frequency: 450
1204
+ }
1205
+ ],
1206
+ belt_cover_close: [
1207
+ {
1208
+ layer: "metal",
1209
+ at: 0,
1210
+ frequency: 1e3,
1211
+ gain: 0.45
1212
+ },
1213
+ {
1214
+ layer: "thud",
1215
+ at: 0.02,
1216
+ gain: 0.3,
1217
+ frequency: 400
1218
+ }
1219
+ ],
1220
+ rocket_load: [
1221
+ {
1222
+ layer: "scene_noise",
1223
+ at: 0,
1224
+ type: "bandpass",
1225
+ f: 600,
1226
+ q: 1.5,
1227
+ d: 0.25,
1228
+ vol: 0.3,
1229
+ f2: 400
1230
+ },
1231
+ {
1232
+ layer: "thud",
1233
+ at: 0.2,
1234
+ gain: 0.2,
1235
+ frequency: 450
1236
+ }
1237
+ ],
1238
+ rocket_seat: [
1239
+ {
1240
+ layer: "metal",
1241
+ at: 0,
1242
+ frequency: 900,
1243
+ gain: 0.45
1244
+ },
1245
+ {
1246
+ layer: "thud",
1247
+ at: 0.01,
1248
+ gain: 0.35,
1249
+ frequency: 350
1250
+ }
1251
+ ],
1252
+ grenade_load: [
1253
+ {
1254
+ layer: "metal",
1255
+ at: 0,
1256
+ frequency: 1100,
1257
+ gain: 0.35
1258
+ },
1259
+ {
1260
+ layer: "scene_noise",
1261
+ at: 0,
1262
+ type: "bandpass",
1263
+ f: 1e3,
1264
+ q: 2,
1265
+ d: 0.1,
1266
+ vol: 0.2,
1267
+ f2: 700
1268
+ }
1269
+ ],
1270
+ breech_open: [
1271
+ {
1272
+ layer: "metal",
1273
+ at: 0,
1274
+ frequency: 1e3,
1275
+ gain: 0.35
1276
+ },
1277
+ {
1278
+ layer: "thud",
1279
+ at: 0.03,
1280
+ gain: 0.15,
1281
+ frequency: 500
1282
+ }
1283
+ ],
1284
+ breech_close: [
1285
+ {
1286
+ layer: "metal",
1287
+ at: 0,
1288
+ frequency: 1100,
1289
+ gain: 0.45
1290
+ },
1291
+ {
1292
+ layer: "thud",
1293
+ at: 0.02,
1294
+ gain: 0.25,
1295
+ frequency: 450
1296
+ }
1297
+ ],
1298
+ tube_drop: [
1299
+ {
1300
+ layer: "thud",
1301
+ at: 0,
1302
+ gain: 0.35,
1303
+ frequency: 380
1304
+ },
1305
+ {
1306
+ layer: "metal",
1307
+ at: 0.05,
1308
+ frequency: 700,
1309
+ gain: 0.2
1310
+ }
1311
+ ],
1312
+ tube_seat: [
1313
+ {
1314
+ layer: "metal",
1315
+ at: 0,
1316
+ frequency: 900,
1317
+ gain: 0.45
1318
+ },
1319
+ {
1320
+ layer: "thud",
1321
+ at: 0.02,
1322
+ gain: 0.3,
1323
+ frequency: 380
1324
+ }
1325
+ ]
1326
+ }
1327
+ };
1328
+
1329
+ // src/audio/reload-sounds.ts
1330
+ var RELOAD_CUE_IDS = Object.keys(reload_sounds_default.cues);
1331
+ var DEFAULT_SOUND_PROFILE = { weight: "medium", material: "steel" };
1332
+ var RELOAD_CUES = reload_sounds_default.cues;
1333
+ var polymer = new Set(reload_sounds_default.polymer);
1334
+ var weights = reload_sounds_default.weights;
1335
+ var soundProfileOf = (weaponId, modelFamily) => ({ weight: weights[modelFamily] ?? "medium", material: polymer.has(weaponId) ? "polymer" : "steel" });
1336
+ var reloadCueSteps = (id, profile = DEFAULT_SOUND_PROFILE, table = RELOAD_CUES) => {
1337
+ const steps = table[id];
1338
+ if (!steps) return [];
1339
+ const weight = reload_sounds_default.profiles.weight[profile.weight];
1340
+ const material = reload_sounds_default.profiles.material[profile.material];
1341
+ const pitch = weight.pitch * material.pitch;
1342
+ const gain = weight.gain * material.gain;
1343
+ return steps.map((entry) => {
1344
+ switch (entry.layer) {
1345
+ case "metal":
1346
+ return { ...entry, frequency: Math.round(entry.frequency * pitch), gain: entry.gain * gain };
1347
+ case "thud":
1348
+ return { ...entry, frequency: Math.round(entry.frequency * pitch), gain: entry.gain * gain };
1349
+ case "scene_tone":
1350
+ return { ...entry, f: Math.round(entry.f * pitch), ...entry.f2 === void 0 ? {} : { f2: Math.round(entry.f2 * pitch) }, vol: entry.vol * gain };
1351
+ case "scene_noise":
1352
+ return { ...entry, f: Math.round(entry.f * pitch), ...entry.f2 === void 0 ? {} : { f2: Math.round(entry.f2 * pitch) }, vol: entry.vol * gain };
1353
+ default:
1354
+ return entry;
1355
+ }
1356
+ });
1357
+ };
1358
+
601
1359
  // src/audio.ts
602
1360
  var NOISE_SECONDS = 1.5;
603
1361
  var DEFAULT_SEED = 1;
@@ -606,6 +1364,7 @@ function createWeaponAudio(volume = 0.6, options = {}) {
606
1364
  const classes = { ...GAME_SOUND_CLASSES, ...options.classes };
607
1365
  const distanceCurve = { ...GAME_DISTANCE_CURVE, ...options.distance };
608
1366
  const mechanicalTable = { ...MECHANICAL_SOUNDS, ...options.mechanical };
1367
+ const cueTable = { ...RELOAD_CUES, ...options.reloadCues };
609
1368
  const shared = options.engine;
610
1369
  const engine = shared ?? createAudioEngine({
611
1370
  seed,
@@ -658,17 +1417,20 @@ function createWeaponAudio(volume = 0.6, options = {}) {
658
1417
  const shotSound = (input) => computeShotSound(input, options);
659
1418
  return {
660
1419
  shot(energyJ, category, extras = {}) {
661
- const sound2 = shotSound({ ...extras, energyJ, category });
662
- if (!sound2.audible) return;
663
- classBurstAt(0, sound2.cutoff, sound2.gain, sound2.duration);
664
- if (sound2.crack.gain > 0) burst({ seconds: sound2.crack.duration, gain: sound2.crack.gain, filter: "highpass", frequency: sound2.crack.cutoff });
665
- tone({ seconds: sound2.body.duration, gain: sound2.body.gain, frequency: sound2.body.frequency, to: sound2.body.to });
1420
+ const sound3 = shotSound({ ...extras, energyJ, category });
1421
+ if (!sound3.audible) return;
1422
+ classBurstAt(0, sound3.cutoff, sound3.gain, sound3.duration);
1423
+ if (sound3.crack.gain > 0) burst({ seconds: sound3.crack.duration, gain: sound3.crack.gain, filter: "highpass", frequency: sound3.crack.cutoff });
1424
+ tone({ seconds: sound3.body.duration, gain: sound3.body.gain, frequency: sound3.body.frequency, to: sound3.body.to });
666
1425
  },
667
1426
  shotSound,
668
1427
  click() {
669
1428
  mechanical("dry_fire");
670
1429
  },
671
1430
  mechanical,
1431
+ cue(id, profile) {
1432
+ for (const entry of reloadCueSteps(id, profile, cueTable)) step(entry);
1433
+ },
672
1434
  event(event) {
673
1435
  const id = mechanicalSoundOf(event);
674
1436
  if (id) mechanical(id);
@@ -717,7 +1479,7 @@ var VIEWMODEL_NODE_NAME = "weapon-viewmodel";
717
1479
  var IN_HAND_NODE_NAME = "weapon-in-hand";
718
1480
  var LASER_RAYCAST_HZ = 30;
719
1481
  var isHeld = (object) => {
720
- for (let node = object; node; node = node.parent) if (node.name === VIEWMODEL_NODE_NAME || node.name === IN_HAND_NODE_NAME) return true;
1482
+ for (let node = object; node; node = node.parent) if (node.name === VIEWMODEL_NODE_NAME || node.name === IN_HAND_NODE_NAME || node.userData?.viewmodel === true) return true;
721
1483
  return false;
722
1484
  };
723
1485
  var isVisibleInScene = (object) => {
@@ -747,8 +1509,144 @@ var createLaserRaycast = (THREE, scene, camera, options = {}) => {
747
1509
  };
748
1510
  };
749
1511
 
1512
+ // src/fx/hand-hold.ts
1513
+ var HAND_HOLD = {
1514
+ /** Half the width of a pistol grip, the palm sits this far (plus the palm) off its middle. */
1515
+ gripHalfWidth: 0.0145,
1516
+ /** Half the thickness of a palm. */
1517
+ palm: 0.011,
1518
+ /** How far up the grip the web of the hand sits from its middle. */
1519
+ gripUp: 6e-3,
1520
+ /** How far down the grip the supporting hand of a pistol sits from the middle. */
1521
+ supportDown: 0.036,
1522
+ /** Where along the handguard (0 the receiver, 1 the end of the forend) the supporting hand closes. */
1523
+ forendAt: 0.6,
1524
+ /** How far under the handguard the palm sits (half a handguard plus the palm). */
1525
+ forendUnder: 0.034,
1526
+ /** The grip of a long gun that has no drawn pistol grip: how far under the receiver the hand sits. */
1527
+ strapUnder: 0.04,
1528
+ /** How far lower than touching the palm hangs under a handguard, so the fingers close under it and not over it. */
1529
+ forendDrop: 0.03,
1530
+ /** How far (radians) the fingers of a support hand under a handguard rise from level. */
1531
+ forendTilt: 0.5,
1532
+ /** How far a handgun's support hand dips for the new magazine, along the grip. */
1533
+ magDip: 0.045,
1534
+ /** Closure of the fingers on a grip and on a forend (0 open, 1 closed). */
1535
+ closed: 0.9,
1536
+ forend: 0.6,
1537
+ /** How fast the supporting hand goes to the magazine and back, per second. */
1538
+ magRate: 7,
1539
+ /** The hands shrink with the tool: a tool drawn at scale `s` takes hands at `s ** handScaleExponent`. */
1540
+ handScaleExponent: 0.55
1541
+ };
1542
+ var handFrame = (THREE, x, y) => {
1543
+ const ax = x.clone().normalize();
1544
+ const az = ax.clone().cross(y).normalize();
1545
+ const ay = az.clone().cross(ax);
1546
+ return new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(ax, ay, az));
1547
+ };
1548
+ var poseOf = (anchor, rootQuaternion) => {
1549
+ const position = anchor.part.localToWorld(anchor.offset.clone());
1550
+ const q = rootQuaternion.clone().multiply(anchor.quaternion);
1551
+ return { position: [position.x, position.y, position.z], quaternion: [q.x, q.y, q.z, q.w] };
1552
+ };
1553
+ var blendPose = (THREE, a, b, k) => {
1554
+ const q = new THREE.Quaternion(...a.quaternion).slerp(new THREE.Quaternion(...b.quaternion), k);
1555
+ return {
1556
+ position: [a.position[0] + (b.position[0] - a.position[0]) * k, a.position[1] + (b.position[1] - a.position[1]) * k, a.position[2] + (b.position[2] - a.position[2]) * k],
1557
+ quaternion: [q.x, q.y, q.z, q.w]
1558
+ };
1559
+ };
1560
+ var targetsOf = (THREE, hold, rootQuaternion, magazine, weights2) => {
1561
+ let right = hold.right ? poseOf(hold.right, rootQuaternion) : null;
1562
+ let left = null;
1563
+ if (hold.left) {
1564
+ left = poseOf(hold.left, rootQuaternion);
1565
+ if (hold.leftMagazine && magazine > 0) left = blendPose(THREE, left, poseOf(hold.leftMagazine, rootQuaternion), magazine);
1566
+ }
1567
+ hold.works?.forEach((work, index) => {
1568
+ const k = weights2?.[index] ?? 0;
1569
+ if (k <= 0) return;
1570
+ const pose = poseOf(work.anchor, rootQuaternion);
1571
+ if (work.hand === "R") right = right ? blendPose(THREE, right, pose, k) : pose;
1572
+ else left = left ? blendPose(THREE, left, pose, k) : pose;
1573
+ });
1574
+ return { R: right, L: left, grip: hold.grip, gripL: hold.gripLeft, scale: hold.scale };
1575
+ };
1576
+ var V = (THREE, x, y, z) => new THREE.Vector3(x, y, z);
1577
+ var gripFrame = (THREE, up, side) => {
1578
+ const forward = V(THREE, 1, 0, 0);
1579
+ const toSide = V(THREE, 0, 0, 1);
1580
+ const tilt = new THREE.Quaternion().setFromUnitVectors(V(THREE, 0, 1, 0), up);
1581
+ return handFrame(THREE, forward.clone().applyQuaternion(tilt), toSide.clone().applyQuaternion(tilt).multiplyScalar(side));
1582
+ };
1583
+ var forendFrame = (THREE, tilt = 0) => handFrame(THREE, V(THREE, 0, Math.sin(tilt), Math.cos(tilt)), V(THREE, 0, -Math.cos(tilt), Math.sin(tilt)));
1584
+ function holdOfModel(THREE, model, holderScale) {
1585
+ const { root, inner, parts } = model;
1586
+ const frames = parts.frames;
1587
+ const toRoot = (x, y, z) => V(THREE, inner.position.x + x * inner.scale.x, y, z);
1588
+ const handgun = parts.family === "pistol" || parts.family === "revolver";
1589
+ root.updateWorldMatrix(true, true);
1590
+ let gripAt;
1591
+ let up;
1592
+ if (parts.grip) {
1593
+ gripAt = toRoot(parts.grip.position.x, parts.grip.position.y, parts.grip.position.z);
1594
+ up = V(THREE, 0, 1, 0).applyQuaternion(parts.grip.quaternion);
1595
+ } else {
1596
+ const receiverHeight = frames?.receiverHeight ?? 0.06;
1597
+ gripAt = toRoot(frames?.gripX ?? 0.07, -receiverHeight / 2 - HAND_HOLD.strapUnder, 0);
1598
+ up = V(THREE, Math.sin(0.3), Math.cos(0.3), 0);
1599
+ }
1600
+ const side = HAND_HOLD.gripHalfWidth + HAND_HOLD.palm;
1601
+ const right = {
1602
+ part: root,
1603
+ offset: gripAt.clone().addScaledVector(up, HAND_HOLD.gripUp).add(V(THREE, 0, 0, side)),
1604
+ quaternion: gripFrame(THREE, up, 1)
1605
+ };
1606
+ let left = null;
1607
+ let leftMagazine = null;
1608
+ if (handgun) {
1609
+ left = { part: root, offset: gripAt.clone().addScaledVector(up, -HAND_HOLD.supportDown).add(V(THREE, 0, 0, -side)), quaternion: gripFrame(THREE, up, -1) };
1610
+ } else if (parts.pump) {
1611
+ const box2 = new THREE.Box3().setFromObject(parts.pump);
1612
+ const center = box2.getCenter(new THREE.Vector3());
1613
+ const under = parts.pump.worldToLocal(V(THREE, center.x, box2.min.y - HAND_HOLD.palm, center.z));
1614
+ left = { part: parts.pump, offset: under, quaternion: forendFrame(THREE) };
1615
+ } else if (frames) {
1616
+ const handguardHeight = frames.receiverHeight * 0.8;
1617
+ const x = frames.receiverEnd + (frames.handguardEnd - frames.receiverEnd) * HAND_HOLD.forendAt;
1618
+ left = { part: root, offset: toRoot(x, frames.barrelY - handguardHeight / 2 - HAND_HOLD.palm - HAND_HOLD.forendDrop, 0), quaternion: forendFrame(THREE, HAND_HOLD.forendTilt) };
1619
+ }
1620
+ if (left && parts.mag) {
1621
+ const box2 = new THREE.Box3().setFromObject(parts.mag);
1622
+ const center = box2.getCenter(new THREE.Vector3());
1623
+ const local = parts.mag.worldToLocal(V(THREE, center.x, box2.min.y - HAND_HOLD.palm, center.z));
1624
+ leftMagazine = handgun && left ? { part: root, offset: left.offset.clone().addScaledVector(up, -HAND_HOLD.magDip), quaternion: left.quaternion } : { part: parts.mag, offset: local, quaternion: forendFrame(THREE) };
1625
+ }
1626
+ return {
1627
+ right,
1628
+ left,
1629
+ leftMagazine,
1630
+ scale: holderScale ** HAND_HOLD.handScaleExponent,
1631
+ grip: HAND_HOLD.closed,
1632
+ gripLeft: HAND_HOLD.forend
1633
+ };
1634
+ }
1635
+ function holdOfProp(THREE, group, category, scale) {
1636
+ const handle = group.getObjectByName("handle") ?? group.getObjectByName("body") ?? group;
1637
+ const head = group.getObjectByName("head");
1638
+ const size = (object) => new THREE.Box3().setFromObject(object).getSize(new THREE.Vector3());
1639
+ const wrap = handFrame(THREE, V(THREE, 0, 1, 0), V(THREE, 0, 0, 1));
1640
+ const handleSize = size(handle);
1641
+ const right = { part: group, offset: handle.position.clone().add(V(THREE, 0, handleSize.y / 2 + 2e-3, 0)), quaternion: wrap };
1642
+ const long = head && ["smg", "carbine", "rifle", "sniper", "shotgun", "crossbow", "flame"].includes(category);
1643
+ const headSize = head ? size(head) : null;
1644
+ const left = long && head && headSize ? { part: group, offset: head.position.clone().add(V(THREE, -headSize.x * 0.15, -headSize.y / 2 - HAND_HOLD.palm, 0)), quaternion: forendFrame(THREE) } : null;
1645
+ return { right, left, leftMagazine: null, scale: scale ** HAND_HOLD.handScaleExponent, grip: HAND_HOLD.closed, gripLeft: HAND_HOLD.forend };
1646
+ }
1647
+
750
1648
  // src/props.ts
751
- import { mergeByMaterial } from "@adatechnology/game-props3d";
1649
+ import { mergeByMaterial, shapeGeometries as shapeGeometries2 } from "@adatechnology/game-shapes-three";
752
1650
 
753
1651
  // src/models/index.ts
754
1652
  import { WEAPONS } from "@adatechnology/game-weapons";
@@ -2306,6 +3204,9 @@ var models_default = {
2306
3204
  }
2307
3205
  };
2308
3206
 
3207
+ // src/models/kit.ts
3208
+ import { shapeGeometries } from "@adatechnology/game-shapes-three";
3209
+
2309
3210
  // src/models/rounded-box.ts
2310
3211
  var uvAlong = (THREE, faceDirection, normal, uvAxis, projectionAxis, radius, sideLength) => {
2311
3212
  const arcLength = 2 * Math.PI * radius / 4;
@@ -2332,11 +3233,11 @@ function createRoundedBoxGeometry(THREE, width, height, depth, segments, radius)
2332
3233
  const count = segments * 2 + 1;
2333
3234
  const rounding = Math.min(width / 2, height / 2, depth / 2, radius);
2334
3235
  const geometry = new THREE.BoxGeometry(1, 1, 1, count, count, count);
2335
- const flat = geometry.toNonIndexed();
3236
+ const flat2 = geometry.toNonIndexed();
2336
3237
  geometry.setIndex(null);
2337
- geometry.setAttribute("position", flat.attributes.position);
2338
- geometry.setAttribute("normal", flat.attributes.normal);
2339
- geometry.setAttribute("uv", flat.attributes.uv);
3238
+ geometry.setAttribute("position", flat2.attributes.position);
3239
+ geometry.setAttribute("normal", flat2.attributes.normal);
3240
+ geometry.setAttribute("uv", flat2.attributes.uv);
2340
3241
  const position = new THREE.Vector3();
2341
3242
  const normal = new THREE.Vector3();
2342
3243
  const box2 = new THREE.Vector3(width, height, depth).divideScalar(2).subScalar(rounding);
@@ -2417,9 +3318,10 @@ var createMaterials = (THREE) => {
2417
3318
  };
2418
3319
  };
2419
3320
  var kits = /* @__PURE__ */ new WeakMap();
2420
- function modelKit(THREE) {
2421
- const cached = kits.get(THREE);
3321
+ function modelKit(host) {
3322
+ const cached = kits.get(host);
2422
3323
  if (cached) return cached;
3324
+ const THREE = shapeGeometries(host);
2423
3325
  const geometries = /* @__PURE__ */ new Map();
2424
3326
  const shadowed = (mesh) => {
2425
3327
  mesh.castShadow = true;
@@ -2478,7 +3380,7 @@ function modelKit(THREE) {
2478
3380
  return group;
2479
3381
  }
2480
3382
  };
2481
- kits.set(THREE, kit);
3383
+ kits.set(host, kit);
2482
3384
  return kit;
2483
3385
  }
2484
3386
  function heatify(glow, mesh) {
@@ -2661,6 +3563,7 @@ function buildBullpup(kit, options) {
2661
3563
  const gx = bodyLength * 0.8;
2662
3564
  const grip = kit.pivot(group, gx, -0.085, 0);
2663
3565
  grip.rotation.z = -0.25;
3566
+ parts.grip = grip;
2664
3567
  box2(grip, 0.03, 0.1, 0.03, M.poly, 0, 0, 0);
2665
3568
  box2(group, 0.1, 6e-3, 0.03, body, gx + 0.01, -0.06, 0);
2666
3569
  parts.trigger = box2(group, 4e-3, 0.016, 4e-3, M.dark, gx + 0.02, -0.05, 0, 0.15);
@@ -2880,6 +3783,7 @@ function buildAt4(kit, group, parts) {
2880
3783
  box2(group, 0.012, 0.05, 0.02, M.dark, 0.71, 0.08, -0.035);
2881
3784
  const grip = kit.pivot(group, 0.68, -0.05, 0);
2882
3785
  grip.rotation.z = -0.15;
3786
+ parts.grip = grip;
2883
3787
  box2(grip, 0.035, 0.11, 0.032, M.dark, 0, -0.05, 0);
2884
3788
  box2(group, 0.14, 0.035, 0.06, M.rubber, 0.32, -0.06, 0);
2885
3789
  box2(group, 0.3, 0.012, 0.055, M.dark, 0.5, -0.05, 0);
@@ -3209,6 +4113,7 @@ function buildFrame(kit, group, parts, options, style, size, materials) {
3209
4113
  const gy = -h / 2 - gripLength / 2 + 6e-3;
3210
4114
  const grip = kit.pivot(group, gx, gy, 0);
3211
4115
  grip.rotation.z = style.ga;
4116
+ parts.grip = grip;
3212
4117
  const gripWidth = style.grip === "chunky" ? w * 1.18 : w * 1.06;
3213
4118
  const gripDepth = style.grip === "chunky" ? 0.036 : 0.032;
3214
4119
  kit.roundness = 0.3;
@@ -3332,6 +4237,7 @@ function buildRevolver(kit, options) {
3332
4237
  parts.hammer = hammer;
3333
4238
  const grip = kit.pivot(group, 0.024, -0.062, 0);
3334
4239
  grip.rotation.z = -0.38;
4240
+ parts.grip = grip;
3335
4241
  box2(grip, 0.034, 0.1, 0.031, options.wood ? M.wood : M.rubber, 0, 0, 0);
3336
4242
  if (!options.wood) for (let i = 0; i < 4; i++) box2(grip, 6e-3, 0.07, 0.033, M.dark, -0.012 + i * 8e-3, 0, 0);
3337
4243
  box2(group, 0.038, 4e-3, 8e-3, metal2, 0.062, -0.036, 0);
@@ -3520,6 +4426,7 @@ function buildGrip(context) {
3520
4426
  if (!context.isTraditional && o.grip !== "none") {
3521
4427
  const grip = kit.pivot(group, gx - 0.012, -hR / 2 - 0.046, 0);
3522
4428
  grip.rotation.z = -0.3;
4429
+ parts.grip = grip;
3523
4430
  box2(grip, 0.032, 0.105, 0.028, furn, 0, 0, 0);
3524
4431
  for (let i = 0; i < 3; i++) box2(grip, 6e-3, 0.012, 0.026, furn, 0.017, 0.022 - i * 0.02, 0);
3525
4432
  box2(grip, 0.034, 8e-3, 0.03, furn, 0, -0.052, 0);
@@ -3899,7 +4806,7 @@ function buildProp(THREE, prop) {
3899
4806
  if (part.shape.kind !== "box") continue;
3900
4807
  const [w, h, d] = part.shape.size;
3901
4808
  const material = new THREE.MeshStandardMaterial({ color: prop.colors[part.id] ?? 7829367, roughness: 0.6, metalness: part.material === "steel" ? 0.6 : 0.05 });
3902
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
4809
+ const mesh = new THREE.Mesh(new (shapeGeometries2(THREE)).BoxGeometry(w, h, d), material);
3903
4810
  mesh.name = part.id;
3904
4811
  mesh.position.set(part.position[0], part.position[1], part.position[2]);
3905
4812
  mesh.castShadow = true;
@@ -3970,11 +4877,13 @@ function createBoxFx(options) {
3970
4877
  let model = null;
3971
4878
  let inHand = null;
3972
4879
  let def = null;
4880
+ let hold = null;
3973
4881
  let kick = 0;
3974
4882
  let dip = 0;
3975
4883
  let reloading = false;
3976
4884
  const v = new THREE.Vector3();
3977
4885
  const q = new THREE.Quaternion();
4886
+ const q2 = new THREE.Quaternion();
3978
4887
  const pointForward = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2);
3979
4888
  const equip = (weapon) => {
3980
4889
  if (model) hand.remove(model);
@@ -3988,6 +4897,7 @@ function createBoxFx(options) {
3988
4897
  model.quaternion.copy(pointForward);
3989
4898
  hand.add(model);
3990
4899
  inHand = def.id;
4900
+ hold = holdOfProp(THREE, model, def.category, model.scale.x);
3991
4901
  };
3992
4902
  const handPoint = (x, y, z, out) => out.set(HAND_OFFSET[0] + x, HAND_OFFSET[1] + y, HAND_OFFSET[2] + z).applyQuaternion(camera.quaternion).add(camera.position);
3993
4903
  const throwBody = (kind, mesh, from, velocity, radius) => {
@@ -4079,9 +4989,15 @@ function createBoxFx(options) {
4079
4989
  if (model) hand.remove(model);
4080
4990
  model = null;
4081
4991
  inHand = null;
4992
+ hold = null;
4993
+ },
4994
+ handTargets() {
4995
+ if (!model || !hold) return null;
4996
+ hand.updateMatrixWorld(true);
4997
+ return targetsOf(THREE, hold, model.getWorldQuaternion(q2), 0);
4082
4998
  },
4083
4999
  handle(event, tool) {
4084
- if (event.type === "explosion") return;
5000
+ if (event.type === "explosion" || event.type === "player") return;
4085
5001
  const weapon = arsenal.weapons.get(event.weapon);
4086
5002
  if (event.type === "tool") equip(event.weapon);
4087
5003
  if (!weapon) return;
@@ -4136,6 +5052,7 @@ var ease = (progress) => progress < 0.5 ? 2 * progress * progress : 1 - (-2 * pr
4136
5052
  var move = (part, axis, start, duration, from, to) => ({ part, channel: "position", axis, start, duration, from, to });
4137
5053
  var turn = (part, axis, start, duration, from, to) => ({ part, channel: "rotation", axis, start, duration, from, to });
4138
5054
  var cue = (t, type, data) => ({ t, type, ...data ? { data } : {} });
5055
+ var sound2 = (t, id) => ({ t, type: "sound", data: { id } });
4139
5056
  var stage = (id, t) => ({ t, type: STAGE_EVENT, data: { stage: id } });
4140
5057
  var valueAt = (tweens, t, includeInstants) => {
4141
5058
  let value = 0;
@@ -4233,7 +5150,7 @@ function magazineSwap(parts, start) {
4233
5150
  move("mag", "y", start + 0.16, 0, 0, direction * drop * 2.2),
4234
5151
  move("mag", "y", start + MAGAZINE_IN_AT, 0.3, direction * drop * 2.2, 0)
4235
5152
  ],
4236
- events: [cue(start + 0.16, "drop_magazine", { up: direction > 0 })],
5153
+ events: [sound2(start, "mag_release"), cue(start + 0.16, "drop_magazine", { up: direction > 0 }), sound2(start + 0.16, "mag_out"), sound2(start + MAGAZINE_IN_AT, "mag_insert"), sound2(start + MAGAZINE_IN_AT + 0.3, "mag_seat")],
4237
5154
  end: start + MAGAZINE_SWAP_SECONDS
4238
5155
  };
4239
5156
  }
@@ -4306,43 +5223,52 @@ function clearClips(context) {
4306
5223
  // src/animation/reload-clips.ts
4307
5224
  import { RELOAD_STAGES } from "@adatechnology/game-weapons";
4308
5225
  var KINDS = ["tactical", "empty", "one_by_one"];
5226
+ var chargeSounds = (start) => [sound2(start, "charging_handle_pull"), sound2(start + 0.2, "charging_handle_release")];
4309
5227
  var charge = (context, start) => {
4310
5228
  const travel = strokeOf(context.parts);
4311
5229
  return [move("bolt", "x", start, 0.12, 0, travel), move("bolt", "x", start + 0.18, 0.06, travel, 0)];
4312
5230
  };
5231
+ var boltSounds = (start) => [sound2(start, "bolt_lift"), sound2(start + 0.1, "bolt_pull"), sound2(start + 0.26, "bolt_push"), sound2(start + 0.45, "bolt_lock")];
4313
5232
  var cycleWithoutEject = (start) => ({ tweens: boltStroke(start), events: [], end: start + 0.55 });
5233
+ var RACK_BACK = 0.14;
5234
+ var RACK_HOLD = 0.04;
5235
+ var RACK_FORWARD = 0.06;
4314
5236
  function magazineReload(context, kind) {
4315
5237
  const { parts, family } = context;
4316
5238
  const swap = magazineSwap(parts, 0);
4317
5239
  const draft = { tweens: [...swap.tweens], events: [...swap.events], end: swap.end };
4318
5240
  if (family === "pistol") {
4319
5241
  if (kind === "empty" && parts.slide) {
4320
- draft.tweens.push(move("slide", "x", 0, 0, 0, strokeOf(parts)), move("slide", "x", swap.end, 0.05, strokeOf(parts), 0));
4321
- draft.end += 0.12;
5242
+ const travel = strokeOf(parts);
5243
+ draft.tweens.push(move("slide", "x", swap.end + 0.04, RACK_BACK, 0, travel), move("slide", "x", swap.end + 0.04 + RACK_BACK + RACK_HOLD, RACK_FORWARD, travel, 0));
5244
+ draft.events.push(sound2(swap.end + 0.04 + RACK_BACK + RACK_HOLD + 0.02, "slide_release"));
5245
+ draft.end += 0.04 + RACK_BACK + RACK_HOLD + RACK_FORWARD + 0.08;
4322
5246
  }
4323
5247
  } else if (family === "boltmag") {
4324
5248
  const stroke = cycleWithoutEject(swap.end);
4325
5249
  draft.tweens.push(...stroke.tweens);
5250
+ draft.events.push(...boltSounds(swap.end));
4326
5251
  draft.end = stroke.end;
4327
5252
  } else if (kind === "empty" && parts.bolt) {
4328
5253
  draft.tweens.push(...charge(context, swap.end));
5254
+ draft.events.push(...chargeSounds(swap.end));
4329
5255
  draft.end += 0.35;
4330
5256
  }
4331
5257
  return draft;
4332
5258
  }
4333
5259
  function revolverReload(kind) {
4334
- const events = [cue(0.32, "eject_casing", { from: "chambers", down: false })];
4335
- if (kind !== "one_by_one") events.push(cue(0.7, "move_in", { from: "chambers" }));
5260
+ const events = [sound2(0, "cylinder_open"), cue(0.32, "eject_casing", { from: "chambers", down: false }), sound2(0.32, "cylinder_eject"), sound2(1.2, "cylinder_close")];
5261
+ if (kind !== "one_by_one") events.push(cue(0.7, "move_in", { from: "chambers" }), sound2(0.7, "speedloader"));
4336
5262
  return { tweens: [turn("crane", "x", 0, 0.22, 0, -1.25), turn("crane", "x", 1.05, 0.2, -1.25, 0)], events, end: 1.3 };
4337
5263
  }
4338
5264
  var breakReload = () => ({
4339
5265
  tweens: [turn("barrels", "z", 0, 0.2, 0, -0.5), turn("barrels", "z", 1, 0.15, -0.5, 0)],
4340
- events: [cue(0.25, "eject_casing", { from: "breech", down: false }), cue(0.65, "move_in", { from: "breech" })],
5266
+ events: [sound2(0, "barrels_open"), cue(0.25, "eject_casing", { from: "breech", down: false }), sound2(0.25, "casings_eject"), cue(0.65, "move_in", { from: "breech" }), sound2(0.65, "shell_insert"), sound2(0.8, "shell_insert"), sound2(1.13, "barrels_close")],
4341
5267
  end: 1.2
4342
5268
  });
4343
5269
  var beltReload = (context) => ({
4344
5270
  tweens: [turn("cover", "z", 0, 0.2, 0, 0.9), move("belt", "z", 0.2, 0.25, 0, -0.3), move("belt", "z", 0.55, 0.3, -0.3, 0), turn("cover", "z", 0.9, 0.18, 0.9, 0), ...charge(context, 1.15)],
4345
- events: [],
5271
+ events: [sound2(0, "belt_cover_open"), sound2(0.8, "belt_insert"), sound2(1.05, "belt_cover_close"), ...chargeSounds(1.15)],
4346
5272
  end: 1.55
4347
5273
  });
4348
5274
  var UNIT = 1;
@@ -4350,9 +5276,16 @@ function tubeReload(context, kind) {
4350
5276
  const { parts, family } = context;
4351
5277
  const draft = { tweens: [], events: [], end: UNIT };
4352
5278
  if (kind !== "empty") return draft;
4353
- if (family === "pump" && parts.pump) draft.tweens.push(move("pump", "x", 0.88, 0.05, 0, PUMP_TRAVEL), move("pump", "x", 0.94, 0.05, PUMP_TRAVEL, 0));
4354
- else if (family === "lever" && parts.lever) draft.tweens.push(turn("lever", "z", 0.87, 0.06, 0, LEVER_TURN), turn("lever", "z", 0.94, 0.06, LEVER_TURN, 0));
4355
- else if (parts.bolt) draft.tweens.push(move("bolt", "x", 0.88, 0.07, 0, strokeOf(parts)), move("bolt", "x", 0.95, 0.04, strokeOf(parts), 0));
5279
+ if (family === "pump" && parts.pump) {
5280
+ draft.tweens.push(move("pump", "x", 0.88, 0.05, 0, PUMP_TRAVEL), move("pump", "x", 0.94, 0.05, PUMP_TRAVEL, 0));
5281
+ draft.events.push(sound2(0.88, "pump_back"), sound2(0.94, "pump_forward"));
5282
+ } else if (family === "lever" && parts.lever) {
5283
+ draft.tweens.push(turn("lever", "z", 0.87, 0.06, 0, LEVER_TURN), turn("lever", "z", 0.94, 0.06, LEVER_TURN, 0));
5284
+ draft.events.push(sound2(0.87, "lever_open"), sound2(0.94, "lever_close"));
5285
+ } else if (parts.bolt) {
5286
+ draft.tweens.push(move("bolt", "x", 0.88, 0.07, 0, strokeOf(parts)), move("bolt", "x", 0.95, 0.04, strokeOf(parts), 0));
5287
+ draft.events.push(sound2(0.88, "charging_handle_pull"), sound2(0.95, "charging_handle_release"));
5288
+ }
4356
5289
  return draft;
4357
5290
  }
4358
5291
  function boltReload(context) {
@@ -4366,13 +5299,13 @@ function boltReload(context) {
4366
5299
  move("bolt", "x", 0.86, 0.07, BOLT_TRAVEL, 0),
4367
5300
  turn("boltHandle", "x", 0.93, 0.05, BOLT_TURN, 0)
4368
5301
  ],
4369
- events: [],
5302
+ events: [sound2(0, "bolt_lift"), sound2(0.05, "bolt_pull"), sound2(0.86, "bolt_push"), sound2(0.95, "bolt_lock")],
4370
5303
  end: UNIT
4371
5304
  };
4372
5305
  }
4373
5306
  function internalMagazineReload(context) {
4374
5307
  const travel = strokeOf(context.parts);
4375
- return { tweens: context.parts.bolt ? [move("bolt", "x", 0, 0.04, 0, travel), move("bolt", "x", 0.89, 0.04, travel, 0)] : [], events: [], end: UNIT };
5308
+ return { tweens: context.parts.bolt ? [move("bolt", "x", 0, 0.04, 0, travel), move("bolt", "x", 0.89, 0.04, travel, 0)] : [], events: context.parts.bolt ? [sound2(0.04, "charging_handle_pull"), sound2(0.89, "charging_handle_release")] : [], end: UNIT };
4376
5309
  }
4377
5310
  function heavyReload(context) {
4378
5311
  const { parts, family } = context;
@@ -4380,20 +5313,20 @@ function heavyReload(context) {
4380
5313
  if (parts.disposable) {
4381
5314
  return {
4382
5315
  tweens: [move(ROOT, "y", 0.6, 0, 0, -0.6), move(ROOT, "y", 0.6, 0.5, -0.6, 0)],
4383
- events: [cue(0, "drop_tube"), cue(0.6, "seat_tube")],
5316
+ events: [cue(0, "drop_tube"), sound2(0, "tube_drop"), cue(0.6, "seat_tube"), sound2(1.08, "tube_seat")],
4384
5317
  end: 1.15
4385
5318
  };
4386
5319
  }
4387
5320
  if (parts.round) {
4388
5321
  return {
4389
5322
  tweens: [move("round", "x", 0, 0.55, 0.45, 0), move("round", "y", 0, 0.55, 0.12, 0)],
4390
- events: [cue(0, "show_round")],
5323
+ events: [cue(0, "show_round"), sound2(0, "rocket_load"), sound2(0.55, "rocket_seat")],
4391
5324
  end: 0.6
4392
5325
  };
4393
5326
  }
4394
5327
  return {
4395
5328
  tweens: [turn("breech_0", "y", 0, 0.25, 0, -1.6), turn("breech_0", "y", 0.76, 0.22, -1.6, 0)],
4396
- events: [cue(0.3, "breech_round")],
5329
+ events: [sound2(0, "breech_open"), cue(0.3, "breech_round"), sound2(0.3, "grenade_load"), sound2(0.96, "breech_close")],
4397
5330
  end: 1.05
4398
5331
  };
4399
5332
  }
@@ -4424,11 +5357,12 @@ function reloadClips(context) {
4424
5357
  }
4425
5358
  return clips.length > 0 ? clips : first ? [renamed(first, "reload")] : [];
4426
5359
  }
5360
+ var FEED_SECONDS = 0.4;
4427
5361
  function feedClip(context) {
4428
5362
  const { family } = context;
4429
- if (family === "revolver") return composeClip("feed", [], [cue(0, "move_in", { from: "chambers", next: true })], 0.24);
4430
- if (family === "bolt" || family === "semiint") return composeClip("feed", [], [cue(0, "move_in", { from: "topLoad" })], 0.24);
4431
- if (isTube(context)) return composeClip("feed", [], [cue(0, "move_in", { from: "loadPort" })], 0.24);
5363
+ if (family === "revolver") return composeClip("feed", [], [cue(0, "move_in", { from: "chambers", next: true }), sound2(0.1, "shell_insert")], 0.24);
5364
+ if (family === "bolt" || family === "semiint") return composeClip("feed", [], [cue(0, "move_in", { from: "topLoad" }), sound2(0.2, "shell_insert")], FEED_SECONDS);
5365
+ if (isTube(context)) return composeClip("feed", [], [cue(0, "move_in", { from: "loadPort" }), sound2(0.2, "shell_insert")], FEED_SECONDS);
4432
5366
  return void 0;
4433
5367
  }
4434
5368
 
@@ -5131,6 +6065,7 @@ function buildRound(kit, dims, shape) {
5131
6065
  import { createVfxRenderer } from "@adatechnology/game-vfx-three";
5132
6066
 
5133
6067
  // src/fx/effects.ts
6068
+ import { MUZZLE_SMOKE } from "@adatechnology/game-weapons";
5134
6069
  var FLASH_BY_CASE = { shell: 1.6, big: 2.6, tiny: 0.5, pistol: 0.8, rifle: 1.2 };
5135
6070
  var VIEWMODEL_EFFECT_IDS = { flash: "viewmodel_muzzle_flash", smoke: "weapon_smoke_puff", backblast: "launcher_backblast", flameJet: "flame_jet" };
5136
6071
  var vec = (vector) => [vector.x, vector.y, vector.z];
@@ -5139,8 +6074,9 @@ function createEffects(THREE, sink, random) {
5139
6074
  const seed = () => 1 + Math.floor(random() * 2147483646);
5140
6075
  const barrel = (orientation) => vec(new THREE.Vector3(1, 0, 0).applyQuaternion(orientation));
5141
6076
  return {
5142
- muzzleFlash(position, orientation, size) {
5143
- sink?.play({ effect: VIEWMODEL_EFFECT_IDS.flash, point: vec(position), direction: barrel(orientation), orientation: quaternion(orientation), seed: seed(), values: { size } });
6077
+ muzzleFlash(position, orientation, size, profile = MUZZLE_SMOKE.smokeless) {
6078
+ if (profile.puffs <= 0 && profile.flash <= 0) return;
6079
+ sink?.play({ effect: VIEWMODEL_EFFECT_IDS.flash, point: vec(position), direction: barrel(orientation), orientation: quaternion(orientation), seed: seed(), values: { size, ...profile } });
5144
6080
  },
5145
6081
  smoke(position, velocity, size, life, opacity) {
5146
6082
  sink?.play({ effect: VIEWMODEL_EFFECT_IDS.smoke, point: vec(position), seed: seed(), values: { vx: velocity.x, vy: velocity.y, vz: velocity.z, radius: size, life, opacity } });
@@ -5165,13 +6101,17 @@ var flameMaterials = (kit) => {
5165
6101
  new THREE.MeshBasicMaterial({ color: 16773296, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false })
5166
6102
  ];
5167
6103
  };
5168
- function launchRocket(context) {
6104
+ function launchRocket(context, flyRound = true) {
5169
6105
  const { kit, scene, parts, random } = context;
5170
6106
  const { THREE } = kit;
5171
6107
  const forward = context.forward();
5172
6108
  const muzzle = context.positionOf(parts.muzzle);
5173
6109
  const rear = context.positionOf(parts.rear);
5174
6110
  context.effects.backblast(rear, context.orientation());
6111
+ if (!flyRound) {
6112
+ if (parts.round) parts.round.visible = false;
6113
+ return;
6114
+ }
5175
6115
  const rocket = parts.makeRound ? parts.makeRound() : buildFlyingRocket(kit, parts.launcherType);
5176
6116
  if (parts.round) parts.round.visible = false;
5177
6117
  rocket.quaternion.copy(context.orientation());
@@ -5366,6 +6306,7 @@ var toWorld = (session, vector, scaled = false) => {
5366
6306
  };
5367
6307
 
5368
6308
  // src/fx/viewmodel-events.ts
6309
+ import { muzzleSmokeOf } from "@adatechnology/game-weapons";
5369
6310
  var PI13 = Math.PI;
5370
6311
  var UGL_FLASH = 1.6;
5371
6312
  var restFlat = (object) => {
@@ -5471,7 +6412,11 @@ function createClipEventHandler(world, session) {
5471
6412
  case "muzzle_flash":
5472
6413
  if (data.at === "ugl") {
5473
6414
  if (parts.uglMuzzle && world.layers.muzzleFlash) effects.muzzleFlash(positionOf(parts.uglMuzzle), root.getWorldQuaternion(new THREE.Quaternion()), UGL_FLASH);
5474
- } else if (parts.muzzle && world.layers.muzzleFlash) effects.muzzleFlash(positionOf(parts.muzzle), root.getWorldQuaternion(new THREE.Quaternion()), session.def.action.kind === "launcher" ? 2.2 : FLASH_BY_CASE[session.caseType]);
6415
+ } else if (parts.muzzle && world.layers.muzzleFlash && (session.def.action.kind === "firearm" || session.def.action.kind === "launcher")) {
6416
+ const profile = muzzleSmokeOf({ caliber: session.caliber, weapon: session.def, suppressed: session.suppressed });
6417
+ const size = session.def.action.kind === "launcher" ? 2.2 : FLASH_BY_CASE[session.caseType];
6418
+ effects.muzzleFlash(positionOf(parts.muzzle), root.getWorldQuaternion(new THREE.Quaternion()), size, profile);
6419
+ }
5475
6420
  break;
5476
6421
  case "eject_casing":
5477
6422
  if (data.from === "chambers") ejectAll(parts.chambers, () => ({ velocity: vector(-0.5 - random() * 0.3, -0.1 + random() * 0.3, (random() - 0.5) * 0.25), spin: vector(10, 20, 5), tilt: 0 }));
@@ -5502,6 +6447,9 @@ function createClipEventHandler(world, session) {
5502
6447
  case "breech_round":
5503
6448
  breechRound();
5504
6449
  break;
6450
+ case "sound":
6451
+ world.sound(session.id, String(data.id), session.soundProfile);
6452
+ break;
5505
6453
  default:
5506
6454
  break;
5507
6455
  }
@@ -5525,10 +6473,321 @@ function createClipEventHandler(world, session) {
5525
6473
  }
5526
6474
  }
5527
6475
 
6476
+ // src/fx/hand-work.ts
6477
+ import { sampleClip } from "@adatechnology/game-contracts";
6478
+
6479
+ // src/data/hand-work.json
6480
+ var hand_work_default = {
6481
+ source: "Authored in the kit: the gallery's clips move the parts (slide, bolt, bolt handle, crane, cover) but the Galeria 3D do Arsenal draws no hands, so which hand works which part is written here. Times are not here: a hand takes a part while the clip of the weapon moves it, so they follow the clip and the seconds of the rule's reload.",
6482
+ timing: {
6483
+ lead: 0.08,
6484
+ tail: 0.06,
6485
+ comment: "fractions of the clip's duration: the hand arrives this long before the part starts to move and lets go this long after it stops"
6486
+ },
6487
+ pistolRelease: {
6488
+ comment: "Pistols whose slide stop is worked with the thumb of the shooting hand after the magazine is in. Empty by default: every pistol is racked by the support hand taking the rear of the slide (the reading shot of the move). Put an id here only for a pistol that is meant to be released by the thumb.",
6489
+ weapons: []
6490
+ },
6491
+ akCharge: {
6492
+ comment: "Guns charged by a handle on the right of the receiver, reached over the top by the support hand; the rest of the magazine guns are charged at the rear top (AR style).",
6493
+ weapons: [
6494
+ "ak_47",
6495
+ "akm",
6496
+ "ak_74",
6497
+ "ak_12",
6498
+ "rpk",
6499
+ "saiga_12",
6500
+ "sks",
6501
+ "m14_ebr",
6502
+ "fn_fal",
6503
+ "fn_parafal",
6504
+ "hk_g3a3",
6505
+ "hk_psg_1",
6506
+ "svd_dragunov"
6507
+ ]
6508
+ },
6509
+ works: [
6510
+ {
6511
+ family: "pistol",
6512
+ clips: [
6513
+ "reload_empty"
6514
+ ],
6515
+ part: "slide",
6516
+ hand: "L",
6517
+ anchor: "slide_rear",
6518
+ style: "rack"
6519
+ },
6520
+ {
6521
+ family: "pistol",
6522
+ clips: [
6523
+ "reload_empty"
6524
+ ],
6525
+ part: "slide",
6526
+ hand: "R",
6527
+ anchor: "thumb_release",
6528
+ style: "release"
6529
+ },
6530
+ {
6531
+ family: "rifle",
6532
+ clips: [
6533
+ "reload_empty"
6534
+ ],
6535
+ part: "bolt",
6536
+ hand: "L",
6537
+ anchor: "rear_top",
6538
+ style: "ar"
6539
+ },
6540
+ {
6541
+ family: "rifle",
6542
+ clips: [
6543
+ "reload_empty"
6544
+ ],
6545
+ part: "bolt",
6546
+ hand: "L",
6547
+ anchor: "side_right",
6548
+ style: "ak"
6549
+ },
6550
+ {
6551
+ family: "p90",
6552
+ clips: [
6553
+ "reload_empty"
6554
+ ],
6555
+ part: "bolt",
6556
+ hand: "L",
6557
+ anchor: "rear_top",
6558
+ style: "ar"
6559
+ },
6560
+ {
6561
+ family: "belt",
6562
+ clips: [
6563
+ "reload"
6564
+ ],
6565
+ part: "cover",
6566
+ hand: "L",
6567
+ anchor: "cover_top",
6568
+ merge: 1
6569
+ },
6570
+ {
6571
+ family: "boltmag",
6572
+ clips: [
6573
+ "reload",
6574
+ "reload_empty",
6575
+ "reload_one_by_one",
6576
+ "cycle"
6577
+ ],
6578
+ part: "boltHandle",
6579
+ hand: "R",
6580
+ anchor: "top_handle"
6581
+ },
6582
+ {
6583
+ family: "bolt",
6584
+ clips: [
6585
+ "reload",
6586
+ "reload_empty",
6587
+ "reload_one_by_one",
6588
+ "feed"
6589
+ ],
6590
+ hand: "R",
6591
+ anchor: "loading_port",
6592
+ whole: true,
6593
+ lead: 0.3,
6594
+ tail: 0.3
6595
+ },
6596
+ {
6597
+ family: "bolt",
6598
+ clips: [
6599
+ "reload",
6600
+ "reload_empty",
6601
+ "reload_one_by_one",
6602
+ "cycle"
6603
+ ],
6604
+ part: "boltHandle",
6605
+ hand: "R",
6606
+ anchor: "top_handle"
6607
+ },
6608
+ {
6609
+ family: "pump",
6610
+ clips: [
6611
+ "feed"
6612
+ ],
6613
+ hand: "R",
6614
+ anchor: "loading_port",
6615
+ whole: true,
6616
+ lead: 0.3,
6617
+ tail: 0.3
6618
+ },
6619
+ {
6620
+ family: "semi12",
6621
+ clips: [
6622
+ "feed"
6623
+ ],
6624
+ hand: "R",
6625
+ anchor: "loading_port",
6626
+ whole: true,
6627
+ lead: 0.3,
6628
+ tail: 0.3
6629
+ },
6630
+ {
6631
+ family: "semiint",
6632
+ clips: [
6633
+ "reload",
6634
+ "reload_empty",
6635
+ "reload_one_by_one",
6636
+ "feed"
6637
+ ],
6638
+ hand: "R",
6639
+ anchor: "loading_port",
6640
+ whole: true,
6641
+ lead: 0.3,
6642
+ tail: 0.3
6643
+ },
6644
+ {
6645
+ family: "lever",
6646
+ clips: [
6647
+ "feed"
6648
+ ],
6649
+ hand: "R",
6650
+ anchor: "loading_port",
6651
+ whole: true,
6652
+ lead: 0.3,
6653
+ tail: 0.3
6654
+ },
6655
+ {
6656
+ family: "revolver",
6657
+ clips: [
6658
+ "reload",
6659
+ "reload_one_by_one"
6660
+ ],
6661
+ part: "crane",
6662
+ hand: "L",
6663
+ anchor: "cylinder_side",
6664
+ merge: 1
6665
+ }
6666
+ ]
6667
+ };
6668
+
6669
+ // src/fx/hand-work.ts
6670
+ var V2 = (THREE, x, y, z) => new THREE.Vector3(x, y, z);
6671
+ var boxIn = (THREE, root, part) => {
6672
+ const world = new THREE.Box3().setFromObject(part);
6673
+ const box2 = new THREE.Box3();
6674
+ for (const x of [world.min.x, world.max.x]) for (const y of [world.min.y, world.max.y]) for (const z of [world.min.z, world.max.z]) box2.expandByPoint(root.worldToLocal(V2(THREE, x, y, z)));
6675
+ return box2;
6676
+ };
6677
+ var anchorsOf = (THREE, model, right) => {
6678
+ const { root, parts } = model;
6679
+ root.updateWorldMatrix(true, true);
6680
+ const palm = HAND_HOLD.palm;
6681
+ const anchorAt = (part, at, quaternion2) => ({ part, offset: part.worldToLocal(root.localToWorld(at.clone())), quaternion: quaternion2 });
6682
+ return (kind, partId) => {
6683
+ const part = partId ? parts[partId] ?? null : null;
6684
+ if (kind === "loading_port") {
6685
+ const port = parts.loadPort;
6686
+ if (!port) return null;
6687
+ const at = root.worldToLocal(port.getWorldPosition(V2(THREE, 0, 0, 0))).add(V2(THREE, 0, -palm - 4e-3, 0));
6688
+ return anchorAt(port, at, handFrame(THREE, V2(THREE, 0, 0, -1), V2(THREE, 0, -1, 0)));
6689
+ }
6690
+ if (kind === "thumb_release") {
6691
+ if (!right || !parts.slide) return null;
6692
+ return { part: right.part, offset: right.offset.clone().add(V2(THREE, 0, 0.012, 0)), quaternion: right.quaternion };
6693
+ }
6694
+ if (!part) return null;
6695
+ const b = boxIn(THREE, root, part);
6696
+ const c = b.getCenter(V2(THREE, 0, 0, 0));
6697
+ switch (kind) {
6698
+ case "slide_rear":
6699
+ return anchorAt(part, V2(THREE, b.min.x + 0.014, b.max.y - 6e-3, b.min.z - palm), handFrame(THREE, V2(THREE, 1, 0, 0), V2(THREE, 0, 0, -1)));
6700
+ case "rear_top":
6701
+ return anchorAt(part, V2(THREE, b.min.x + 0.016, b.max.y + palm, c.z), handFrame(THREE, V2(THREE, 1, 0, 0), V2(THREE, 0, 1, 0)));
6702
+ case "side_right":
6703
+ return anchorAt(part, V2(THREE, c.x, c.y, b.max.z + palm), handFrame(THREE, V2(THREE, 1, 0, 0), V2(THREE, 0, 0, 1)));
6704
+ case "top_handle":
6705
+ return anchorAt(part, V2(THREE, c.x, c.y, b.max.z + palm), handFrame(THREE, V2(THREE, 1, 0, 0), V2(THREE, 0, 0, 1)));
6706
+ case "cover_top":
6707
+ return anchorAt(part, V2(THREE, c.x, b.max.y + palm, c.z), handFrame(THREE, V2(THREE, 1, 0, 0), V2(THREE, 0, 1, 0)));
6708
+ case "cylinder_side":
6709
+ return anchorAt(part, V2(THREE, c.x, c.y, b.min.z - palm), handFrame(THREE, V2(THREE, 1, 0, 0), V2(THREE, 0, 0, -1)));
6710
+ default:
6711
+ return null;
6712
+ }
6713
+ };
6714
+ };
6715
+ var releasePistols = new Set(hand_work_default.pistolRelease.weapons);
6716
+ var akCharge = new Set(hand_work_default.akCharge.weapons);
6717
+ function handWorkOf(THREE, model, weaponId, right) {
6718
+ const family = model.parts.family;
6719
+ const anchors = anchorsOf(THREE, model, right);
6720
+ const style = family === "pistol" ? releasePistols.has(weaponId) ? "release" : "rack" : akCharge.has(weaponId) ? "ak" : "ar";
6721
+ const out = [];
6722
+ for (const entry of hand_work_default.works) {
6723
+ if (entry.family !== family || entry.style && entry.style !== style) continue;
6724
+ const anchor = anchors(entry.anchor, entry.part ?? null);
6725
+ if (!anchor) continue;
6726
+ out.push({ hand: entry.hand, anchor, part: entry.whole ? null : entry.part ?? null, clips: entry.clips, merge: entry.merge ?? 0, id: entry.anchor, ...entry.lead === void 0 ? {} : { lead: entry.lead }, ...entry.tail === void 0 ? {} : { tail: entry.tail } });
6727
+ }
6728
+ return out;
6729
+ }
6730
+ var segmentsCache = /* @__PURE__ */ new WeakMap();
6731
+ var motionOf = (clip, part, merge) => {
6732
+ const cache = segmentsCache.get(clip) ?? /* @__PURE__ */ new Map();
6733
+ segmentsCache.set(clip, cache);
6734
+ const key = `${part}|${merge}`;
6735
+ const found = cache.get(key);
6736
+ if (found) return found;
6737
+ const steps = 240;
6738
+ const dt = clip.duration / steps;
6739
+ const spans = [];
6740
+ let start = null;
6741
+ for (let i = 1; i <= steps; i++) {
6742
+ const a = sampleClip(clip, part, (i - 1) * dt);
6743
+ const b = sampleClip(clip, part, i * dt);
6744
+ const moving = [0, 1, 2].some((axis) => Math.abs(b.position[axis] - a.position[axis]) > 1e-6 || Math.abs(b.rotation[axis] - a.rotation[axis]) > 1e-6);
6745
+ if (moving && start === null) start = (i - 1) * dt;
6746
+ if (!moving && start !== null) {
6747
+ spans.push([start, (i - 1) * dt]);
6748
+ start = null;
6749
+ }
6750
+ }
6751
+ if (start !== null) spans.push([start, clip.duration]);
6752
+ const joined = [];
6753
+ for (const span of spans) {
6754
+ const last = joined[joined.length - 1];
6755
+ if (last && span[0] - last[1] <= merge * clip.duration) last[1] = span[1];
6756
+ else joined.push([span[0], span[1]]);
6757
+ }
6758
+ cache.set(key, joined);
6759
+ return joined;
6760
+ };
6761
+ var smooth = (x) => {
6762
+ const k = Math.min(1, Math.max(0, x));
6763
+ return k * k * (3 - 2 * k);
6764
+ };
6765
+ function workWeights(works, running) {
6766
+ return works.map((work) => {
6767
+ const lead = work.lead ?? hand_work_default.timing.lead;
6768
+ const tail = work.tail ?? hand_work_default.timing.tail;
6769
+ let best = 0;
6770
+ for (const { name, time, clip } of running) {
6771
+ if (!work.clips.includes(name)) continue;
6772
+ const spans = work.part ? motionOf(clip, work.part, work.merge) : [[0, clip.duration]];
6773
+ for (const [from, to] of spans) {
6774
+ const before = lead * clip.duration;
6775
+ const after = tail * clip.duration;
6776
+ const w = work.part ? smooth((time - (from - before)) / before) * smooth((to + after - time) / after) : smooth(time / before) * smooth((clip.duration - time) / after);
6777
+ best = Math.max(best, w);
6778
+ }
6779
+ }
6780
+ return best;
6781
+ });
6782
+ }
6783
+
5528
6784
  // src/fx/viewmodel.ts
5529
6785
  var PI14 = Math.PI;
5530
6786
  var HAND_OFFSET2 = [0.2, -0.2, -0.42];
6787
+ var HANDGUN_OFFSET = [0.1, -0.12, -0.44];
5531
6788
  var HEAT_DISPLAY_MAX_FALLBACK = 120;
6789
+ var WALL_PULL = { margin: 0.03, max: 0.7, rate: 14, probe: 3 };
6790
+ var HAND_IN_VIEW = { floor: -0.88, max: 0.18, rate: 10 };
5532
6791
  var LAUNCH_VOLUME = 0.6;
5533
6792
  var fittedOf2 = (parts) => parts.map((part) => ({ id: part.id, mount: part.mount, ...part.integral ? { integral: true } : {}, ...part.on === void 0 ? {} : { on: part.on } }));
5534
6793
  var collapsedStockPose = (move2) => {
@@ -5537,6 +6796,14 @@ var collapsedStockPose = (move2) => {
5537
6796
  };
5538
6797
  var underbarrelStep = (step, tool) => tool.underbarrelActive && step.type === "play" && (step.clip === "shot" || step.clip === "reload") ? { ...step, variant: "ugl" } : step;
5539
6798
  var caliberOf = (arsenal, def) => def.action.kind === "firearm" ? arsenal.calibers.get(def.action.caliber) : void 0;
6799
+ var TRIGGER_FINGER = { safe: 0, ready: 0.5, pulled: 1, readySeconds: 1.5, burstSeconds: 0.14, tremor: 0.03 };
6800
+ var TRIGGER_TURN = 0.35;
6801
+ var indexCurl = (turn2, sinceShot) => {
6802
+ const ready = sinceShot < TRIGGER_FINGER.readySeconds ? TRIGGER_FINGER.ready : TRIGGER_FINGER.safe;
6803
+ const pull = Math.min(1, Math.max(0, turn2 / TRIGGER_TURN));
6804
+ const held = sinceShot < TRIGGER_FINGER.burstSeconds ? TRIGGER_FINGER.pulled - TRIGGER_FINGER.tremor * (0.5 + 0.5 * Math.sin(sinceShot * 220)) : 0;
6805
+ return Math.max(ready + (TRIGGER_FINGER.pulled - ready) * pull, held);
6806
+ };
5540
6807
  function createViewmodel(options) {
5541
6808
  const { THREE, scene, camera, arsenal, groundAt, audio } = options;
5542
6809
  const kit = modelKit(THREE);
@@ -5559,6 +6826,15 @@ function createViewmodel(options) {
5559
6826
  let condition = options.condition ?? "new";
5560
6827
  let lastAmmo = 0;
5561
6828
  let smokeDebt = 0;
6829
+ let secondsSinceShot = Number.POSITIVE_INFINITY;
6830
+ let hold = null;
6831
+ let reloading = false;
6832
+ let magazineHand = 0;
6833
+ let clock = 0;
6834
+ let lastShot = -Infinity;
6835
+ let triggerRest = 0;
6836
+ let feedCued = false;
6837
+ const rootQuaternion = new THREE.Quaternion();
5562
6838
  const world = {
5563
6839
  kit,
5564
6840
  scene,
@@ -5568,9 +6844,15 @@ function createViewmodel(options) {
5568
6844
  audio,
5569
6845
  layers,
5570
6846
  groundAt,
6847
+ sound: (weapon, cue2, profile) => {
6848
+ audio?.cue(cue2, profile);
6849
+ options.onSound?.({ weapon, cue: cue2, profile });
6850
+ },
5571
6851
  schedule: (delay, seconds, step, done) => void tasks.push({ delay, seconds, elapsed: 0, step: (progress) => step(ease(progress)), done })
5572
6852
  };
5573
6853
  const heatMax = arsenal.rules.heat.displayMax || HEAT_DISPLAY_MAX_FALLBACK;
6854
+ const visual = arsenal.rules.heat.visual;
6855
+ const hasSuppressor = (parts) => parts.some((part) => arsenal.attachments.get(part.id)?.kind === "suppressor");
5574
6856
  const release = () => {
5575
6857
  if (!session) return;
5576
6858
  session.attachments?.dispose();
@@ -5578,6 +6860,7 @@ function createViewmodel(options) {
5578
6860
  player = null;
5579
6861
  session = null;
5580
6862
  tasks = [];
6863
+ hold = null;
5581
6864
  };
5582
6865
  const heavy = (current) => ({
5583
6866
  kit,
@@ -5597,6 +6880,14 @@ function createViewmodel(options) {
5597
6880
  const model = def ? buildWeaponModelById(THREE, weapon) : void 0;
5598
6881
  if (!def || !model) return false;
5599
6882
  release();
6883
+ hold = null;
6884
+ reloading = false;
6885
+ magazineHand = 0;
6886
+ reach = 0;
6887
+ pull = 0;
6888
+ lift = 0;
6889
+ handHeight = 0;
6890
+ handDepth = 0;
5600
6891
  rigStock(THREE, model, def);
5601
6892
  rigUnderbarrel(THREE, model, def);
5602
6893
  const length = new THREE.Box3().setFromObject(model.root).getSize(new THREE.Vector3()).x;
@@ -5606,31 +6897,36 @@ function createViewmodel(options) {
5606
6897
  holder.add(model.root);
5607
6898
  const caliber = caliberOf(arsenal, def);
5608
6899
  const dims = caliber?.cartridge;
6900
+ const hostTool = options.tool?.();
6901
+ const onTool = hostTool?.weapon === weapon ? hostTool : void 0;
6902
+ const fitted = onTool?.attachments ? fittedOf2(onTool.attachments) : [...def.fitted ?? []];
5609
6903
  session = {
5610
6904
  id: weapon,
5611
6905
  def,
6906
+ caliber,
5612
6907
  model,
5613
6908
  holderScale: scale,
5614
6909
  lengthMetres: length,
5615
6910
  caseType: caliber?.caseType ?? "rifle",
6911
+ soundProfile: soundProfileOf(weapon, model.parts.family),
5616
6912
  kit,
5617
6913
  cartridge: caliber && dims ? { dims, shape: cartridgeShape(caliber, dims) } : null,
5618
6914
  stuckCase: null,
5619
6915
  nextChamber: 0,
5620
6916
  attachments: null,
5621
- attachmentsSignature: ""
6917
+ attachmentsSignature: "",
6918
+ suppressed: onTool?.suppressed ?? hasSuppressor(fitted)
5622
6919
  };
5623
- const hostTool = options.tool?.();
5624
- const onTool = hostTool?.weapon === weapon ? hostTool : void 0;
5625
- const fitted = onTool?.attachments ? fittedOf2(onTool.attachments) : [...def.fitted ?? []];
5626
6920
  session.attachments = fitAttachmentModels(model, fitted, kit, { catalogue: arsenal.attachments, isDrawn: () => layers.attachments, areBeamsOn: () => layers.beams, ...options.attachmentModels ? { overrides: options.attachmentModels } : {}, ...options.raycast ? { raycast: options.raycast } : {} });
5627
6921
  session.attachmentsSignature = attachmentsKey(onTool?.attachments);
5628
6922
  const action = def.action;
5629
6923
  const reloadFamily = action.kind === "firearm" || action.kind === "flame" || action.kind === "launcher" ? action.family : void 0;
5630
6924
  const rpm = action.kind === "firearm" ? action.rpm : 0;
5631
6925
  const rig = createModelRig(model);
6926
+ const clips = buildToolClips({ parts: model.parts, family: model.parts.family, caseType: session.caseType, reloadFamily });
6927
+ feedCued = clips.feed?.events?.some((entry) => entry.type === "sound") ?? false;
5632
6928
  player = createToolClipPlayer({
5633
- clips: buildToolClips({ parts: model.parts, family: model.parts.family, caseType: session.caseType, reloadFamily }),
6929
+ clips,
5634
6930
  rig,
5635
6931
  onEvent: createClipEventHandler(world, session),
5636
6932
  repeatInterval: () => rpm > 0 ? 60 / rpm : 0.1
@@ -5638,25 +6934,67 @@ function createViewmodel(options) {
5638
6934
  if (model.parts.stockMove && onTool?.stock === "collapsed") rig.setPartPose("stock", collapsedStockPose(model.parts.stockMove));
5639
6935
  applyCondition(kit, model, condition);
5640
6936
  lastAmmo = options.tool?.().ammo ?? 0;
6937
+ hand.updateMatrixWorld(true);
6938
+ hold = holdOfModel(THREE, model, scale);
6939
+ hold = { ...hold, works: handWorkOf(THREE, model, weapon, hold.right) };
6940
+ triggerRest = model.parts.trigger?.rotation.z ?? 0;
6941
+ lastShot = -Infinity;
5641
6942
  return true;
5642
6943
  };
5643
6944
  const syncAttachments = (current, tool) => {
5644
- if (!current.attachments || tool?.weapon !== current.id || !tool.attachments) return;
6945
+ if (!current.attachments || tool?.weapon !== current.id) return;
6946
+ if (tool.suppressed !== void 0) current.suppressed = tool.suppressed;
6947
+ if (!tool.attachments) return;
5645
6948
  const signature = attachmentsKey(tool.attachments);
5646
6949
  if (signature === current.attachmentsSignature) return;
5647
6950
  current.attachmentsSignature = signature;
5648
- current.attachments.sync(fittedOf2(tool.attachments));
6951
+ const fitted = fittedOf2(tool.attachments);
6952
+ if (tool.suppressed === void 0) current.suppressed = hasSuppressor(fitted);
6953
+ current.attachments.sync(fitted);
6954
+ };
6955
+ let reach = 0;
6956
+ let pull = 0;
6957
+ let lift = 0;
6958
+ let handHeight = 0;
6959
+ let handDepth = 0;
6960
+ const ahead = new THREE.Vector3();
6961
+ const up = new THREE.Vector3();
6962
+ const seen = new THREE.Vector3();
6963
+ const eyeTurn = new THREE.Quaternion();
6964
+ const corner = new THREE.Vector3();
6965
+ const bounds = new THREE.Box3();
6966
+ const measureReach = (current) => {
6967
+ bounds.setFromObject(current.model.root);
6968
+ let farthest = 0;
6969
+ for (const x of [bounds.min.x, bounds.max.x]) for (const y of [bounds.min.y, bounds.max.y]) for (const z of [bounds.min.z, bounds.max.z]) farthest = Math.max(farthest, corner.set(x, y, z).sub(camera.position).dot(ahead));
6970
+ return farthest;
5649
6971
  };
5650
- const place = () => {
5651
- const [x, y, z] = HAND_OFFSET2;
6972
+ const place = (dt = 0) => {
6973
+ const family = session?.model.parts.family;
6974
+ const [x, y, z] = family === "pistol" || family === "revolver" ? HANDGUN_OFFSET : HAND_OFFSET2;
5652
6975
  hand.position.set(x, y, z).applyQuaternion(camera.quaternion).add(camera.position);
5653
6976
  hand.quaternion.copy(camera.quaternion);
6977
+ if (session && handDepth > 0.05 && "fov" in camera) {
6978
+ const tanHalf = Math.tan((camera.fov ?? 70) * PI14 / 360);
6979
+ const want2 = Math.max(0, Math.min(HAND_IN_VIEW.max, lift + (HAND_IN_VIEW.floor - handHeight) * tanHalf * handDepth));
6980
+ lift += (want2 - lift) * Math.min(1, dt * HAND_IN_VIEW.rate);
6981
+ if (lift > 1e-4) hand.position.addScaledVector(up.set(0, 1, 0).applyQuaternion(camera.quaternion), lift);
6982
+ }
6983
+ hand.updateMatrixWorld(true);
6984
+ if (!options.contact || !session) return;
6985
+ ahead.set(0, 0, -1).applyQuaternion(camera.quaternion);
6986
+ if (reach === 0) reach = Math.max(0.01, measureReach(session));
6987
+ const free = options.contact(camera.position, ahead, WALL_PULL.probe);
6988
+ const want = free === null ? 0 : Math.max(0, Math.min(WALL_PULL.max, reach + WALL_PULL.margin - free));
6989
+ pull += (want - pull) * Math.min(1, dt * WALL_PULL.rate);
6990
+ if (pull < 1e-4) return;
6991
+ hand.position.addScaledVector(ahead, -pull);
5654
6992
  hand.updateMatrixWorld(true);
5655
6993
  };
5656
6994
  const glow = (tool) => {
5657
6995
  if (!session) return;
5658
6996
  const heat = (tool?.heat ?? 0) * heatMax;
5659
- const level = Math.max(0, Math.min(1, (heat - 50) / 50));
6997
+ const level = Math.max(0, Math.min(1, (heat - visual.glowFrom) / visual.glowSpan));
5660
6998
  for (const mesh of session.model.parts.glow) {
5661
6999
  const material = mesh.material;
5662
7000
  material.emissive.setRGB(level, 0.2 * level * level, 0.02 * level * level);
@@ -5664,56 +7002,73 @@ function createViewmodel(options) {
5664
7002
  }
5665
7003
  };
5666
7004
  const handleShot = (current, event, tool) => {
7005
+ secondsSinceShot = 0;
7006
+ if (event.suppressed !== void 0) current.suppressed = event.suppressed;
7007
+ else if (tool.suppressed !== void 0) current.suppressed = tool.suppressed;
7008
+ else current.suppressed = hasSuppressor(tool.attachments ? fittedOf2(tool.attachments) : current.def.fitted ?? []);
5667
7009
  if (tool.underbarrelActive && current.model.parts.uglMuzzle) {
5668
7010
  audio?.play("explosion", LAUNCH_VOLUME, distance());
5669
7011
  return;
5670
7012
  }
5671
7013
  const action = current.def.action;
5672
7014
  if (action.kind === "launcher") {
5673
- launchRocket(heavy(current));
7015
+ launchRocket(heavy(current), options.flyRocket !== false);
5674
7016
  audio?.play("explosion", LAUNCH_VOLUME, distance());
5675
7017
  }
5676
7018
  if (action.kind !== "firearm") return;
5677
7019
  const caliber = caliberOf(arsenal, current.def);
5678
7020
  if (!caliber) return;
5679
- audio?.shot(muzzleEnergy3(caliber) * (caliber.pellets ?? 1), current.def.category, { caliber, distance: distance(), ...event.suppressed === true ? { suppressed: true } : {} });
7021
+ audio?.shot(muzzleEnergy3(caliber) * (caliber.pellets ?? 1), current.def.category, { caliber, distance: distance(), ...current.suppressed ? { suppressed: true } : {} });
5680
7022
  };
5681
7023
  return {
5682
7024
  equip,
5683
7025
  unequip: release,
5684
7026
  handle(event, tool) {
5685
- if (event.type === "explosion") return;
7027
+ if (event.type === "explosion" || event.type === "player") return;
5686
7028
  if (event.type === "tool") equip(event.weapon);
5687
7029
  if (!session || !player || event.weapon !== session.id) return;
5688
7030
  const current = session;
5689
- if (event.type === "shot") handleShot(current, event, tool);
5690
- else if (event.type === "reload" && event.phase === "start") current.nextChamber = 0;
7031
+ if (event.type === "shot") {
7032
+ lastShot = clock;
7033
+ handleShot(current, event, tool);
7034
+ } else if (event.type === "reload" && event.phase === "start") {
7035
+ current.nextChamber = 0;
7036
+ reloading = true;
7037
+ } else if (event.type === "reload" && event.phase === "end") reloading = false;
5691
7038
  else if (event.type === "clear" && event.phase === "end") current.stuckCase = null;
5692
7039
  const mechanical = weaponEventOf(event);
5693
- if (mechanical) audio?.event(mechanical);
7040
+ const cued = mechanical?.type === "feed" ? feedCued : mechanical?.type === "reload_stage" && (player.running.some(({ clip }) => clip.events?.some((entry) => entry.type === "sound")) ?? false);
7041
+ if (mechanical && !cued) audio?.event(mechanical);
5694
7042
  const step = toolAnimationStep(event);
5695
7043
  if (!step) return;
5696
7044
  const last = step.type === "play" && step.clip === "shot" && current.model.parts.family === "pistol" && tool.ammo === 0;
5697
7045
  player.apply(last ? { ...step, variant: "last" } : underbarrelStep(step, tool));
5698
7046
  },
5699
7047
  update(dt) {
7048
+ clock += dt;
5700
7049
  if (session) {
5701
- place();
7050
+ place(dt);
5702
7051
  player?.update(dt);
7052
+ const wantsMagazine = reloading || options.tool?.().reloading === true;
7053
+ magazineHand += ((wantsMagazine ? 1 : 0) - magazineHand) * Math.min(1, dt * HAND_HOLD.magRate);
5703
7054
  const tool = options.tool?.();
5704
7055
  syncAttachments(session, tool);
5705
7056
  session.attachments?.update();
5706
7057
  glow(tool);
5707
7058
  if (tool && session.def.action.kind === "flame") {
5708
- if (tool.ammo < lastAmmo) flameJet(heavy(session), dt, session.model.parts.launcherType === "flame" ? 1 : 0.6);
7059
+ if (tool.ammo < lastAmmo) {
7060
+ flameJet(heavy(session), dt, session.model.parts.launcherType === "flame" ? 1 : 0.6);
7061
+ lastShot = clock;
7062
+ }
5709
7063
  lastAmmo = tool.ammo;
5710
7064
  }
5711
- if (tool && tool.heat !== void 0 && tool.heat * heatMax > 10 && session.model.parts.muzzle) {
5712
- smokeDebt += dt * tool.heat * heatMax * 0.1;
7065
+ secondsSinceShot += dt;
7066
+ if (tool && tool.heat !== void 0 && tool.heat * heatMax > visual.smokeFrom && secondsSinceShot < visual.smokeSeconds && session.model.parts.muzzle) {
7067
+ smokeDebt += dt * (tool.heat * heatMax - visual.smokeFrom) * 0.1;
5713
7068
  while (smokeDebt > 1) {
5714
7069
  smokeDebt -= 1;
5715
- const k = 0.5 + tool.heat * heatMax / 60;
5716
- effects.smoke(worldPositionOf(session, session.model.parts.muzzle, new THREE.Vector3()), new THREE.Vector3((random() - 0.5) * 0.03, 0.07 + random() * 0.08, (random() - 0.5) * 0.03), 7e-3 * k, 1.6 + random(), Math.min(0.42, 0.14 + k * 0.1));
7070
+ const k = 0.5 + (tool.heat * heatMax - visual.smokeFrom) / 60;
7071
+ effects.smoke(worldPositionOf(session, session.model.parts.muzzle, new THREE.Vector3()), new THREE.Vector3((random() - 0.5) * 0.015, 0.07 + random() * 0.08, (random() - 0.5) * 0.015), 4e-3 * k, 1.2 + random() * 0.6, Math.min(0.18, 0.06 + k * 0.04));
5717
7072
  }
5718
7073
  }
5719
7074
  }
@@ -5734,9 +7089,25 @@ function createViewmodel(options) {
5734
7089
  condition = next;
5735
7090
  if (session) applyCondition(kit, session.model, condition);
5736
7091
  },
7092
+ handTargets() {
7093
+ if (!session || !hold) return null;
7094
+ hand.updateMatrixWorld(true);
7095
+ const targets = targetsOf(THREE, hold, session.model.root.getWorldQuaternion(rootQuaternion), magazineHand, hold.works ? workWeights(hold.works, player?.running ?? []) : void 0);
7096
+ const trigger = session.model.parts.trigger;
7097
+ if (trigger) targets.fingers = { index: indexCurl(trigger.rotation.z - triggerRest, clock - lastShot) };
7098
+ if (targets.R && "fov" in camera) {
7099
+ seen.set(...targets.R.position).sub(camera.position).applyQuaternion(eyeTurn.copy(camera.quaternion).invert());
7100
+ handDepth = -seen.z;
7101
+ handHeight = handDepth > 0.05 ? seen.y / handDepth / Math.tan((camera.fov ?? 70) * PI14 / 360) : 0;
7102
+ }
7103
+ return targets;
7104
+ },
5737
7105
  get inHand() {
5738
7106
  return session?.id ?? null;
5739
7107
  },
7108
+ get clips() {
7109
+ return (player?.running ?? []).map(({ name, clip }) => ({ name, seconds: clip.duration }));
7110
+ },
5740
7111
  get casings() {
5741
7112
  return particles.count("casing");
5742
7113
  },
@@ -5759,6 +7130,7 @@ function createWeaponFx(options) {
5759
7130
  const model = createViewmodel({
5760
7131
  layers,
5761
7132
  ...options.soundDistance ? { soundDistance: options.soundDistance } : {},
7133
+ ...options.onSound ? { onSound: options.onSound } : {},
5762
7134
  THREE: options.THREE,
5763
7135
  scene: options.scene,
5764
7136
  camera: options.camera,
@@ -5770,11 +7142,13 @@ function createWeaponFx(options) {
5770
7142
  ...options.condition ? { condition: options.condition } : {},
5771
7143
  ...options.attachmentModels ? { attachmentModels: options.attachmentModels } : {},
5772
7144
  ...options.raycast ? { raycast: options.raycast } : {},
5773
- ...options.effects ? { effects: options.effects } : {}
7145
+ ...options.contact ? { contact: options.contact } : {},
7146
+ ...options.effects ? { effects: options.effects } : {},
7147
+ ...options.flyRocket === void 0 ? {} : { flyRocket: options.flyRocket }
5774
7148
  });
5775
7149
  return {
5776
7150
  handle(event, tool) {
5777
- if (event.type === "explosion") return;
7151
+ if (event.type === "explosion" || event.type === "player") return;
5778
7152
  if (event.type === "tool") {
5779
7153
  if (hasWeaponModel(event.weapon)) box2.unequip();
5780
7154
  else model.unequip();
@@ -5787,10 +7161,14 @@ function createWeaponFx(options) {
5787
7161
  model.update(dt);
5788
7162
  },
5789
7163
  setCondition: (condition) => model.setCondition(condition),
7164
+ handTargets: () => model.handTargets() ?? box2.handTargets(),
5790
7165
  layers,
5791
7166
  get inHand() {
5792
7167
  return model.inHand ?? box2.inHand;
5793
7168
  },
7169
+ get clips() {
7170
+ return model.clips;
7171
+ },
5794
7172
  get casings() {
5795
7173
  return box2.casings + model.casings;
5796
7174
  },
@@ -6536,21 +7914,34 @@ function weaponThumbnailUrl(id, base = DEFAULT_THUMBNAIL_BASE) {
6536
7914
  }
6537
7915
 
6538
7916
  // src/world-explosives.ts
7917
+ import { buildItem } from "@adatechnology/game-items-three";
6539
7918
  var WORLD_EXPLOSIVE_PREFIX = "world_";
7919
+ var P13_VALVE_MESH = "valve";
7920
+ var P13_VALVE_COLOR = 12095802;
7921
+ var P13_WALL = 25e-4;
6540
7922
  var SHAPES2 = {
6541
7923
  fuel_barrel: { shape: { kind: "cylinder", radius: 0.3, height: 0.9 }, position: [0, 0.45, 0], color: 3104666 },
6542
- propane_p13: { shape: { kind: "cylinder", radius: 0.15, height: 0.48 }, position: [0, 0.24, 0], color: 13620184 },
7924
+ propane_p13: {
7925
+ shape: { kind: "cylinder", radius: 0.176, height: 0.49 },
7926
+ position: [0, 0.245, 0],
7927
+ color: 3497862,
7928
+ model: { id: "lpg_cylinder_13kg", category: "fuel", size: [0.35299, 0.49, 0.35844], model: "lpg_cylinder_13kg" },
7929
+ wall: P13_WALL
7930
+ },
6543
7931
  car_fuel_tank: { shape: { kind: "box", size: [0.9, 0.25, 0.5] }, position: [0, 0.4, 0], color: 4935509 }
6544
7932
  };
6545
7933
  var WORLD_EXPLOSIVE_IDS = Object.keys(SHAPES2);
6546
7934
  var SHELL = { thickness: 1e-3, fill: 0.03 };
6547
- var explosivePart = (def, shape) => ({
7935
+ var explosivePart = (def, shape, scale) => ({
6548
7936
  id: "tank",
6549
7937
  material: "steel",
6550
7938
  shape: shape.shape,
6551
7939
  position: shape.position,
6552
- ...SHELL,
6553
- explodes: { energy: def.energy, radius: def.injuryRadius, effect: def.id, ...def.chain ? { chain: { ...def.chain } } : {} }
7940
+ ...shape.wall === void 0 ? SHELL : { thickness: shape.wall },
7941
+ // a real mass (the vessel and what it holds) replaces the hollow-sheet density
7942
+ ...def.mass !== void 0 && shape.wall !== void 0 ? { mass: def.mass } : {},
7943
+ ...def.leak ? { contains: { fluid: def.leak.fluid, liters: def.leak.liters, pressurized: def.leak.pressurized } } : {},
7944
+ explodes: { energy: def.energy * scale, radius: def.injuryRadius * scale, lethalRadius: def.lethalRadius * scale, effect: def.id, ...def.chain ? { chain: { ...def.chain } } : {} }
6554
7945
  });
6555
7946
  var meshOf = (THREE, shape) => {
6556
7947
  const geometry = shape.shape.kind === "cylinder" ? new THREE.CylinderGeometry(shape.shape.radius, shape.shape.radius, shape.shape.height, 16) : shape.shape.kind === "box" ? new THREE.BoxGeometry(...shape.shape.size) : new THREE.SphereGeometry(0.2);
@@ -6562,16 +7953,55 @@ var meshOf = (THREE, shape) => {
6562
7953
  mesh.receiveShadow = true;
6563
7954
  return mesh;
6564
7955
  };
6565
- var worldExplosiveItem = (def, name) => {
7956
+ var modelOf = (THREE, shape, wear = 0) => {
7957
+ if (!shape.model) return void 0;
7958
+ const { root, kind } = buildItem(THREE, shape.model, { wear });
7959
+ if (kind !== "model") return void 0;
7960
+ root.traverse((node) => {
7961
+ const mesh = node;
7962
+ if (!mesh.isMesh) return;
7963
+ mesh.userData.destructionPart = "tank";
7964
+ mesh.castShadow = true;
7965
+ mesh.receiveShadow = true;
7966
+ const material = mesh.material;
7967
+ if (material.color?.getHex() === P13_VALVE_COLOR) mesh.name = P13_VALVE_MESH;
7968
+ });
7969
+ return root;
7970
+ };
7971
+ var worldExplosiveValve = (THREE, root) => {
7972
+ const valve = root.getObjectByName(P13_VALVE_MESH);
7973
+ if (!valve) return void 0;
7974
+ root.updateMatrixWorld(true);
7975
+ const centre = new THREE.Box3().setFromObject(valve).getCenter(new THREE.Vector3());
7976
+ return [centre.x, centre.y, centre.z];
7977
+ };
7978
+ var worldExplosiveShell = (THREE, defId) => {
7979
+ const shape = SHAPES2[defId];
7980
+ const model = shape ? modelOf(THREE, shape, 2) : void 0;
7981
+ if (!model) return void 0;
7982
+ model.traverse((node) => {
7983
+ const mesh = node;
7984
+ if (!mesh.isMesh) return;
7985
+ delete mesh.userData.destructionPart;
7986
+ mesh.material.color.multiplyScalar(0.35);
7987
+ });
7988
+ model.name = `${WORLD_EXPLOSIVE_PREFIX}${defId}_shell`;
7989
+ return model;
7990
+ };
7991
+ var worldExplosiveItem = (def, name, options = {}) => {
6566
7992
  const shape = SHAPES2[def.id];
6567
7993
  if (!shape) return void 0;
6568
- const spec = { name, parts: [explosivePart(def, shape)], joints: [], tags: ["explosive"] };
7994
+ const scale = () => options.scale?.() ?? 1;
7995
+ const spec = () => ({ name, parts: [explosivePart(def, shape, scale())], joints: [], tags: ["explosive"] });
6569
7996
  return {
6570
7997
  id: `${WORLD_EXPLOSIVE_PREFIX}${def.id}`,
6571
7998
  name,
6572
7999
  build: (THREE) => {
6573
- const group = new THREE.Group();
6574
- group.add(meshOf(THREE, shape));
8000
+ const T = THREE;
8001
+ const model = modelOf(T, shape);
8002
+ if (model) return model;
8003
+ const group = new T.Group();
8004
+ group.add(meshOf(T, shape));
6575
8005
  return group;
6576
8006
  },
6577
8007
  spec
@@ -6623,6 +8053,100 @@ function createWorldEffects(options) {
6623
8053
  };
6624
8054
  }
6625
8055
 
8056
+ // src/scenarios.ts
8057
+ var TARGET_Z = -4;
8058
+ var SHOOTER_Z = -1.5;
8059
+ function weaponsScenarios(locale) {
8060
+ const pt = locale.toLowerCase().startsWith("pt");
8061
+ const T = (p, e) => pt ? p : e;
8062
+ const faceY = (id) => id === "target_steel_plate" ? 1.35 : id === "target_plywood" ? 1.5 : 1.2;
8063
+ const aimAt = (room, id) => room.look([0, faceY(id) + 0.05, SHOOTER_Z], [0, faceY(id), TARGET_Z]);
8064
+ return [
8065
+ {
8066
+ id: "target_steel_heavy_through_hole",
8067
+ name: T("Alvo de a\xE7o: um .50 BMG do Barrett M82A1 atravessa a chapa e o 9 mm s\xF3 amassa", "Steel target: the .50 BMG of the Barrett M82A1 goes through the plate and the 9 mm only dents it"),
8068
+ run: ({ room, step, expect }) => {
8069
+ const id = room.spawn("target_steel_plate", [0, 0, TARGET_Z]).objectId;
8070
+ room.setTool("pistol_9mm");
8071
+ room.waitUntilReady();
8072
+ aimAt(room, "target_steel_plate");
8073
+ room.use();
8074
+ step(0.3);
8075
+ const pistol = room.world.part(id, "face");
8076
+ expect((pistol?.dents.length ?? 0) > 0 && !pistol?.holes.some((h) => h.through), T(`9 mm na chapa de 10 mm: amassados ${pistol?.dents.length ?? 0}, furos que atravessam ${pistol?.holes.filter((h) => h.through).length ?? 0} (esperado: amassa e n\xE3o atravessa)`, `9 mm on the 10 mm plate: dents ${pistol?.dents.length ?? 0}, through holes ${pistol?.holes.filter((h) => h.through).length ?? 0} (expected: dents it, no through hole)`));
8077
+ room.setTool("barrett_m82a1");
8078
+ room.waitUntilReady();
8079
+ aimAt(room, "target_steel_plate");
8080
+ room.use();
8081
+ step(0.3);
8082
+ const rifle = room.world.part(id, "face");
8083
+ const hole = rifle?.holes.find((h) => h.through);
8084
+ expect(!!hole, T(`.50 BMG na chapa: furo que atravessa ${hole ? "sim" : "n\xE3o"} (furos: ${rifle?.holes.length ?? 0})`, `.50 BMG on the plate: through hole ${hole ? "yes" : "no"} (holes: ${rifle?.holes.length ?? 0})`), { holes: rifle?.holes.length });
8085
+ }
8086
+ },
8087
+ {
8088
+ id: "target_concrete_stops_bullet",
8089
+ name: T("Alvo de concreto: um 9 mm n\xE3o atravessa o bloco", "Concrete target: a 9 mm does not go through the block"),
8090
+ run: ({ room, step, expect }) => {
8091
+ const id = room.spawn("target_concrete", [0, 0, TARGET_Z]).objectId;
8092
+ room.setTool("pistol_9mm");
8093
+ room.waitUntilReady();
8094
+ aimAt(room, "target_concrete");
8095
+ room.use();
8096
+ step(0.2);
8097
+ const face = room.world.part(id, "face");
8098
+ const through = face?.holes.some((h) => h.through) ?? true;
8099
+ const marks = (face?.holes.length ?? 0) + (face?.dents.length ?? 0) + (face?.cracks.length ?? 0);
8100
+ expect(!through && marks > 0, T(`Bloco de concreto: atravessou ${through ? "sim" : "n\xE3o"}, marcas ${marks} (esperado: n\xE3o atravessar e marcar)`, `Concrete block: went through ${through ? "yes" : "no"}, marks ${marks} (expected: stopped and marked)`), { through, marks });
8101
+ }
8102
+ },
8103
+ {
8104
+ id: "target_plywood_shotgun_holes",
8105
+ name: T("Alvo de compensado: a escopeta de ferrolho crava v\xE1rios chumbos na madeira", "Plywood target: the pump shotgun drives several pellets through the wood"),
8106
+ run: ({ room, step, expect }) => {
8107
+ const id = room.spawn("target_plywood", [0, 0, TARGET_Z]).objectId;
8108
+ room.setTool("shotgun_pump_12");
8109
+ room.waitUntilReady();
8110
+ aimAt(room, "target_plywood");
8111
+ room.use();
8112
+ step(1.1);
8113
+ const holes = room.world.part(id, "face")?.holes.length ?? 0;
8114
+ expect(holes >= 3, T(`Chumbos na madeira num tiro: ${holes} furos (esperado: 3 ou mais)`, `Pellets in the wood from one shot: ${holes} holes (expected: 3 or more)`), { holes });
8115
+ }
8116
+ },
8117
+ {
8118
+ id: "target_steel_hammer_dents",
8119
+ name: T("Alvo de a\xE7o: a marreta amassa a chapa sem romper", "Steel target: the hammer dents the plate without breaking it"),
8120
+ run: ({ room, step, expect }) => {
8121
+ const id = room.spawn("target_steel_plate", [0, 0, TARGET_Z]).objectId;
8122
+ room.setTool("hammer");
8123
+ room.waitUntilReady();
8124
+ room.look([0, 1.4, TARGET_Z + 0.65], [0, 1.35, TARGET_Z]);
8125
+ let swings = 0;
8126
+ for (; swings < 8 && (room.world.part(id, "face")?.dents.length ?? 0) === 0; swings++) {
8127
+ room.use();
8128
+ step(1);
8129
+ }
8130
+ const face = room.world.part(id, "face");
8131
+ expect((face?.dents.length ?? 0) > 0 && face?.status !== "broken", T(`Amassados na chapa depois de ${swings} golpe(s): ${face?.dents.length ?? 0}`, `Dents in the plate after ${swings} swing(s): ${face?.dents.length ?? 0}`), { swings });
8132
+ }
8133
+ },
8134
+ {
8135
+ id: "target_steel_torch_melts",
8136
+ name: T("Alvo de a\xE7o: o ma\xE7arico de corte derrete a chapa", "Steel target: the cutting torch melts the plate"),
8137
+ run: ({ room, expect }) => {
8138
+ const id = room.spawn("target_steel_plate", [0, 0, TARGET_Z]).objectId;
8139
+ room.setTool("cutting_torch");
8140
+ room.waitUntilReady();
8141
+ room.look([0, faceY("target_steel_plate") + 0.05, TARGET_Z + 0.2], [0, faceY("target_steel_plate"), TARGET_Z]);
8142
+ room.use(20);
8143
+ const face = room.world.part(id, "face");
8144
+ expect((face?.melt ?? 0) > 0.02 || face?.status === "melted" || (face?.holes.length ?? 0) > 0, T(`Chapa depois do ma\xE7arico: ${Math.round(face?.temperature ?? 0)} \xB0C, derretido ${Math.round((face?.melt ?? 0) * 100)}%`, `Plate after the torch: ${Math.round(face?.temperature ?? 0)} \xB0C, ${Math.round((face?.melt ?? 0) * 100)}% melted`), { temperature: face?.temperature, melt: face?.melt });
8145
+ }
8146
+ }
8147
+ ];
8148
+ }
8149
+
6626
8150
  // src/area.ts
6627
8151
  var WEAPON_ITEM_PREFIX = "weapon_";
6628
8152
  var COUNTER_ITEM_ID = "counter_weapons";
@@ -6658,6 +8182,10 @@ var TEXT = {
6658
8182
  counter: "Weapon counter",
6659
8183
  targets: { target_steel_plate: "Steel plate target", target_plywood: "Plywood target", target_concrete: "Concrete block target" },
6660
8184
  reloadStart: "Reload started: {weapon} ({ammo}/{capacity})",
8185
+ reloadClip: "Reload of {weapon}: {kind}, clip {clip} ({seconds} s)",
8186
+ soundCue: "Sound: {cue} ({weapon})",
8187
+ soundCues: { mag_release: "magazine release button", mag_out: "magazine slides out", mag_insert: "magazine slides in", mag_seat: "magazine seats (click)", slide_release: "slide snaps forward", charging_handle_pull: "charging handle pulled", charging_handle_release: "charging handle released", bolt_lift: "bolt handle lifted", bolt_pull: "bolt pulled back", bolt_push: "bolt pushed forward", bolt_lock: "bolt handle locked down", shell_insert: "round inserted", pump_back: "pump back", pump_forward: "pump forward", lever_open: "lever down", lever_close: "lever up", cylinder_open: "cylinder opens", cylinder_eject: "cases fall out", speedloader: "speed loader empties", cylinder_close: "cylinder closes", barrels_open: "barrels open", casings_eject: "cases pop out", barrels_close: "barrels close", belt_cover_open: "cover opens", belt_insert: "belt seats", belt_cover_close: "cover closes", rocket_load: "rocket slides in", rocket_seat: "rocket seats", grenade_load: "grenade goes in", breech_open: "breech opens", breech_close: "breech closes", tube_drop: "empty tube dropped", tube_seat: "new tube seats" },
8188
+ reloadKinds: { tactical: "tactical (round chambered, no pull)", empty: "empty chamber (works the action)", one_by_one: "one by one", full: "speed loader" },
6661
8189
  reloadEnd: "Reload done: {weapon} ({ammo}/{capacity})",
6662
8190
  worldExplosives: { fuel_barrel: "Fuel barrel", propane_p13: "Propane cylinder (P13)", car_fuel_tank: "Car fuel tank" },
6663
8191
  mountedM134: "M134 Minigun on its tripod",
@@ -6680,6 +8208,10 @@ var TEXT = {
6680
8208
  counter: "Balc\xE3o de armas",
6681
8209
  targets: { target_steel_plate: "Alvo: chapa de a\xE7o", target_plywood: "Alvo: compensado", target_concrete: "Alvo: bloco de concreto" },
6682
8210
  reloadStart: "Recarga iniciada: {weapon} ({ammo}/{capacity})",
8211
+ reloadClip: "Recarga de {weapon}: {kind}, clipe {clip} ({seconds} s)",
8212
+ soundCue: "Som: {cue} ({weapon})",
8213
+ soundCues: { mag_release: "bot\xE3o do carregador", mag_out: "carregador desliza para fora", mag_insert: "carregador desliza para dentro", mag_seat: "carregador encaixa (clique)", slide_release: "ferrolho volta com estalo", charging_handle_pull: "alavanca de armar puxada", charging_handle_release: "alavanca de armar solta", bolt_lift: "alavanca do ferrolho levantada", bolt_pull: "ferrolho puxado", bolt_push: "ferrolho empurrado", bolt_lock: "alavanca do ferrolho travada", shell_insert: "cartucho entra", pump_back: "bomba para tr\xE1s", pump_forward: "bomba para frente", lever_open: "alavanca para baixo", lever_close: "alavanca para cima", cylinder_open: "tambor abre", cylinder_eject: "c\xE1psulas caem", speedloader: "speedloader esvazia", cylinder_close: "tambor fecha", barrels_open: "canos abrem", casings_eject: "estojos saltam", barrels_close: "canos fecham", belt_cover_open: "tampa abre", belt_insert: "cinta encaixa", belt_cover_close: "tampa fecha", rocket_load: "foguete desliza para dentro", rocket_seat: "foguete encaixa", grenade_load: "granada entra", breech_open: "culatra abre", breech_close: "culatra fecha", tube_drop: "tubo vazio cai", tube_seat: "tubo novo encaixa" },
8214
+ reloadKinds: { tactical: "t\xE1tica (com bala na c\xE2mara, sem puxar)", empty: "vazia (puxa o ferrolho ou a trava)", one_by_one: "uma a uma", full: "carregador r\xE1pido" },
6683
8215
  reloadEnd: "Recarga completa: {weapon} ({ammo}/{capacity})",
6684
8216
  worldExplosives: { fuel_barrel: "Barril de combust\xEDvel", propane_p13: "Botij\xE3o de g\xE1s (P13)", car_fuel_tank: "Tanque de combust\xEDvel do carro" },
6685
8217
  mountedM134: "M134 Minigun no trip\xE9",
@@ -6832,6 +8364,7 @@ function createWeaponsTestModule(options = {}) {
6832
8364
  ...worldExplosives.map((item) => withThumbnail(item, item.id.slice(WORLD_EXPLOSIVE_PREFIX.length))),
6833
8365
  ...mountedM134.map((item) => withThumbnail(item, MOUNTED_M134.weaponId))
6834
8366
  ],
8367
+ scenarios: (scenarioLocale) => weaponsScenarios(scenarioLocale ?? locale),
6835
8368
  area: () => ({
6836
8369
  placements: [
6837
8370
  ...weaponsLayout(weaponIds, pitchOf).filter((placement) => !placement.item.startsWith(WORLD_EXPLOSIVE_PREFIX) || worldExplosiveIds.has(placement.item)),
@@ -6874,7 +8407,10 @@ function createWeaponsTestModule(options = {}) {
6874
8407
  audio.dispose();
6875
8408
  audio = createWeaponAudio(void 0, { ...seedOption, engine: lentEngine });
6876
8409
  }
8410
+ let simulatedMs = 0;
8411
+ const offClock = lent.onFrame((dt) => simulatedMs += dt * 1e3);
6877
8412
  const created = createWeaponFx({
8413
+ onSound: ({ weapon, cue: cue2, profile }) => lent.log(fill(text.soundCue, { weapon: arsenal.name(weapon), cue: text.soundCues[cue2] ?? cue2 }), { weapon, cue: cue2, weight: profile.weight, material: profile.material }, "sound"),
6878
8414
  THREE: lent.THREE,
6879
8415
  scene: lent.scene,
6880
8416
  camera: lent.camera,
@@ -6884,10 +8420,13 @@ function createWeaponsTestModule(options = {}) {
6884
8420
  tool: lent.tool,
6885
8421
  soundDistance: () => soundDistance,
6886
8422
  raycast: createLaserRaycast(lent.THREE, lent.scene, lent.camera),
8423
+ // the weapon against a wall asks as often as the laser, on the clock of the simulation (so a fast-forwarded room measures as often per frame)
8424
+ contact: createLaserRaycast(lent.THREE, lent.scene, lent.camera, { now: () => simulatedMs }),
6887
8425
  ...lent.effects ? { effects: lent.effects } : {},
6888
8426
  ...options.attachmentModels ? { attachmentModels: options.attachmentModels } : {},
6889
8427
  ...options.condition ? { condition: options.condition } : {},
6890
- ...options.seed === void 0 ? {} : { seed: options.seed }
8428
+ ...options.seed === void 0 ? {} : { seed: options.seed },
8429
+ ...options.flyRocket === void 0 ? {} : { flyRocket: options.flyRocket }
6891
8430
  });
6892
8431
  fx = created;
6893
8432
  const camera = lent.camera;
@@ -6918,15 +8457,25 @@ function createWeaponsTestModule(options = {}) {
6918
8457
  }
6919
8458
  const tool = lent.tool();
6920
8459
  created.handle(event, tool);
8460
+ if (event.type === "reload" && event.phase === "start") {
8461
+ const clip = created.clips.find((entry) => entry.name.startsWith("reload"));
8462
+ if (clip) {
8463
+ const kind = event.kind ?? "tactical";
8464
+ lent.log(fill(text.reloadClip, { weapon: arsenal.name(event.weapon), kind: text.reloadKinds[kind] ?? kind, clip: clip.name, seconds: clip.seconds.toFixed(2) }), { weapon: event.weapon, kind, clip: clip.name, seconds: clip.seconds });
8465
+ }
8466
+ }
6921
8467
  if (event.type === "reload") lent.log(fill(event.phase === "start" ? text.reloadStart : text.reloadEnd, { weapon: arsenal.name(event.weapon), ammo: tool.ammo, capacity: tool.capacity }), { weapon: event.weapon, phase: event.phase });
6922
8468
  });
6923
8469
  const offFrame = lent.onFrame((dt) => created.update(dt));
8470
+ const offHands = lent.setHandTargets?.(() => created.handTargets());
6924
8471
  created.handle({ type: "tool", weapon: lent.tool().weapon }, lent.tool());
6925
8472
  return () => {
6926
8473
  accessories.dispose();
6927
8474
  offBench?.();
6928
8475
  offEvents();
6929
8476
  offFrame();
8477
+ offClock();
8478
+ offHands?.();
6930
8479
  created.dispose();
6931
8480
  audio?.dispose();
6932
8481
  fx = null;
@@ -6935,6 +8484,973 @@ function createWeaponsTestModule(options = {}) {
6935
8484
  }
6936
8485
  };
6937
8486
  }
8487
+
8488
+ // src/explosives-area.ts
8489
+ import { EXPLOSIONS } from "@adatechnology/game-vfx";
8490
+ import { createArsenal as createArsenal2 } from "@adatechnology/game-weapons";
8491
+
8492
+ // src/explosives-text.ts
8493
+ var EXPLOSIVES_TEXT = {
8494
+ en: {
8495
+ group: "Explosives",
8496
+ name: "Explosives area",
8497
+ cylinder: "Propane cylinder (P13)",
8498
+ actions: {
8499
+ shoot: { name: "Shoot the cylinder", hint: "Equips the 9 mm pistol, aims at the cylinder and fires: a hole in the shell, a white gas jet, a torch, the blast" },
8500
+ valve: { name: "Open the valve", hint: "Opens (or closes) the valve: the gas leaks with no fire until a flame reaches it" },
8501
+ light: { name: "Light it (lighter)", hint: "Holds a lighter flame at the valve: a gas leak catches fire and the cylinder goes off" },
8502
+ fire: { name: "Throw it in the fire", hint: "Lights a fire next to the cylinder (and the table): the shell heats up until the fire chain sets it off" },
8503
+ reset: { name: "Restart", hint: "Lays the scene again: a new cylinder, wall, furniture and people" }
8504
+ },
8505
+ blastScale: { name: "Blast strength (game multiplier)", hint: "Multiplies the energy and the lethal and injury radii of the P13 for the next one laid (restarts the scene). 1 is the arsenal; the arsenal itself is changed with ArsenalOptions.patch.explosives.propane_p13" },
8506
+ layers: { rings: "Lethal and injury rings" },
8507
+ metrics: { gas: "Gas left in the cylinder", clock: "Seconds since the first leak", phase: "Phase" },
8508
+ phases: { rest: "At rest", leaking: "Leaking gas", torch: "Burning jet", primed: "About to go off", exploded: "Exploded" },
8509
+ rings: { lethal: "lethal", injury: "injury" },
8510
+ triggers: { shot: "a bullet", fire: "fire", explosion: "another blast" },
8511
+ things: { zombie: "Zombie", survivor: "Survivor", fridge: "Fridge", kitchen_cabinet_base: "Kitchen cabinet", stove: "Stove", camp_table: "Table", can: "Can of beans", bottle: "Water bottle", chair: "Chair" },
8512
+ verdicts: { wall_pieces: "broke loose into {pieces} pieces and cracked ({n} marks)", lost_parts: "lost {n} parts", dead: "dead", hurt: "hurt (vitality {vitality}%)", unhurt: "unhurt", wall_broken: "cracked and lost {n} brick panels", wall_cracked: "cracked ({n} marks)", wall_intact: "intact", burning: "burning", standing: "standing" },
8513
+ log: {
8514
+ leak_shot: "Hole of {diameter} mm in the shell: gas leaking at {rate} l/s ({liters} l left in the cylinder)",
8515
+ leak_valve: "Valve open: gas leaking at {rate} l/s with no fire ({liters} l left in the cylinder)",
8516
+ valve_closed: "Valve closed: the leak stopped ({liters} l left)",
8517
+ valve_closed_late: "Too late to close the valve: the cylinder is already going off",
8518
+ primed: "The cylinder was set off by {trigger}: it goes off in {delay} s",
8519
+ jet_lit: "The jet caught fire (torch) {seconds} s after the hole; the cylinder goes off in {rest} s",
8520
+ jet_lit_by: "The leaking gas caught fire (torch) from {by}; the cylinder goes off in {rest} s",
8521
+ lighter_none: "Lighter flame at the valve: no gas is leaking, nothing happens",
8522
+ lighter_lit: "Lighter flame at the leaking valve: the gas lights",
8523
+ fire_set: "Fire lit next to the cylinder ({temperature} \xB0C, radius {radius} m): the shell heats up",
8524
+ heating: "Shell at {temperature} \xB0C (the fire chain starts at {limit} \xB0C)",
8525
+ explode: "Cylinder exploded: fireball of {fireball} m, lethal up to {lethal} m, injuries up to {injury} m (energy {energy} kJ, strength x{scale})",
8526
+ shell: "The torn shell flew up at {speed} m/s trailing fire for {trail} s",
8527
+ shell_landed: "The shell came down {distance} m away after {seconds} s",
8528
+ residual: "Residual fire of {radius} m for {seconds} s",
8529
+ aftermath_person: "{who} at {distance} m: {verdict}",
8530
+ aftermath_wall: "Wall behind the cylinder: {verdict}",
8531
+ aftermath_object: "{what} at {distance} m: {verdict}",
8532
+ reset: "Scene laid again with a blast strength of x{scale}",
8533
+ scale: "Blast strength x{scale}: energy {energy} kJ, lethal {lethal} m, injury {injury} m (valid for the next cylinder; the scene restarts)",
8534
+ no_room: "The explosives area was loaded without the room: this action needs it",
8535
+ shoot: "9 mm shot at the cylinder from {distance} m",
8536
+ no_vessel: "There is no cylinder in the scene (restart it)"
8537
+ },
8538
+ sheet: {
8539
+ kicker: "P13 cooking gas cylinder",
8540
+ title: "What happens to a P13 under a bullet, a flame or a blast",
8541
+ body: "Every kitchen has one. It goes off from a bullet or from fire, and it chains. The sequence is the Explosives Lab one (SC.p13): the gas jet hisses, catches fire as a torch, and the shell goes off.",
8542
+ facts: { energy: "Energy", lethal: "Lethal radius", injury: "Injury radius", gas: "Gas", mass: "Mass", torch: "Torch after the hole", shot_chain: "Blast after a bullet", fire_chain: "Blast after fire" },
8543
+ steps: [
8544
+ { at: "0 s", text: "The bullet goes through the 2.5 mm steel shell: a white gas jet leaves the hole, hissing and shoving the cylinder" },
8545
+ { at: "0.8 s", text: "The jet catches fire and burns as a torch at the hole" },
8546
+ { at: "1.4 s", text: "The fuel fireball (light 70), the mushroom rises 7 m over 8 s, the shell flies up at 13 m/s trailing fire" },
8547
+ { at: "1.4 s - 8.4 s", text: "Residual fire of 1.4 m for 7 s; walls, furniture and people inside the rings take the blast" }
8548
+ ],
8549
+ segments: { jet: "White gas jet", torch: "Torch", blast: "Blast" },
8550
+ note: "Opening the valve leaks the gas with no fire; a flame near it lights the leak and the cylinder goes off 1.4 s later. The numbers come from the arsenal (propane_p13); the strength multiplier is a game value."
8551
+ },
8552
+ scenarios: {
8553
+ shot: "P13 cylinder: a 9 mm through the shell leaks gas, the jet lights and the cylinder goes off with its chain delay",
8554
+ shot_leak: "The shot made a gas leak with a white pressurized jet",
8555
+ shot_ignite: "The jet caught fire (torch) between 0.7 and 1.0 s after the hole",
8556
+ shot_explode: "The cylinder exploded within 1.6 s of the shot",
8557
+ shot_zombie: "The zombie 3 m from the cylinder did not survive the blast",
8558
+ shot_survivor: "The survivor 8 m away, past the lethal ring, is hurt but alive",
8559
+ shot_wall: "The brick wall behind it was marked by the blast",
8560
+ valve: "P13 cylinder: the open valve leaks gas without fire and the cylinder goes off only when a flame lights it",
8561
+ valve_leak: "The open valve leaks gas",
8562
+ valve_quiet: "Four seconds of leaking gas with no flame: no fire and no blast",
8563
+ valve_lit: "The lighter flame lit the leak",
8564
+ valve_explode: "The cylinder went off within 1.6 s of the flame",
8565
+ fire: "P13 cylinder in a fire: the heat sets it off by the fire chain",
8566
+ fire_explode: "The cylinder in the fire exploded",
8567
+ chain: "P13 cylinder: another blast next to it sets it off by the explosion chain",
8568
+ chain_explode: "The second cylinder exploded after the first one",
8569
+ push_p13: "P13 blast: the loose items, the furniture and the people around it are thrown away from it, less the farther they stand",
8570
+ push_grenade: "Thrown M67 grenade: the loose items and the people around it are thrown away from it, less the farther they stand",
8571
+ push_told: "The explosion reached the host as an event",
8572
+ push_found: "{n} bodies were laid around the blast to be measured ({kinds})",
8573
+ push_outward: "Every body inside the reach moved away from the blast ({moved} of {n}, {least} m at the least)",
8574
+ push_falloff: "{what}: the nearer one moved more than the farther one ({near} m at {nearDistance} m, {far} m at {farDistance} m)",
8575
+ push_person: "{who} at {distance} m was pushed {moved} m away",
8576
+ push_heavy: "{what} (too heavy to grab, not fixed) was moved {moved} m by the blast"
8577
+ }
8578
+ },
8579
+ "pt-BR": {
8580
+ group: "Explosivos",
8581
+ name: "\xC1rea de explosivos",
8582
+ cylinder: "Botij\xE3o de g\xE1s (P13)",
8583
+ actions: {
8584
+ shoot: { name: "Atirar no botij\xE3o", hint: "Equipa a pistola 9 mm, mira no botij\xE3o e atira: furo no casco, jato branco de g\xE1s, ma\xE7arico, explos\xE3o" },
8585
+ valve: { name: "Abrir o registro", hint: "Abre (ou fecha) o registro: o g\xE1s vaza sem fogo at\xE9 uma chama encostar" },
8586
+ light: { name: "Acender (isqueiro)", hint: "P\xF5e a chama de um isqueiro no registro: g\xE1s vazando pega fogo e o botij\xE3o explode" },
8587
+ fire: { name: "Jogar no fogo", hint: "Acende um fogo ao lado do botij\xE3o (e da mesa): o casco esquenta at\xE9 a cadeia de fogo detonar" },
8588
+ reset: { name: "Reiniciar", hint: "Monta a cena de novo: botij\xE3o novo, parede, m\xF3veis e pessoas" }
8589
+ },
8590
+ blastScale: { name: "For\xE7a da explos\xE3o (multiplicador de jogo)", hint: "Multiplica a energia e os raios letal e de ferimento do P13 para o pr\xF3ximo botij\xE3o montado (reinicia a cena). 1 \xE9 o arsenal; o arsenal em si muda com ArsenalOptions.patch.explosives.propane_p13" },
8591
+ layers: { rings: "An\xE9is letal e de ferimento" },
8592
+ metrics: { gas: "G\xE1s no botij\xE3o", clock: "Segundos desde o primeiro vazamento", phase: "Fase" },
8593
+ phases: { rest: "Em repouso", leaking: "Vazando g\xE1s", torch: "Jato em chamas", primed: "Prestes a explodir", exploded: "Explodiu" },
8594
+ rings: { lethal: "letal", injury: "ferimento" },
8595
+ triggers: { shot: "um tiro", fire: "fogo", explosion: "outra explos\xE3o" },
8596
+ things: { zombie: "Zumbi", survivor: "Sobrevivente", fridge: "Geladeira", kitchen_cabinet_base: "Arm\xE1rio de cozinha", stove: "Fog\xE3o", camp_table: "Mesa", can: "Lata de feij\xE3o", bottle: "Garrafa de \xE1gua", chair: "Cadeira" },
8597
+ verdicts: { wall_pieces: "se soltou em {pieces} peda\xE7os e trincou ({n} marcas)", lost_parts: "perdeu {n} pe\xE7as", dead: "morto", hurt: "ferido (vitalidade {vitality}%)", unhurt: "ileso", wall_broken: "trincou e perdeu {n} pain\xE9is de tijolo", wall_cracked: "trincou ({n} marcas)", wall_intact: "intacta", burning: "pegando fogo", standing: "de p\xE9" },
8598
+ log: {
8599
+ leak_shot: "Furo de {diameter} mm no casco: g\xE1s vazando a {rate} l/s ({liters} l no botij\xE3o)",
8600
+ leak_valve: "Registro aberto: g\xE1s vazando a {rate} l/s, sem fogo ({liters} l no botij\xE3o)",
8601
+ valve_closed: "Registro fechado: o vazamento parou ({liters} l restantes)",
8602
+ valve_closed_late: "Tarde demais para fechar o registro: o botij\xE3o j\xE1 vai explodir",
8603
+ primed: "O botij\xE3o foi detonado por {trigger}: explode em {delay} s",
8604
+ jet_lit: "O jato pegou fogo (ma\xE7arico) {seconds} s depois do furo; o botij\xE3o explode em {rest} s",
8605
+ jet_lit_by: "O g\xE1s vazando pegou fogo (ma\xE7arico) por {by}; o botij\xE3o explode em {rest} s",
8606
+ lighter_none: "Chama do isqueiro no registro: n\xE3o h\xE1 g\xE1s vazando, nada acontece",
8607
+ lighter_lit: "Chama do isqueiro no registro que vaza: o g\xE1s acendeu",
8608
+ fire_set: "Fogo aceso ao lado do botij\xE3o ({temperature} \xB0C, raio {radius} m): o casco esquenta",
8609
+ heating: "Casco a {temperature} \xB0C (a cadeia de fogo come\xE7a em {limit} \xB0C)",
8610
+ explode: "Botij\xE3o explodiu: bola de fogo de {fireball} m, letal at\xE9 {lethal} m, ferimentos at\xE9 {injury} m (energia {energy} kJ, for\xE7a x{scale})",
8611
+ shell: "O casco rasgado voou para cima a {speed} m/s com rastro de fogo por {trail} s",
8612
+ shell_landed: "O casco caiu a {distance} m depois de {seconds} s",
8613
+ residual: "Fogo residual de {radius} m por {seconds} s",
8614
+ aftermath_person: "{who} a {distance} m: {verdict}",
8615
+ aftermath_wall: "Parede atr\xE1s do botij\xE3o: {verdict}",
8616
+ aftermath_object: "{what} a {distance} m: {verdict}",
8617
+ reset: "Cena montada de novo com for\xE7a de explos\xE3o x{scale}",
8618
+ scale: "For\xE7a da explos\xE3o x{scale}: energia {energy} kJ, letal {lethal} m, ferimento {injury} m (vale para o pr\xF3ximo botij\xE3o; a cena reinicia)",
8619
+ no_room: "A \xE1rea de explosivos foi carregada sem a sala: esta a\xE7\xE3o precisa dela",
8620
+ shoot: "Tiro de 9 mm no botij\xE3o a {distance} m",
8621
+ no_vessel: "N\xE3o h\xE1 botij\xE3o na cena (reinicie)"
8622
+ },
8623
+ sheet: {
8624
+ kicker: "Botij\xE3o de g\xE1s de cozinha P13",
8625
+ title: "O que acontece com um P13 sob um tiro, uma chama ou uma explos\xE3o",
8626
+ body: "Toda cozinha tem um. Explode com tiro ou fogo, e entra em cadeia. A sequ\xEAncia \xE9 a do Laborat\xF3rio de Explos\xF5es (SC.p13): o jato de g\xE1s assobia, pega fogo como ma\xE7arico e o casco explode.",
8627
+ facts: { energy: "Energia", lethal: "Raio letal", injury: "Raio de ferimento", gas: "G\xE1s", mass: "Massa", torch: "Ma\xE7arico ap\xF3s o furo", shot_chain: "Explos\xE3o ap\xF3s um tiro", fire_chain: "Explos\xE3o ap\xF3s fogo" },
8628
+ steps: [
8629
+ { at: "0 s", text: "A bala atravessa o casco de a\xE7o de 2,5 mm: um jato branco de g\xE1s sai do furo, assobiando e sacudindo o botij\xE3o" },
8630
+ { at: "0,8 s", text: "O jato pega fogo e queima como ma\xE7arico no furo" },
8631
+ { at: "1,4 s", text: "A bola de fogo de combust\xEDvel (luz 70), o cogumelo sobe 7 m em 8 s, o casco voa a 13 m/s com rastro de fogo" },
8632
+ { at: "1,4 s - 8,4 s", text: "Fogo residual de 1,4 m por 7 s; paredes, m\xF3veis e pessoas dentro dos an\xE9is levam a explos\xE3o" }
8633
+ ],
8634
+ segments: { jet: "Jato branco de g\xE1s", torch: "Ma\xE7arico", blast: "Explos\xE3o" },
8635
+ note: "Abrir o registro vaza o g\xE1s sem fogo; uma chama perto acende o vazamento e o botij\xE3o explode 1,4 s depois. Os n\xFAmeros v\xEAm do arsenal (propane_p13); o multiplicador de for\xE7a \xE9 um valor de jogo."
8636
+ },
8637
+ scenarios: {
8638
+ shot: "Botij\xE3o P13: um 9 mm no casco vaza g\xE1s, o jato acende e o botij\xE3o explode com o atraso da cadeia",
8639
+ shot_leak: "O tiro fez um vazamento de g\xE1s com jato branco pressurizado",
8640
+ shot_ignite: "O jato pegou fogo (ma\xE7arico) entre 0,7 e 1,0 s depois do furo",
8641
+ shot_explode: "O botij\xE3o explodiu at\xE9 1,6 s depois do tiro",
8642
+ shot_zombie: "O zumbi a 3 m do botij\xE3o n\xE3o sobreviveu \xE0 explos\xE3o",
8643
+ shot_survivor: "O sobrevivente a 8 m, depois do anel letal, fica ferido mas vivo",
8644
+ shot_wall: "A parede de tijolo atr\xE1s dele ficou marcada pela explos\xE3o",
8645
+ valve: "Botij\xE3o P13: o registro aberto vaza g\xE1s sem fogo e o botij\xE3o s\xF3 explode quando uma chama acende",
8646
+ valve_leak: "O registro aberto vaza g\xE1s",
8647
+ valve_quiet: "Quatro segundos de g\xE1s vazando sem chama: sem fogo e sem explos\xE3o",
8648
+ valve_lit: "A chama do isqueiro acendeu o vazamento",
8649
+ valve_explode: "O botij\xE3o explodiu at\xE9 1,6 s depois da chama",
8650
+ fire: "Botij\xE3o P13 no fogo: o calor detona pela cadeia de fogo",
8651
+ fire_explode: "O botij\xE3o no fogo explodiu",
8652
+ chain: "Botij\xE3o P13: outra explos\xE3o ao lado detona pela cadeia de explos\xE3o",
8653
+ chain_explode: "O segundo botij\xE3o explodiu depois do primeiro",
8654
+ push_p13: "Explos\xE3o do P13: os itens soltos, os m\xF3veis e as pessoas em volta s\xE3o jogados para longe, menos quanto mais longe est\xE3o",
8655
+ push_grenade: "Granada M67 arremessada: os itens soltos e as pessoas em volta s\xE3o jogados para longe, menos quanto mais longe est\xE3o",
8656
+ push_told: "A explos\xE3o chegou ao host como evento",
8657
+ push_found: "{n} corpos foram postos em volta da explos\xE3o para serem medidos ({kinds})",
8658
+ push_outward: "Todo corpo dentro do alcance se afastou da explos\xE3o ({moved} de {n}, no m\xEDnimo {least} m)",
8659
+ push_falloff: "{what}: o mais perto andou mais que o mais longe ({near} m a {nearDistance} m, {far} m a {farDistance} m)",
8660
+ push_person: "{who} a {distance} m foi empurrado {moved} m para longe",
8661
+ push_heavy: "{what} (pesado demais para pegar, mas solto) foi movido {moved} m pela explos\xE3o"
8662
+ }
8663
+ }
8664
+ };
8665
+ var explosivesTextOf = (locale) => EXPLOSIVES_TEXT[locale] ?? EXPLOSIVES_TEXT.en;
8666
+ var fillText2 = (template, vars) => template.replace(/\{(\w+)\}/g, (_, key) => String(vars[key] ?? ""));
8667
+
8668
+ // src/explosives-bench.ts
8669
+ var EXPLOSIVES_AREA_ID = "explosives_area";
8670
+ var FLAME_C = 1100;
8671
+ var FLAME_RADIUS = 0.2;
8672
+ var FLAME_SECONDS = 0.3;
8673
+ var FIRE_CHAIN_C = 300;
8674
+ var FIRE_OFFSET = [0.4, 0, 0.15];
8675
+ var FIRE_VISUAL_RADIUS = 0.6;
8676
+ var HISS_SHOVE = 0.35;
8677
+ var AFTERMATH_DELAY = 2;
8678
+ var SHOOTER = "pistol_9mm";
8679
+ var AIM_HEIGHT = 0.2;
8680
+ var GRAVITY2 = 9.81;
8681
+ var REPORTED_OBJECTS = ["fridge", "kitchen_cabinet_base", "stove", "camp_table"];
8682
+ var RING_SEGMENTS = 128;
8683
+ var WALL_ID = "target_wall";
8684
+ var WALL_Z = -11;
8685
+ var dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
8686
+ var num = (value, digits = 1) => value.toFixed(digits);
8687
+ function createExplosivesBench(options) {
8688
+ const { text, def, room, profile } = options;
8689
+ const log = text.log;
8690
+ let host = null;
8691
+ let strength = options.blastScale;
8692
+ let inArea = false;
8693
+ let lastBlast;
8694
+ let ringsOn = true;
8695
+ let initial = true;
8696
+ let aftermathDue = [];
8697
+ const vessels = /* @__PURE__ */ new Map();
8698
+ const wallParts = /* @__PURE__ */ new Set();
8699
+ const shells = [];
8700
+ const T = () => host?.THREE;
8701
+ const now = () => room?.world.time ?? 0;
8702
+ const say = (message, data = {}) => host?.log(message, { source: EXPLOSIVES_AREA_ID, ...data });
8703
+ const leakConfig = def.leak;
8704
+ const igniteDelay = leakConfig?.igniteDelay ?? 0.8;
8705
+ const hissSeconds = leakConfig?.hissSeconds ?? igniteDelay;
8706
+ const chainFire = (() => {
8707
+ const fire2 = def.chain?.fire;
8708
+ return fire2 === void 0 ? 1.4 : typeof fire2 === "number" ? fire2 : fire2[0];
8709
+ })();
8710
+ const liters = (vessel) => room?.world.part(vessel.objectId, "tank")?.liters ?? 0;
8711
+ const throughHoles = (vessel) => room?.world.part(vessel.objectId, "tank")?.holes.filter((hole) => hole.through).length ?? 0;
8712
+ const leaking = (vessel) => !vessel.exploded && liters(vessel) > 0 && throughHoles(vessel) > 0;
8713
+ const centreOf = (vessel) => {
8714
+ const p = vessel.root.getWorldPosition(new (T()).Vector3());
8715
+ return [p.x, p.y + AIM_HEIGHT, p.z];
8716
+ };
8717
+ const valveOf = (vessel) => worldExplosiveValve(T(), vessel.root) ?? centreOf(vessel);
8718
+ const target = () => [...vessels.values()].find((vessel) => !vessel.exploded) ?? [...vessels.values()][0];
8719
+ const lethalOf = (vessel) => def.lethalRadius * vessel.scale;
8720
+ const injuryOf = (vessel) => def.injuryRadius * vessel.scale;
8721
+ const labelSprite = (words, colour, at) => {
8722
+ try {
8723
+ const canvas = document.createElement("canvas");
8724
+ canvas.width = 256;
8725
+ canvas.height = 64;
8726
+ const context = canvas.getContext("2d");
8727
+ if (!context) return null;
8728
+ context.fillStyle = colour;
8729
+ context.font = "bold 40px sans-serif";
8730
+ context.textAlign = "center";
8731
+ context.fillText(words, 128, 46);
8732
+ const sprite = new (T()).Sprite(new (T()).SpriteMaterial({ map: new (T()).CanvasTexture(canvas), transparent: true, depthWrite: false }));
8733
+ sprite.scale.set(2.4, 0.6, 1);
8734
+ sprite.position.set(...at);
8735
+ return sprite;
8736
+ } catch {
8737
+ return null;
8738
+ }
8739
+ };
8740
+ const ringLine = (radius, colour) => {
8741
+ const points = [];
8742
+ for (let i = 0; i < RING_SEGMENTS; i++) {
8743
+ const angle = i / RING_SEGMENTS * Math.PI * 2;
8744
+ points.push(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
8745
+ }
8746
+ const geometry = new (T()).BufferGeometry();
8747
+ geometry.setAttribute("position", new (T()).Float32BufferAttribute(points, 3));
8748
+ return new (T()).LineLoop(geometry, new (T()).LineBasicMaterial({ color: colour }));
8749
+ };
8750
+ const drawRings = (vessel) => {
8751
+ const group = new (T()).Group();
8752
+ group.name = "blast_rings";
8753
+ const lethal = lethalOf(vessel);
8754
+ const injury = injuryOf(vessel);
8755
+ group.add(ringLine(lethal, 16726832), ringLine(injury, 16756768));
8756
+ const lethalLabel = labelSprite(`${num(lethal, 0)} m ${text.rings.lethal}`, "#ff6a5e", [0, 0.35, lethal]);
8757
+ const injuryLabel = labelSprite(`${num(injury, 0)} m ${text.rings.injury}`, "#ffc44d", [0, 0.35, injury]);
8758
+ for (const label of [lethalLabel, injuryLabel]) if (label) group.add(label);
8759
+ const p = vessel.root.getWorldPosition(new (T()).Vector3());
8760
+ group.position.set(p.x, 0.03, p.z);
8761
+ group.visible = ringsOn;
8762
+ (host?.scene).add(group);
8763
+ vessel.rings = group;
8764
+ };
8765
+ const clearRings = (vessel) => {
8766
+ vessel.rings?.removeFromParent();
8767
+ vessel.rings = void 0;
8768
+ };
8769
+ const phaseOf = (vessel) => {
8770
+ if (!vessel) return "rest";
8771
+ if (vessel.exploded) return "exploded";
8772
+ if (vessel.explodeAt !== void 0) return "primed";
8773
+ if (vessel.lit) return "torch";
8774
+ return vessel.leakAt === void 0 ? "rest" : "leaking";
8775
+ };
8776
+ const ignite = (vessel, by) => {
8777
+ if (vessel.lit || vessel.exploded || !room) return;
8778
+ const leak = vessel.leaks[vessel.leaks.length - 1];
8779
+ const point = leak?.point ?? valveOf(vessel);
8780
+ room.world.hit(vessel.objectId, { type: "heat", point, temperature: FLAME_C, radius: FLAME_RADIUS, duration: FLAME_SECONDS, source: "p13_flame" });
8781
+ vessel.lit = true;
8782
+ vessel.litAt = now();
8783
+ const rest = vessel.explodeAt === void 0 ? chainFire : Math.max(0, vessel.explodeAt - now());
8784
+ if (by === "shot") say(fillText2(log.jet_lit, { seconds: num(now() - (vessel.leakAt ?? now()), 1), rest: num(rest, 1) }), { phase: "torch", objectId: vessel.objectId, seconds: now() - (vessel.leakAt ?? now()), rest });
8785
+ else say(fillText2(log.lighter_lit, {}), { phase: "torch", objectId: vessel.objectId, by });
8786
+ };
8787
+ const shove = (vessel) => {
8788
+ const leak = vessel.leaks[vessel.leaks.length - 1];
8789
+ if (!room || !leak) return;
8790
+ const [dx, dy, dz] = leak.direction;
8791
+ const length = Math.hypot(dx, dy, dz) || 1;
8792
+ room.renderer.impulse(vessel.objectId, [-dx / length * HISS_SHOVE, -dy / length * HISS_SHOVE * 0.2, -dz / length * HISS_SHOVE], leak.point);
8793
+ };
8794
+ const torchSprites = (vessel) => {
8795
+ const leak = vessel.leaks[vessel.leaks.length - 1];
8796
+ if (!room || !leak) return;
8797
+ const [dx, dy, dz] = leak.direction;
8798
+ const length = Math.hypot(dx, dy, dz) || 1;
8799
+ const flame = () => 3 + Math.random() * 2.5;
8800
+ for (let i = 0; i < 3; i++) room.vfx.spawn("fire", leak.point, [dx / length * flame() + (Math.random() - 0.5) * 0.7, dy / length * flame() + 0.6 + (Math.random() - 0.5) * 0.7, dz / length * flame() + (Math.random() - 0.5) * 0.7], { size: 0.45 + Math.random() * 0.35, life: 0.7 });
8801
+ if (vessel.torchFrame++ % 3 === 0) room.vfx.spawn("smoke", leak.point, [dx / length * 2, 1.5, dz / length * 2], { size: 0.7, life: 1 });
8802
+ };
8803
+ const launchShell = (vessel, point) => {
8804
+ const config = def.shell;
8805
+ if (!config || !host) return;
8806
+ const mesh = worldExplosiveShell(T(), def.id);
8807
+ if (!mesh) return;
8808
+ mesh.position.set(point[0], Math.max(point[1], 0.3), point[2]);
8809
+ host.scene.add(mesh);
8810
+ shells.push({
8811
+ mesh,
8812
+ position: [point[0], Math.max(point[1], 0.3), point[2]],
8813
+ velocity: [(Math.random() - 0.5) * 3, config.launchSpeed, (Math.random() - 0.5) * 3],
8814
+ spin: [(Math.random() - 0.5) * 12, (Math.random() - 0.5) * 6, (Math.random() - 0.5) * 12],
8815
+ age: 0,
8816
+ trail: config.trailSeconds,
8817
+ landed: false,
8818
+ start: point
8819
+ });
8820
+ say(fillText2(log.shell, { speed: num(config.launchSpeed, 0), trail: num(config.trailSeconds, 1) }), { phase: "shell", speed: config.launchSpeed, trailSeconds: config.trailSeconds });
8821
+ void vessel;
8822
+ };
8823
+ const stepShells = (dt) => {
8824
+ if (!host) return;
8825
+ for (const shell of shells) {
8826
+ if (shell.landed) continue;
8827
+ shell.age += dt;
8828
+ shell.velocity[1] -= GRAVITY2 * dt;
8829
+ for (let axis = 0; axis < 3; axis++) shell.position[axis] = shell.position[axis] + shell.velocity[axis] * dt;
8830
+ const ground = host.groundAt(shell.position[0], shell.position[2]) + 0.18;
8831
+ if (shell.position[1] <= ground && shell.velocity[1] < 0) {
8832
+ shell.position[1] = ground;
8833
+ if (Math.abs(shell.velocity[1]) > 1.5) {
8834
+ shell.velocity = [shell.velocity[0] * 0.5, -shell.velocity[1] * 0.35, shell.velocity[2] * 0.5];
8835
+ } else {
8836
+ shell.landed = true;
8837
+ shell.mesh.rotation.set(Math.PI / 2, shell.mesh.rotation.y, 0);
8838
+ const away = dist(shell.start, [shell.position[0], shell.position[1], shell.position[2]]);
8839
+ say(fillText2(log.shell_landed, { distance: num(away, 1), seconds: num(shell.age, 1) }), { phase: "shell_landed", distance: away, seconds: shell.age });
8840
+ }
8841
+ } else {
8842
+ shell.mesh.rotation.x += shell.spin[0] * dt;
8843
+ shell.mesh.rotation.y += shell.spin[1] * dt;
8844
+ shell.mesh.rotation.z += shell.spin[2] * dt;
8845
+ }
8846
+ shell.mesh.position.set(shell.position[0], shell.position[1], shell.position[2]);
8847
+ if (room && shell.age < shell.trail) {
8848
+ const at = [shell.position[0], shell.position[1], shell.position[2]];
8849
+ room.vfx.spawn("fire", at, [(Math.random() - 0.5) * 0.6, -0.3 + Math.random() * 0.4, (Math.random() - 0.5) * 0.6], { size: 0.6, life: 0.6 });
8850
+ room.vfx.spawn("smoke", at, [(Math.random() - 0.5) * 0.4, 0.2, (Math.random() - 0.5) * 0.4], { size: 0.7, life: 1.2 });
8851
+ }
8852
+ }
8853
+ };
8854
+ const clearShells = () => {
8855
+ for (const shell of shells) shell.mesh.removeFromParent();
8856
+ shells.length = 0;
8857
+ };
8858
+ const fullVitality = /* @__PURE__ */ new Map();
8859
+ const witnesses = () => {
8860
+ if (!room) return [];
8861
+ const origin = [...vessels.values()][0]?.centre ?? options.scene.cylinder;
8862
+ const found = [];
8863
+ for (const spawned of room.spawned()) {
8864
+ const position = spawned.root.getWorldPosition(new (T()).Vector3());
8865
+ const at = [position.x, position.y, position.z];
8866
+ const distance = dist([at[0], 0, at[2]], [origin[0], 0, origin[2]]);
8867
+ if (spawned.itemId.startsWith("zombie_") || spawned.itemId.startsWith("person_")) {
8868
+ const state = room.world.state(spawned.objectId);
8869
+ const alive = state?.body?.alive ?? true;
8870
+ const raw = state?.body?.vitality;
8871
+ if (raw !== void 0) fullVitality.set(spawned.objectId, Math.max(fullVitality.get(spawned.objectId) ?? 0, raw));
8872
+ const vitality = raw === void 0 ? void 0 : Math.max(0, raw) / Math.max(1e-9, fullVitality.get(spawned.objectId) ?? raw);
8873
+ found.push({ kind: "person", id: spawned.objectId, itemId: spawned.itemId, distance, alive, ...vitality === void 0 ? {} : { vitality } });
8874
+ } else if (REPORTED_OBJECTS.includes(spawned.itemId)) {
8875
+ const parts = room.world.state(spawned.objectId)?.parts ?? [];
8876
+ found.push({ kind: "object", id: spawned.objectId, itemId: spawned.itemId, distance, broken: parts.filter((part) => part.status === "broken").length, marks: parts.filter((part) => part.burning).length });
8877
+ }
8878
+ }
8879
+ if (wallParts.size > 0) {
8880
+ let broken = 0;
8881
+ let marks = 0;
8882
+ let pieces = 0;
8883
+ for (const id of room.world.ids()) {
8884
+ const mine = (room.world.state(id)?.parts ?? []).filter((part) => wallParts.has(part.id));
8885
+ if (mine.length === 0) continue;
8886
+ pieces++;
8887
+ broken += mine.filter((part) => part.status === "broken").length;
8888
+ marks += mine.reduce((n, part) => n + part.cracks.length + part.holes.length, 0);
8889
+ }
8890
+ found.push({ kind: "wall", id: "target_wall", itemId: "target_wall", distance: Math.abs(WALL_Z - origin[2]), broken, marks, pieces });
8891
+ }
8892
+ return found;
8893
+ };
8894
+ const whoIs = (itemId) => itemId.startsWith("zombie_") ? text.things.zombie : text.things.survivor;
8895
+ const aftermath = (point) => {
8896
+ for (const w of witnesses()) {
8897
+ const distance = num(w.distance, 1);
8898
+ if (w.kind === "person") {
8899
+ const verdict = w.alive === false ? text.verdicts.dead : w.vitality !== void 0 && w.vitality < 0.999 ? fillText2(text.verdicts.hurt, { vitality: num(w.vitality * 100, 0) }) : text.verdicts.unhurt;
8900
+ say(fillText2(log.aftermath_person, { who: whoIs(w.itemId), distance, verdict }), { phase: "aftermath", kind: w.kind, objectId: w.id, distance: w.distance, alive: w.alive, vitality: w.vitality, point });
8901
+ } else if (w.kind === "wall") {
8902
+ const verdict = (w.pieces ?? 1) > 1 ? fillText2(text.verdicts.wall_pieces, { pieces: w.pieces ?? 0, n: w.marks ?? 0 }) : (w.broken ?? 0) > 0 ? fillText2(text.verdicts.wall_broken, { n: w.broken ?? 0 }) : (w.marks ?? 0) > 0 ? fillText2(text.verdicts.wall_cracked, { n: w.marks ?? 0 }) : text.verdicts.wall_intact;
8903
+ say(fillText2(log.aftermath_wall, { verdict }), { phase: "aftermath", kind: w.kind, objectId: w.id, broken: w.broken, marks: w.marks, pieces: w.pieces });
8904
+ } else {
8905
+ const verdict = (w.broken ?? 0) > 0 ? fillText2(text.verdicts.lost_parts, { n: w.broken ?? 0 }) : (w.marks ?? 0) > 0 ? text.verdicts.burning : text.verdicts.standing;
8906
+ say(fillText2(log.aftermath_object, { what: text.things[w.itemId] ?? w.itemId, distance, verdict }), { phase: "aftermath", kind: w.kind, objectId: w.id, distance: w.distance, broken: w.broken });
8907
+ }
8908
+ }
8909
+ };
8910
+ const onEvent = (event) => {
8911
+ const vessel = vessels.get(event.object);
8912
+ if (!vessel || !("part" in event) || event.part !== "tank") return;
8913
+ if (event.type === "leak") {
8914
+ witnesses();
8915
+ if (vessel.leakAt === void 0) {
8916
+ vessel.leakAt = event.time;
8917
+ vessel.cause = vessel.valveRequested ? "valve" : "shot";
8918
+ }
8919
+ vessel.valveRequested = false;
8920
+ vessel.leaks.push({ point: event.point, direction: event.direction, rate: event.rate });
8921
+ if (vessel.leaks.length > 8) vessel.leaks.shift();
8922
+ const holes = room?.world.part(vessel.objectId, "tank")?.holes ?? [];
8923
+ const diameter = (holes[holes.length - 1]?.diameter ?? 9e-3) * 1e3;
8924
+ const vars = { diameter: num(diameter, 0), rate: num(event.rate, 2), liters: num(liters(vessel), 1) };
8925
+ say(fillText2(vessel.cause === "valve" && vessel.leaks.length === 1 ? log.leak_valve : log.leak_shot, vars), { phase: "leak", objectId: vessel.objectId, cause: vessel.cause, ratePerS: event.rate, diameterMm: diameter, liters: liters(vessel), pressurized: event.pressurized });
8926
+ } else if (event.type === "primed") {
8927
+ vessel.explodeAt = event.time + event.delay;
8928
+ say(fillText2(log.primed, { trigger: text.triggers[event.trigger], delay: num(event.delay, 1) }), { phase: "primed", objectId: vessel.objectId, trigger: event.trigger, delaySeconds: event.delay });
8929
+ } else if (event.type === "explode") {
8930
+ vessel.exploded = true;
8931
+ vessel.explodedAt = event.time;
8932
+ vessel.centre = event.point;
8933
+ const radius = profile?.visualRadius;
8934
+ const fireball = (radius ?? 2) * 2;
8935
+ const lethal = lethalOf(vessel);
8936
+ const injury = injuryOf(vessel);
8937
+ say(fillText2(log.explode, { fireball: num(fireball, 1), lethal: num(lethal, 1), injury: num(injury, 1), energy: num(def.energy * vessel.scale / 1e3, 0), scale: num(vessel.scale, 2) }), {
8938
+ phase: "explode",
8939
+ objectId: vessel.objectId,
8940
+ fireballMetres: fireball,
8941
+ lethalRadius: lethal,
8942
+ injuryRadius: injury,
8943
+ energyJ: def.energy * vessel.scale,
8944
+ scale: vessel.scale,
8945
+ point: event.point,
8946
+ sinceLeakSeconds: vessel.leakAt === void 0 ? void 0 : event.time - vessel.leakAt
8947
+ });
8948
+ const residual = profile?.residualFires?.[0];
8949
+ if (residual) say(fillText2(log.residual, { radius: num(residual.radius, 1), seconds: num(residual.life, 0) }), { phase: "residual", radius: residual.radius, seconds: residual.life });
8950
+ if (vessel.fireHandle !== void 0) room?.vfx.stop(vessel.fireHandle);
8951
+ launchShell(vessel, event.point);
8952
+ aftermathDue.push({ at: now() + AFTERMATH_DELAY, vessel, point: event.point });
8953
+ }
8954
+ };
8955
+ const frame = (dt) => {
8956
+ const t = now();
8957
+ for (const vessel of vessels.values()) {
8958
+ if (vessel.exploded) {
8959
+ vessel.lit = false;
8960
+ continue;
8961
+ }
8962
+ if (vessel.leakAt !== void 0) {
8963
+ if (t - vessel.leakAt <= hissSeconds && leaking(vessel)) shove(vessel);
8964
+ if (vessel.cause === "shot" && !vessel.lit && t - vessel.leakAt >= igniteDelay && leaking(vessel)) ignite(vessel, "shot");
8965
+ }
8966
+ if (vessel.lit) {
8967
+ if (leaking(vessel)) torchSprites(vessel);
8968
+ else vessel.lit = false;
8969
+ }
8970
+ if (vessel.fireUntil !== void 0 && t < vessel.fireUntil && room && vessel.firePoint) {
8971
+ const fire2 = def.fire;
8972
+ room.world.hit(vessel.objectId, { type: "heat", point: vessel.firePoint, temperature: fire2?.temperature ?? 700, radius: fire2?.radius ?? 1.4, duration: dt, source: "p13_fire" });
8973
+ if (vessel.explodeAt === void 0 && t >= vessel.nextHeatLog) {
8974
+ vessel.nextHeatLog = t + 1;
8975
+ say(fillText2(log.heating, { temperature: num(room.world.part(vessel.objectId, "tank")?.temperature ?? 0, 0), limit: num(FIRE_CHAIN_C, 0) }), { phase: "heating", objectId: vessel.objectId });
8976
+ }
8977
+ }
8978
+ }
8979
+ stepShells(dt);
8980
+ if (aftermathDue.length > 0) {
8981
+ const ready = aftermathDue.filter((entry) => entry.at <= t);
8982
+ aftermathDue = aftermathDue.filter((entry) => entry.at > t);
8983
+ for (const entry of ready) aftermath(entry.point);
8984
+ }
8985
+ };
8986
+ const needRoom = () => {
8987
+ if (room) return true;
8988
+ say(log.no_room);
8989
+ return false;
8990
+ };
8991
+ const withVessel = () => {
8992
+ const vessel = target();
8993
+ if (!vessel || vessel.exploded) say(log.no_vessel);
8994
+ return vessel && !vessel.exploded ? vessel : void 0;
8995
+ };
8996
+ const shoot = () => {
8997
+ if (!needRoom() || !room || !host) return false;
8998
+ const vessel = withVessel();
8999
+ if (!vessel) return false;
9000
+ if (host.tool().weapon !== SHOOTER) room.setTool(SHOOTER);
9001
+ room.waitUntilReady();
9002
+ const eye = host.camera.position;
9003
+ const aim = centreOf(vessel);
9004
+ room.look([eye.x, eye.y, eye.z], aim);
9005
+ say(fillText2(log.shoot, { distance: num(dist([eye.x, eye.y, eye.z], aim), 1) }), { phase: "shoot", objectId: vessel.objectId, weapon: SHOOTER });
9006
+ room.use();
9007
+ return true;
9008
+ };
9009
+ const valve = () => {
9010
+ if (!needRoom() || !room) return false;
9011
+ const vessel = withVessel();
9012
+ if (!vessel) return false;
9013
+ if (vessel.valveOpen) {
9014
+ if (vessel.explodeAt !== void 0) {
9015
+ say(log.valve_closed_late, { phase: "valve", objectId: vessel.objectId });
9016
+ return false;
9017
+ }
9018
+ room.world.seal(vessel.objectId, "tank");
9019
+ vessel.valveOpen = false;
9020
+ if (throughHoles(vessel) === 0) {
9021
+ vessel.leakAt = void 0;
9022
+ vessel.cause = void 0;
9023
+ vessel.leaks = [];
9024
+ vessel.lit = false;
9025
+ }
9026
+ say(fillText2(log.valve_closed, { liters: num(liters(vessel), 1) }), { phase: "valve_closed", objectId: vessel.objectId, liters: liters(vessel) });
9027
+ return true;
9028
+ }
9029
+ vessel.valveRequested = true;
9030
+ const opened = room.world.vent(vessel.objectId, "tank", { point: valveOf(vessel), normal: [0, 1, 0] });
9031
+ vessel.valveRequested = false;
9032
+ vessel.valveOpen = opened;
9033
+ return opened;
9034
+ };
9035
+ const light = () => {
9036
+ if (!needRoom() || !room) return false;
9037
+ const vessel = withVessel();
9038
+ if (!vessel) return false;
9039
+ const at = vessel.leaks[vessel.leaks.length - 1]?.point ?? valveOf(vessel);
9040
+ room.vfx.burst("fire", at, [0, 1, 0], 6, 0.8);
9041
+ if (!leaking(vessel)) {
9042
+ say(log.lighter_none, { phase: "lighter", objectId: vessel.objectId, leaking: false });
9043
+ return false;
9044
+ }
9045
+ ignite(vessel, "lighter");
9046
+ return true;
9047
+ };
9048
+ const fire = () => {
9049
+ if (!needRoom() || !room) return false;
9050
+ const vessel = withVessel();
9051
+ if (!vessel) return false;
9052
+ const base = vessel.root.getWorldPosition(new (T()).Vector3());
9053
+ const point = [base.x + FIRE_OFFSET[0], base.y + FIRE_OFFSET[1], base.z + FIRE_OFFSET[2]];
9054
+ const config = def.fire ?? { temperature: 700, radius: 1.4, seconds: 7 };
9055
+ vessel.firePoint = point;
9056
+ vessel.fireUntil = now() + config.seconds + 3;
9057
+ vessel.nextHeatLog = now();
9058
+ vessel.fireHandle = room.vfx.fire({ point, radius: FIRE_VISUAL_RADIUS, life: config.seconds + 3 });
9059
+ for (const spawned of room.spawned()) {
9060
+ if (spawned.itemId !== "camp_table") continue;
9061
+ const top = spawned.root.getWorldPosition(new (T()).Vector3());
9062
+ room.world.hit(spawned.objectId, { type: "heat", point: [top.x, top.y + 0.7, top.z], temperature: config.temperature + 200, radius: 0.9, duration: 3, source: "p13_fire" });
9063
+ }
9064
+ say(fillText2(log.fire_set, { temperature: num(config.temperature, 0), radius: num(config.radius, 1) }), { phase: "fire", objectId: vessel.objectId, temperatureC: config.temperature, radius: config.radius });
9065
+ return true;
9066
+ };
9067
+ const reset = () => {
9068
+ if (!needRoom() || !room) return false;
9069
+ room.openArea(EXPLOSIVES_AREA_ID);
9070
+ say(fillText2(log.reset, { scale: num(strength, 2) }), { phase: "reset", scale: strength });
9071
+ return true;
9072
+ };
9073
+ const actionIds = ["shoot", "valve", "light", "fire", "reset"];
9074
+ const actions = { shoot, valve, light, fire, reset };
9075
+ const sheet = () => {
9076
+ const energy = def.energy * strength / 1e3;
9077
+ return {
9078
+ kicker: text.sheet.kicker,
9079
+ title: text.sheet.title,
9080
+ body: text.sheet.body,
9081
+ facts: [
9082
+ { label: text.sheet.facts.energy, value: `${num(energy, 0)} kJ (x${num(strength, 2)})` },
9083
+ { label: text.sheet.facts.lethal, value: `${num(def.lethalRadius * strength, 1)} m` },
9084
+ { label: text.sheet.facts.injury, value: `${num(def.injuryRadius * strength, 1)} m` },
9085
+ { label: text.sheet.facts.gas, value: `${num(leakConfig?.liters ?? 0, 0)} l` },
9086
+ { label: text.sheet.facts.mass, value: `${num(def.mass ?? 0, 0)} kg` },
9087
+ { label: text.sheet.facts.torch, value: `${num(igniteDelay, 1)} s` },
9088
+ { label: text.sheet.facts.shot_chain, value: `${num(typeof def.chain?.shot === "number" ? def.chain.shot : def.chain?.shot?.[0] ?? 0, 1)} s` },
9089
+ { label: text.sheet.facts.fire_chain, value: `${num(chainFire, 1)} s` }
9090
+ ],
9091
+ steps: text.sheet.steps,
9092
+ note: text.sheet.note,
9093
+ timeline: {
9094
+ segments: [
9095
+ { name: text.sheet.segments.jet, start: 0, end: igniteDelay, color: "#e8eef2" },
9096
+ { name: text.sheet.segments.torch, start: igniteDelay, end: chainFire, color: "#ff9a2e" },
9097
+ { name: text.sheet.segments.blast, start: chainFire, end: chainFire + 1, color: "#ff4d2e" }
9098
+ ],
9099
+ ticks: [0, igniteDelay, chainFire],
9100
+ metric: "clock"
9101
+ }
9102
+ };
9103
+ };
9104
+ const adopt = (data) => {
9105
+ const spawned = data;
9106
+ if (spawned.itemId !== `${WORLD_EXPLOSIVE_PREFIX}${def.id}`) return;
9107
+ const vessel = { objectId: spawned.objectId, root: spawned.root, scale: strength, leaks: [], valveRequested: false, valveOpen: false, lit: false, exploded: false, nextHeatLog: 0, torchFrame: 0 };
9108
+ vessels.set(vessel.objectId, vessel);
9109
+ if (wallParts.size === 0) for (const part of room?.world.state(WALL_ID)?.parts ?? []) wallParts.add(part.id);
9110
+ if (inArea && host) drawRings(vessel);
9111
+ };
9112
+ const change = (change2) => {
9113
+ if (change2.kind === "action") {
9114
+ if (actionIds.includes(change2.id)) actions[change2.id]();
9115
+ } else if (change2.kind === "layer" && change2.id === "rings") {
9116
+ ringsOn = change2.on;
9117
+ for (const vessel of vessels.values()) if (vessel.rings) vessel.rings.visible = ringsOn;
9118
+ } else if (change2.kind === "control" && change2.id === "blast_scale" && typeof change2.value === "number") {
9119
+ const first = initial;
9120
+ initial = false;
9121
+ if (change2.value === strength && !first) return;
9122
+ strength = change2.value;
9123
+ host?.bench?.sheet?.(sheet());
9124
+ if (first) return;
9125
+ say(fillText2(log.scale, { scale: num(strength, 2), energy: num(def.energy * strength / 1e3, 0), lethal: num(def.lethalRadius * strength, 1), injury: num(def.injuryRadius * strength, 1) }), { phase: "scale", scale: strength });
9126
+ if (inArea) reset();
9127
+ }
9128
+ };
9129
+ return {
9130
+ room,
9131
+ def,
9132
+ blastScale: () => strength,
9133
+ areaOpened: () => {
9134
+ inArea = true;
9135
+ },
9136
+ controls: () => [
9137
+ ...actionIds.map((id) => ({ kind: "action", id, name: text.actions[id].name, hint: text.actions[id].hint })),
9138
+ { kind: "number", id: "blast_scale", name: text.blastScale.name, hint: text.blastScale.hint, unit: "x", min: 0.25, max: 3, step: 0.05, default: options.blastScale }
9139
+ ],
9140
+ layers: () => [{ id: "rings", name: text.layers.rings }],
9141
+ metrics: () => [
9142
+ { id: "gas", name: text.metrics.gas, unit: "l", digits: 1, max: leakConfig?.liters ?? 24, read: () => liters(target() ?? { objectId: "" }) },
9143
+ { id: "clock", name: text.metrics.clock, unit: "s", digits: 2, read: () => {
9144
+ const v = target();
9145
+ return v?.leakAt === void 0 ? 0 : (v.explodedAt ?? now()) - v.leakAt;
9146
+ } },
9147
+ { id: "phase", name: text.metrics.phase, read: () => text.phases[phaseOf(target())] }
9148
+ ],
9149
+ attach(lent) {
9150
+ host = lent;
9151
+ const offs = [];
9152
+ if (room) {
9153
+ offs.push(room.world.on("*", onEvent));
9154
+ offs.push(room.on("spawn", adopt));
9155
+ for (const spawned of room.spawned()) adopt(spawned);
9156
+ }
9157
+ offs.push(
9158
+ lent.on((event) => {
9159
+ if (event.type === "explosion") lastBlast = { point: event.point, energy: event.energy, radius: event.radius, cause: event.cause, ...event.explosive === void 0 ? {} : { explosive: event.explosive } };
9160
+ })
9161
+ );
9162
+ offs.push(lent.onFrame(frame));
9163
+ offs.push(lent.bench?.onChange(change) ?? (() => void 0));
9164
+ offs.push(
9165
+ lent.onClear?.(() => {
9166
+ for (const vessel of vessels.values()) clearRings(vessel);
9167
+ vessels.clear();
9168
+ clearShells();
9169
+ wallParts.clear();
9170
+ aftermathDue = [];
9171
+ lastBlast = void 0;
9172
+ inArea = false;
9173
+ }) ?? (() => void 0)
9174
+ );
9175
+ lent.bench?.sheet?.(sheet());
9176
+ return () => {
9177
+ for (const off of offs) off();
9178
+ for (const vessel of vessels.values()) clearRings(vessel);
9179
+ vessels.clear();
9180
+ clearShells();
9181
+ host = null;
9182
+ };
9183
+ },
9184
+ actions,
9185
+ vessel: () => {
9186
+ const v = target();
9187
+ if (!v) return void 0;
9188
+ return { objectId: v.objectId, leakAt: v.leakAt, litAt: v.litAt, explodedAt: v.explodedAt, cause: v.cause, valveOpen: v.valveOpen, lit: v.lit, exploded: v.exploded, point: v.leaks[v.leaks.length - 1]?.point };
9189
+ },
9190
+ lastBlast: () => lastBlast,
9191
+ witnesses
9192
+ };
9193
+ }
9194
+
9195
+ // src/explosives-scenarios.ts
9196
+ var TORCH_WINDOW = [0.7, 1];
9197
+ var BLAST_WITHIN = 1.6;
9198
+ var CYLINDER_ITEM = `${WORLD_EXPLOSIVE_PREFIX}propane_p13`;
9199
+ var PUSH_KINDS = [
9200
+ { kind: "can", item: "item_canned_beans" },
9201
+ { kind: "bottle", item: "item_water_bottle" },
9202
+ { kind: "chair", item: "dining_chair" }
9203
+ ];
9204
+ var PUSH_READ_AT = 0.8;
9205
+ var MOVED = 0.15;
9206
+ var HEAVY_ITEMS = ["camp_table"];
9207
+ var flat = (a, b) => Math.hypot(a[0] - b[0], a[2] - b[2]);
9208
+ var bearing = (centre, distance, degrees) => [centre[0] + Math.sin(degrees * Math.PI / 180) * distance, 0, centre[2] + Math.cos(degrees * Math.PI / 180) * distance];
9209
+ var PUSH_LAYOUT = {
9210
+ can: [[2.5, -52], [5, 12], [8, 78]],
9211
+ bottle: [[2.5, 26], [5, -62], [8, -78]],
9212
+ chair: [[2.5, -18], [5, 60], [8, -42]]
9213
+ };
9214
+ function pushScenario(all, bench, mode) {
9215
+ const text = all.scenarios;
9216
+ const things = all.things;
9217
+ return {
9218
+ id: `explosives_push_${mode}`,
9219
+ name: mode === "p13" ? text.push_p13 : text.push_grenade,
9220
+ run: ({ room, step, expect }) => {
9221
+ let centre;
9222
+ if (mode === "p13") {
9223
+ room.openArea(EXPLOSIVES_AREA_ID);
9224
+ const cylinder = room.spawned().find((spawned) => spawned.itemId === CYLINDER_ITEM);
9225
+ const at = cylinder?.root?.position;
9226
+ centre = [at?.x ?? 0, 0, at?.z ?? -10.66];
9227
+ } else {
9228
+ room.clear();
9229
+ centre = [0, 0, -6];
9230
+ }
9231
+ const placed = [];
9232
+ const lay = (kind, item, at) => {
9233
+ try {
9234
+ const spawned = room.spawn(item, at, 0);
9235
+ placed.push({ kind, objectId: spawned.objectId, itemId: item, from: at, root: spawned.root });
9236
+ } catch {
9237
+ }
9238
+ };
9239
+ for (const { kind, item } of PUSH_KINDS) PUSH_LAYOUT[kind].forEach(([distance, degrees]) => lay(kind, item, bearing(centre, distance, degrees)));
9240
+ if (mode === "grenade") {
9241
+ lay("zombie", "zombie_walker", bearing(centre, 3.4, -34));
9242
+ lay("survivor", "person_police_officer", bearing(centre, 7.5, 78));
9243
+ lay("camp_table", "camp_table", bearing(centre, 3, 90));
9244
+ }
9245
+ step(0.6);
9246
+ const start = (body) => [body.root.position.x, body.root.position.y, body.root.position.z];
9247
+ const origin = new Map(placed.map((body) => [body.objectId, start(body)]));
9248
+ const partsOf = (objectId) => room.world.state(objectId)?.parts.length ?? 0;
9249
+ for (const spawned of room.spawned()) {
9250
+ const known = placed.some((body2) => body2.objectId === spawned.objectId);
9251
+ const role = spawned.itemId.startsWith("zombie_") ? "zombie" : spawned.itemId.startsWith("person_") ? "survivor" : HEAVY_ITEMS.includes(spawned.itemId) ? spawned.itemId : void 0;
9252
+ if (known || !role || mode !== "p13") continue;
9253
+ const root = spawned.root;
9254
+ const body = { kind: role, objectId: spawned.objectId, itemId: spawned.itemId, from: [root.position.x, root.position.y, root.position.z], root };
9255
+ placed.push(body);
9256
+ origin.set(body.objectId, body.from);
9257
+ }
9258
+ const partsBefore = new Map(placed.map((body) => [body.objectId, partsOf(body.objectId)]));
9259
+ expect(placed.length > 0, fillText2(text.push_found, { n: placed.length, kinds: [...new Set(placed.map((body) => body.kind))].join(", ") }), { placed: placed.map((body) => body.itemId) });
9260
+ if (mode === "p13") bench.actions.shoot();
9261
+ else {
9262
+ room.setTool("frag_grenade");
9263
+ step(0.5);
9264
+ room.look([0, 1.6, 9.5], [0, 1, -2.5]);
9265
+ room.use();
9266
+ }
9267
+ let waited = 0;
9268
+ while (!bench.lastBlast() && waited < 9) {
9269
+ step(0.05);
9270
+ waited += 0.05;
9271
+ }
9272
+ const blast = bench.lastBlast();
9273
+ expect(!!blast, text.push_told, { waited });
9274
+ if (!blast) return;
9275
+ step(PUSH_READ_AT);
9276
+ const rows = placed.map((body) => {
9277
+ const from = origin.get(body.objectId) ?? body.from;
9278
+ const now = start(body);
9279
+ const distance = flat(from, blast.point);
9280
+ const away = Math.max(1e-6, distance);
9281
+ const outward = ((now[0] - from[0]) * (from[0] - blast.point[0]) + (now[2] - from[2]) * (from[2] - blast.point[2])) / away;
9282
+ return { ...body, distance, outward, moved: flat(now, from) };
9283
+ });
9284
+ const torn = (row) => PUSH_KINDS.some((entry) => entry.kind === row.kind) && partsOf(row.objectId) < (partsBefore.get(row.objectId) ?? 0);
9285
+ const reached = rows.filter((row) => row.distance < blast.radius * 0.85 && !torn(row));
9286
+ const movedAway = reached.filter((row) => row.outward >= MOVED);
9287
+ const least = reached.length === 0 ? 0 : Math.min(...reached.map((row) => row.outward));
9288
+ expect(reached.length > 0 && movedAway.length === reached.length, fillText2(text.push_outward, { moved: movedAway.length, n: reached.length, least: least.toFixed(2) }), { rows: reached.map((row) => ({ kind: row.kind, distance: row.distance, outward: row.outward })), torn: rows.filter(torn).map((row) => row.objectId) });
9289
+ for (const { kind } of PUSH_KINDS) {
9290
+ const ofKind = reached.filter((row) => row.kind === kind).sort((a, b) => a.distance - b.distance);
9291
+ if (ofKind.length < 2) continue;
9292
+ const near = ofKind[0];
9293
+ const far = ofKind[ofKind.length - 1];
9294
+ expect(near.outward > far.outward, fillText2(text.push_falloff, { what: things[kind] ?? kind, near: near.outward.toFixed(2), nearDistance: near.distance.toFixed(1), far: far.outward.toFixed(2), farDistance: far.distance.toFixed(1) }), { near: near.outward, far: far.outward });
9295
+ }
9296
+ for (const row of reached.filter((entry) => entry.kind === "zombie" || entry.kind === "survivor")) {
9297
+ expect(row.outward >= MOVED, fillText2(text.push_person, { who: things[row.kind] ?? row.kind, distance: row.distance.toFixed(1), moved: row.outward.toFixed(2) }), { row });
9298
+ }
9299
+ const table = reached.find((row) => HEAVY_ITEMS.includes(row.kind));
9300
+ if (table) expect(table.moved >= MOVED, fillText2(text.push_heavy, { what: things[table.itemId] ?? table.itemId, moved: table.moved.toFixed(2) }), { table });
9301
+ }
9302
+ };
9303
+ }
9304
+ function explosivesScenarios(locale, bench) {
9305
+ const text = explosivesTextOf(locale).scenarios;
9306
+ const record = (room) => {
9307
+ const events = [];
9308
+ const stop = room.world.on("*", (event) => {
9309
+ if (event.object.startsWith(CYLINDER_ITEM)) events.push({ type: event.type, time: event.time, object: event.object });
9310
+ });
9311
+ return { events, stop };
9312
+ };
9313
+ const first = (events, type, after = -Infinity) => events.find((event) => event.type === type && event.time >= after);
9314
+ return [
9315
+ {
9316
+ id: "explosives_p13_shot",
9317
+ name: text.shot,
9318
+ run: ({ room, step, expect }) => {
9319
+ room.openArea(EXPLOSIVES_AREA_ID);
9320
+ const { events, stop } = record(room);
9321
+ bench.actions.shoot();
9322
+ step(2.4);
9323
+ stop();
9324
+ const leak = first(events, "leak");
9325
+ const explode = first(events, "explode");
9326
+ const view = bench.vessel();
9327
+ expect(!!leak, text.shot_leak, { events: events.map((event) => event.type) });
9328
+ const torchAfter = view?.litAt !== void 0 && view.leakAt !== void 0 ? view.litAt - view.leakAt : -1;
9329
+ expect(torchAfter >= TORCH_WINDOW[0] && torchAfter <= TORCH_WINDOW[1] + 0.05, `${text.shot_ignite} (${torchAfter.toFixed(2)} s)`, { torchAfter });
9330
+ const blastAfter = leak && explode ? explode.time - leak.time : -1;
9331
+ expect(blastAfter > 0 && blastAfter <= BLAST_WITHIN, `${text.shot_explode} (${blastAfter.toFixed(2)} s)`, { blastAfter });
9332
+ const witnesses = bench.witnesses();
9333
+ const zombie = witnesses.find((w) => w.itemId.startsWith("zombie_"));
9334
+ expect(!!zombie && (zombie.alive === false || (zombie.vitality ?? 1) < 0.5), `${text.shot_zombie} (${zombie?.distance.toFixed(1)} m)`, { zombie });
9335
+ const survivor = witnesses.find((w) => w.kind === "person" && !w.itemId.startsWith("zombie_"));
9336
+ expect(!!survivor && survivor.alive === true && (survivor.vitality ?? 1) < 1, `${text.shot_survivor} (${survivor?.distance.toFixed(1)} m, ${survivor?.alive ? "alive" : "dead"})`, { survivor });
9337
+ const wall = witnesses.find((w) => w.kind === "wall");
9338
+ expect((wall?.marks ?? 0) + (wall?.broken ?? 0) + ((wall?.pieces ?? 1) - 1) > 0, text.shot_wall, { wall });
9339
+ }
9340
+ },
9341
+ {
9342
+ id: "explosives_p13_valve",
9343
+ name: text.valve,
9344
+ run: ({ room, step, expect }) => {
9345
+ room.openArea(EXPLOSIVES_AREA_ID);
9346
+ const { events, stop } = record(room);
9347
+ bench.actions.valve();
9348
+ step(4);
9349
+ expect(!!first(events, "leak"), text.valve_leak);
9350
+ expect(!first(events, "primed") && !first(events, "explode"), text.valve_quiet, { events: events.map((event) => event.type) });
9351
+ const before = room.world.time;
9352
+ bench.actions.light();
9353
+ step(2.2);
9354
+ stop();
9355
+ expect(bench.vessel()?.lit === true || bench.vessel()?.litAt !== void 0, text.valve_lit);
9356
+ const explode = first(events, "explode", before);
9357
+ expect(!!explode && explode.time - before <= BLAST_WITHIN, `${text.valve_explode} (${explode ? (explode.time - before).toFixed(2) : "no blast"} s)`, { after: explode ? explode.time - before : null });
9358
+ }
9359
+ },
9360
+ {
9361
+ id: "explosives_p13_fire",
9362
+ name: text.fire,
9363
+ run: ({ room, step, expect }) => {
9364
+ room.openArea(EXPLOSIVES_AREA_ID);
9365
+ const { events, stop } = record(room);
9366
+ bench.actions.fire();
9367
+ step(8);
9368
+ stop();
9369
+ expect(!!first(events, "explode"), text.fire_explode, { events: events.map((event) => event.type) });
9370
+ }
9371
+ },
9372
+ {
9373
+ id: "explosives_p13_chain",
9374
+ name: text.chain,
9375
+ run: ({ room, step, expect }) => {
9376
+ room.openArea(EXPLOSIVES_AREA_ID);
9377
+ const near = [1, 0, -10];
9378
+ const second = room.spawn(CYLINDER_ITEM, near, 0);
9379
+ const { events, stop } = record(room);
9380
+ bench.actions.shoot();
9381
+ step(4);
9382
+ stop();
9383
+ const blasts = events.filter((event) => event.type === "explode");
9384
+ const other = blasts.find((event) => event.object === second.objectId);
9385
+ expect(blasts.length >= 2 && !!other, `${text.chain_explode} (${blasts.length} blasts)`, { blasts: blasts.map((event) => `${event.object}@${event.time.toFixed(2)}`) });
9386
+ }
9387
+ },
9388
+ pushScenario(explosivesTextOf(locale), bench, "p13"),
9389
+ pushScenario(explosivesTextOf(locale), bench, "grenade")
9390
+ ];
9391
+ }
9392
+
9393
+ // src/explosives-area.ts
9394
+ var DEFAULT_EXPLOSIVES_SCENE = {
9395
+ furniture: [
9396
+ { item: "fridge", at: [-1.65, 0, -10.55] },
9397
+ { item: "kitchen_cabinet_base", at: [-0.78, 0, -10.6] },
9398
+ { item: "stove", at: [0.72, 0, -10.58] },
9399
+ { item: "camp_table", at: [2.4, 0, -10.2] }
9400
+ ],
9401
+ cylinder: [0.02, 0, -10.66],
9402
+ zombie: { item: "zombie_walker", at: [-1.6, 0, -8.1] },
9403
+ survivor: { item: "person_police_officer", at: [6, 0, -5.3] },
9404
+ eye: [0, 1.7, -4.7]
9405
+ };
9406
+ var EXPLOSIVE_ID = "propane_p13";
9407
+ var AIM_HEIGHT2 = 0.25;
9408
+ function createExplosivesTestModule(options = {}) {
9409
+ const locale = options.locale ?? "pt-BR";
9410
+ const arsenal = options.arsenal ?? createArsenal2({ locale });
9411
+ const text = explosivesTextOf(locale);
9412
+ const scene = { ...DEFAULT_EXPLOSIVES_SCENE, ...options.scene };
9413
+ const def = arsenal.explosives.get(EXPLOSIVE_ID);
9414
+ if (!def) throw new Error(`explosives area: the arsenal has no "${EXPLOSIVE_ID}"`);
9415
+ const bench = createExplosivesBench({ locale, text, def, scene, room: options.room, blastScale: options.blastScale ?? 1, profile: EXPLOSIONS[EXPLOSIVE_ID] });
9416
+ return {
9417
+ id: EXPLOSIVES_AREA_ID,
9418
+ name: text.name,
9419
+ group: text.group,
9420
+ bench,
9421
+ // the cylinder of this area is the world explosive of the weapons module with the bench's strength: the same id, the same model, the same part
9422
+ items: () => {
9423
+ const item = worldExplosiveItem(def, text.cylinder, { scale: () => bench.blastScale() });
9424
+ return item ? [item] : [];
9425
+ },
9426
+ area: () => {
9427
+ bench.areaOpened();
9428
+ const placements = [
9429
+ ...scene.furniture.map((piece) => ({ item: piece.item, position: piece.at, ...piece.rotationY === void 0 ? {} : { rotationY: piece.rotationY } })),
9430
+ { item: `${WORLD_EXPLOSIVE_PREFIX}${EXPLOSIVE_ID}`, position: scene.cylinder },
9431
+ // facing the cylinder (the front of an item looks to +z, the cylinder is at -z)
9432
+ { item: scene.zombie.item, position: scene.zombie.at, rotationY: Math.PI },
9433
+ { item: scene.survivor.item, position: scene.survivor.at, rotationY: Math.PI }
9434
+ ];
9435
+ const centre = [scene.cylinder[0], AIM_HEIGHT2, scene.cylinder[2]];
9436
+ return { placements, view: { eye: scene.eye, target: centre } };
9437
+ },
9438
+ controls: () => bench.controls(),
9439
+ layers: () => bench.layers(),
9440
+ metrics: () => bench.metrics(),
9441
+ scenarios: (scenarioLocale) => explosivesScenarios(scenarioLocale ?? locale, bench),
9442
+ attach: (host) => bench.attach(host)
9443
+ };
9444
+ }
9445
+
9446
+ // src/projectile-model.ts
9447
+ function projectileModel(THREE, query) {
9448
+ if (query.kind !== "rocket") return null;
9449
+ const launcher = buildWeaponModelById(THREE, query.weapon);
9450
+ if (!launcher || launcher.parts.family !== "rocket") return null;
9451
+ const round2 = launcher.parts.makeRound ? launcher.parts.makeRound() : buildFlyingRocket(modelKit(THREE), launcher.parts.launcherType);
9452
+ return { model: round2, axis: [1, 0, 0] };
9453
+ }
6938
9454
  export {
6939
9455
  ARCHETYPE_MODELS,
6940
9456
  ATTACHMENT_MODELS,
@@ -6942,12 +9458,16 @@ export {
6942
9458
  CLASS_REFERENCE_ENERGY,
6943
9459
  COUNTER_ITEM_ID,
6944
9460
  DEFAULT_ENERGY_CURVE,
9461
+ DEFAULT_EXPLOSIVES_SCENE,
6945
9462
  DEFAULT_THUMBNAIL_BASE,
9463
+ EXPLOSIVES_AREA_ID,
9464
+ EXPLOSIVES_TEXT,
6946
9465
  GAME_DISTANCE_CURVE,
6947
9466
  GAME_SOUND_CLASSES,
6948
9467
  MECHANICAL_SOUNDS,
6949
9468
  MODEL_ENTRIES,
6950
9469
  MOUNTED_M134_ITEM_ID,
9470
+ P13_VALVE_MESH,
6951
9471
  WEAPON_ITEM_PREFIX,
6952
9472
  WEAPON_THUMBNAILS,
6953
9473
  WORLD_EFFECTS,
@@ -6963,23 +9483,28 @@ export {
6963
9483
  buildWeaponModelById,
6964
9484
  cartridgeShape,
6965
9485
  computeShotSound,
9486
+ createExplosivesTestModule,
6966
9487
  createWeaponAudio,
6967
9488
  createWeaponFx,
6968
9489
  createWeaponsTestModule,
6969
9490
  createWorldEffects,
6970
9491
  deriveFrames,
9492
+ explosivesTextOf,
6971
9493
  fitAttachmentModels,
6972
9494
  hasWeaponModel,
6973
9495
  mechanicalSoundOf,
6974
9496
  modelKit,
6975
9497
  mountFrame,
6976
9498
  partRegistry,
9499
+ projectileModel,
6977
9500
  rigStock,
6978
9501
  rigUnderbarrel,
6979
9502
  weaponProp,
6980
9503
  weaponThumbnailCredit,
6981
9504
  weaponThumbnailUrl,
6982
9505
  weaponsLayout,
6983
- worldExplosiveItem
9506
+ worldExplosiveItem,
9507
+ worldExplosiveShell,
9508
+ worldExplosiveValve
6984
9509
  };
6985
9510
  //# sourceMappingURL=index.js.map