@adatechnology/game-weapons-three 0.7.2 → 0.9.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
@@ -1,5 +1,7 @@
1
1
  // src/area.ts
2
+ import { markKitOrigin as markKitOrigin4 } from "@adatechnology/game-contracts";
2
3
  import { createArsenal } from "@adatechnology/game-weapons";
4
+ import { furnitureSpec } from "@adatechnology/game-floorplan-three";
3
5
 
4
6
  // src/attachments-key.ts
5
7
  var attachmentsKey = (parts) => {
@@ -219,7 +221,40 @@ var ACCESSORY_TEXT = {
219
221
  metrics: { mass: "Weapon mass", aim: "Time to the sights", fitted: "Accessories on the weapon", suppressed: "Suppressed" },
220
222
  yes: "yes",
221
223
  no: "no",
222
- attachments: {},
224
+ attachments: {
225
+ suppressor: "Suppressor",
226
+ oil_filter_suppressor: "Improvised oil filter suppressor",
227
+ integral_suppressor: "Integral suppressor",
228
+ muzzle_brake: "Muzzle brake",
229
+ muzzle_brake_heavy: "Heavy muzzle brake",
230
+ compensator: "Compensator",
231
+ red_dot: "Red dot sight",
232
+ holographic_sight: "Holographic sight",
233
+ acog_4x: "ACOG 4x",
234
+ scope_3_9x: "Rifle scope 3-9x",
235
+ scope_6_24x: "Rifle scope 6-24x",
236
+ night_vision_scope: "Night vision scope",
237
+ thermal_scope: "Thermal scope",
238
+ integral_optic_1_5x: "Integral 1.5x optic of the AUG",
239
+ integral_optic_3x: "Integral 3x optic of the G36",
240
+ pso_1: "PSO-1 scope",
241
+ fixed_scope_10x: "Fixed 10x scope of the M24",
242
+ vertical_grip: "Vertical foregrip",
243
+ bipod: "Bipod",
244
+ m203: "M203 grenade launcher",
245
+ gp_25: "GP-25 grenade launcher",
246
+ ag36: "AG36 grenade launcher",
247
+ weapon_light: "Weapon light",
248
+ laser_sight: "Laser sight",
249
+ extended_magazine: "Extended magazine",
250
+ drum_magazine: "Drum magazine",
251
+ dual_coupler: "Dual magazine coupler",
252
+ sawn_off_barrel: "Sawn-off barrel",
253
+ long_barrel: "Long barrel",
254
+ sling: "Sling",
255
+ ammo_backpack: "Ammunition backpack",
256
+ tripod: "Tripod mount"
257
+ },
223
258
  fitRefused: "Could not change {mount} of {weapon}: {reason}",
224
259
  fitting: "Fitting {attachment} on {weapon} ({seconds} s)",
225
260
  removing: "Taking {attachment} off {weapon} ({seconds} s)",
@@ -598,6 +633,731 @@ function weaponRecipes(curve) {
598
633
  ];
599
634
  }
600
635
 
636
+ // src/data/reload-sounds.json
637
+ var reload_sounds_default = {
638
+ 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.",
639
+ profiles: {
640
+ weight: {
641
+ light: {
642
+ pitch: 1.3,
643
+ gain: 0.85
644
+ },
645
+ medium: {
646
+ pitch: 1,
647
+ gain: 1
648
+ },
649
+ heavy: {
650
+ pitch: 0.78,
651
+ gain: 1.2
652
+ }
653
+ },
654
+ material: {
655
+ steel: {
656
+ pitch: 1,
657
+ gain: 1
658
+ },
659
+ polymer: {
660
+ pitch: 0.82,
661
+ gain: 0.8
662
+ }
663
+ }
664
+ },
665
+ 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`.",
666
+ weights: {
667
+ pistol: "light",
668
+ revolver: "light",
669
+ rifle: "medium",
670
+ semiint: "medium",
671
+ semi12: "heavy",
672
+ p90: "medium",
673
+ belt: "heavy",
674
+ pump: "heavy",
675
+ lever: "medium",
676
+ bolt: "medium",
677
+ boltmag: "medium",
678
+ break: "heavy",
679
+ rocket: "heavy",
680
+ flame: "heavy"
681
+ },
682
+ polymer: [
683
+ "glock_17",
684
+ "glock_18",
685
+ "glock_19",
686
+ "taurus_g2c",
687
+ "taurus_ts9",
688
+ "fn_five_seven",
689
+ "pistol_9mm",
690
+ "fn_p90",
691
+ "hk_ump_45"
692
+ ],
693
+ cues: {
694
+ mag_release: [
695
+ {
696
+ layer: "scene_tone",
697
+ at: 0,
698
+ wave: "square",
699
+ f: 2400,
700
+ f2: 1680,
701
+ d: 0.02,
702
+ vol: 0.3
703
+ },
704
+ {
705
+ layer: "scene_noise",
706
+ at: 0,
707
+ type: "highpass",
708
+ f: 3e3,
709
+ q: 0.7,
710
+ d: 0.03,
711
+ vol: 0.24
712
+ },
713
+ {
714
+ layer: "thud",
715
+ at: 0.02,
716
+ gain: 0.12,
717
+ frequency: 700
718
+ }
719
+ ],
720
+ mag_out: [
721
+ {
722
+ layer: "scene_noise",
723
+ at: 0,
724
+ type: "bandpass",
725
+ f: 900,
726
+ q: 2,
727
+ d: 0.1,
728
+ vol: 0.3,
729
+ f2: 600
730
+ },
731
+ {
732
+ layer: "metal",
733
+ at: 0.08,
734
+ frequency: 1200,
735
+ gain: 0.2
736
+ }
737
+ ],
738
+ mag_insert: [
739
+ {
740
+ layer: "scene_noise",
741
+ at: 0,
742
+ type: "bandpass",
743
+ f: 700,
744
+ q: 2,
745
+ d: 0.12,
746
+ vol: 0.3,
747
+ f2: 1100
748
+ }
749
+ ],
750
+ mag_seat: [
751
+ {
752
+ layer: "scene_tone",
753
+ at: 0,
754
+ wave: "square",
755
+ f: 1100,
756
+ f2: 770,
757
+ d: 0.02,
758
+ vol: 0.5
759
+ },
760
+ {
761
+ layer: "scene_noise",
762
+ at: 0,
763
+ type: "highpass",
764
+ f: 3e3,
765
+ q: 0.7,
766
+ d: 0.03,
767
+ vol: 0.4
768
+ },
769
+ {
770
+ layer: "scene_tone",
771
+ at: 0,
772
+ wave: "sine",
773
+ f: 320,
774
+ f2: 200,
775
+ d: 0.05,
776
+ vol: 0.35
777
+ },
778
+ {
779
+ layer: "thud",
780
+ at: 0.01,
781
+ gain: 0.18,
782
+ frequency: 600
783
+ }
784
+ ],
785
+ slide_release: [
786
+ {
787
+ layer: "metal",
788
+ at: 0,
789
+ frequency: 900,
790
+ gain: 0.35
791
+ },
792
+ {
793
+ layer: "scene_tone",
794
+ at: 0.02,
795
+ wave: "square",
796
+ f: 2e3,
797
+ f2: 1400,
798
+ d: 0.02,
799
+ vol: 0.4
800
+ },
801
+ {
802
+ layer: "scene_noise",
803
+ at: 0.02,
804
+ type: "highpass",
805
+ f: 3e3,
806
+ q: 0.7,
807
+ d: 0.03,
808
+ vol: 0.32
809
+ },
810
+ {
811
+ layer: "thud",
812
+ at: 0.03,
813
+ gain: 0.25,
814
+ frequency: 500
815
+ }
816
+ ],
817
+ charging_handle_pull: [
818
+ {
819
+ layer: "scene_noise",
820
+ at: 0,
821
+ type: "bandpass",
822
+ f: 1500,
823
+ q: 3,
824
+ d: 0.09,
825
+ vol: 0.3,
826
+ f2: 900
827
+ },
828
+ {
829
+ layer: "metal",
830
+ at: 0.06,
831
+ frequency: 1300,
832
+ gain: 0.25
833
+ }
834
+ ],
835
+ charging_handle_release: [
836
+ {
837
+ layer: "scene_tone",
838
+ at: 0,
839
+ wave: "square",
840
+ f: 2200,
841
+ f2: 1540,
842
+ d: 0.02,
843
+ vol: 0.4
844
+ },
845
+ {
846
+ layer: "scene_noise",
847
+ at: 0,
848
+ type: "highpass",
849
+ f: 3e3,
850
+ q: 0.7,
851
+ d: 0.03,
852
+ vol: 0.32
853
+ },
854
+ {
855
+ layer: "scene_tone",
856
+ at: 0.02,
857
+ wave: "square",
858
+ f: 2700,
859
+ f2: 1890,
860
+ d: 0.02,
861
+ vol: 0.4
862
+ },
863
+ {
864
+ layer: "scene_noise",
865
+ at: 0.02,
866
+ type: "highpass",
867
+ f: 3e3,
868
+ q: 0.7,
869
+ d: 0.03,
870
+ vol: 0.32
871
+ },
872
+ {
873
+ layer: "thud",
874
+ at: 0.03,
875
+ gain: 0.2,
876
+ frequency: 600
877
+ }
878
+ ],
879
+ bolt_lift: [
880
+ {
881
+ layer: "metal",
882
+ at: 0,
883
+ frequency: 1500,
884
+ gain: 0.3
885
+ },
886
+ {
887
+ layer: "scene_noise",
888
+ at: 0,
889
+ type: "bandpass",
890
+ f: 2e3,
891
+ q: 3,
892
+ d: 0.04,
893
+ vol: 0.15,
894
+ f2: 1500
895
+ }
896
+ ],
897
+ bolt_pull: [
898
+ {
899
+ layer: "scene_noise",
900
+ at: 0,
901
+ type: "bandpass",
902
+ f: 1400,
903
+ q: 3,
904
+ d: 0.12,
905
+ vol: 0.3,
906
+ f2: 800
907
+ },
908
+ {
909
+ layer: "metal",
910
+ at: 0.1,
911
+ frequency: 900,
912
+ gain: 0.3
913
+ }
914
+ ],
915
+ bolt_push: [
916
+ {
917
+ layer: "scene_noise",
918
+ at: 0,
919
+ type: "bandpass",
920
+ f: 800,
921
+ q: 3,
922
+ d: 0.1,
923
+ vol: 0.3,
924
+ f2: 1400
925
+ },
926
+ {
927
+ layer: "metal",
928
+ at: 0.08,
929
+ frequency: 1100,
930
+ gain: 0.3
931
+ }
932
+ ],
933
+ bolt_lock: [
934
+ {
935
+ layer: "metal",
936
+ at: 0,
937
+ frequency: 1800,
938
+ gain: 0.4
939
+ },
940
+ {
941
+ layer: "thud",
942
+ at: 0.02,
943
+ gain: 0.2,
944
+ frequency: 700
945
+ }
946
+ ],
947
+ shell_insert: [
948
+ {
949
+ layer: "scene_tone",
950
+ at: 0,
951
+ wave: "square",
952
+ f: 1400,
953
+ f2: 980,
954
+ d: 0.02,
955
+ vol: 0.35
956
+ },
957
+ {
958
+ layer: "scene_noise",
959
+ at: 0,
960
+ type: "highpass",
961
+ f: 3e3,
962
+ q: 0.7,
963
+ d: 0.03,
964
+ vol: 0.28
965
+ },
966
+ {
967
+ layer: "scene_noise",
968
+ at: 0.02,
969
+ type: "bandpass",
970
+ f: 1200,
971
+ q: 3,
972
+ d: 0.05,
973
+ vol: 0.25
974
+ }
975
+ ],
976
+ pump_back: [
977
+ {
978
+ layer: "scene_noise",
979
+ at: 0,
980
+ type: "bandpass",
981
+ f: 1e3,
982
+ q: 2,
983
+ d: 0.12,
984
+ vol: 0.3,
985
+ f2: 700
986
+ },
987
+ {
988
+ layer: "metal",
989
+ at: 0.1,
990
+ frequency: 900,
991
+ gain: 0.35
992
+ }
993
+ ],
994
+ pump_forward: [
995
+ {
996
+ layer: "scene_noise",
997
+ at: 0,
998
+ type: "bandpass",
999
+ f: 700,
1000
+ q: 2,
1001
+ d: 0.1,
1002
+ vol: 0.3,
1003
+ f2: 1e3
1004
+ },
1005
+ {
1006
+ layer: "metal",
1007
+ at: 0.08,
1008
+ frequency: 1300,
1009
+ gain: 0.4
1010
+ },
1011
+ {
1012
+ layer: "thud",
1013
+ at: 0.1,
1014
+ gain: 0.25,
1015
+ frequency: 500
1016
+ }
1017
+ ],
1018
+ lever_open: [
1019
+ {
1020
+ layer: "metal",
1021
+ at: 0,
1022
+ frequency: 1e3,
1023
+ gain: 0.35
1024
+ },
1025
+ {
1026
+ layer: "scene_noise",
1027
+ at: 0,
1028
+ type: "bandpass",
1029
+ f: 1200,
1030
+ q: 3,
1031
+ d: 0.08,
1032
+ vol: 0.2,
1033
+ f2: 800
1034
+ }
1035
+ ],
1036
+ lever_close: [
1037
+ {
1038
+ layer: "metal",
1039
+ at: 0,
1040
+ frequency: 1500,
1041
+ gain: 0.4
1042
+ },
1043
+ {
1044
+ layer: "thud",
1045
+ at: 0.02,
1046
+ gain: 0.2,
1047
+ frequency: 550
1048
+ }
1049
+ ],
1050
+ cylinder_open: [
1051
+ {
1052
+ layer: "metal",
1053
+ at: 0,
1054
+ frequency: 1500,
1055
+ gain: 0.3
1056
+ },
1057
+ {
1058
+ layer: "scene_noise",
1059
+ at: 0.02,
1060
+ type: "bandpass",
1061
+ f: 1800,
1062
+ q: 3,
1063
+ d: 0.08,
1064
+ vol: 0.2,
1065
+ f2: 1200
1066
+ }
1067
+ ],
1068
+ cylinder_eject: [
1069
+ {
1070
+ layer: "metal",
1071
+ at: 0,
1072
+ frequency: 2400,
1073
+ gain: 0.25
1074
+ },
1075
+ {
1076
+ layer: "metal",
1077
+ at: 0.05,
1078
+ frequency: 2800,
1079
+ gain: 0.2
1080
+ },
1081
+ {
1082
+ layer: "metal",
1083
+ at: 0.11,
1084
+ frequency: 3200,
1085
+ gain: 0.15
1086
+ }
1087
+ ],
1088
+ speedloader: [
1089
+ {
1090
+ layer: "scene_noise",
1091
+ at: 0,
1092
+ type: "bandpass",
1093
+ f: 1500,
1094
+ q: 3,
1095
+ d: 0.08,
1096
+ vol: 0.25,
1097
+ f2: 2e3
1098
+ },
1099
+ {
1100
+ layer: "metal",
1101
+ at: 0,
1102
+ frequency: 2e3,
1103
+ gain: 0.25
1104
+ },
1105
+ {
1106
+ layer: "metal",
1107
+ at: 0.05,
1108
+ frequency: 2400,
1109
+ gain: 0.2
1110
+ },
1111
+ {
1112
+ layer: "thud",
1113
+ at: 0.07,
1114
+ gain: 0.15,
1115
+ frequency: 800
1116
+ }
1117
+ ],
1118
+ cylinder_close: [
1119
+ {
1120
+ layer: "metal",
1121
+ at: 0,
1122
+ frequency: 1100,
1123
+ gain: 0.45
1124
+ },
1125
+ {
1126
+ layer: "thud",
1127
+ at: 0.01,
1128
+ gain: 0.2,
1129
+ frequency: 500
1130
+ }
1131
+ ],
1132
+ barrels_open: [
1133
+ {
1134
+ layer: "metal",
1135
+ at: 0,
1136
+ frequency: 1e3,
1137
+ gain: 0.4
1138
+ },
1139
+ {
1140
+ layer: "thud",
1141
+ at: 0.03,
1142
+ gain: 0.2,
1143
+ frequency: 450
1144
+ }
1145
+ ],
1146
+ casings_eject: [
1147
+ {
1148
+ layer: "metal",
1149
+ at: 0,
1150
+ frequency: 2600,
1151
+ gain: 0.25
1152
+ },
1153
+ {
1154
+ layer: "metal",
1155
+ at: 0.08,
1156
+ frequency: 3e3,
1157
+ gain: 0.2
1158
+ }
1159
+ ],
1160
+ barrels_close: [
1161
+ {
1162
+ layer: "metal",
1163
+ at: 0,
1164
+ frequency: 1100,
1165
+ gain: 0.5
1166
+ },
1167
+ {
1168
+ layer: "thud",
1169
+ at: 0.02,
1170
+ gain: 0.3,
1171
+ frequency: 450
1172
+ }
1173
+ ],
1174
+ belt_cover_open: [
1175
+ {
1176
+ layer: "metal",
1177
+ at: 0,
1178
+ frequency: 800,
1179
+ gain: 0.4
1180
+ },
1181
+ {
1182
+ layer: "thud",
1183
+ at: 0.03,
1184
+ gain: 0.2,
1185
+ frequency: 500
1186
+ }
1187
+ ],
1188
+ belt_insert: [
1189
+ {
1190
+ layer: "metal",
1191
+ at: 0,
1192
+ frequency: 1400,
1193
+ gain: 0.3
1194
+ },
1195
+ {
1196
+ layer: "metal",
1197
+ at: 0.08,
1198
+ frequency: 1800,
1199
+ gain: 0.25
1200
+ },
1201
+ {
1202
+ layer: "thud",
1203
+ at: 0.1,
1204
+ gain: 0.25,
1205
+ frequency: 450
1206
+ }
1207
+ ],
1208
+ belt_cover_close: [
1209
+ {
1210
+ layer: "metal",
1211
+ at: 0,
1212
+ frequency: 1e3,
1213
+ gain: 0.45
1214
+ },
1215
+ {
1216
+ layer: "thud",
1217
+ at: 0.02,
1218
+ gain: 0.3,
1219
+ frequency: 400
1220
+ }
1221
+ ],
1222
+ rocket_load: [
1223
+ {
1224
+ layer: "scene_noise",
1225
+ at: 0,
1226
+ type: "bandpass",
1227
+ f: 600,
1228
+ q: 1.5,
1229
+ d: 0.25,
1230
+ vol: 0.3,
1231
+ f2: 400
1232
+ },
1233
+ {
1234
+ layer: "thud",
1235
+ at: 0.2,
1236
+ gain: 0.2,
1237
+ frequency: 450
1238
+ }
1239
+ ],
1240
+ rocket_seat: [
1241
+ {
1242
+ layer: "metal",
1243
+ at: 0,
1244
+ frequency: 900,
1245
+ gain: 0.45
1246
+ },
1247
+ {
1248
+ layer: "thud",
1249
+ at: 0.01,
1250
+ gain: 0.35,
1251
+ frequency: 350
1252
+ }
1253
+ ],
1254
+ grenade_load: [
1255
+ {
1256
+ layer: "metal",
1257
+ at: 0,
1258
+ frequency: 1100,
1259
+ gain: 0.35
1260
+ },
1261
+ {
1262
+ layer: "scene_noise",
1263
+ at: 0,
1264
+ type: "bandpass",
1265
+ f: 1e3,
1266
+ q: 2,
1267
+ d: 0.1,
1268
+ vol: 0.2,
1269
+ f2: 700
1270
+ }
1271
+ ],
1272
+ breech_open: [
1273
+ {
1274
+ layer: "metal",
1275
+ at: 0,
1276
+ frequency: 1e3,
1277
+ gain: 0.35
1278
+ },
1279
+ {
1280
+ layer: "thud",
1281
+ at: 0.03,
1282
+ gain: 0.15,
1283
+ frequency: 500
1284
+ }
1285
+ ],
1286
+ breech_close: [
1287
+ {
1288
+ layer: "metal",
1289
+ at: 0,
1290
+ frequency: 1100,
1291
+ gain: 0.45
1292
+ },
1293
+ {
1294
+ layer: "thud",
1295
+ at: 0.02,
1296
+ gain: 0.25,
1297
+ frequency: 450
1298
+ }
1299
+ ],
1300
+ tube_drop: [
1301
+ {
1302
+ layer: "thud",
1303
+ at: 0,
1304
+ gain: 0.35,
1305
+ frequency: 380
1306
+ },
1307
+ {
1308
+ layer: "metal",
1309
+ at: 0.05,
1310
+ frequency: 700,
1311
+ gain: 0.2
1312
+ }
1313
+ ],
1314
+ tube_seat: [
1315
+ {
1316
+ layer: "metal",
1317
+ at: 0,
1318
+ frequency: 900,
1319
+ gain: 0.45
1320
+ },
1321
+ {
1322
+ layer: "thud",
1323
+ at: 0.02,
1324
+ gain: 0.3,
1325
+ frequency: 380
1326
+ }
1327
+ ]
1328
+ }
1329
+ };
1330
+
1331
+ // src/audio/reload-sounds.ts
1332
+ var RELOAD_CUE_IDS = Object.keys(reload_sounds_default.cues);
1333
+ var DEFAULT_SOUND_PROFILE = { weight: "medium", material: "steel" };
1334
+ var RELOAD_CUES = reload_sounds_default.cues;
1335
+ var polymer = new Set(reload_sounds_default.polymer);
1336
+ var weights = reload_sounds_default.weights;
1337
+ var soundProfileOf = (weaponId, modelFamily) => ({ weight: weights[modelFamily] ?? "medium", material: polymer.has(weaponId) ? "polymer" : "steel" });
1338
+ var reloadCueSteps = (id, profile = DEFAULT_SOUND_PROFILE, table = RELOAD_CUES) => {
1339
+ const steps = table[id];
1340
+ if (!steps) return [];
1341
+ const weight = reload_sounds_default.profiles.weight[profile.weight];
1342
+ const material = reload_sounds_default.profiles.material[profile.material];
1343
+ const pitch = weight.pitch * material.pitch;
1344
+ const gain = weight.gain * material.gain;
1345
+ return steps.map((entry) => {
1346
+ switch (entry.layer) {
1347
+ case "metal":
1348
+ return { ...entry, frequency: Math.round(entry.frequency * pitch), gain: entry.gain * gain };
1349
+ case "thud":
1350
+ return { ...entry, frequency: Math.round(entry.frequency * pitch), gain: entry.gain * gain };
1351
+ case "scene_tone":
1352
+ return { ...entry, f: Math.round(entry.f * pitch), ...entry.f2 === void 0 ? {} : { f2: Math.round(entry.f2 * pitch) }, vol: entry.vol * gain };
1353
+ case "scene_noise":
1354
+ return { ...entry, f: Math.round(entry.f * pitch), ...entry.f2 === void 0 ? {} : { f2: Math.round(entry.f2 * pitch) }, vol: entry.vol * gain };
1355
+ default:
1356
+ return entry;
1357
+ }
1358
+ });
1359
+ };
1360
+
601
1361
  // src/audio.ts
602
1362
  var NOISE_SECONDS = 1.5;
603
1363
  var DEFAULT_SEED = 1;
@@ -606,6 +1366,7 @@ function createWeaponAudio(volume = 0.6, options = {}) {
606
1366
  const classes = { ...GAME_SOUND_CLASSES, ...options.classes };
607
1367
  const distanceCurve = { ...GAME_DISTANCE_CURVE, ...options.distance };
608
1368
  const mechanicalTable = { ...MECHANICAL_SOUNDS, ...options.mechanical };
1369
+ const cueTable = { ...RELOAD_CUES, ...options.reloadCues };
609
1370
  const shared = options.engine;
610
1371
  const engine = shared ?? createAudioEngine({
611
1372
  seed,
@@ -658,17 +1419,20 @@ function createWeaponAudio(volume = 0.6, options = {}) {
658
1419
  const shotSound = (input) => computeShotSound(input, options);
659
1420
  return {
660
1421
  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 });
1422
+ const sound3 = shotSound({ ...extras, energyJ, category });
1423
+ if (!sound3.audible) return;
1424
+ classBurstAt(0, sound3.cutoff, sound3.gain, sound3.duration);
1425
+ if (sound3.crack.gain > 0) burst({ seconds: sound3.crack.duration, gain: sound3.crack.gain, filter: "highpass", frequency: sound3.crack.cutoff });
1426
+ tone({ seconds: sound3.body.duration, gain: sound3.body.gain, frequency: sound3.body.frequency, to: sound3.body.to });
666
1427
  },
667
1428
  shotSound,
668
1429
  click() {
669
1430
  mechanical("dry_fire");
670
1431
  },
671
1432
  mechanical,
1433
+ cue(id, profile) {
1434
+ for (const entry of reloadCueSteps(id, profile, cueTable)) step(entry);
1435
+ },
672
1436
  event(event) {
673
1437
  const id = mechanicalSoundOf(event);
674
1438
  if (id) mechanical(id);
@@ -717,7 +1481,7 @@ var VIEWMODEL_NODE_NAME = "weapon-viewmodel";
717
1481
  var IN_HAND_NODE_NAME = "weapon-in-hand";
718
1482
  var LASER_RAYCAST_HZ = 30;
719
1483
  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;
1484
+ 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
1485
  return false;
722
1486
  };
723
1487
  var isVisibleInScene = (object) => {
@@ -747,10 +1511,149 @@ var createLaserRaycast = (THREE, scene, camera, options = {}) => {
747
1511
  };
748
1512
  };
749
1513
 
1514
+ // src/fx/hand-hold.ts
1515
+ var HAND_HOLD = {
1516
+ /** Half the width of a pistol grip, the palm sits this far (plus the palm) off its middle. */
1517
+ gripHalfWidth: 0.0145,
1518
+ /** Half the thickness of a palm. */
1519
+ palm: 0.011,
1520
+ /** How far up the grip the web of the hand sits from its middle. */
1521
+ gripUp: 6e-3,
1522
+ /** How far down the grip the supporting hand of a pistol sits from the middle. */
1523
+ supportDown: 0.036,
1524
+ /** Where along the handguard (0 the receiver, 1 the end of the forend) the supporting hand closes. */
1525
+ forendAt: 0.6,
1526
+ /** How far under the handguard the palm sits (half a handguard plus the palm). */
1527
+ forendUnder: 0.034,
1528
+ /** The grip of a long gun that has no drawn pistol grip: how far under the receiver the hand sits. */
1529
+ strapUnder: 0.04,
1530
+ /** How far lower than touching the palm hangs under a handguard, so the fingers close under it and not over it. */
1531
+ forendDrop: 0.03,
1532
+ /** How far (radians) the fingers of a support hand under a handguard rise from level. */
1533
+ forendTilt: 0.5,
1534
+ /** How far a handgun's support hand dips for the new magazine, along the grip. */
1535
+ magDip: 0.045,
1536
+ /** Closure of the fingers on a grip and on a forend (0 open, 1 closed). */
1537
+ closed: 0.9,
1538
+ forend: 0.6,
1539
+ /** How fast the supporting hand goes to the magazine and back, per second. */
1540
+ magRate: 7,
1541
+ /** The hands shrink with the tool: a tool drawn at scale `s` takes hands at `s ** handScaleExponent`. */
1542
+ handScaleExponent: 0.55
1543
+ };
1544
+ var handFrame = (THREE, x, y) => {
1545
+ const ax = x.clone().normalize();
1546
+ const az = ax.clone().cross(y).normalize();
1547
+ const ay = az.clone().cross(ax);
1548
+ return new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(ax, ay, az));
1549
+ };
1550
+ var poseOf = (anchor, rootQuaternion) => {
1551
+ const position = anchor.part.localToWorld(anchor.offset.clone());
1552
+ const q = rootQuaternion.clone().multiply(anchor.quaternion);
1553
+ return { position: [position.x, position.y, position.z], quaternion: [q.x, q.y, q.z, q.w] };
1554
+ };
1555
+ var blendPose = (THREE, a, b, k) => {
1556
+ const q = new THREE.Quaternion(...a.quaternion).slerp(new THREE.Quaternion(...b.quaternion), k);
1557
+ return {
1558
+ 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],
1559
+ quaternion: [q.x, q.y, q.z, q.w]
1560
+ };
1561
+ };
1562
+ var targetsOf = (THREE, hold, rootQuaternion, magazine, weights2) => {
1563
+ let right = hold.right ? poseOf(hold.right, rootQuaternion) : null;
1564
+ let left = null;
1565
+ if (hold.left) {
1566
+ left = poseOf(hold.left, rootQuaternion);
1567
+ if (hold.leftMagazine && magazine > 0) left = blendPose(THREE, left, poseOf(hold.leftMagazine, rootQuaternion), magazine);
1568
+ }
1569
+ hold.works?.forEach((work, index) => {
1570
+ const k = weights2?.[index] ?? 0;
1571
+ if (k <= 0) return;
1572
+ const pose = poseOf(work.anchor, rootQuaternion);
1573
+ if (work.hand === "R") right = right ? blendPose(THREE, right, pose, k) : pose;
1574
+ else left = left ? blendPose(THREE, left, pose, k) : pose;
1575
+ });
1576
+ return { R: right, L: left, grip: hold.grip, gripL: hold.gripLeft, scale: hold.scale };
1577
+ };
1578
+ var V = (THREE, x, y, z) => new THREE.Vector3(x, y, z);
1579
+ var gripFrame = (THREE, up, side) => {
1580
+ const forward = V(THREE, 1, 0, 0);
1581
+ const toSide = V(THREE, 0, 0, 1);
1582
+ const tilt = new THREE.Quaternion().setFromUnitVectors(V(THREE, 0, 1, 0), up);
1583
+ return handFrame(THREE, forward.clone().applyQuaternion(tilt), toSide.clone().applyQuaternion(tilt).multiplyScalar(side));
1584
+ };
1585
+ 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)));
1586
+ function holdOfModel(THREE, model, holderScale) {
1587
+ const { root, inner, parts } = model;
1588
+ const frames = parts.frames;
1589
+ const toRoot = (x, y, z) => V(THREE, inner.position.x + x * inner.scale.x, y, z);
1590
+ const handgun = parts.family === "pistol" || parts.family === "revolver";
1591
+ root.updateWorldMatrix(true, true);
1592
+ let gripAt;
1593
+ let up;
1594
+ if (parts.grip) {
1595
+ gripAt = toRoot(parts.grip.position.x, parts.grip.position.y, parts.grip.position.z);
1596
+ up = V(THREE, 0, 1, 0).applyQuaternion(parts.grip.quaternion);
1597
+ } else {
1598
+ const receiverHeight = frames?.receiverHeight ?? 0.06;
1599
+ gripAt = toRoot(frames?.gripX ?? 0.07, -receiverHeight / 2 - HAND_HOLD.strapUnder, 0);
1600
+ up = V(THREE, Math.sin(0.3), Math.cos(0.3), 0);
1601
+ }
1602
+ const side = HAND_HOLD.gripHalfWidth + HAND_HOLD.palm;
1603
+ const right = {
1604
+ part: root,
1605
+ offset: gripAt.clone().addScaledVector(up, HAND_HOLD.gripUp).add(V(THREE, 0, 0, side)),
1606
+ quaternion: gripFrame(THREE, up, 1)
1607
+ };
1608
+ let left = null;
1609
+ let leftMagazine = null;
1610
+ if (handgun) {
1611
+ left = { part: root, offset: gripAt.clone().addScaledVector(up, -HAND_HOLD.supportDown).add(V(THREE, 0, 0, -side)), quaternion: gripFrame(THREE, up, -1) };
1612
+ } else if (parts.pump) {
1613
+ const box2 = new THREE.Box3().setFromObject(parts.pump);
1614
+ const center = box2.getCenter(new THREE.Vector3());
1615
+ const under = parts.pump.worldToLocal(V(THREE, center.x, box2.min.y - HAND_HOLD.palm, center.z));
1616
+ left = { part: parts.pump, offset: under, quaternion: forendFrame(THREE) };
1617
+ } else if (frames) {
1618
+ const handguardHeight = frames.receiverHeight * 0.8;
1619
+ const x = frames.receiverEnd + (frames.handguardEnd - frames.receiverEnd) * HAND_HOLD.forendAt;
1620
+ left = { part: root, offset: toRoot(x, frames.barrelY - handguardHeight / 2 - HAND_HOLD.palm - HAND_HOLD.forendDrop, 0), quaternion: forendFrame(THREE, HAND_HOLD.forendTilt) };
1621
+ }
1622
+ if (left && parts.mag) {
1623
+ const box2 = new THREE.Box3().setFromObject(parts.mag);
1624
+ const center = box2.getCenter(new THREE.Vector3());
1625
+ const local = parts.mag.worldToLocal(V(THREE, center.x, box2.min.y - HAND_HOLD.palm, center.z));
1626
+ 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) };
1627
+ }
1628
+ return {
1629
+ right,
1630
+ left,
1631
+ leftMagazine,
1632
+ scale: holderScale ** HAND_HOLD.handScaleExponent,
1633
+ grip: HAND_HOLD.closed,
1634
+ gripLeft: HAND_HOLD.forend
1635
+ };
1636
+ }
1637
+ function holdOfProp(THREE, group, category, scale) {
1638
+ const handle = group.getObjectByName("handle") ?? group.getObjectByName("body") ?? group;
1639
+ const head = group.getObjectByName("head");
1640
+ const size = (object) => new THREE.Box3().setFromObject(object).getSize(new THREE.Vector3());
1641
+ const wrap = handFrame(THREE, V(THREE, 0, 1, 0), V(THREE, 0, 0, 1));
1642
+ const handleSize = size(handle);
1643
+ const right = { part: group, offset: handle.position.clone().add(V(THREE, 0, handleSize.y / 2 + 2e-3, 0)), quaternion: wrap };
1644
+ const long = head && ["smg", "carbine", "rifle", "sniper", "shotgun", "crossbow", "flame"].includes(category);
1645
+ const headSize = head ? size(head) : null;
1646
+ 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;
1647
+ return { right, left, leftMagazine: null, scale: scale ** HAND_HOLD.handScaleExponent, grip: HAND_HOLD.closed, gripLeft: HAND_HOLD.forend };
1648
+ }
1649
+
750
1650
  // src/props.ts
751
- import { mergeByMaterial } from "@adatechnology/game-props3d";
1651
+ import { markKitOrigin as markKitOrigin2 } from "@adatechnology/game-contracts";
1652
+ import { buildItem, hasItemModel } from "@adatechnology/game-items-three";
1653
+ import { mergeByMaterial, shapeGeometries as shapeGeometries2 } from "@adatechnology/game-shapes-three";
752
1654
 
753
1655
  // src/models/index.ts
1656
+ import { markKitOrigin } from "@adatechnology/game-contracts";
754
1657
  import { WEAPONS } from "@adatechnology/game-weapons";
755
1658
 
756
1659
  // src/data/models.json
@@ -2306,6 +3209,9 @@ var models_default = {
2306
3209
  }
2307
3210
  };
2308
3211
 
3212
+ // src/models/kit.ts
3213
+ import { shapeGeometries } from "@adatechnology/game-shapes-three";
3214
+
2309
3215
  // src/models/rounded-box.ts
2310
3216
  var uvAlong = (THREE, faceDirection, normal, uvAxis, projectionAxis, radius, sideLength) => {
2311
3217
  const arcLength = 2 * Math.PI * radius / 4;
@@ -2332,11 +3238,11 @@ function createRoundedBoxGeometry(THREE, width, height, depth, segments, radius)
2332
3238
  const count = segments * 2 + 1;
2333
3239
  const rounding = Math.min(width / 2, height / 2, depth / 2, radius);
2334
3240
  const geometry = new THREE.BoxGeometry(1, 1, 1, count, count, count);
2335
- const flat = geometry.toNonIndexed();
3241
+ const flat2 = geometry.toNonIndexed();
2336
3242
  geometry.setIndex(null);
2337
- geometry.setAttribute("position", flat.attributes.position);
2338
- geometry.setAttribute("normal", flat.attributes.normal);
2339
- geometry.setAttribute("uv", flat.attributes.uv);
3243
+ geometry.setAttribute("position", flat2.attributes.position);
3244
+ geometry.setAttribute("normal", flat2.attributes.normal);
3245
+ geometry.setAttribute("uv", flat2.attributes.uv);
2340
3246
  const position = new THREE.Vector3();
2341
3247
  const normal = new THREE.Vector3();
2342
3248
  const box2 = new THREE.Vector3(width, height, depth).divideScalar(2).subScalar(rounding);
@@ -2417,9 +3323,10 @@ var createMaterials = (THREE) => {
2417
3323
  };
2418
3324
  };
2419
3325
  var kits = /* @__PURE__ */ new WeakMap();
2420
- function modelKit(THREE) {
2421
- const cached = kits.get(THREE);
3326
+ function modelKit(host) {
3327
+ const cached = kits.get(host);
2422
3328
  if (cached) return cached;
3329
+ const THREE = shapeGeometries(host);
2423
3330
  const geometries = /* @__PURE__ */ new Map();
2424
3331
  const shadowed = (mesh) => {
2425
3332
  mesh.castShadow = true;
@@ -2478,7 +3385,7 @@ function modelKit(THREE) {
2478
3385
  return group;
2479
3386
  }
2480
3387
  };
2481
- kits.set(THREE, kit);
3388
+ kits.set(host, kit);
2482
3389
  return kit;
2483
3390
  }
2484
3391
  function heatify(glow, mesh) {
@@ -2661,6 +3568,7 @@ function buildBullpup(kit, options) {
2661
3568
  const gx = bodyLength * 0.8;
2662
3569
  const grip = kit.pivot(group, gx, -0.085, 0);
2663
3570
  grip.rotation.z = -0.25;
3571
+ parts.grip = grip;
2664
3572
  box2(grip, 0.03, 0.1, 0.03, M.poly, 0, 0, 0);
2665
3573
  box2(group, 0.1, 6e-3, 0.03, body, gx + 0.01, -0.06, 0);
2666
3574
  parts.trigger = box2(group, 4e-3, 0.016, 4e-3, M.dark, gx + 0.02, -0.05, 0, 0.15);
@@ -2880,6 +3788,7 @@ function buildAt4(kit, group, parts) {
2880
3788
  box2(group, 0.012, 0.05, 0.02, M.dark, 0.71, 0.08, -0.035);
2881
3789
  const grip = kit.pivot(group, 0.68, -0.05, 0);
2882
3790
  grip.rotation.z = -0.15;
3791
+ parts.grip = grip;
2883
3792
  box2(grip, 0.035, 0.11, 0.032, M.dark, 0, -0.05, 0);
2884
3793
  box2(group, 0.14, 0.035, 0.06, M.rubber, 0.32, -0.06, 0);
2885
3794
  box2(group, 0.3, 0.012, 0.055, M.dark, 0.5, -0.05, 0);
@@ -3209,6 +4118,7 @@ function buildFrame(kit, group, parts, options, style, size, materials) {
3209
4118
  const gy = -h / 2 - gripLength / 2 + 6e-3;
3210
4119
  const grip = kit.pivot(group, gx, gy, 0);
3211
4120
  grip.rotation.z = style.ga;
4121
+ parts.grip = grip;
3212
4122
  const gripWidth = style.grip === "chunky" ? w * 1.18 : w * 1.06;
3213
4123
  const gripDepth = style.grip === "chunky" ? 0.036 : 0.032;
3214
4124
  kit.roundness = 0.3;
@@ -3332,6 +4242,7 @@ function buildRevolver(kit, options) {
3332
4242
  parts.hammer = hammer;
3333
4243
  const grip = kit.pivot(group, 0.024, -0.062, 0);
3334
4244
  grip.rotation.z = -0.38;
4245
+ parts.grip = grip;
3335
4246
  box2(grip, 0.034, 0.1, 0.031, options.wood ? M.wood : M.rubber, 0, 0, 0);
3336
4247
  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
4248
  box2(group, 0.038, 4e-3, 8e-3, metal2, 0.062, -0.036, 0);
@@ -3520,6 +4431,7 @@ function buildGrip(context) {
3520
4431
  if (!context.isTraditional && o.grip !== "none") {
3521
4432
  const grip = kit.pivot(group, gx - 0.012, -hR / 2 - 0.046, 0);
3522
4433
  grip.rotation.z = -0.3;
4434
+ parts.grip = grip;
3523
4435
  box2(grip, 0.032, 0.105, 0.028, furn, 0, 0, 0);
3524
4436
  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
4437
  box2(grip, 0.034, 8e-3, 0.03, furn, 0, -0.052, 0);
@@ -3820,6 +4732,7 @@ function buildWeaponModelById(THREE, id) {
3820
4732
  built.group.position.x = -new THREE.Box3().setFromObject(built.group).min.x;
3821
4733
  } else root = fitLength(kit, built.group, entry.length / 1e3);
3822
4734
  root.name = id;
4735
+ markKitOrigin(root, { owner: "game-weapons-three", kind: "weapon", id });
3823
4736
  const model = { id, root, inner: built.group, parts: built.parts };
3824
4737
  if (!built.parts.frames) built.parts.frames = deriveFrames(THREE, model);
3825
4738
  return model;
@@ -3899,7 +4812,7 @@ function buildProp(THREE, prop) {
3899
4812
  if (part.shape.kind !== "box") continue;
3900
4813
  const [w, h, d] = part.shape.size;
3901
4814
  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);
4815
+ const mesh = new THREE.Mesh(new (shapeGeometries2(THREE)).BoxGeometry(w, h, d), material);
3903
4816
  mesh.name = part.id;
3904
4817
  mesh.position.set(part.position[0], part.position[1], part.position[2]);
3905
4818
  mesh.castShadow = true;
@@ -3908,15 +4821,90 @@ function buildProp(THREE, prop) {
3908
4821
  }
3909
4822
  return group;
3910
4823
  }
4824
+ var WEAPON_ITEM_MODELS = /* @__PURE__ */ new Map([
4825
+ ["wrench", { item: "adjustable_wrench" }],
4826
+ ["extinguisher", { item: "fire_extinguisher", flip: true }],
4827
+ ["blowtorch", { item: "gas_torch", flip: true }],
4828
+ ["crossbow", { turn: 1 }],
4829
+ ["knife", { flip: true }],
4830
+ ["meat_cleaver", { flip: true }],
4831
+ ["screwdriver", { flip: true }]
4832
+ ]);
4833
+ function registerWeaponItemModel(weaponId, model) {
4834
+ WEAPON_ITEM_MODELS.set(weaponId, model ?? { item: "" });
4835
+ }
4836
+ function itemModelOfWeapon(weaponId) {
4837
+ const id = WEAPON_ITEM_MODELS.get(weaponId)?.item ?? weaponId;
4838
+ return id && hasItemModel(id) ? id : void 0;
4839
+ }
4840
+ function buildItemWeaponModel(THREE, def) {
4841
+ const itemId = itemModelOfWeapon(def.id);
4842
+ if (!itemId) return void 0;
4843
+ const { root } = buildItem(THREE, { id: itemId, category: "weapon", size: [0, 0, 0], model: itemId });
4844
+ const group = markKitOrigin2(new THREE.Group(), { owner: "game-weapons-three", kind: "weapon", id: def.id });
4845
+ group.name = def.id;
4846
+ group.add(root);
4847
+ const sizeOf = () => {
4848
+ group.updateMatrixWorld(true);
4849
+ return new THREE.Box3().setFromObject(root).getSize(new THREE.Vector3());
4850
+ };
4851
+ let size = sizeOf();
4852
+ if (size.y > size.x && size.y >= size.z) root.rotateOnWorldAxis(new THREE.Vector3(0, 0, 1), -Math.PI / 2);
4853
+ else if (size.z > size.x && size.z > size.y) root.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), Math.PI / 2);
4854
+ size = sizeOf();
4855
+ if (size.z < size.y) root.rotateOnWorldAxis(new THREE.Vector3(1, 0, 0), Math.PI / 2);
4856
+ const turn2 = WEAPON_ITEM_MODELS.get(def.id)?.turn ?? 0;
4857
+ if (turn2) root.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), turn2 * Math.PI / 2);
4858
+ const box2 = new THREE.Box3().setFromObject(root);
4859
+ const middle = (box2.min.x + box2.max.x) / 2;
4860
+ const span = [
4861
+ [Infinity, -Infinity],
4862
+ [Infinity, -Infinity]
4863
+ ];
4864
+ const point = new THREE.Vector3();
4865
+ root.traverse((node) => {
4866
+ const mesh = node;
4867
+ const position = mesh.isMesh ? mesh.geometry.getAttribute("position") : void 0;
4868
+ if (!position) return;
4869
+ for (let i = 0; i < position.count; i++) {
4870
+ point.fromBufferAttribute(position, i).applyMatrix4(mesh.matrixWorld);
4871
+ const half = span[point.x > middle ? 1 : 0];
4872
+ half[0] = Math.min(half[0], point.z);
4873
+ half[1] = Math.max(half[1], point.z);
4874
+ }
4875
+ });
4876
+ const reach = span.map(([low, high]) => high - low);
4877
+ const flip = WEAPON_ITEM_MODELS.get(def.id)?.flip === true;
4878
+ if (reach[0] > reach[1] !== flip) root.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), Math.PI);
4879
+ group.updateMatrixWorld(true);
4880
+ const laid = new THREE.Box3().setFromObject(root);
4881
+ const centre = laid.getCenter(new THREE.Vector3());
4882
+ root.position.sub(new THREE.Vector3(centre.x, laid.min.y, centre.z));
4883
+ const length = laid.max.x - laid.min.x;
4884
+ const handle = new THREE.Object3D();
4885
+ handle.name = "handle";
4886
+ handle.position.set(-length * 0.35, 0, 0);
4887
+ group.add(handle);
4888
+ root.traverse((node) => {
4889
+ const mesh = node;
4890
+ if (!mesh.isMesh) return;
4891
+ mesh.castShadow = true;
4892
+ mesh.receiveShadow = true;
4893
+ });
4894
+ return group;
4895
+ }
3911
4896
  function buildWeaponModel(THREE, def) {
3912
- return buildProp(THREE, weaponProp(def));
4897
+ return buildItemWeaponModel(THREE, def) ?? buildProp(THREE, weaponProp(def));
3913
4898
  }
3914
4899
  var MODEL_BODY = "body";
3915
4900
  function buildModeledProp(THREE, def, name, decorate) {
3916
- const model = buildWeaponModelById(THREE, def.id);
4901
+ const model = buildWeaponModelById(THREE, def.id) ?? (() => {
4902
+ const root = buildItemWeaponModel(THREE, def);
4903
+ return root ? { root } : void 0;
4904
+ })();
3917
4905
  if (!model) return void 0;
3918
4906
  decorate?.(model.root);
3919
- const group = new THREE.Group();
4907
+ const group = markKitOrigin2(new THREE.Group(), { owner: "game-weapons-three", kind: "weapon", id: def.id });
3920
4908
  group.name = name;
3921
4909
  const box2 = new THREE.Box3().setFromObject(model.root);
3922
4910
  const center = box2.getCenter(new THREE.Vector3());
@@ -3970,11 +4958,13 @@ function createBoxFx(options) {
3970
4958
  let model = null;
3971
4959
  let inHand = null;
3972
4960
  let def = null;
4961
+ let hold = null;
3973
4962
  let kick = 0;
3974
4963
  let dip = 0;
3975
4964
  let reloading = false;
3976
4965
  const v = new THREE.Vector3();
3977
4966
  const q = new THREE.Quaternion();
4967
+ const q2 = new THREE.Quaternion();
3978
4968
  const pointForward = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2);
3979
4969
  const equip = (weapon) => {
3980
4970
  if (model) hand.remove(model);
@@ -3988,6 +4978,7 @@ function createBoxFx(options) {
3988
4978
  model.quaternion.copy(pointForward);
3989
4979
  hand.add(model);
3990
4980
  inHand = def.id;
4981
+ hold = holdOfProp(THREE, model, def.category, model.scale.x);
3991
4982
  };
3992
4983
  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
4984
  const throwBody = (kind, mesh, from, velocity, radius) => {
@@ -4079,9 +5070,15 @@ function createBoxFx(options) {
4079
5070
  if (model) hand.remove(model);
4080
5071
  model = null;
4081
5072
  inHand = null;
5073
+ hold = null;
5074
+ },
5075
+ handTargets() {
5076
+ if (!model || !hold) return null;
5077
+ hand.updateMatrixWorld(true);
5078
+ return targetsOf(THREE, hold, model.getWorldQuaternion(q2), 0);
4082
5079
  },
4083
5080
  handle(event, tool) {
4084
- if (event.type === "explosion") return;
5081
+ if (event.type === "explosion" || event.type === "player") return;
4085
5082
  const weapon = arsenal.weapons.get(event.weapon);
4086
5083
  if (event.type === "tool") equip(event.weapon);
4087
5084
  if (!weapon) return;
@@ -4136,6 +5133,7 @@ var ease = (progress) => progress < 0.5 ? 2 * progress * progress : 1 - (-2 * pr
4136
5133
  var move = (part, axis, start, duration, from, to) => ({ part, channel: "position", axis, start, duration, from, to });
4137
5134
  var turn = (part, axis, start, duration, from, to) => ({ part, channel: "rotation", axis, start, duration, from, to });
4138
5135
  var cue = (t, type, data) => ({ t, type, ...data ? { data } : {} });
5136
+ var sound2 = (t, id) => ({ t, type: "sound", data: { id } });
4139
5137
  var stage = (id, t) => ({ t, type: STAGE_EVENT, data: { stage: id } });
4140
5138
  var valueAt = (tweens, t, includeInstants) => {
4141
5139
  let value = 0;
@@ -4233,7 +5231,7 @@ function magazineSwap(parts, start) {
4233
5231
  move("mag", "y", start + 0.16, 0, 0, direction * drop * 2.2),
4234
5232
  move("mag", "y", start + MAGAZINE_IN_AT, 0.3, direction * drop * 2.2, 0)
4235
5233
  ],
4236
- events: [cue(start + 0.16, "drop_magazine", { up: direction > 0 })],
5234
+ 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
5235
  end: start + MAGAZINE_SWAP_SECONDS
4238
5236
  };
4239
5237
  }
@@ -4306,43 +5304,52 @@ function clearClips(context) {
4306
5304
  // src/animation/reload-clips.ts
4307
5305
  import { RELOAD_STAGES } from "@adatechnology/game-weapons";
4308
5306
  var KINDS = ["tactical", "empty", "one_by_one"];
5307
+ var chargeSounds = (start) => [sound2(start, "charging_handle_pull"), sound2(start + 0.2, "charging_handle_release")];
4309
5308
  var charge = (context, start) => {
4310
5309
  const travel = strokeOf(context.parts);
4311
5310
  return [move("bolt", "x", start, 0.12, 0, travel), move("bolt", "x", start + 0.18, 0.06, travel, 0)];
4312
5311
  };
5312
+ 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
5313
  var cycleWithoutEject = (start) => ({ tweens: boltStroke(start), events: [], end: start + 0.55 });
5314
+ var RACK_BACK = 0.14;
5315
+ var RACK_HOLD = 0.04;
5316
+ var RACK_FORWARD = 0.06;
4314
5317
  function magazineReload(context, kind) {
4315
5318
  const { parts, family } = context;
4316
5319
  const swap = magazineSwap(parts, 0);
4317
5320
  const draft = { tweens: [...swap.tweens], events: [...swap.events], end: swap.end };
4318
5321
  if (family === "pistol") {
4319
5322
  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;
5323
+ const travel = strokeOf(parts);
5324
+ 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));
5325
+ draft.events.push(sound2(swap.end + 0.04 + RACK_BACK + RACK_HOLD + 0.02, "slide_release"));
5326
+ draft.end += 0.04 + RACK_BACK + RACK_HOLD + RACK_FORWARD + 0.08;
4322
5327
  }
4323
5328
  } else if (family === "boltmag") {
4324
5329
  const stroke = cycleWithoutEject(swap.end);
4325
5330
  draft.tweens.push(...stroke.tweens);
5331
+ draft.events.push(...boltSounds(swap.end));
4326
5332
  draft.end = stroke.end;
4327
5333
  } else if (kind === "empty" && parts.bolt) {
4328
5334
  draft.tweens.push(...charge(context, swap.end));
5335
+ draft.events.push(...chargeSounds(swap.end));
4329
5336
  draft.end += 0.35;
4330
5337
  }
4331
5338
  return draft;
4332
5339
  }
4333
5340
  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" }));
5341
+ 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")];
5342
+ if (kind !== "one_by_one") events.push(cue(0.7, "move_in", { from: "chambers" }), sound2(0.7, "speedloader"));
4336
5343
  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
5344
  }
4338
5345
  var breakReload = () => ({
4339
5346
  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" })],
5347
+ 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
5348
  end: 1.2
4342
5349
  });
4343
5350
  var beltReload = (context) => ({
4344
5351
  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: [],
5352
+ events: [sound2(0, "belt_cover_open"), sound2(0.8, "belt_insert"), sound2(1.05, "belt_cover_close"), ...chargeSounds(1.15)],
4346
5353
  end: 1.55
4347
5354
  });
4348
5355
  var UNIT = 1;
@@ -4350,9 +5357,16 @@ function tubeReload(context, kind) {
4350
5357
  const { parts, family } = context;
4351
5358
  const draft = { tweens: [], events: [], end: UNIT };
4352
5359
  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));
5360
+ if (family === "pump" && parts.pump) {
5361
+ draft.tweens.push(move("pump", "x", 0.88, 0.05, 0, PUMP_TRAVEL), move("pump", "x", 0.94, 0.05, PUMP_TRAVEL, 0));
5362
+ draft.events.push(sound2(0.88, "pump_back"), sound2(0.94, "pump_forward"));
5363
+ } else if (family === "lever" && parts.lever) {
5364
+ draft.tweens.push(turn("lever", "z", 0.87, 0.06, 0, LEVER_TURN), turn("lever", "z", 0.94, 0.06, LEVER_TURN, 0));
5365
+ draft.events.push(sound2(0.87, "lever_open"), sound2(0.94, "lever_close"));
5366
+ } else if (parts.bolt) {
5367
+ draft.tweens.push(move("bolt", "x", 0.88, 0.07, 0, strokeOf(parts)), move("bolt", "x", 0.95, 0.04, strokeOf(parts), 0));
5368
+ draft.events.push(sound2(0.88, "charging_handle_pull"), sound2(0.95, "charging_handle_release"));
5369
+ }
4356
5370
  return draft;
4357
5371
  }
4358
5372
  function boltReload(context) {
@@ -4366,13 +5380,13 @@ function boltReload(context) {
4366
5380
  move("bolt", "x", 0.86, 0.07, BOLT_TRAVEL, 0),
4367
5381
  turn("boltHandle", "x", 0.93, 0.05, BOLT_TURN, 0)
4368
5382
  ],
4369
- events: [],
5383
+ events: [sound2(0, "bolt_lift"), sound2(0.05, "bolt_pull"), sound2(0.86, "bolt_push"), sound2(0.95, "bolt_lock")],
4370
5384
  end: UNIT
4371
5385
  };
4372
5386
  }
4373
5387
  function internalMagazineReload(context) {
4374
5388
  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 };
5389
+ 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
5390
  }
4377
5391
  function heavyReload(context) {
4378
5392
  const { parts, family } = context;
@@ -4380,20 +5394,20 @@ function heavyReload(context) {
4380
5394
  if (parts.disposable) {
4381
5395
  return {
4382
5396
  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")],
5397
+ events: [cue(0, "drop_tube"), sound2(0, "tube_drop"), cue(0.6, "seat_tube"), sound2(1.08, "tube_seat")],
4384
5398
  end: 1.15
4385
5399
  };
4386
5400
  }
4387
5401
  if (parts.round) {
4388
5402
  return {
4389
5403
  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")],
5404
+ events: [cue(0, "show_round"), sound2(0, "rocket_load"), sound2(0.55, "rocket_seat")],
4391
5405
  end: 0.6
4392
5406
  };
4393
5407
  }
4394
5408
  return {
4395
5409
  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")],
5410
+ events: [sound2(0, "breech_open"), cue(0.3, "breech_round"), sound2(0.3, "grenade_load"), sound2(0.96, "breech_close")],
4397
5411
  end: 1.05
4398
5412
  };
4399
5413
  }
@@ -4424,11 +5438,12 @@ function reloadClips(context) {
4424
5438
  }
4425
5439
  return clips.length > 0 ? clips : first ? [renamed(first, "reload")] : [];
4426
5440
  }
5441
+ var FEED_SECONDS = 0.4;
4427
5442
  function feedClip(context) {
4428
5443
  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);
5444
+ if (family === "revolver") return composeClip("feed", [], [cue(0, "move_in", { from: "chambers", next: true }), sound2(0.1, "shell_insert")], 0.24);
5445
+ if (family === "bolt" || family === "semiint") return composeClip("feed", [], [cue(0, "move_in", { from: "topLoad" }), sound2(0.2, "shell_insert")], FEED_SECONDS);
5446
+ if (isTube(context)) return composeClip("feed", [], [cue(0, "move_in", { from: "loadPort" }), sound2(0.2, "shell_insert")], FEED_SECONDS);
4432
5447
  return void 0;
4433
5448
  }
4434
5449
 
@@ -5060,6 +6075,7 @@ function applyCondition(kit, model, condition) {
5060
6075
  }
5061
6076
 
5062
6077
  // src/models/cartridge.ts
6078
+ import { markKitOrigin as markKitOrigin3 } from "@adatechnology/game-contracts";
5063
6079
  var BOTTLENECK_RATIO = 0.8;
5064
6080
  function cartridgeShape(caliber, dims) {
5065
6081
  if (caliber.caseType === "shell" || caliber.rim === "shell") return "shell";
@@ -5071,7 +6087,7 @@ function cartridgeShape(caliber, dims) {
5071
6087
  }
5072
6088
  function buildCartridge(kit, dims, shape) {
5073
6089
  const { M, cylinderY } = kit;
5074
- const group = kit.group();
6090
+ const group = markKitOrigin3(kit.group(), { owner: "game-weapons-three", kind: "ammunition", id: shape });
5075
6091
  const radius = dims.caseDiameter / 2;
5076
6092
  const bulletRadius = dims.projectileDiameter / 2;
5077
6093
  const caseLength = dims.caseLength;
@@ -5103,7 +6119,7 @@ function buildCartridge(kit, dims, shape) {
5103
6119
  }
5104
6120
  function buildCasing(kit, dims, shape) {
5105
6121
  const { M, cylinderX } = kit;
5106
- const group = kit.group();
6122
+ const group = markKitOrigin3(kit.group(), { owner: "game-weapons-three", kind: "ammunition", id: shape });
5107
6123
  const radius = dims.caseDiameter / 2e3;
5108
6124
  const length = dims.caseLength / 1e3;
5109
6125
  if (shape === "shell") {
@@ -5119,7 +6135,7 @@ function buildCasing(kit, dims, shape) {
5119
6135
  return { group, radius, length };
5120
6136
  }
5121
6137
  function buildRound(kit, dims, shape) {
5122
- const group = kit.group();
6138
+ const group = markKitOrigin3(kit.group(), { owner: "game-weapons-three", kind: "ammunition", id: shape });
5123
6139
  const cartridge = buildCartridge(kit, dims, shape);
5124
6140
  cartridge.scale.setScalar(1e-3);
5125
6141
  cartridge.rotation.z = -Math.PI / 2;
@@ -5131,6 +6147,7 @@ function buildRound(kit, dims, shape) {
5131
6147
  import { createVfxRenderer } from "@adatechnology/game-vfx-three";
5132
6148
 
5133
6149
  // src/fx/effects.ts
6150
+ import { MUZZLE_SMOKE } from "@adatechnology/game-weapons";
5134
6151
  var FLASH_BY_CASE = { shell: 1.6, big: 2.6, tiny: 0.5, pistol: 0.8, rifle: 1.2 };
5135
6152
  var VIEWMODEL_EFFECT_IDS = { flash: "viewmodel_muzzle_flash", smoke: "weapon_smoke_puff", backblast: "launcher_backblast", flameJet: "flame_jet" };
5136
6153
  var vec = (vector) => [vector.x, vector.y, vector.z];
@@ -5139,8 +6156,9 @@ function createEffects(THREE, sink, random) {
5139
6156
  const seed = () => 1 + Math.floor(random() * 2147483646);
5140
6157
  const barrel = (orientation) => vec(new THREE.Vector3(1, 0, 0).applyQuaternion(orientation));
5141
6158
  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 } });
6159
+ muzzleFlash(position, orientation, size, profile = MUZZLE_SMOKE.smokeless) {
6160
+ if (profile.puffs <= 0 && profile.flash <= 0) return;
6161
+ sink?.play({ effect: VIEWMODEL_EFFECT_IDS.flash, point: vec(position), direction: barrel(orientation), orientation: quaternion(orientation), seed: seed(), values: { size, ...profile } });
5144
6162
  },
5145
6163
  smoke(position, velocity, size, life, opacity) {
5146
6164
  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 +6183,17 @@ var flameMaterials = (kit) => {
5165
6183
  new THREE.MeshBasicMaterial({ color: 16773296, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false })
5166
6184
  ];
5167
6185
  };
5168
- function launchRocket(context) {
6186
+ function launchRocket(context, flyRound = true) {
5169
6187
  const { kit, scene, parts, random } = context;
5170
6188
  const { THREE } = kit;
5171
6189
  const forward = context.forward();
5172
6190
  const muzzle = context.positionOf(parts.muzzle);
5173
6191
  const rear = context.positionOf(parts.rear);
5174
6192
  context.effects.backblast(rear, context.orientation());
6193
+ if (!flyRound) {
6194
+ if (parts.round) parts.round.visible = false;
6195
+ return;
6196
+ }
5175
6197
  const rocket = parts.makeRound ? parts.makeRound() : buildFlyingRocket(kit, parts.launcherType);
5176
6198
  if (parts.round) parts.round.visible = false;
5177
6199
  rocket.quaternion.copy(context.orientation());
@@ -5366,6 +6388,7 @@ var toWorld = (session, vector, scaled = false) => {
5366
6388
  };
5367
6389
 
5368
6390
  // src/fx/viewmodel-events.ts
6391
+ import { muzzleSmokeOf } from "@adatechnology/game-weapons";
5369
6392
  var PI13 = Math.PI;
5370
6393
  var UGL_FLASH = 1.6;
5371
6394
  var restFlat = (object) => {
@@ -5471,7 +6494,11 @@ function createClipEventHandler(world, session) {
5471
6494
  case "muzzle_flash":
5472
6495
  if (data.at === "ugl") {
5473
6496
  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]);
6497
+ } else if (parts.muzzle && world.layers.muzzleFlash && (session.def.action.kind === "firearm" || session.def.action.kind === "launcher")) {
6498
+ const profile = muzzleSmokeOf({ caliber: session.caliber, weapon: session.def, suppressed: session.suppressed });
6499
+ const size = session.def.action.kind === "launcher" ? 2.2 : FLASH_BY_CASE[session.caseType];
6500
+ effects.muzzleFlash(positionOf(parts.muzzle), root.getWorldQuaternion(new THREE.Quaternion()), size, profile);
6501
+ }
5475
6502
  break;
5476
6503
  case "eject_casing":
5477
6504
  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 +6529,9 @@ function createClipEventHandler(world, session) {
5502
6529
  case "breech_round":
5503
6530
  breechRound();
5504
6531
  break;
6532
+ case "sound":
6533
+ world.sound(session.id, String(data.id), session.soundProfile);
6534
+ break;
5505
6535
  default:
5506
6536
  break;
5507
6537
  }
@@ -5525,10 +6555,321 @@ function createClipEventHandler(world, session) {
5525
6555
  }
5526
6556
  }
5527
6557
 
6558
+ // src/fx/hand-work.ts
6559
+ import { sampleClip } from "@adatechnology/game-contracts";
6560
+
6561
+ // src/data/hand-work.json
6562
+ var hand_work_default = {
6563
+ 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.",
6564
+ timing: {
6565
+ lead: 0.08,
6566
+ tail: 0.06,
6567
+ 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"
6568
+ },
6569
+ pistolRelease: {
6570
+ 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.",
6571
+ weapons: []
6572
+ },
6573
+ akCharge: {
6574
+ 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).",
6575
+ weapons: [
6576
+ "ak_47",
6577
+ "akm",
6578
+ "ak_74",
6579
+ "ak_12",
6580
+ "rpk",
6581
+ "saiga_12",
6582
+ "sks",
6583
+ "m14_ebr",
6584
+ "fn_fal",
6585
+ "fn_parafal",
6586
+ "hk_g3a3",
6587
+ "hk_psg_1",
6588
+ "svd_dragunov"
6589
+ ]
6590
+ },
6591
+ works: [
6592
+ {
6593
+ family: "pistol",
6594
+ clips: [
6595
+ "reload_empty"
6596
+ ],
6597
+ part: "slide",
6598
+ hand: "L",
6599
+ anchor: "slide_rear",
6600
+ style: "rack"
6601
+ },
6602
+ {
6603
+ family: "pistol",
6604
+ clips: [
6605
+ "reload_empty"
6606
+ ],
6607
+ part: "slide",
6608
+ hand: "R",
6609
+ anchor: "thumb_release",
6610
+ style: "release"
6611
+ },
6612
+ {
6613
+ family: "rifle",
6614
+ clips: [
6615
+ "reload_empty"
6616
+ ],
6617
+ part: "bolt",
6618
+ hand: "L",
6619
+ anchor: "rear_top",
6620
+ style: "ar"
6621
+ },
6622
+ {
6623
+ family: "rifle",
6624
+ clips: [
6625
+ "reload_empty"
6626
+ ],
6627
+ part: "bolt",
6628
+ hand: "L",
6629
+ anchor: "side_right",
6630
+ style: "ak"
6631
+ },
6632
+ {
6633
+ family: "p90",
6634
+ clips: [
6635
+ "reload_empty"
6636
+ ],
6637
+ part: "bolt",
6638
+ hand: "L",
6639
+ anchor: "rear_top",
6640
+ style: "ar"
6641
+ },
6642
+ {
6643
+ family: "belt",
6644
+ clips: [
6645
+ "reload"
6646
+ ],
6647
+ part: "cover",
6648
+ hand: "L",
6649
+ anchor: "cover_top",
6650
+ merge: 1
6651
+ },
6652
+ {
6653
+ family: "boltmag",
6654
+ clips: [
6655
+ "reload",
6656
+ "reload_empty",
6657
+ "reload_one_by_one",
6658
+ "cycle"
6659
+ ],
6660
+ part: "boltHandle",
6661
+ hand: "R",
6662
+ anchor: "top_handle"
6663
+ },
6664
+ {
6665
+ family: "bolt",
6666
+ clips: [
6667
+ "reload",
6668
+ "reload_empty",
6669
+ "reload_one_by_one",
6670
+ "feed"
6671
+ ],
6672
+ hand: "R",
6673
+ anchor: "loading_port",
6674
+ whole: true,
6675
+ lead: 0.3,
6676
+ tail: 0.3
6677
+ },
6678
+ {
6679
+ family: "bolt",
6680
+ clips: [
6681
+ "reload",
6682
+ "reload_empty",
6683
+ "reload_one_by_one",
6684
+ "cycle"
6685
+ ],
6686
+ part: "boltHandle",
6687
+ hand: "R",
6688
+ anchor: "top_handle"
6689
+ },
6690
+ {
6691
+ family: "pump",
6692
+ clips: [
6693
+ "feed"
6694
+ ],
6695
+ hand: "R",
6696
+ anchor: "loading_port",
6697
+ whole: true,
6698
+ lead: 0.3,
6699
+ tail: 0.3
6700
+ },
6701
+ {
6702
+ family: "semi12",
6703
+ clips: [
6704
+ "feed"
6705
+ ],
6706
+ hand: "R",
6707
+ anchor: "loading_port",
6708
+ whole: true,
6709
+ lead: 0.3,
6710
+ tail: 0.3
6711
+ },
6712
+ {
6713
+ family: "semiint",
6714
+ clips: [
6715
+ "reload",
6716
+ "reload_empty",
6717
+ "reload_one_by_one",
6718
+ "feed"
6719
+ ],
6720
+ hand: "R",
6721
+ anchor: "loading_port",
6722
+ whole: true,
6723
+ lead: 0.3,
6724
+ tail: 0.3
6725
+ },
6726
+ {
6727
+ family: "lever",
6728
+ clips: [
6729
+ "feed"
6730
+ ],
6731
+ hand: "R",
6732
+ anchor: "loading_port",
6733
+ whole: true,
6734
+ lead: 0.3,
6735
+ tail: 0.3
6736
+ },
6737
+ {
6738
+ family: "revolver",
6739
+ clips: [
6740
+ "reload",
6741
+ "reload_one_by_one"
6742
+ ],
6743
+ part: "crane",
6744
+ hand: "L",
6745
+ anchor: "cylinder_side",
6746
+ merge: 1
6747
+ }
6748
+ ]
6749
+ };
6750
+
6751
+ // src/fx/hand-work.ts
6752
+ var V2 = (THREE, x, y, z) => new THREE.Vector3(x, y, z);
6753
+ var boxIn = (THREE, root, part) => {
6754
+ const world = new THREE.Box3().setFromObject(part);
6755
+ const box2 = new THREE.Box3();
6756
+ 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)));
6757
+ return box2;
6758
+ };
6759
+ var anchorsOf = (THREE, model, right) => {
6760
+ const { root, parts } = model;
6761
+ root.updateWorldMatrix(true, true);
6762
+ const palm = HAND_HOLD.palm;
6763
+ const anchorAt = (part, at, quaternion2) => ({ part, offset: part.worldToLocal(root.localToWorld(at.clone())), quaternion: quaternion2 });
6764
+ return (kind, partId) => {
6765
+ const part = partId ? parts[partId] ?? null : null;
6766
+ if (kind === "loading_port") {
6767
+ const port = parts.loadPort;
6768
+ if (!port) return null;
6769
+ const at = root.worldToLocal(port.getWorldPosition(V2(THREE, 0, 0, 0))).add(V2(THREE, 0, -palm - 4e-3, 0));
6770
+ return anchorAt(port, at, handFrame(THREE, V2(THREE, 0, 0, -1), V2(THREE, 0, -1, 0)));
6771
+ }
6772
+ if (kind === "thumb_release") {
6773
+ if (!right || !parts.slide) return null;
6774
+ return { part: right.part, offset: right.offset.clone().add(V2(THREE, 0, 0.012, 0)), quaternion: right.quaternion };
6775
+ }
6776
+ if (!part) return null;
6777
+ const b = boxIn(THREE, root, part);
6778
+ const c = b.getCenter(V2(THREE, 0, 0, 0));
6779
+ switch (kind) {
6780
+ case "slide_rear":
6781
+ 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)));
6782
+ case "rear_top":
6783
+ 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)));
6784
+ case "side_right":
6785
+ 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)));
6786
+ case "top_handle":
6787
+ 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)));
6788
+ case "cover_top":
6789
+ 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)));
6790
+ case "cylinder_side":
6791
+ 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)));
6792
+ default:
6793
+ return null;
6794
+ }
6795
+ };
6796
+ };
6797
+ var releasePistols = new Set(hand_work_default.pistolRelease.weapons);
6798
+ var akCharge = new Set(hand_work_default.akCharge.weapons);
6799
+ function handWorkOf(THREE, model, weaponId, right) {
6800
+ const family = model.parts.family;
6801
+ const anchors = anchorsOf(THREE, model, right);
6802
+ const style = family === "pistol" ? releasePistols.has(weaponId) ? "release" : "rack" : akCharge.has(weaponId) ? "ak" : "ar";
6803
+ const out = [];
6804
+ for (const entry of hand_work_default.works) {
6805
+ if (entry.family !== family || entry.style && entry.style !== style) continue;
6806
+ const anchor = anchors(entry.anchor, entry.part ?? null);
6807
+ if (!anchor) continue;
6808
+ 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 } });
6809
+ }
6810
+ return out;
6811
+ }
6812
+ var segmentsCache = /* @__PURE__ */ new WeakMap();
6813
+ var motionOf = (clip, part, merge) => {
6814
+ const cache = segmentsCache.get(clip) ?? /* @__PURE__ */ new Map();
6815
+ segmentsCache.set(clip, cache);
6816
+ const key = `${part}|${merge}`;
6817
+ const found = cache.get(key);
6818
+ if (found) return found;
6819
+ const steps = 240;
6820
+ const dt = clip.duration / steps;
6821
+ const spans = [];
6822
+ let start = null;
6823
+ for (let i = 1; i <= steps; i++) {
6824
+ const a = sampleClip(clip, part, (i - 1) * dt);
6825
+ const b = sampleClip(clip, part, i * dt);
6826
+ 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);
6827
+ if (moving && start === null) start = (i - 1) * dt;
6828
+ if (!moving && start !== null) {
6829
+ spans.push([start, (i - 1) * dt]);
6830
+ start = null;
6831
+ }
6832
+ }
6833
+ if (start !== null) spans.push([start, clip.duration]);
6834
+ const joined = [];
6835
+ for (const span of spans) {
6836
+ const last = joined[joined.length - 1];
6837
+ if (last && span[0] - last[1] <= merge * clip.duration) last[1] = span[1];
6838
+ else joined.push([span[0], span[1]]);
6839
+ }
6840
+ cache.set(key, joined);
6841
+ return joined;
6842
+ };
6843
+ var smooth = (x) => {
6844
+ const k = Math.min(1, Math.max(0, x));
6845
+ return k * k * (3 - 2 * k);
6846
+ };
6847
+ function workWeights(works, running) {
6848
+ return works.map((work) => {
6849
+ const lead = work.lead ?? hand_work_default.timing.lead;
6850
+ const tail = work.tail ?? hand_work_default.timing.tail;
6851
+ let best = 0;
6852
+ for (const { name, time, clip } of running) {
6853
+ if (!work.clips.includes(name)) continue;
6854
+ const spans = work.part ? motionOf(clip, work.part, work.merge) : [[0, clip.duration]];
6855
+ for (const [from, to] of spans) {
6856
+ const before = lead * clip.duration;
6857
+ const after = tail * clip.duration;
6858
+ const w = work.part ? smooth((time - (from - before)) / before) * smooth((to + after - time) / after) : smooth(time / before) * smooth((clip.duration - time) / after);
6859
+ best = Math.max(best, w);
6860
+ }
6861
+ }
6862
+ return best;
6863
+ });
6864
+ }
6865
+
5528
6866
  // src/fx/viewmodel.ts
5529
6867
  var PI14 = Math.PI;
5530
6868
  var HAND_OFFSET2 = [0.2, -0.2, -0.42];
6869
+ var HANDGUN_OFFSET = [0.1, -0.12, -0.44];
5531
6870
  var HEAT_DISPLAY_MAX_FALLBACK = 120;
6871
+ var WALL_PULL = { margin: 0.03, max: 0.7, rate: 14, probe: 3 };
6872
+ var HAND_IN_VIEW = { floor: -0.88, max: 0.18, rate: 10 };
5532
6873
  var LAUNCH_VOLUME = 0.6;
5533
6874
  var fittedOf2 = (parts) => parts.map((part) => ({ id: part.id, mount: part.mount, ...part.integral ? { integral: true } : {}, ...part.on === void 0 ? {} : { on: part.on } }));
5534
6875
  var collapsedStockPose = (move2) => {
@@ -5537,6 +6878,14 @@ var collapsedStockPose = (move2) => {
5537
6878
  };
5538
6879
  var underbarrelStep = (step, tool) => tool.underbarrelActive && step.type === "play" && (step.clip === "shot" || step.clip === "reload") ? { ...step, variant: "ugl" } : step;
5539
6880
  var caliberOf = (arsenal, def) => def.action.kind === "firearm" ? arsenal.calibers.get(def.action.caliber) : void 0;
6881
+ var TRIGGER_FINGER = { safe: 0, ready: 0.5, pulled: 1, readySeconds: 1.5, burstSeconds: 0.14, tremor: 0.03 };
6882
+ var TRIGGER_TURN = 0.35;
6883
+ var indexCurl = (turn2, sinceShot) => {
6884
+ const ready = sinceShot < TRIGGER_FINGER.readySeconds ? TRIGGER_FINGER.ready : TRIGGER_FINGER.safe;
6885
+ const pull = Math.min(1, Math.max(0, turn2 / TRIGGER_TURN));
6886
+ const held = sinceShot < TRIGGER_FINGER.burstSeconds ? TRIGGER_FINGER.pulled - TRIGGER_FINGER.tremor * (0.5 + 0.5 * Math.sin(sinceShot * 220)) : 0;
6887
+ return Math.max(ready + (TRIGGER_FINGER.pulled - ready) * pull, held);
6888
+ };
5540
6889
  function createViewmodel(options) {
5541
6890
  const { THREE, scene, camera, arsenal, groundAt, audio } = options;
5542
6891
  const kit = modelKit(THREE);
@@ -5559,6 +6908,15 @@ function createViewmodel(options) {
5559
6908
  let condition = options.condition ?? "new";
5560
6909
  let lastAmmo = 0;
5561
6910
  let smokeDebt = 0;
6911
+ let secondsSinceShot = Number.POSITIVE_INFINITY;
6912
+ let hold = null;
6913
+ let reloading = false;
6914
+ let magazineHand = 0;
6915
+ let clock = 0;
6916
+ let lastShot = -Infinity;
6917
+ let triggerRest = 0;
6918
+ let feedCued = false;
6919
+ const rootQuaternion = new THREE.Quaternion();
5562
6920
  const world = {
5563
6921
  kit,
5564
6922
  scene,
@@ -5568,9 +6926,15 @@ function createViewmodel(options) {
5568
6926
  audio,
5569
6927
  layers,
5570
6928
  groundAt,
6929
+ sound: (weapon, cue2, profile) => {
6930
+ audio?.cue(cue2, profile);
6931
+ options.onSound?.({ weapon, cue: cue2, profile });
6932
+ },
5571
6933
  schedule: (delay, seconds, step, done) => void tasks.push({ delay, seconds, elapsed: 0, step: (progress) => step(ease(progress)), done })
5572
6934
  };
5573
6935
  const heatMax = arsenal.rules.heat.displayMax || HEAT_DISPLAY_MAX_FALLBACK;
6936
+ const visual = arsenal.rules.heat.visual;
6937
+ const hasSuppressor = (parts) => parts.some((part) => arsenal.attachments.get(part.id)?.kind === "suppressor");
5574
6938
  const release = () => {
5575
6939
  if (!session) return;
5576
6940
  session.attachments?.dispose();
@@ -5578,6 +6942,7 @@ function createViewmodel(options) {
5578
6942
  player = null;
5579
6943
  session = null;
5580
6944
  tasks = [];
6945
+ hold = null;
5581
6946
  };
5582
6947
  const heavy = (current) => ({
5583
6948
  kit,
@@ -5597,6 +6962,14 @@ function createViewmodel(options) {
5597
6962
  const model = def ? buildWeaponModelById(THREE, weapon) : void 0;
5598
6963
  if (!def || !model) return false;
5599
6964
  release();
6965
+ hold = null;
6966
+ reloading = false;
6967
+ magazineHand = 0;
6968
+ reach = 0;
6969
+ pull = 0;
6970
+ lift = 0;
6971
+ handHeight = 0;
6972
+ handDepth = 0;
5600
6973
  rigStock(THREE, model, def);
5601
6974
  rigUnderbarrel(THREE, model, def);
5602
6975
  const length = new THREE.Box3().setFromObject(model.root).getSize(new THREE.Vector3()).x;
@@ -5606,31 +6979,36 @@ function createViewmodel(options) {
5606
6979
  holder.add(model.root);
5607
6980
  const caliber = caliberOf(arsenal, def);
5608
6981
  const dims = caliber?.cartridge;
6982
+ const hostTool = options.tool?.();
6983
+ const onTool = hostTool?.weapon === weapon ? hostTool : void 0;
6984
+ const fitted = onTool?.attachments ? fittedOf2(onTool.attachments) : [...def.fitted ?? []];
5609
6985
  session = {
5610
6986
  id: weapon,
5611
6987
  def,
6988
+ caliber,
5612
6989
  model,
5613
6990
  holderScale: scale,
5614
6991
  lengthMetres: length,
5615
6992
  caseType: caliber?.caseType ?? "rifle",
6993
+ soundProfile: soundProfileOf(weapon, model.parts.family),
5616
6994
  kit,
5617
6995
  cartridge: caliber && dims ? { dims, shape: cartridgeShape(caliber, dims) } : null,
5618
6996
  stuckCase: null,
5619
6997
  nextChamber: 0,
5620
6998
  attachments: null,
5621
- attachmentsSignature: ""
6999
+ attachmentsSignature: "",
7000
+ suppressed: onTool?.suppressed ?? hasSuppressor(fitted)
5622
7001
  };
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
7002
  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
7003
  session.attachmentsSignature = attachmentsKey(onTool?.attachments);
5628
7004
  const action = def.action;
5629
7005
  const reloadFamily = action.kind === "firearm" || action.kind === "flame" || action.kind === "launcher" ? action.family : void 0;
5630
7006
  const rpm = action.kind === "firearm" ? action.rpm : 0;
5631
7007
  const rig = createModelRig(model);
7008
+ const clips = buildToolClips({ parts: model.parts, family: model.parts.family, caseType: session.caseType, reloadFamily });
7009
+ feedCued = clips.feed?.events?.some((entry) => entry.type === "sound") ?? false;
5632
7010
  player = createToolClipPlayer({
5633
- clips: buildToolClips({ parts: model.parts, family: model.parts.family, caseType: session.caseType, reloadFamily }),
7011
+ clips,
5634
7012
  rig,
5635
7013
  onEvent: createClipEventHandler(world, session),
5636
7014
  repeatInterval: () => rpm > 0 ? 60 / rpm : 0.1
@@ -5638,25 +7016,67 @@ function createViewmodel(options) {
5638
7016
  if (model.parts.stockMove && onTool?.stock === "collapsed") rig.setPartPose("stock", collapsedStockPose(model.parts.stockMove));
5639
7017
  applyCondition(kit, model, condition);
5640
7018
  lastAmmo = options.tool?.().ammo ?? 0;
7019
+ hand.updateMatrixWorld(true);
7020
+ hold = holdOfModel(THREE, model, scale);
7021
+ hold = { ...hold, works: handWorkOf(THREE, model, weapon, hold.right) };
7022
+ triggerRest = model.parts.trigger?.rotation.z ?? 0;
7023
+ lastShot = -Infinity;
5641
7024
  return true;
5642
7025
  };
5643
7026
  const syncAttachments = (current, tool) => {
5644
- if (!current.attachments || tool?.weapon !== current.id || !tool.attachments) return;
7027
+ if (!current.attachments || tool?.weapon !== current.id) return;
7028
+ if (tool.suppressed !== void 0) current.suppressed = tool.suppressed;
7029
+ if (!tool.attachments) return;
5645
7030
  const signature = attachmentsKey(tool.attachments);
5646
7031
  if (signature === current.attachmentsSignature) return;
5647
7032
  current.attachmentsSignature = signature;
5648
- current.attachments.sync(fittedOf2(tool.attachments));
7033
+ const fitted = fittedOf2(tool.attachments);
7034
+ if (tool.suppressed === void 0) current.suppressed = hasSuppressor(fitted);
7035
+ current.attachments.sync(fitted);
7036
+ };
7037
+ let reach = 0;
7038
+ let pull = 0;
7039
+ let lift = 0;
7040
+ let handHeight = 0;
7041
+ let handDepth = 0;
7042
+ const ahead = new THREE.Vector3();
7043
+ const up = new THREE.Vector3();
7044
+ const seen = new THREE.Vector3();
7045
+ const eyeTurn = new THREE.Quaternion();
7046
+ const corner = new THREE.Vector3();
7047
+ const bounds = new THREE.Box3();
7048
+ const measureReach = (current) => {
7049
+ bounds.setFromObject(current.model.root);
7050
+ let farthest = 0;
7051
+ 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));
7052
+ return farthest;
5649
7053
  };
5650
- const place = () => {
5651
- const [x, y, z] = HAND_OFFSET2;
7054
+ const place = (dt = 0) => {
7055
+ const family = session?.model.parts.family;
7056
+ const [x, y, z] = family === "pistol" || family === "revolver" ? HANDGUN_OFFSET : HAND_OFFSET2;
5652
7057
  hand.position.set(x, y, z).applyQuaternion(camera.quaternion).add(camera.position);
5653
7058
  hand.quaternion.copy(camera.quaternion);
7059
+ if (session && handDepth > 0.05 && "fov" in camera) {
7060
+ const tanHalf = Math.tan((camera.fov ?? 70) * PI14 / 360);
7061
+ const want2 = Math.max(0, Math.min(HAND_IN_VIEW.max, lift + (HAND_IN_VIEW.floor - handHeight) * tanHalf * handDepth));
7062
+ lift += (want2 - lift) * Math.min(1, dt * HAND_IN_VIEW.rate);
7063
+ if (lift > 1e-4) hand.position.addScaledVector(up.set(0, 1, 0).applyQuaternion(camera.quaternion), lift);
7064
+ }
7065
+ hand.updateMatrixWorld(true);
7066
+ if (!options.contact || !session) return;
7067
+ ahead.set(0, 0, -1).applyQuaternion(camera.quaternion);
7068
+ if (reach === 0) reach = Math.max(0.01, measureReach(session));
7069
+ const free = options.contact(camera.position, ahead, WALL_PULL.probe);
7070
+ const want = free === null ? 0 : Math.max(0, Math.min(WALL_PULL.max, reach + WALL_PULL.margin - free));
7071
+ pull += (want - pull) * Math.min(1, dt * WALL_PULL.rate);
7072
+ if (pull < 1e-4) return;
7073
+ hand.position.addScaledVector(ahead, -pull);
5654
7074
  hand.updateMatrixWorld(true);
5655
7075
  };
5656
7076
  const glow = (tool) => {
5657
7077
  if (!session) return;
5658
7078
  const heat = (tool?.heat ?? 0) * heatMax;
5659
- const level = Math.max(0, Math.min(1, (heat - 50) / 50));
7079
+ const level = Math.max(0, Math.min(1, (heat - visual.glowFrom) / visual.glowSpan));
5660
7080
  for (const mesh of session.model.parts.glow) {
5661
7081
  const material = mesh.material;
5662
7082
  material.emissive.setRGB(level, 0.2 * level * level, 0.02 * level * level);
@@ -5664,56 +7084,73 @@ function createViewmodel(options) {
5664
7084
  }
5665
7085
  };
5666
7086
  const handleShot = (current, event, tool) => {
7087
+ secondsSinceShot = 0;
7088
+ if (event.suppressed !== void 0) current.suppressed = event.suppressed;
7089
+ else if (tool.suppressed !== void 0) current.suppressed = tool.suppressed;
7090
+ else current.suppressed = hasSuppressor(tool.attachments ? fittedOf2(tool.attachments) : current.def.fitted ?? []);
5667
7091
  if (tool.underbarrelActive && current.model.parts.uglMuzzle) {
5668
7092
  audio?.play("explosion", LAUNCH_VOLUME, distance());
5669
7093
  return;
5670
7094
  }
5671
7095
  const action = current.def.action;
5672
7096
  if (action.kind === "launcher") {
5673
- launchRocket(heavy(current));
7097
+ launchRocket(heavy(current), options.flyRocket !== false);
5674
7098
  audio?.play("explosion", LAUNCH_VOLUME, distance());
5675
7099
  }
5676
7100
  if (action.kind !== "firearm") return;
5677
7101
  const caliber = caliberOf(arsenal, current.def);
5678
7102
  if (!caliber) return;
5679
- audio?.shot(muzzleEnergy3(caliber) * (caliber.pellets ?? 1), current.def.category, { caliber, distance: distance(), ...event.suppressed === true ? { suppressed: true } : {} });
7103
+ audio?.shot(muzzleEnergy3(caliber) * (caliber.pellets ?? 1), current.def.category, { caliber, distance: distance(), ...current.suppressed ? { suppressed: true } : {} });
5680
7104
  };
5681
7105
  return {
5682
7106
  equip,
5683
7107
  unequip: release,
5684
7108
  handle(event, tool) {
5685
- if (event.type === "explosion") return;
7109
+ if (event.type === "explosion" || event.type === "player") return;
5686
7110
  if (event.type === "tool") equip(event.weapon);
5687
7111
  if (!session || !player || event.weapon !== session.id) return;
5688
7112
  const current = session;
5689
- if (event.type === "shot") handleShot(current, event, tool);
5690
- else if (event.type === "reload" && event.phase === "start") current.nextChamber = 0;
7113
+ if (event.type === "shot") {
7114
+ lastShot = clock;
7115
+ handleShot(current, event, tool);
7116
+ } else if (event.type === "reload" && event.phase === "start") {
7117
+ current.nextChamber = 0;
7118
+ reloading = true;
7119
+ } else if (event.type === "reload" && event.phase === "end") reloading = false;
5691
7120
  else if (event.type === "clear" && event.phase === "end") current.stuckCase = null;
5692
7121
  const mechanical = weaponEventOf(event);
5693
- if (mechanical) audio?.event(mechanical);
7122
+ const cued = mechanical?.type === "feed" ? feedCued : mechanical?.type === "reload_stage" && (player.running.some(({ clip }) => clip.events?.some((entry) => entry.type === "sound")) ?? false);
7123
+ if (mechanical && !cued) audio?.event(mechanical);
5694
7124
  const step = toolAnimationStep(event);
5695
7125
  if (!step) return;
5696
7126
  const last = step.type === "play" && step.clip === "shot" && current.model.parts.family === "pistol" && tool.ammo === 0;
5697
7127
  player.apply(last ? { ...step, variant: "last" } : underbarrelStep(step, tool));
5698
7128
  },
5699
7129
  update(dt) {
7130
+ clock += dt;
5700
7131
  if (session) {
5701
- place();
7132
+ place(dt);
5702
7133
  player?.update(dt);
7134
+ const wantsMagazine = reloading || options.tool?.().reloading === true;
7135
+ magazineHand += ((wantsMagazine ? 1 : 0) - magazineHand) * Math.min(1, dt * HAND_HOLD.magRate);
5703
7136
  const tool = options.tool?.();
5704
7137
  syncAttachments(session, tool);
5705
7138
  session.attachments?.update();
5706
7139
  glow(tool);
5707
7140
  if (tool && session.def.action.kind === "flame") {
5708
- if (tool.ammo < lastAmmo) flameJet(heavy(session), dt, session.model.parts.launcherType === "flame" ? 1 : 0.6);
7141
+ if (tool.ammo < lastAmmo) {
7142
+ flameJet(heavy(session), dt, session.model.parts.launcherType === "flame" ? 1 : 0.6);
7143
+ lastShot = clock;
7144
+ }
5709
7145
  lastAmmo = tool.ammo;
5710
7146
  }
5711
- if (tool && tool.heat !== void 0 && tool.heat * heatMax > 10 && session.model.parts.muzzle) {
5712
- smokeDebt += dt * tool.heat * heatMax * 0.1;
7147
+ secondsSinceShot += dt;
7148
+ if (tool && tool.heat !== void 0 && tool.heat * heatMax > visual.smokeFrom && secondsSinceShot < visual.smokeSeconds && session.model.parts.muzzle) {
7149
+ smokeDebt += dt * (tool.heat * heatMax - visual.smokeFrom) * 0.1;
5713
7150
  while (smokeDebt > 1) {
5714
7151
  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));
7152
+ const k = 0.5 + (tool.heat * heatMax - visual.smokeFrom) / 60;
7153
+ 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
7154
  }
5718
7155
  }
5719
7156
  }
@@ -5734,9 +7171,25 @@ function createViewmodel(options) {
5734
7171
  condition = next;
5735
7172
  if (session) applyCondition(kit, session.model, condition);
5736
7173
  },
7174
+ handTargets() {
7175
+ if (!session || !hold) return null;
7176
+ hand.updateMatrixWorld(true);
7177
+ const targets = targetsOf(THREE, hold, session.model.root.getWorldQuaternion(rootQuaternion), magazineHand, hold.works ? workWeights(hold.works, player?.running ?? []) : void 0);
7178
+ const trigger = session.model.parts.trigger;
7179
+ if (trigger) targets.fingers = { index: indexCurl(trigger.rotation.z - triggerRest, clock - lastShot) };
7180
+ if (targets.R && "fov" in camera) {
7181
+ seen.set(...targets.R.position).sub(camera.position).applyQuaternion(eyeTurn.copy(camera.quaternion).invert());
7182
+ handDepth = -seen.z;
7183
+ handHeight = handDepth > 0.05 ? seen.y / handDepth / Math.tan((camera.fov ?? 70) * PI14 / 360) : 0;
7184
+ }
7185
+ return targets;
7186
+ },
5737
7187
  get inHand() {
5738
7188
  return session?.id ?? null;
5739
7189
  },
7190
+ get clips() {
7191
+ return (player?.running ?? []).map(({ name, clip }) => ({ name, seconds: clip.duration }));
7192
+ },
5740
7193
  get casings() {
5741
7194
  return particles.count("casing");
5742
7195
  },
@@ -5759,6 +7212,7 @@ function createWeaponFx(options) {
5759
7212
  const model = createViewmodel({
5760
7213
  layers,
5761
7214
  ...options.soundDistance ? { soundDistance: options.soundDistance } : {},
7215
+ ...options.onSound ? { onSound: options.onSound } : {},
5762
7216
  THREE: options.THREE,
5763
7217
  scene: options.scene,
5764
7218
  camera: options.camera,
@@ -5770,11 +7224,13 @@ function createWeaponFx(options) {
5770
7224
  ...options.condition ? { condition: options.condition } : {},
5771
7225
  ...options.attachmentModels ? { attachmentModels: options.attachmentModels } : {},
5772
7226
  ...options.raycast ? { raycast: options.raycast } : {},
5773
- ...options.effects ? { effects: options.effects } : {}
7227
+ ...options.contact ? { contact: options.contact } : {},
7228
+ ...options.effects ? { effects: options.effects } : {},
7229
+ ...options.flyRocket === void 0 ? {} : { flyRocket: options.flyRocket }
5774
7230
  });
5775
7231
  return {
5776
7232
  handle(event, tool) {
5777
- if (event.type === "explosion") return;
7233
+ if (event.type === "explosion" || event.type === "player") return;
5778
7234
  if (event.type === "tool") {
5779
7235
  if (hasWeaponModel(event.weapon)) box2.unequip();
5780
7236
  else model.unequip();
@@ -5787,10 +7243,14 @@ function createWeaponFx(options) {
5787
7243
  model.update(dt);
5788
7244
  },
5789
7245
  setCondition: (condition) => model.setCondition(condition),
7246
+ handTargets: () => model.handTargets() ?? box2.handTargets(),
5790
7247
  layers,
5791
7248
  get inHand() {
5792
7249
  return model.inHand ?? box2.inHand;
5793
7250
  },
7251
+ get clips() {
7252
+ return model.clips;
7253
+ },
5794
7254
  get casings() {
5795
7255
  return box2.casings + model.casings;
5796
7256
  },
@@ -6536,21 +7996,34 @@ function weaponThumbnailUrl(id, base = DEFAULT_THUMBNAIL_BASE) {
6536
7996
  }
6537
7997
 
6538
7998
  // src/world-explosives.ts
7999
+ import { buildItem as buildItem2 } from "@adatechnology/game-items-three";
6539
8000
  var WORLD_EXPLOSIVE_PREFIX = "world_";
8001
+ var P13_VALVE_MESH = "valve";
8002
+ var P13_VALVE_COLOR = 12095802;
8003
+ var P13_WALL = 25e-4;
6540
8004
  var SHAPES2 = {
6541
8005
  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 },
8006
+ propane_p13: {
8007
+ shape: { kind: "cylinder", radius: 0.176, height: 0.49 },
8008
+ position: [0, 0.245, 0],
8009
+ color: 3497862,
8010
+ model: { id: "lpg_cylinder_13kg", category: "fuel", size: [0.35299, 0.49, 0.35844], model: "lpg_cylinder_13kg" },
8011
+ wall: P13_WALL
8012
+ },
6543
8013
  car_fuel_tank: { shape: { kind: "box", size: [0.9, 0.25, 0.5] }, position: [0, 0.4, 0], color: 4935509 }
6544
8014
  };
6545
8015
  var WORLD_EXPLOSIVE_IDS = Object.keys(SHAPES2);
6546
8016
  var SHELL = { thickness: 1e-3, fill: 0.03 };
6547
- var explosivePart = (def, shape) => ({
8017
+ var explosivePart = (def, shape, scale) => ({
6548
8018
  id: "tank",
6549
8019
  material: "steel",
6550
8020
  shape: shape.shape,
6551
8021
  position: shape.position,
6552
- ...SHELL,
6553
- explodes: { energy: def.energy, radius: def.injuryRadius, effect: def.id, ...def.chain ? { chain: { ...def.chain } } : {} }
8022
+ ...shape.wall === void 0 ? SHELL : { thickness: shape.wall },
8023
+ // a real mass (the vessel and what it holds) replaces the hollow-sheet density
8024
+ ...def.mass !== void 0 && shape.wall !== void 0 ? { mass: def.mass } : {},
8025
+ ...def.leak ? { contains: { fluid: def.leak.fluid, liters: def.leak.liters, pressurized: def.leak.pressurized } } : {},
8026
+ explodes: { energy: def.energy * scale, radius: def.injuryRadius * scale, lethalRadius: def.lethalRadius * scale, effect: def.id, ...def.chain ? { chain: { ...def.chain } } : {} }
6554
8027
  });
6555
8028
  var meshOf = (THREE, shape) => {
6556
8029
  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 +8035,55 @@ var meshOf = (THREE, shape) => {
6562
8035
  mesh.receiveShadow = true;
6563
8036
  return mesh;
6564
8037
  };
6565
- var worldExplosiveItem = (def, name) => {
8038
+ var modelOf = (THREE, shape, wear = 0) => {
8039
+ if (!shape.model) return void 0;
8040
+ const { root, kind } = buildItem2(THREE, shape.model, { wear });
8041
+ if (kind !== "model") return void 0;
8042
+ root.traverse((node) => {
8043
+ const mesh = node;
8044
+ if (!mesh.isMesh) return;
8045
+ mesh.userData.destructionPart = "tank";
8046
+ mesh.castShadow = true;
8047
+ mesh.receiveShadow = true;
8048
+ const material = mesh.material;
8049
+ if (material.color?.getHex() === P13_VALVE_COLOR) mesh.name = P13_VALVE_MESH;
8050
+ });
8051
+ return root;
8052
+ };
8053
+ var worldExplosiveValve = (THREE, root) => {
8054
+ const valve = root.getObjectByName(P13_VALVE_MESH);
8055
+ if (!valve) return void 0;
8056
+ root.updateMatrixWorld(true);
8057
+ const centre = new THREE.Box3().setFromObject(valve).getCenter(new THREE.Vector3());
8058
+ return [centre.x, centre.y, centre.z];
8059
+ };
8060
+ var worldExplosiveShell = (THREE, defId) => {
8061
+ const shape = SHAPES2[defId];
8062
+ const model = shape ? modelOf(THREE, shape, 2) : void 0;
8063
+ if (!model) return void 0;
8064
+ model.traverse((node) => {
8065
+ const mesh = node;
8066
+ if (!mesh.isMesh) return;
8067
+ delete mesh.userData.destructionPart;
8068
+ mesh.material.color.multiplyScalar(0.35);
8069
+ });
8070
+ model.name = `${WORLD_EXPLOSIVE_PREFIX}${defId}_shell`;
8071
+ return model;
8072
+ };
8073
+ var worldExplosiveItem = (def, name, options = {}) => {
6566
8074
  const shape = SHAPES2[def.id];
6567
8075
  if (!shape) return void 0;
6568
- const spec = { name, parts: [explosivePart(def, shape)], joints: [], tags: ["explosive"] };
8076
+ const scale = () => options.scale?.() ?? 1;
8077
+ const spec = () => ({ name, parts: [explosivePart(def, shape, scale())], joints: [], tags: ["explosive"] });
6569
8078
  return {
6570
8079
  id: `${WORLD_EXPLOSIVE_PREFIX}${def.id}`,
6571
8080
  name,
6572
8081
  build: (THREE) => {
6573
- const group = new THREE.Group();
6574
- group.add(meshOf(THREE, shape));
8082
+ const T = THREE;
8083
+ const model = modelOf(T, shape);
8084
+ if (model) return model;
8085
+ const group = new T.Group();
8086
+ group.add(meshOf(T, shape));
6575
8087
  return group;
6576
8088
  },
6577
8089
  spec
@@ -6623,6 +8135,100 @@ function createWorldEffects(options) {
6623
8135
  };
6624
8136
  }
6625
8137
 
8138
+ // src/scenarios.ts
8139
+ var TARGET_Z = -4;
8140
+ var SHOOTER_Z = -1.5;
8141
+ function weaponsScenarios(locale) {
8142
+ const pt = locale.toLowerCase().startsWith("pt");
8143
+ const T = (p, e) => pt ? p : e;
8144
+ const faceY = (id) => id === "target_steel_plate" ? 1.35 : id === "target_plywood" ? 1.5 : 1.2;
8145
+ const aimAt = (room, id) => room.look([0, faceY(id) + 0.05, SHOOTER_Z], [0, faceY(id), TARGET_Z]);
8146
+ return [
8147
+ {
8148
+ id: "target_steel_heavy_through_hole",
8149
+ 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"),
8150
+ run: ({ room, step, expect }) => {
8151
+ const id = room.spawn("target_steel_plate", [0, 0, TARGET_Z]).objectId;
8152
+ room.setTool("pistol_9mm");
8153
+ room.waitUntilReady();
8154
+ aimAt(room, "target_steel_plate");
8155
+ room.use();
8156
+ step(0.3);
8157
+ const pistol = room.world.part(id, "face");
8158
+ 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)`));
8159
+ room.setTool("barrett_m82a1");
8160
+ room.waitUntilReady();
8161
+ aimAt(room, "target_steel_plate");
8162
+ room.use();
8163
+ step(0.3);
8164
+ const rifle = room.world.part(id, "face");
8165
+ const hole = rifle?.holes.find((h) => h.through);
8166
+ 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 });
8167
+ }
8168
+ },
8169
+ {
8170
+ id: "target_concrete_stops_bullet",
8171
+ name: T("Alvo de concreto: um 9 mm n\xE3o atravessa o bloco", "Concrete target: a 9 mm does not go through the block"),
8172
+ run: ({ room, step, expect }) => {
8173
+ const id = room.spawn("target_concrete", [0, 0, TARGET_Z]).objectId;
8174
+ room.setTool("pistol_9mm");
8175
+ room.waitUntilReady();
8176
+ aimAt(room, "target_concrete");
8177
+ room.use();
8178
+ step(0.2);
8179
+ const face = room.world.part(id, "face");
8180
+ const through = face?.holes.some((h) => h.through) ?? true;
8181
+ const marks = (face?.holes.length ?? 0) + (face?.dents.length ?? 0) + (face?.cracks.length ?? 0);
8182
+ 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 });
8183
+ }
8184
+ },
8185
+ {
8186
+ id: "target_plywood_shotgun_holes",
8187
+ 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"),
8188
+ run: ({ room, step, expect }) => {
8189
+ const id = room.spawn("target_plywood", [0, 0, TARGET_Z]).objectId;
8190
+ room.setTool("shotgun_pump_12");
8191
+ room.waitUntilReady();
8192
+ aimAt(room, "target_plywood");
8193
+ room.use();
8194
+ step(1.1);
8195
+ const holes = room.world.part(id, "face")?.holes.length ?? 0;
8196
+ 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 });
8197
+ }
8198
+ },
8199
+ {
8200
+ id: "target_steel_hammer_dents",
8201
+ name: T("Alvo de a\xE7o: a marreta amassa a chapa sem romper", "Steel target: the hammer dents the plate without breaking it"),
8202
+ run: ({ room, step, expect }) => {
8203
+ const id = room.spawn("target_steel_plate", [0, 0, TARGET_Z]).objectId;
8204
+ room.setTool("hammer");
8205
+ room.waitUntilReady();
8206
+ room.look([0, 1.4, TARGET_Z + 0.65], [0, 1.35, TARGET_Z]);
8207
+ let swings = 0;
8208
+ for (; swings < 8 && (room.world.part(id, "face")?.dents.length ?? 0) === 0; swings++) {
8209
+ room.use();
8210
+ step(1);
8211
+ }
8212
+ const face = room.world.part(id, "face");
8213
+ 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 });
8214
+ }
8215
+ },
8216
+ {
8217
+ id: "target_steel_torch_melts",
8218
+ name: T("Alvo de a\xE7o: o ma\xE7arico de corte derrete a chapa", "Steel target: the cutting torch melts the plate"),
8219
+ run: ({ room, expect }) => {
8220
+ const id = room.spawn("target_steel_plate", [0, 0, TARGET_Z]).objectId;
8221
+ room.setTool("cutting_torch");
8222
+ room.waitUntilReady();
8223
+ room.look([0, faceY("target_steel_plate") + 0.05, TARGET_Z + 0.2], [0, faceY("target_steel_plate"), TARGET_Z]);
8224
+ room.use(20);
8225
+ const face = room.world.part(id, "face");
8226
+ 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 });
8227
+ }
8228
+ }
8229
+ ];
8230
+ }
8231
+
6626
8232
  // src/area.ts
6627
8233
  var WEAPON_ITEM_PREFIX = "weapon_";
6628
8234
  var COUNTER_ITEM_ID = "counter_weapons";
@@ -6658,6 +8264,10 @@ var TEXT = {
6658
8264
  counter: "Weapon counter",
6659
8265
  targets: { target_steel_plate: "Steel plate target", target_plywood: "Plywood target", target_concrete: "Concrete block target" },
6660
8266
  reloadStart: "Reload started: {weapon} ({ammo}/{capacity})",
8267
+ reloadClip: "Reload of {weapon}: {kind}, clip {clip} ({seconds} s)",
8268
+ soundCue: "Sound: {cue} ({weapon})",
8269
+ 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" },
8270
+ reloadKinds: { tactical: "tactical (round chambered, no pull)", empty: "empty chamber (works the action)", one_by_one: "one by one", full: "speed loader" },
6661
8271
  reloadEnd: "Reload done: {weapon} ({ammo}/{capacity})",
6662
8272
  worldExplosives: { fuel_barrel: "Fuel barrel", propane_p13: "Propane cylinder (P13)", car_fuel_tank: "Car fuel tank" },
6663
8273
  mountedM134: "M134 Minigun on its tripod",
@@ -6680,6 +8290,10 @@ var TEXT = {
6680
8290
  counter: "Balc\xE3o de armas",
6681
8291
  targets: { target_steel_plate: "Alvo: chapa de a\xE7o", target_plywood: "Alvo: compensado", target_concrete: "Alvo: bloco de concreto" },
6682
8292
  reloadStart: "Recarga iniciada: {weapon} ({ammo}/{capacity})",
8293
+ reloadClip: "Recarga de {weapon}: {kind}, clipe {clip} ({seconds} s)",
8294
+ soundCue: "Som: {cue} ({weapon})",
8295
+ 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" },
8296
+ 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
8297
  reloadEnd: "Recarga completa: {weapon} ({ammo}/{capacity})",
6684
8298
  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
8299
  mountedM134: "M134 Minigun no trip\xE9",
@@ -6699,23 +8313,20 @@ var TEXT = {
6699
8313
  };
6700
8314
  var textOf = (locale) => TEXT[locale] ?? TEXT.en;
6701
8315
  var fill = (template, vars) => template.replace(/\{(\w+)\}/g, (_, key) => String(vars[key] ?? ""));
6702
- var counterProp = (name) => {
6703
- const { length, depth, height } = COUNTER;
8316
+ var COUNTER_FURNITURE = "buffet";
8317
+ var counterItem = (name) => {
8318
+ const counter = (THREE) => furnitureSpec(THREE, COUNTER_FURNITURE, { w: COUNTER.depth, d: COUNTER.length, h: COUNTER.height });
6704
8319
  return {
6705
- spec: {
6706
- name,
6707
- parts: [
6708
- { id: "top", material: "wood_soft", shape: { kind: "box", size: [depth, 0.05, length] }, position: [0, height - 0.025, 0] },
6709
- { id: "side_a", material: "plywood", shape: { kind: "box", size: [depth - 0.1, height - 0.05, 0.05] }, position: [0, (height - 0.05) / 2, -length / 2 + 0.05], anchored: true },
6710
- { id: "side_b", material: "plywood", shape: { kind: "box", size: [depth - 0.1, height - 0.05, 0.05] }, position: [0, (height - 0.05) / 2, length / 2 - 0.05], anchored: true }
6711
- ],
6712
- joints: [
6713
- { a: "top", b: "side_a", kind: "screw" },
6714
- { a: "top", b: "side_b", kind: "screw" }
6715
- ],
6716
- tags: ["furniture"]
8320
+ id: COUNTER_ITEM_ID,
8321
+ name,
8322
+ build: (THREE) => {
8323
+ const piece = counter(THREE);
8324
+ const root = piece.build(THREE);
8325
+ root.userData.counterSpec = { ...piece.spec, name, tags: [...piece.spec.tags ?? [], "furniture"] };
8326
+ return root;
6717
8327
  },
6718
- colors: { top: 9067056, side_a: 7029798, side_b: 7029798 }
8328
+ spec: (root) => root.userData.counterSpec,
8329
+ movable: false
6719
8330
  };
6720
8331
  };
6721
8332
  var targetProp = (id, name) => {
@@ -6738,7 +8349,7 @@ var targetProp = (id, name) => {
6738
8349
  };
6739
8350
  var weaponItem = (def, name) => {
6740
8351
  const id = `${WEAPON_ITEM_PREFIX}${def.id}`;
6741
- if (!hasWeaponModel(def.id)) return asItem(id, name, weaponProp(def, name), { weapon: def.id, movable: true });
8352
+ if (!hasWeaponModel(def.id) && !itemModelOfWeapon(def.id)) return asItem(id, name, weaponProp(def, name), { weapon: def.id, movable: true });
6742
8353
  return {
6743
8354
  id,
6744
8355
  name,
@@ -6756,6 +8367,10 @@ var mountedM134Item = (def, name) => ({
6756
8367
  weapon: def.id,
6757
8368
  movable: false
6758
8369
  });
8370
+ var targetItem = (id, name) => {
8371
+ const prop = targetProp(id, name);
8372
+ return { id, name, build: (THREE) => markKitOrigin4(buildProp(THREE, prop), { owner: "game-weapons-three", kind: "target", id }), spec: prop.spec, movable: false };
8373
+ };
6759
8374
  var asItem = (id, name, prop, extra = {}) => ({
6760
8375
  id,
6761
8376
  name,
@@ -6827,11 +8442,12 @@ function createWeaponsTestModule(options = {}) {
6827
8442
  },
6828
8443
  items: () => [
6829
8444
  ...weaponDefs.map((def) => withThumbnail(weaponItem(def, arsenal.name(def.id)), def.id)),
6830
- asItem(COUNTER_ITEM_ID, text.counter, counterProp(text.counter), { movable: false }),
6831
- ...TARGETS.map((target) => asItem(target.id, text.targets[target.id] ?? target.id, targetProp(target.id, text.targets[target.id] ?? target.id), { movable: false })),
8445
+ counterItem(text.counter),
8446
+ ...TARGETS.map((target) => targetItem(target.id, text.targets[target.id] ?? target.id)),
6832
8447
  ...worldExplosives.map((item) => withThumbnail(item, item.id.slice(WORLD_EXPLOSIVE_PREFIX.length))),
6833
8448
  ...mountedM134.map((item) => withThumbnail(item, MOUNTED_M134.weaponId))
6834
8449
  ],
8450
+ scenarios: (scenarioLocale) => weaponsScenarios(scenarioLocale ?? locale),
6835
8451
  area: () => ({
6836
8452
  placements: [
6837
8453
  ...weaponsLayout(weaponIds, pitchOf).filter((placement) => !placement.item.startsWith(WORLD_EXPLOSIVE_PREFIX) || worldExplosiveIds.has(placement.item)),
@@ -6874,7 +8490,10 @@ function createWeaponsTestModule(options = {}) {
6874
8490
  audio.dispose();
6875
8491
  audio = createWeaponAudio(void 0, { ...seedOption, engine: lentEngine });
6876
8492
  }
8493
+ let simulatedMs = 0;
8494
+ const offClock = lent.onFrame((dt) => simulatedMs += dt * 1e3);
6877
8495
  const created = createWeaponFx({
8496
+ 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
8497
  THREE: lent.THREE,
6879
8498
  scene: lent.scene,
6880
8499
  camera: lent.camera,
@@ -6884,10 +8503,13 @@ function createWeaponsTestModule(options = {}) {
6884
8503
  tool: lent.tool,
6885
8504
  soundDistance: () => soundDistance,
6886
8505
  raycast: createLaserRaycast(lent.THREE, lent.scene, lent.camera),
8506
+ // 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)
8507
+ contact: createLaserRaycast(lent.THREE, lent.scene, lent.camera, { now: () => simulatedMs }),
6887
8508
  ...lent.effects ? { effects: lent.effects } : {},
6888
8509
  ...options.attachmentModels ? { attachmentModels: options.attachmentModels } : {},
6889
8510
  ...options.condition ? { condition: options.condition } : {},
6890
- ...options.seed === void 0 ? {} : { seed: options.seed }
8511
+ ...options.seed === void 0 ? {} : { seed: options.seed },
8512
+ ...options.flyRocket === void 0 ? {} : { flyRocket: options.flyRocket }
6891
8513
  });
6892
8514
  fx = created;
6893
8515
  const camera = lent.camera;
@@ -6918,15 +8540,25 @@ function createWeaponsTestModule(options = {}) {
6918
8540
  }
6919
8541
  const tool = lent.tool();
6920
8542
  created.handle(event, tool);
8543
+ if (event.type === "reload" && event.phase === "start") {
8544
+ const clip = created.clips.find((entry) => entry.name.startsWith("reload"));
8545
+ if (clip) {
8546
+ const kind = event.kind ?? "tactical";
8547
+ 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 });
8548
+ }
8549
+ }
6921
8550
  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
8551
  });
6923
8552
  const offFrame = lent.onFrame((dt) => created.update(dt));
8553
+ const offHands = lent.setHandTargets?.(() => created.handTargets());
6924
8554
  created.handle({ type: "tool", weapon: lent.tool().weapon }, lent.tool());
6925
8555
  return () => {
6926
8556
  accessories.dispose();
6927
8557
  offBench?.();
6928
8558
  offEvents();
6929
8559
  offFrame();
8560
+ offClock();
8561
+ offHands?.();
6930
8562
  created.dispose();
6931
8563
  audio?.dispose();
6932
8564
  fx = null;
@@ -6935,6 +8567,976 @@ function createWeaponsTestModule(options = {}) {
6935
8567
  }
6936
8568
  };
6937
8569
  }
8570
+
8571
+ // src/explosives-area.ts
8572
+ import { EXPLOSIONS } from "@adatechnology/game-vfx";
8573
+ import { createArsenal as createArsenal2 } from "@adatechnology/game-weapons";
8574
+
8575
+ // src/explosives-bench.ts
8576
+ import { markKitOrigin as markKitOrigin5 } from "@adatechnology/game-contracts";
8577
+
8578
+ // src/explosives-text.ts
8579
+ var EXPLOSIVES_TEXT = {
8580
+ en: {
8581
+ group: "Explosives",
8582
+ name: "Explosives area",
8583
+ cylinder: "Propane cylinder (P13)",
8584
+ actions: {
8585
+ 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" },
8586
+ valve: { name: "Open the valve", hint: "Opens (or closes) the valve: the gas leaks with no fire until a flame reaches it" },
8587
+ light: { name: "Light it (lighter)", hint: "Holds a lighter flame at the valve: a gas leak catches fire and the cylinder goes off" },
8588
+ 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" },
8589
+ reset: { name: "Restart", hint: "Lays the scene again: a new cylinder, wall, furniture and people" }
8590
+ },
8591
+ 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" },
8592
+ layers: { rings: "Lethal and injury rings" },
8593
+ metrics: { gas: "Gas left in the cylinder", clock: "Seconds since the first leak", phase: "Phase" },
8594
+ phases: { rest: "At rest", leaking: "Leaking gas", torch: "Burning jet", primed: "About to go off", exploded: "Exploded" },
8595
+ rings: { lethal: "lethal", injury: "injury" },
8596
+ triggers: { shot: "a bullet", fire: "fire", explosion: "another blast" },
8597
+ 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" },
8598
+ 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" },
8599
+ log: {
8600
+ leak_shot: "Hole of {diameter} mm in the shell: gas leaking at {rate} l/s ({liters} l left in the cylinder)",
8601
+ leak_valve: "Valve open: gas leaking at {rate} l/s with no fire ({liters} l left in the cylinder)",
8602
+ valve_closed: "Valve closed: the leak stopped ({liters} l left)",
8603
+ valve_closed_late: "Too late to close the valve: the cylinder is already going off",
8604
+ primed: "The cylinder was set off by {trigger}: it goes off in {delay} s",
8605
+ jet_lit: "The jet caught fire (torch) {seconds} s after the hole; the cylinder goes off in {rest} s",
8606
+ jet_lit_by: "The leaking gas caught fire (torch) from {by}; the cylinder goes off in {rest} s",
8607
+ lighter_none: "Lighter flame at the valve: no gas is leaking, nothing happens",
8608
+ lighter_lit: "Lighter flame at the leaking valve: the gas lights",
8609
+ fire_set: "Fire lit next to the cylinder ({temperature} \xB0C, radius {radius} m): the shell heats up",
8610
+ heating: "Shell at {temperature} \xB0C (the fire chain starts at {limit} \xB0C)",
8611
+ explode: "Cylinder exploded: fireball of {fireball} m, lethal up to {lethal} m, injuries up to {injury} m (energy {energy} kJ, strength x{scale})",
8612
+ shell: "The torn shell flew up at {speed} m/s trailing fire for {trail} s",
8613
+ shell_landed: "The shell came down {distance} m away after {seconds} s",
8614
+ residual: "Residual fire of {radius} m for {seconds} s",
8615
+ aftermath_person: "{who} at {distance} m: {verdict}",
8616
+ aftermath_wall: "Wall behind the cylinder: {verdict}",
8617
+ aftermath_object: "{what} at {distance} m: {verdict}",
8618
+ reset: "Scene laid again with a blast strength of x{scale}",
8619
+ scale: "Blast strength x{scale}: energy {energy} kJ, lethal {lethal} m, injury {injury} m (valid for the next cylinder; the scene restarts)",
8620
+ no_room: "The explosives area was loaded without the room: this action needs it",
8621
+ shoot: "9 mm shot at the cylinder from {distance} m",
8622
+ no_vessel: "There is no cylinder in the scene (restart it)"
8623
+ },
8624
+ sheet: {
8625
+ kicker: "P13 cooking gas cylinder",
8626
+ title: "What happens to a P13 under a bullet, a flame or a blast",
8627
+ 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.",
8628
+ 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" },
8629
+ steps: [
8630
+ { 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" },
8631
+ { at: "0.8 s", text: "The jet catches fire and burns as a torch at the hole" },
8632
+ { 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" },
8633
+ { 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" }
8634
+ ],
8635
+ segments: { jet: "White gas jet", torch: "Torch", blast: "Blast" },
8636
+ 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."
8637
+ },
8638
+ scenarios: {
8639
+ shot: "P13 cylinder: a 9 mm through the shell leaks gas, the jet lights and the cylinder goes off with its chain delay",
8640
+ shot_leak: "The shot made a gas leak with a white pressurized jet",
8641
+ shot_ignite: "The jet caught fire (torch) between 0.7 and 1.0 s after the hole",
8642
+ shot_explode: "The cylinder exploded within 1.6 s of the shot",
8643
+ shot_zombie: "The zombie 3 m from the cylinder did not survive the blast",
8644
+ shot_survivor: "The survivor 8 m away, past the lethal ring, is hurt but alive",
8645
+ shot_wall: "The brick wall behind it was marked by the blast",
8646
+ valve: "P13 cylinder: the open valve leaks gas without fire and the cylinder goes off only when a flame lights it",
8647
+ valve_leak: "The open valve leaks gas",
8648
+ valve_quiet: "Four seconds of leaking gas with no flame: no fire and no blast",
8649
+ valve_lit: "The lighter flame lit the leak",
8650
+ valve_explode: "The cylinder went off within 1.6 s of the flame",
8651
+ fire: "P13 cylinder in a fire: the heat sets it off by the fire chain",
8652
+ fire_explode: "The cylinder in the fire exploded",
8653
+ chain: "P13 cylinder: another blast next to it sets it off by the explosion chain",
8654
+ chain_explode: "The second cylinder exploded after the first one",
8655
+ push_p13: "P13 blast: the loose items, the furniture and the people around it are thrown away from it, less the farther they stand",
8656
+ push_grenade: "Thrown M67 grenade: the loose items and the people around it are thrown away from it, less the farther they stand",
8657
+ push_told: "The explosion reached the host as an event",
8658
+ push_found: "{n} bodies were laid around the blast to be measured ({kinds})",
8659
+ push_outward: "Every body inside the reach moved away from the blast ({moved} of {n}, {least} m at the least)",
8660
+ push_falloff: "{what}: the nearer one moved more than the farther one ({near} m at {nearDistance} m, {far} m at {farDistance} m)",
8661
+ push_person: "{who} at {distance} m was pushed {moved} m away",
8662
+ push_heavy: "{what} (too heavy to grab, not fixed) was moved {moved} m by the blast"
8663
+ }
8664
+ },
8665
+ "pt-BR": {
8666
+ group: "Explosivos",
8667
+ name: "\xC1rea de explosivos",
8668
+ cylinder: "Botij\xE3o de g\xE1s (P13)",
8669
+ actions: {
8670
+ 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" },
8671
+ valve: { name: "Abrir o registro", hint: "Abre (ou fecha) o registro: o g\xE1s vaza sem fogo at\xE9 uma chama encostar" },
8672
+ light: { name: "Acender (isqueiro)", hint: "P\xF5e a chama de um isqueiro no registro: g\xE1s vazando pega fogo e o botij\xE3o explode" },
8673
+ 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" },
8674
+ reset: { name: "Reiniciar", hint: "Monta a cena de novo: botij\xE3o novo, parede, m\xF3veis e pessoas" }
8675
+ },
8676
+ 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" },
8677
+ layers: { rings: "An\xE9is letal e de ferimento" },
8678
+ metrics: { gas: "G\xE1s no botij\xE3o", clock: "Segundos desde o primeiro vazamento", phase: "Fase" },
8679
+ phases: { rest: "Em repouso", leaking: "Vazando g\xE1s", torch: "Jato em chamas", primed: "Prestes a explodir", exploded: "Explodiu" },
8680
+ rings: { lethal: "letal", injury: "ferimento" },
8681
+ triggers: { shot: "um tiro", fire: "fogo", explosion: "outra explos\xE3o" },
8682
+ 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" },
8683
+ 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" },
8684
+ log: {
8685
+ leak_shot: "Furo de {diameter} mm no casco: g\xE1s vazando a {rate} l/s ({liters} l no botij\xE3o)",
8686
+ leak_valve: "Registro aberto: g\xE1s vazando a {rate} l/s, sem fogo ({liters} l no botij\xE3o)",
8687
+ valve_closed: "Registro fechado: o vazamento parou ({liters} l restantes)",
8688
+ valve_closed_late: "Tarde demais para fechar o registro: o botij\xE3o j\xE1 vai explodir",
8689
+ primed: "O botij\xE3o foi detonado por {trigger}: explode em {delay} s",
8690
+ jet_lit: "O jato pegou fogo (ma\xE7arico) {seconds} s depois do furo; o botij\xE3o explode em {rest} s",
8691
+ jet_lit_by: "O g\xE1s vazando pegou fogo (ma\xE7arico) por {by}; o botij\xE3o explode em {rest} s",
8692
+ lighter_none: "Chama do isqueiro no registro: n\xE3o h\xE1 g\xE1s vazando, nada acontece",
8693
+ lighter_lit: "Chama do isqueiro no registro que vaza: o g\xE1s acendeu",
8694
+ fire_set: "Fogo aceso ao lado do botij\xE3o ({temperature} \xB0C, raio {radius} m): o casco esquenta",
8695
+ heating: "Casco a {temperature} \xB0C (a cadeia de fogo come\xE7a em {limit} \xB0C)",
8696
+ 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})",
8697
+ shell: "O casco rasgado voou para cima a {speed} m/s com rastro de fogo por {trail} s",
8698
+ shell_landed: "O casco caiu a {distance} m depois de {seconds} s",
8699
+ residual: "Fogo residual de {radius} m por {seconds} s",
8700
+ aftermath_person: "{who} a {distance} m: {verdict}",
8701
+ aftermath_wall: "Parede atr\xE1s do botij\xE3o: {verdict}",
8702
+ aftermath_object: "{what} a {distance} m: {verdict}",
8703
+ reset: "Cena montada de novo com for\xE7a de explos\xE3o x{scale}",
8704
+ 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)",
8705
+ no_room: "A \xE1rea de explosivos foi carregada sem a sala: esta a\xE7\xE3o precisa dela",
8706
+ shoot: "Tiro de 9 mm no botij\xE3o a {distance} m",
8707
+ no_vessel: "N\xE3o h\xE1 botij\xE3o na cena (reinicie)"
8708
+ },
8709
+ sheet: {
8710
+ kicker: "Botij\xE3o de g\xE1s de cozinha P13",
8711
+ title: "O que acontece com um P13 sob um tiro, uma chama ou uma explos\xE3o",
8712
+ 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.",
8713
+ 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" },
8714
+ steps: [
8715
+ { 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" },
8716
+ { at: "0,8 s", text: "O jato pega fogo e queima como ma\xE7arico no furo" },
8717
+ { 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" },
8718
+ { 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" }
8719
+ ],
8720
+ segments: { jet: "Jato branco de g\xE1s", torch: "Ma\xE7arico", blast: "Explos\xE3o" },
8721
+ 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."
8722
+ },
8723
+ scenarios: {
8724
+ 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",
8725
+ shot_leak: "O tiro fez um vazamento de g\xE1s com jato branco pressurizado",
8726
+ shot_ignite: "O jato pegou fogo (ma\xE7arico) entre 0,7 e 1,0 s depois do furo",
8727
+ shot_explode: "O botij\xE3o explodiu at\xE9 1,6 s depois do tiro",
8728
+ shot_zombie: "O zumbi a 3 m do botij\xE3o n\xE3o sobreviveu \xE0 explos\xE3o",
8729
+ shot_survivor: "O sobrevivente a 8 m, depois do anel letal, fica ferido mas vivo",
8730
+ shot_wall: "A parede de tijolo atr\xE1s dele ficou marcada pela explos\xE3o",
8731
+ valve: "Botij\xE3o P13: o registro aberto vaza g\xE1s sem fogo e o botij\xE3o s\xF3 explode quando uma chama acende",
8732
+ valve_leak: "O registro aberto vaza g\xE1s",
8733
+ valve_quiet: "Quatro segundos de g\xE1s vazando sem chama: sem fogo e sem explos\xE3o",
8734
+ valve_lit: "A chama do isqueiro acendeu o vazamento",
8735
+ valve_explode: "O botij\xE3o explodiu at\xE9 1,6 s depois da chama",
8736
+ fire: "Botij\xE3o P13 no fogo: o calor detona pela cadeia de fogo",
8737
+ fire_explode: "O botij\xE3o no fogo explodiu",
8738
+ chain: "Botij\xE3o P13: outra explos\xE3o ao lado detona pela cadeia de explos\xE3o",
8739
+ chain_explode: "O segundo botij\xE3o explodiu depois do primeiro",
8740
+ 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",
8741
+ push_grenade: "Granada M67 arremessada: os itens soltos e as pessoas em volta s\xE3o jogados para longe, menos quanto mais longe est\xE3o",
8742
+ push_told: "A explos\xE3o chegou ao host como evento",
8743
+ push_found: "{n} corpos foram postos em volta da explos\xE3o para serem medidos ({kinds})",
8744
+ push_outward: "Todo corpo dentro do alcance se afastou da explos\xE3o ({moved} de {n}, no m\xEDnimo {least} m)",
8745
+ push_falloff: "{what}: o mais perto andou mais que o mais longe ({near} m a {nearDistance} m, {far} m a {farDistance} m)",
8746
+ push_person: "{who} a {distance} m foi empurrado {moved} m para longe",
8747
+ push_heavy: "{what} (pesado demais para pegar, mas solto) foi movido {moved} m pela explos\xE3o"
8748
+ }
8749
+ }
8750
+ };
8751
+ var explosivesTextOf = (locale) => EXPLOSIVES_TEXT[locale] ?? EXPLOSIVES_TEXT.en;
8752
+ var fillText2 = (template, vars) => template.replace(/\{(\w+)\}/g, (_, key) => String(vars[key] ?? ""));
8753
+
8754
+ // src/explosives-bench.ts
8755
+ var EXPLOSIVES_AREA_ID = "explosives_area";
8756
+ var FLAME_C = 1100;
8757
+ var FLAME_RADIUS = 0.2;
8758
+ var FLAME_SECONDS = 0.3;
8759
+ var FIRE_CHAIN_C = 300;
8760
+ var FIRE_OFFSET = [0.4, 0, 0.15];
8761
+ var FIRE_VISUAL_RADIUS = 0.6;
8762
+ var HISS_SHOVE = 0.35;
8763
+ var AFTERMATH_DELAY = 2;
8764
+ var SHOOTER = "pistol_9mm";
8765
+ var AIM_HEIGHT = 0.2;
8766
+ var GRAVITY2 = 9.81;
8767
+ var REPORTED_OBJECTS = ["fridge", "kitchen_cabinet_base", "stove", "camp_table"];
8768
+ var RING_SEGMENTS = 128;
8769
+ var WALL_ID = "target_wall";
8770
+ var WALL_Z = -11;
8771
+ var dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
8772
+ var num = (value, digits = 1) => value.toFixed(digits);
8773
+ function createExplosivesBench(options) {
8774
+ const { text, def, room, profile } = options;
8775
+ const log = text.log;
8776
+ let host = null;
8777
+ let strength = options.blastScale;
8778
+ let inArea = false;
8779
+ let lastBlast;
8780
+ let ringsOn = true;
8781
+ let initial = true;
8782
+ let aftermathDue = [];
8783
+ const vessels = /* @__PURE__ */ new Map();
8784
+ const wallParts = /* @__PURE__ */ new Set();
8785
+ const shells = [];
8786
+ const T = () => host?.THREE;
8787
+ const now = () => room?.world.time ?? 0;
8788
+ const say = (message, data = {}) => host?.log(message, { source: EXPLOSIVES_AREA_ID, ...data });
8789
+ const leakConfig = def.leak;
8790
+ const igniteDelay = leakConfig?.igniteDelay ?? 0.8;
8791
+ const hissSeconds = leakConfig?.hissSeconds ?? igniteDelay;
8792
+ const chainFire = (() => {
8793
+ const fire2 = def.chain?.fire;
8794
+ return fire2 === void 0 ? 1.4 : typeof fire2 === "number" ? fire2 : fire2[0];
8795
+ })();
8796
+ const liters = (vessel) => room?.world.part(vessel.objectId, "tank")?.liters ?? 0;
8797
+ const throughHoles = (vessel) => room?.world.part(vessel.objectId, "tank")?.holes.filter((hole) => hole.through).length ?? 0;
8798
+ const leaking = (vessel) => !vessel.exploded && liters(vessel) > 0 && throughHoles(vessel) > 0;
8799
+ const centreOf = (vessel) => {
8800
+ const p = vessel.root.getWorldPosition(new (T()).Vector3());
8801
+ return [p.x, p.y + AIM_HEIGHT, p.z];
8802
+ };
8803
+ const valveOf = (vessel) => worldExplosiveValve(T(), vessel.root) ?? centreOf(vessel);
8804
+ const target = () => [...vessels.values()].find((vessel) => !vessel.exploded) ?? [...vessels.values()][0];
8805
+ const lethalOf = (vessel) => def.lethalRadius * vessel.scale;
8806
+ const injuryOf = (vessel) => def.injuryRadius * vessel.scale;
8807
+ const labelSprite = (words, colour, at) => {
8808
+ try {
8809
+ const canvas = document.createElement("canvas");
8810
+ canvas.width = 256;
8811
+ canvas.height = 64;
8812
+ const context = canvas.getContext("2d");
8813
+ if (!context) return null;
8814
+ context.fillStyle = colour;
8815
+ context.font = "bold 40px sans-serif";
8816
+ context.textAlign = "center";
8817
+ context.fillText(words, 128, 46);
8818
+ const sprite = new (T()).Sprite(new (T()).SpriteMaterial({ map: new (T()).CanvasTexture(canvas), transparent: true, depthWrite: false }));
8819
+ sprite.scale.set(2.4, 0.6, 1);
8820
+ sprite.position.set(...at);
8821
+ return sprite;
8822
+ } catch {
8823
+ return null;
8824
+ }
8825
+ };
8826
+ const ringLine = (radius, colour) => {
8827
+ const points = [];
8828
+ for (let i = 0; i < RING_SEGMENTS; i++) {
8829
+ const angle = i / RING_SEGMENTS * Math.PI * 2;
8830
+ points.push(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
8831
+ }
8832
+ const geometry = new (T()).BufferGeometry();
8833
+ geometry.setAttribute("position", new (T()).Float32BufferAttribute(points, 3));
8834
+ return new (T()).LineLoop(geometry, new (T()).LineBasicMaterial({ color: colour }));
8835
+ };
8836
+ const drawRings = (vessel) => {
8837
+ const group = markKitOrigin5(new (T()).Group(), { owner: "game-weapons-three", kind: "aid" });
8838
+ group.name = "blast_rings";
8839
+ const lethal = lethalOf(vessel);
8840
+ const injury = injuryOf(vessel);
8841
+ group.add(ringLine(lethal, 16726832), ringLine(injury, 16756768));
8842
+ const lethalLabel = labelSprite(`${num(lethal, 0)} m ${text.rings.lethal}`, "#ff6a5e", [0, 0.35, lethal]);
8843
+ const injuryLabel = labelSprite(`${num(injury, 0)} m ${text.rings.injury}`, "#ffc44d", [0, 0.35, injury]);
8844
+ for (const label of [lethalLabel, injuryLabel]) if (label) group.add(label);
8845
+ const p = vessel.root.getWorldPosition(new (T()).Vector3());
8846
+ group.position.set(p.x, 0.03, p.z);
8847
+ group.visible = ringsOn;
8848
+ (host?.scene).add(group);
8849
+ vessel.rings = group;
8850
+ };
8851
+ const clearRings = (vessel) => {
8852
+ vessel.rings?.removeFromParent();
8853
+ vessel.rings = void 0;
8854
+ };
8855
+ const phaseOf = (vessel) => {
8856
+ if (!vessel) return "rest";
8857
+ if (vessel.exploded) return "exploded";
8858
+ if (vessel.explodeAt !== void 0) return "primed";
8859
+ if (vessel.lit) return "torch";
8860
+ return vessel.leakAt === void 0 ? "rest" : "leaking";
8861
+ };
8862
+ const ignite = (vessel, by) => {
8863
+ if (vessel.lit || vessel.exploded || !room) return;
8864
+ const leak = vessel.leaks[vessel.leaks.length - 1];
8865
+ const point = leak?.point ?? valveOf(vessel);
8866
+ room.world.hit(vessel.objectId, { type: "heat", point, temperature: FLAME_C, radius: FLAME_RADIUS, duration: FLAME_SECONDS, source: "p13_flame" });
8867
+ vessel.lit = true;
8868
+ vessel.litAt = now();
8869
+ const rest = vessel.explodeAt === void 0 ? chainFire : Math.max(0, vessel.explodeAt - now());
8870
+ 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 });
8871
+ else say(fillText2(log.lighter_lit, {}), { phase: "torch", objectId: vessel.objectId, by });
8872
+ };
8873
+ const shove = (vessel) => {
8874
+ const leak = vessel.leaks[vessel.leaks.length - 1];
8875
+ if (!room || !leak) return;
8876
+ const [dx, dy, dz] = leak.direction;
8877
+ const length = Math.hypot(dx, dy, dz) || 1;
8878
+ room.renderer.impulse(vessel.objectId, [-dx / length * HISS_SHOVE, -dy / length * HISS_SHOVE * 0.2, -dz / length * HISS_SHOVE], leak.point);
8879
+ };
8880
+ const torchSprites = (vessel) => {
8881
+ const leak = vessel.leaks[vessel.leaks.length - 1];
8882
+ if (!room || !leak) return;
8883
+ const [dx, dy, dz] = leak.direction;
8884
+ const length = Math.hypot(dx, dy, dz) || 1;
8885
+ const flame = () => 3 + Math.random() * 2.5;
8886
+ 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 });
8887
+ if (vessel.torchFrame++ % 3 === 0) room.vfx.spawn("smoke", leak.point, [dx / length * 2, 1.5, dz / length * 2], { size: 0.7, life: 1 });
8888
+ };
8889
+ const launchShell = (vessel, point) => {
8890
+ const config = def.shell;
8891
+ if (!config || !host) return;
8892
+ const mesh = worldExplosiveShell(T(), def.id);
8893
+ if (!mesh) return;
8894
+ mesh.position.set(point[0], Math.max(point[1], 0.3), point[2]);
8895
+ host.scene.add(mesh);
8896
+ shells.push({
8897
+ mesh,
8898
+ position: [point[0], Math.max(point[1], 0.3), point[2]],
8899
+ velocity: [(Math.random() - 0.5) * 3, config.launchSpeed, (Math.random() - 0.5) * 3],
8900
+ spin: [(Math.random() - 0.5) * 12, (Math.random() - 0.5) * 6, (Math.random() - 0.5) * 12],
8901
+ age: 0,
8902
+ trail: config.trailSeconds,
8903
+ landed: false,
8904
+ start: point
8905
+ });
8906
+ say(fillText2(log.shell, { speed: num(config.launchSpeed, 0), trail: num(config.trailSeconds, 1) }), { phase: "shell", speed: config.launchSpeed, trailSeconds: config.trailSeconds });
8907
+ void vessel;
8908
+ };
8909
+ const stepShells = (dt) => {
8910
+ if (!host) return;
8911
+ for (const shell of shells) {
8912
+ if (shell.landed) continue;
8913
+ shell.age += dt;
8914
+ shell.velocity[1] -= GRAVITY2 * dt;
8915
+ for (let axis = 0; axis < 3; axis++) shell.position[axis] = shell.position[axis] + shell.velocity[axis] * dt;
8916
+ const ground = host.groundAt(shell.position[0], shell.position[2]) + 0.18;
8917
+ if (shell.position[1] <= ground && shell.velocity[1] < 0) {
8918
+ shell.position[1] = ground;
8919
+ if (Math.abs(shell.velocity[1]) > 1.5) {
8920
+ shell.velocity = [shell.velocity[0] * 0.5, -shell.velocity[1] * 0.35, shell.velocity[2] * 0.5];
8921
+ } else {
8922
+ shell.landed = true;
8923
+ shell.mesh.rotation.set(Math.PI / 2, shell.mesh.rotation.y, 0);
8924
+ const away = dist(shell.start, [shell.position[0], shell.position[1], shell.position[2]]);
8925
+ say(fillText2(log.shell_landed, { distance: num(away, 1), seconds: num(shell.age, 1) }), { phase: "shell_landed", distance: away, seconds: shell.age });
8926
+ }
8927
+ } else {
8928
+ shell.mesh.rotation.x += shell.spin[0] * dt;
8929
+ shell.mesh.rotation.y += shell.spin[1] * dt;
8930
+ shell.mesh.rotation.z += shell.spin[2] * dt;
8931
+ }
8932
+ shell.mesh.position.set(shell.position[0], shell.position[1], shell.position[2]);
8933
+ if (room && shell.age < shell.trail) {
8934
+ const at = [shell.position[0], shell.position[1], shell.position[2]];
8935
+ 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 });
8936
+ 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 });
8937
+ }
8938
+ }
8939
+ };
8940
+ const clearShells = () => {
8941
+ for (const shell of shells) shell.mesh.removeFromParent();
8942
+ shells.length = 0;
8943
+ };
8944
+ const fullVitality = /* @__PURE__ */ new Map();
8945
+ const witnesses = () => {
8946
+ if (!room) return [];
8947
+ const origin = [...vessels.values()][0]?.centre ?? options.scene.cylinder;
8948
+ const found = [];
8949
+ for (const spawned of room.spawned()) {
8950
+ const position = spawned.root.getWorldPosition(new (T()).Vector3());
8951
+ const at = [position.x, position.y, position.z];
8952
+ const distance = dist([at[0], 0, at[2]], [origin[0], 0, origin[2]]);
8953
+ if (spawned.itemId.startsWith("zombie_") || spawned.itemId.startsWith("person_")) {
8954
+ const state = room.world.state(spawned.objectId);
8955
+ const alive = state?.body?.alive ?? true;
8956
+ const raw = state?.body?.vitality;
8957
+ if (raw !== void 0) fullVitality.set(spawned.objectId, Math.max(fullVitality.get(spawned.objectId) ?? 0, raw));
8958
+ const vitality = raw === void 0 ? void 0 : Math.max(0, raw) / Math.max(1e-9, fullVitality.get(spawned.objectId) ?? raw);
8959
+ found.push({ kind: "person", id: spawned.objectId, itemId: spawned.itemId, distance, alive, ...vitality === void 0 ? {} : { vitality } });
8960
+ } else if (REPORTED_OBJECTS.includes(spawned.itemId)) {
8961
+ const parts = room.world.state(spawned.objectId)?.parts ?? [];
8962
+ 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 });
8963
+ }
8964
+ }
8965
+ if (wallParts.size > 0) {
8966
+ let broken = 0;
8967
+ let marks = 0;
8968
+ let pieces = 0;
8969
+ for (const id of room.world.ids()) {
8970
+ const mine = (room.world.state(id)?.parts ?? []).filter((part) => wallParts.has(part.id));
8971
+ if (mine.length === 0) continue;
8972
+ pieces++;
8973
+ broken += mine.filter((part) => part.status === "broken").length;
8974
+ marks += mine.reduce((n, part) => n + part.cracks.length + part.holes.length, 0);
8975
+ }
8976
+ found.push({ kind: "wall", id: "target_wall", itemId: "target_wall", distance: Math.abs(WALL_Z - origin[2]), broken, marks, pieces });
8977
+ }
8978
+ return found;
8979
+ };
8980
+ const whoIs = (itemId) => itemId.startsWith("zombie_") ? text.things.zombie : text.things.survivor;
8981
+ const aftermath = (point) => {
8982
+ for (const w of witnesses()) {
8983
+ const distance = num(w.distance, 1);
8984
+ if (w.kind === "person") {
8985
+ 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;
8986
+ 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 });
8987
+ } else if (w.kind === "wall") {
8988
+ 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;
8989
+ say(fillText2(log.aftermath_wall, { verdict }), { phase: "aftermath", kind: w.kind, objectId: w.id, broken: w.broken, marks: w.marks, pieces: w.pieces });
8990
+ } else {
8991
+ 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;
8992
+ 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 });
8993
+ }
8994
+ }
8995
+ };
8996
+ const onEvent = (event) => {
8997
+ const vessel = vessels.get(event.object);
8998
+ if (!vessel || !("part" in event) || event.part !== "tank") return;
8999
+ if (event.type === "leak") {
9000
+ witnesses();
9001
+ if (vessel.leakAt === void 0) {
9002
+ vessel.leakAt = event.time;
9003
+ vessel.cause = vessel.valveRequested ? "valve" : "shot";
9004
+ }
9005
+ vessel.valveRequested = false;
9006
+ vessel.leaks.push({ point: event.point, direction: event.direction, rate: event.rate });
9007
+ if (vessel.leaks.length > 8) vessel.leaks.shift();
9008
+ const holes = room?.world.part(vessel.objectId, "tank")?.holes ?? [];
9009
+ const diameter = (holes[holes.length - 1]?.diameter ?? 9e-3) * 1e3;
9010
+ const vars = { diameter: num(diameter, 0), rate: num(event.rate, 2), liters: num(liters(vessel), 1) };
9011
+ 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 });
9012
+ } else if (event.type === "primed") {
9013
+ vessel.explodeAt = event.time + event.delay;
9014
+ 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 });
9015
+ } else if (event.type === "explode") {
9016
+ vessel.exploded = true;
9017
+ vessel.explodedAt = event.time;
9018
+ vessel.centre = event.point;
9019
+ const radius = profile?.visualRadius;
9020
+ const fireball = (radius ?? 2) * 2;
9021
+ const lethal = lethalOf(vessel);
9022
+ const injury = injuryOf(vessel);
9023
+ 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) }), {
9024
+ phase: "explode",
9025
+ objectId: vessel.objectId,
9026
+ fireballMetres: fireball,
9027
+ lethalRadius: lethal,
9028
+ injuryRadius: injury,
9029
+ energyJ: def.energy * vessel.scale,
9030
+ scale: vessel.scale,
9031
+ point: event.point,
9032
+ sinceLeakSeconds: vessel.leakAt === void 0 ? void 0 : event.time - vessel.leakAt
9033
+ });
9034
+ const residual = profile?.residualFires?.[0];
9035
+ if (residual) say(fillText2(log.residual, { radius: num(residual.radius, 1), seconds: num(residual.life, 0) }), { phase: "residual", radius: residual.radius, seconds: residual.life });
9036
+ if (vessel.fireHandle !== void 0) room?.vfx.stop(vessel.fireHandle);
9037
+ launchShell(vessel, event.point);
9038
+ aftermathDue.push({ at: now() + AFTERMATH_DELAY, vessel, point: event.point });
9039
+ }
9040
+ };
9041
+ const frame = (dt) => {
9042
+ const t = now();
9043
+ for (const vessel of vessels.values()) {
9044
+ if (vessel.exploded) {
9045
+ vessel.lit = false;
9046
+ continue;
9047
+ }
9048
+ if (vessel.leakAt !== void 0) {
9049
+ if (t - vessel.leakAt <= hissSeconds && leaking(vessel)) shove(vessel);
9050
+ if (vessel.cause === "shot" && !vessel.lit && t - vessel.leakAt >= igniteDelay && leaking(vessel)) ignite(vessel, "shot");
9051
+ }
9052
+ if (vessel.lit) {
9053
+ if (leaking(vessel)) torchSprites(vessel);
9054
+ else vessel.lit = false;
9055
+ }
9056
+ if (vessel.fireUntil !== void 0 && t < vessel.fireUntil && room && vessel.firePoint) {
9057
+ const fire2 = def.fire;
9058
+ room.world.hit(vessel.objectId, { type: "heat", point: vessel.firePoint, temperature: fire2?.temperature ?? 700, radius: fire2?.radius ?? 1.4, duration: dt, source: "p13_fire" });
9059
+ if (vessel.explodeAt === void 0 && t >= vessel.nextHeatLog) {
9060
+ vessel.nextHeatLog = t + 1;
9061
+ 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 });
9062
+ }
9063
+ }
9064
+ }
9065
+ stepShells(dt);
9066
+ if (aftermathDue.length > 0) {
9067
+ const ready = aftermathDue.filter((entry) => entry.at <= t);
9068
+ aftermathDue = aftermathDue.filter((entry) => entry.at > t);
9069
+ for (const entry of ready) aftermath(entry.point);
9070
+ }
9071
+ };
9072
+ const needRoom = () => {
9073
+ if (room) return true;
9074
+ say(log.no_room);
9075
+ return false;
9076
+ };
9077
+ const withVessel = () => {
9078
+ const vessel = target();
9079
+ if (!vessel || vessel.exploded) say(log.no_vessel);
9080
+ return vessel && !vessel.exploded ? vessel : void 0;
9081
+ };
9082
+ const shoot = () => {
9083
+ if (!needRoom() || !room || !host) return false;
9084
+ const vessel = withVessel();
9085
+ if (!vessel) return false;
9086
+ if (host.tool().weapon !== SHOOTER) room.setTool(SHOOTER);
9087
+ room.waitUntilReady();
9088
+ const eye = host.camera.position;
9089
+ const aim = centreOf(vessel);
9090
+ room.look([eye.x, eye.y, eye.z], aim);
9091
+ say(fillText2(log.shoot, { distance: num(dist([eye.x, eye.y, eye.z], aim), 1) }), { phase: "shoot", objectId: vessel.objectId, weapon: SHOOTER });
9092
+ room.use();
9093
+ return true;
9094
+ };
9095
+ const valve = () => {
9096
+ if (!needRoom() || !room) return false;
9097
+ const vessel = withVessel();
9098
+ if (!vessel) return false;
9099
+ if (vessel.valveOpen) {
9100
+ if (vessel.explodeAt !== void 0) {
9101
+ say(log.valve_closed_late, { phase: "valve", objectId: vessel.objectId });
9102
+ return false;
9103
+ }
9104
+ room.world.seal(vessel.objectId, "tank");
9105
+ vessel.valveOpen = false;
9106
+ if (throughHoles(vessel) === 0) {
9107
+ vessel.leakAt = void 0;
9108
+ vessel.cause = void 0;
9109
+ vessel.leaks = [];
9110
+ vessel.lit = false;
9111
+ }
9112
+ say(fillText2(log.valve_closed, { liters: num(liters(vessel), 1) }), { phase: "valve_closed", objectId: vessel.objectId, liters: liters(vessel) });
9113
+ return true;
9114
+ }
9115
+ vessel.valveRequested = true;
9116
+ const opened = room.world.vent(vessel.objectId, "tank", { point: valveOf(vessel), normal: [0, 1, 0] });
9117
+ vessel.valveRequested = false;
9118
+ vessel.valveOpen = opened;
9119
+ return opened;
9120
+ };
9121
+ const light = () => {
9122
+ if (!needRoom() || !room) return false;
9123
+ const vessel = withVessel();
9124
+ if (!vessel) return false;
9125
+ const at = vessel.leaks[vessel.leaks.length - 1]?.point ?? valveOf(vessel);
9126
+ room.vfx.burst("fire", at, [0, 1, 0], 6, 0.8);
9127
+ if (!leaking(vessel)) {
9128
+ say(log.lighter_none, { phase: "lighter", objectId: vessel.objectId, leaking: false });
9129
+ return false;
9130
+ }
9131
+ ignite(vessel, "lighter");
9132
+ return true;
9133
+ };
9134
+ const fire = () => {
9135
+ if (!needRoom() || !room) return false;
9136
+ const vessel = withVessel();
9137
+ if (!vessel) return false;
9138
+ const base = vessel.root.getWorldPosition(new (T()).Vector3());
9139
+ const point = [base.x + FIRE_OFFSET[0], base.y + FIRE_OFFSET[1], base.z + FIRE_OFFSET[2]];
9140
+ const config = def.fire ?? { temperature: 700, radius: 1.4, seconds: 7 };
9141
+ vessel.firePoint = point;
9142
+ vessel.fireUntil = now() + config.seconds + 3;
9143
+ vessel.nextHeatLog = now();
9144
+ vessel.fireHandle = room.vfx.fire({ point, radius: FIRE_VISUAL_RADIUS, life: config.seconds + 3 });
9145
+ for (const spawned of room.spawned()) {
9146
+ if (spawned.itemId !== "camp_table") continue;
9147
+ const top = spawned.root.getWorldPosition(new (T()).Vector3());
9148
+ 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" });
9149
+ }
9150
+ 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 });
9151
+ return true;
9152
+ };
9153
+ const reset = () => {
9154
+ if (!needRoom() || !room) return false;
9155
+ room.openArea(EXPLOSIVES_AREA_ID);
9156
+ say(fillText2(log.reset, { scale: num(strength, 2) }), { phase: "reset", scale: strength });
9157
+ return true;
9158
+ };
9159
+ const actionIds = ["shoot", "valve", "light", "fire", "reset"];
9160
+ const actions = { shoot, valve, light, fire, reset };
9161
+ const sheet = () => {
9162
+ const energy = def.energy * strength / 1e3;
9163
+ return {
9164
+ kicker: text.sheet.kicker,
9165
+ title: text.sheet.title,
9166
+ body: text.sheet.body,
9167
+ facts: [
9168
+ { label: text.sheet.facts.energy, value: `${num(energy, 0)} kJ (x${num(strength, 2)})` },
9169
+ { label: text.sheet.facts.lethal, value: `${num(def.lethalRadius * strength, 1)} m` },
9170
+ { label: text.sheet.facts.injury, value: `${num(def.injuryRadius * strength, 1)} m` },
9171
+ { label: text.sheet.facts.gas, value: `${num(leakConfig?.liters ?? 0, 0)} l` },
9172
+ { label: text.sheet.facts.mass, value: `${num(def.mass ?? 0, 0)} kg` },
9173
+ { label: text.sheet.facts.torch, value: `${num(igniteDelay, 1)} s` },
9174
+ { label: text.sheet.facts.shot_chain, value: `${num(typeof def.chain?.shot === "number" ? def.chain.shot : def.chain?.shot?.[0] ?? 0, 1)} s` },
9175
+ { label: text.sheet.facts.fire_chain, value: `${num(chainFire, 1)} s` }
9176
+ ],
9177
+ steps: text.sheet.steps,
9178
+ note: text.sheet.note,
9179
+ timeline: {
9180
+ segments: [
9181
+ { name: text.sheet.segments.jet, start: 0, end: igniteDelay, color: "#e8eef2" },
9182
+ { name: text.sheet.segments.torch, start: igniteDelay, end: chainFire, color: "#ff9a2e" },
9183
+ { name: text.sheet.segments.blast, start: chainFire, end: chainFire + 1, color: "#ff4d2e" }
9184
+ ],
9185
+ ticks: [0, igniteDelay, chainFire],
9186
+ metric: "clock"
9187
+ }
9188
+ };
9189
+ };
9190
+ const adopt = (data) => {
9191
+ const spawned = data;
9192
+ if (spawned.itemId !== `${WORLD_EXPLOSIVE_PREFIX}${def.id}`) return;
9193
+ const vessel = { objectId: spawned.objectId, root: spawned.root, scale: strength, leaks: [], valveRequested: false, valveOpen: false, lit: false, exploded: false, nextHeatLog: 0, torchFrame: 0 };
9194
+ vessels.set(vessel.objectId, vessel);
9195
+ if (wallParts.size === 0) for (const part of room?.world.state(WALL_ID)?.parts ?? []) wallParts.add(part.id);
9196
+ if (inArea && host) drawRings(vessel);
9197
+ };
9198
+ const change = (change2) => {
9199
+ if (change2.kind === "action") {
9200
+ if (actionIds.includes(change2.id)) actions[change2.id]();
9201
+ } else if (change2.kind === "layer" && change2.id === "rings") {
9202
+ ringsOn = change2.on;
9203
+ for (const vessel of vessels.values()) if (vessel.rings) vessel.rings.visible = ringsOn;
9204
+ } else if (change2.kind === "control" && change2.id === "blast_scale" && typeof change2.value === "number") {
9205
+ const first = initial;
9206
+ initial = false;
9207
+ if (change2.value === strength && !first) return;
9208
+ strength = change2.value;
9209
+ host?.bench?.sheet?.(sheet());
9210
+ if (first) return;
9211
+ 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 });
9212
+ if (inArea) reset();
9213
+ }
9214
+ };
9215
+ return {
9216
+ room,
9217
+ def,
9218
+ blastScale: () => strength,
9219
+ areaOpened: () => {
9220
+ inArea = true;
9221
+ },
9222
+ controls: () => [
9223
+ ...actionIds.map((id) => ({ kind: "action", id, name: text.actions[id].name, hint: text.actions[id].hint })),
9224
+ { 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 }
9225
+ ],
9226
+ layers: () => [{ id: "rings", name: text.layers.rings }],
9227
+ metrics: () => [
9228
+ { id: "gas", name: text.metrics.gas, unit: "l", digits: 1, max: leakConfig?.liters ?? 24, read: () => liters(target() ?? { objectId: "" }) },
9229
+ { id: "clock", name: text.metrics.clock, unit: "s", digits: 2, read: () => {
9230
+ const v = target();
9231
+ return v?.leakAt === void 0 ? 0 : (v.explodedAt ?? now()) - v.leakAt;
9232
+ } },
9233
+ { id: "phase", name: text.metrics.phase, read: () => text.phases[phaseOf(target())] }
9234
+ ],
9235
+ attach(lent) {
9236
+ host = lent;
9237
+ const offs = [];
9238
+ if (room) {
9239
+ offs.push(room.world.on("*", onEvent));
9240
+ offs.push(room.on("spawn", adopt));
9241
+ for (const spawned of room.spawned()) adopt(spawned);
9242
+ }
9243
+ offs.push(
9244
+ lent.on((event) => {
9245
+ if (event.type === "explosion") lastBlast = { point: event.point, energy: event.energy, radius: event.radius, cause: event.cause, ...event.explosive === void 0 ? {} : { explosive: event.explosive } };
9246
+ })
9247
+ );
9248
+ offs.push(lent.onFrame(frame));
9249
+ offs.push(lent.bench?.onChange(change) ?? (() => void 0));
9250
+ offs.push(
9251
+ lent.onClear?.(() => {
9252
+ for (const vessel of vessels.values()) clearRings(vessel);
9253
+ vessels.clear();
9254
+ clearShells();
9255
+ wallParts.clear();
9256
+ aftermathDue = [];
9257
+ lastBlast = void 0;
9258
+ inArea = false;
9259
+ }) ?? (() => void 0)
9260
+ );
9261
+ lent.bench?.sheet?.(sheet());
9262
+ return () => {
9263
+ for (const off of offs) off();
9264
+ for (const vessel of vessels.values()) clearRings(vessel);
9265
+ vessels.clear();
9266
+ clearShells();
9267
+ host = null;
9268
+ };
9269
+ },
9270
+ actions,
9271
+ vessel: () => {
9272
+ const v = target();
9273
+ if (!v) return void 0;
9274
+ 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 };
9275
+ },
9276
+ lastBlast: () => lastBlast,
9277
+ witnesses
9278
+ };
9279
+ }
9280
+
9281
+ // src/explosives-scenarios.ts
9282
+ var TORCH_WINDOW = [0.7, 1];
9283
+ var BLAST_WITHIN = 1.6;
9284
+ var CYLINDER_ITEM = `${WORLD_EXPLOSIVE_PREFIX}propane_p13`;
9285
+ var PUSH_KINDS = [
9286
+ { kind: "can", item: "item_canned_beans" },
9287
+ { kind: "bottle", item: "item_water_bottle" },
9288
+ { kind: "chair", item: "dining_chair" }
9289
+ ];
9290
+ var PUSH_READ_AT = 0.8;
9291
+ var MOVED = 0.15;
9292
+ var HEAVY_ITEMS = ["camp_table"];
9293
+ var flat = (a, b) => Math.hypot(a[0] - b[0], a[2] - b[2]);
9294
+ var bearing = (centre, distance, degrees) => [centre[0] + Math.sin(degrees * Math.PI / 180) * distance, 0, centre[2] + Math.cos(degrees * Math.PI / 180) * distance];
9295
+ var PUSH_LAYOUT = {
9296
+ can: [[2.5, -52], [5, 12], [8, 78]],
9297
+ bottle: [[2.5, 26], [5, -62], [8, -78]],
9298
+ chair: [[2.5, -18], [5, 60], [8, -42]]
9299
+ };
9300
+ function pushScenario(all, bench, mode) {
9301
+ const text = all.scenarios;
9302
+ const things = all.things;
9303
+ return {
9304
+ id: `explosives_push_${mode}`,
9305
+ name: mode === "p13" ? text.push_p13 : text.push_grenade,
9306
+ run: ({ room, step, expect }) => {
9307
+ let centre;
9308
+ if (mode === "p13") {
9309
+ room.openArea(EXPLOSIVES_AREA_ID);
9310
+ const cylinder = room.spawned().find((spawned) => spawned.itemId === CYLINDER_ITEM);
9311
+ const at = cylinder?.root?.position;
9312
+ centre = [at?.x ?? 0, 0, at?.z ?? -10.66];
9313
+ } else {
9314
+ room.clear();
9315
+ centre = [0, 0, -6];
9316
+ }
9317
+ const placed = [];
9318
+ const lay = (kind, item, at) => {
9319
+ try {
9320
+ const spawned = room.spawn(item, at, 0);
9321
+ placed.push({ kind, objectId: spawned.objectId, itemId: item, from: at, root: spawned.root });
9322
+ } catch {
9323
+ }
9324
+ };
9325
+ for (const { kind, item } of PUSH_KINDS) PUSH_LAYOUT[kind].forEach(([distance, degrees]) => lay(kind, item, bearing(centre, distance, degrees)));
9326
+ if (mode === "grenade") {
9327
+ lay("zombie", "zombie_walker", bearing(centre, 3.4, -34));
9328
+ lay("survivor", "person_police_officer", bearing(centre, 7.5, 78));
9329
+ lay("camp_table", "camp_table", bearing(centre, 3, 90));
9330
+ }
9331
+ step(0.6);
9332
+ const start = (body) => [body.root.position.x, body.root.position.y, body.root.position.z];
9333
+ const origin = new Map(placed.map((body) => [body.objectId, start(body)]));
9334
+ const partsOf = (objectId) => room.world.state(objectId)?.parts.length ?? 0;
9335
+ for (const spawned of room.spawned()) {
9336
+ const known = placed.some((body2) => body2.objectId === spawned.objectId);
9337
+ const role = spawned.itemId.startsWith("zombie_") ? "zombie" : spawned.itemId.startsWith("person_") ? "survivor" : HEAVY_ITEMS.includes(spawned.itemId) ? spawned.itemId : void 0;
9338
+ if (known || !role || mode !== "p13") continue;
9339
+ const root = spawned.root;
9340
+ const body = { kind: role, objectId: spawned.objectId, itemId: spawned.itemId, from: [root.position.x, root.position.y, root.position.z], root };
9341
+ placed.push(body);
9342
+ origin.set(body.objectId, body.from);
9343
+ }
9344
+ const partsBefore = new Map(placed.map((body) => [body.objectId, partsOf(body.objectId)]));
9345
+ 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) });
9346
+ if (mode === "p13") bench.actions.shoot();
9347
+ else {
9348
+ room.setTool("frag_grenade");
9349
+ step(0.5);
9350
+ room.look([0, 1.6, 9.5], [0, 1, -2.5]);
9351
+ room.use();
9352
+ }
9353
+ let waited = 0;
9354
+ while (!bench.lastBlast() && waited < 9) {
9355
+ step(0.05);
9356
+ waited += 0.05;
9357
+ }
9358
+ const blast = bench.lastBlast();
9359
+ expect(!!blast, text.push_told, { waited });
9360
+ if (!blast) return;
9361
+ step(PUSH_READ_AT);
9362
+ const rows = placed.map((body) => {
9363
+ const from = origin.get(body.objectId) ?? body.from;
9364
+ const now = start(body);
9365
+ const distance = flat(from, blast.point);
9366
+ const away = Math.max(1e-6, distance);
9367
+ const outward = ((now[0] - from[0]) * (from[0] - blast.point[0]) + (now[2] - from[2]) * (from[2] - blast.point[2])) / away;
9368
+ return { ...body, distance, outward, moved: flat(now, from) };
9369
+ });
9370
+ const torn = (row) => PUSH_KINDS.some((entry) => entry.kind === row.kind) && partsOf(row.objectId) < (partsBefore.get(row.objectId) ?? 0);
9371
+ const reached = rows.filter((row) => row.distance < blast.radius * 0.85 && !torn(row));
9372
+ const movedAway = reached.filter((row) => row.outward >= MOVED);
9373
+ const least = reached.length === 0 ? 0 : Math.min(...reached.map((row) => row.outward));
9374
+ 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) });
9375
+ for (const { kind } of PUSH_KINDS) {
9376
+ const ofKind = reached.filter((row) => row.kind === kind).sort((a, b) => a.distance - b.distance);
9377
+ if (ofKind.length < 2) continue;
9378
+ const near = ofKind[0];
9379
+ const far = ofKind[ofKind.length - 1];
9380
+ 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 });
9381
+ }
9382
+ for (const row of reached.filter((entry) => entry.kind === "zombie" || entry.kind === "survivor")) {
9383
+ 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 });
9384
+ }
9385
+ const table = reached.find((row) => HEAVY_ITEMS.includes(row.kind));
9386
+ if (table) expect(table.moved >= MOVED, fillText2(text.push_heavy, { what: things[table.itemId] ?? table.itemId, moved: table.moved.toFixed(2) }), { table });
9387
+ }
9388
+ };
9389
+ }
9390
+ function explosivesScenarios(locale, bench) {
9391
+ const text = explosivesTextOf(locale).scenarios;
9392
+ const record = (room) => {
9393
+ const events = [];
9394
+ const stop = room.world.on("*", (event) => {
9395
+ if (event.object.startsWith(CYLINDER_ITEM)) events.push({ type: event.type, time: event.time, object: event.object });
9396
+ });
9397
+ return { events, stop };
9398
+ };
9399
+ const first = (events, type, after = -Infinity) => events.find((event) => event.type === type && event.time >= after);
9400
+ return [
9401
+ {
9402
+ id: "explosives_p13_shot",
9403
+ name: text.shot,
9404
+ run: ({ room, step, expect }) => {
9405
+ room.openArea(EXPLOSIVES_AREA_ID);
9406
+ const { events, stop } = record(room);
9407
+ bench.actions.shoot();
9408
+ step(2.4);
9409
+ stop();
9410
+ const leak = first(events, "leak");
9411
+ const explode = first(events, "explode");
9412
+ const view = bench.vessel();
9413
+ expect(!!leak, text.shot_leak, { events: events.map((event) => event.type) });
9414
+ const torchAfter = view?.litAt !== void 0 && view.leakAt !== void 0 ? view.litAt - view.leakAt : -1;
9415
+ expect(torchAfter >= TORCH_WINDOW[0] && torchAfter <= TORCH_WINDOW[1] + 0.05, `${text.shot_ignite} (${torchAfter.toFixed(2)} s)`, { torchAfter });
9416
+ const blastAfter = leak && explode ? explode.time - leak.time : -1;
9417
+ expect(blastAfter > 0 && blastAfter <= BLAST_WITHIN, `${text.shot_explode} (${blastAfter.toFixed(2)} s)`, { blastAfter });
9418
+ const witnesses = bench.witnesses();
9419
+ const zombie = witnesses.find((w) => w.itemId.startsWith("zombie_"));
9420
+ expect(!!zombie && (zombie.alive === false || (zombie.vitality ?? 1) < 0.5), `${text.shot_zombie} (${zombie?.distance.toFixed(1)} m)`, { zombie });
9421
+ const survivor = witnesses.find((w) => w.kind === "person" && !w.itemId.startsWith("zombie_"));
9422
+ expect(!!survivor && survivor.alive === true && (survivor.vitality ?? 1) < 1, `${text.shot_survivor} (${survivor?.distance.toFixed(1)} m, ${survivor?.alive ? "alive" : "dead"})`, { survivor });
9423
+ const wall = witnesses.find((w) => w.kind === "wall");
9424
+ expect((wall?.marks ?? 0) + (wall?.broken ?? 0) + ((wall?.pieces ?? 1) - 1) > 0, text.shot_wall, { wall });
9425
+ }
9426
+ },
9427
+ {
9428
+ id: "explosives_p13_valve",
9429
+ name: text.valve,
9430
+ run: ({ room, step, expect }) => {
9431
+ room.openArea(EXPLOSIVES_AREA_ID);
9432
+ const { events, stop } = record(room);
9433
+ bench.actions.valve();
9434
+ step(4);
9435
+ expect(!!first(events, "leak"), text.valve_leak);
9436
+ expect(!first(events, "primed") && !first(events, "explode"), text.valve_quiet, { events: events.map((event) => event.type) });
9437
+ const before = room.world.time;
9438
+ bench.actions.light();
9439
+ step(2.2);
9440
+ stop();
9441
+ expect(bench.vessel()?.lit === true || bench.vessel()?.litAt !== void 0, text.valve_lit);
9442
+ const explode = first(events, "explode", before);
9443
+ 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 });
9444
+ }
9445
+ },
9446
+ {
9447
+ id: "explosives_p13_fire",
9448
+ name: text.fire,
9449
+ run: ({ room, step, expect }) => {
9450
+ room.openArea(EXPLOSIVES_AREA_ID);
9451
+ const { events, stop } = record(room);
9452
+ bench.actions.fire();
9453
+ step(8);
9454
+ stop();
9455
+ expect(!!first(events, "explode"), text.fire_explode, { events: events.map((event) => event.type) });
9456
+ }
9457
+ },
9458
+ {
9459
+ id: "explosives_p13_chain",
9460
+ name: text.chain,
9461
+ run: ({ room, step, expect }) => {
9462
+ room.openArea(EXPLOSIVES_AREA_ID);
9463
+ const near = [1, 0, -10];
9464
+ const second = room.spawn(CYLINDER_ITEM, near, 0);
9465
+ const { events, stop } = record(room);
9466
+ bench.actions.shoot();
9467
+ step(4);
9468
+ stop();
9469
+ const blasts = events.filter((event) => event.type === "explode");
9470
+ const other = blasts.find((event) => event.object === second.objectId);
9471
+ expect(blasts.length >= 2 && !!other, `${text.chain_explode} (${blasts.length} blasts)`, { blasts: blasts.map((event) => `${event.object}@${event.time.toFixed(2)}`) });
9472
+ }
9473
+ },
9474
+ pushScenario(explosivesTextOf(locale), bench, "p13"),
9475
+ pushScenario(explosivesTextOf(locale), bench, "grenade")
9476
+ ];
9477
+ }
9478
+
9479
+ // src/explosives-area.ts
9480
+ var DEFAULT_EXPLOSIVES_SCENE = {
9481
+ furniture: [
9482
+ { item: "fridge", at: [-1.65, 0, -10.55] },
9483
+ { item: "kitchen_cabinet_base", at: [-0.78, 0, -10.6] },
9484
+ { item: "stove", at: [0.72, 0, -10.58] },
9485
+ { item: "camp_table", at: [2.4, 0, -10.2] }
9486
+ ],
9487
+ cylinder: [0.02, 0, -10.66],
9488
+ zombie: { item: "zombie_walker", at: [-1.6, 0, -8.1] },
9489
+ survivor: { item: "person_police_officer", at: [6, 0, -5.3] },
9490
+ eye: [0, 1.7, -4.7]
9491
+ };
9492
+ var EXPLOSIVE_ID = "propane_p13";
9493
+ var AIM_HEIGHT2 = 0.25;
9494
+ function createExplosivesTestModule(options = {}) {
9495
+ const locale = options.locale ?? "pt-BR";
9496
+ const arsenal = options.arsenal ?? createArsenal2({ locale });
9497
+ const text = explosivesTextOf(locale);
9498
+ const scene = { ...DEFAULT_EXPLOSIVES_SCENE, ...options.scene };
9499
+ const def = arsenal.explosives.get(EXPLOSIVE_ID);
9500
+ if (!def) throw new Error(`explosives area: the arsenal has no "${EXPLOSIVE_ID}"`);
9501
+ const bench = createExplosivesBench({ locale, text, def, scene, room: options.room, blastScale: options.blastScale ?? 1, profile: EXPLOSIONS[EXPLOSIVE_ID] });
9502
+ return {
9503
+ id: EXPLOSIVES_AREA_ID,
9504
+ name: text.name,
9505
+ group: text.group,
9506
+ bench,
9507
+ // 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
9508
+ items: () => {
9509
+ const item = worldExplosiveItem(def, text.cylinder, { scale: () => bench.blastScale() });
9510
+ return item ? [item] : [];
9511
+ },
9512
+ area: () => {
9513
+ bench.areaOpened();
9514
+ const placements = [
9515
+ ...scene.furniture.map((piece) => ({ item: piece.item, position: piece.at, ...piece.rotationY === void 0 ? {} : { rotationY: piece.rotationY } })),
9516
+ { item: `${WORLD_EXPLOSIVE_PREFIX}${EXPLOSIVE_ID}`, position: scene.cylinder },
9517
+ // facing the cylinder (the front of an item looks to +z, the cylinder is at -z)
9518
+ { item: scene.zombie.item, position: scene.zombie.at, rotationY: Math.PI },
9519
+ { item: scene.survivor.item, position: scene.survivor.at, rotationY: Math.PI }
9520
+ ];
9521
+ const centre = [scene.cylinder[0], AIM_HEIGHT2, scene.cylinder[2]];
9522
+ return { placements, view: { eye: scene.eye, target: centre } };
9523
+ },
9524
+ controls: () => bench.controls(),
9525
+ layers: () => bench.layers(),
9526
+ metrics: () => bench.metrics(),
9527
+ scenarios: (scenarioLocale) => explosivesScenarios(scenarioLocale ?? locale, bench),
9528
+ attach: (host) => bench.attach(host)
9529
+ };
9530
+ }
9531
+
9532
+ // src/projectile-model.ts
9533
+ function projectileModel(THREE, query) {
9534
+ if (query.kind !== "rocket") return null;
9535
+ const launcher = buildWeaponModelById(THREE, query.weapon);
9536
+ if (!launcher || launcher.parts.family !== "rocket") return null;
9537
+ const round2 = launcher.parts.makeRound ? launcher.parts.makeRound() : buildFlyingRocket(modelKit(THREE), launcher.parts.launcherType);
9538
+ return { model: round2, axis: [1, 0, 0] };
9539
+ }
6938
9540
  export {
6939
9541
  ARCHETYPE_MODELS,
6940
9542
  ATTACHMENT_MODELS,
@@ -6942,12 +9544,16 @@ export {
6942
9544
  CLASS_REFERENCE_ENERGY,
6943
9545
  COUNTER_ITEM_ID,
6944
9546
  DEFAULT_ENERGY_CURVE,
9547
+ DEFAULT_EXPLOSIVES_SCENE,
6945
9548
  DEFAULT_THUMBNAIL_BASE,
9549
+ EXPLOSIVES_AREA_ID,
9550
+ EXPLOSIVES_TEXT,
6946
9551
  GAME_DISTANCE_CURVE,
6947
9552
  GAME_SOUND_CLASSES,
6948
9553
  MECHANICAL_SOUNDS,
6949
9554
  MODEL_ENTRIES,
6950
9555
  MOUNTED_M134_ITEM_ID,
9556
+ P13_VALVE_MESH,
6951
9557
  WEAPON_ITEM_PREFIX,
6952
9558
  WEAPON_THUMBNAILS,
6953
9559
  WORLD_EFFECTS,
@@ -6956,6 +9562,7 @@ export {
6956
9562
  applyCondition,
6957
9563
  buildCartridge,
6958
9564
  buildCasing,
9565
+ buildItemWeaponModel,
6959
9566
  buildProp,
6960
9567
  buildRound,
6961
9568
  buildToolClips,
@@ -6963,23 +9570,30 @@ export {
6963
9570
  buildWeaponModelById,
6964
9571
  cartridgeShape,
6965
9572
  computeShotSound,
9573
+ createExplosivesTestModule,
6966
9574
  createWeaponAudio,
6967
9575
  createWeaponFx,
6968
9576
  createWeaponsTestModule,
6969
9577
  createWorldEffects,
6970
9578
  deriveFrames,
9579
+ explosivesTextOf,
6971
9580
  fitAttachmentModels,
6972
9581
  hasWeaponModel,
9582
+ itemModelOfWeapon,
6973
9583
  mechanicalSoundOf,
6974
9584
  modelKit,
6975
9585
  mountFrame,
6976
9586
  partRegistry,
9587
+ projectileModel,
9588
+ registerWeaponItemModel,
6977
9589
  rigStock,
6978
9590
  rigUnderbarrel,
6979
9591
  weaponProp,
6980
9592
  weaponThumbnailCredit,
6981
9593
  weaponThumbnailUrl,
6982
9594
  weaponsLayout,
6983
- worldExplosiveItem
9595
+ worldExplosiveItem,
9596
+ worldExplosiveShell,
9597
+ worldExplosiveValve
6984
9598
  };
6985
9599
  //# sourceMappingURL=index.js.map