@adatechnology/game-contracts 0.4.0 → 0.6.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/README.md CHANGED
@@ -118,7 +118,8 @@ metrics: () => [{ id: 'particles', name: 'Particles', max: 400, read: () => fx.p
118
118
  ```
119
119
 
120
120
  Tool events are named after a tool's cycle, not a firearm: `mode`, `fault`, `clear` (start / end) and `safety`, and `shot` carries `rounds`
121
- (cartridges spent) next to `shots` (projectiles) and the `mode`. `HostTool` may report `mode`, `fault`, `safety` and `heat` (0..1).
121
+ (cartridges spent) next to `shots` (projectiles, always 1 or more) and the `mode`; a dud round is its own `misfire` event, not a `shot` of zero. `HostTool` may report `mode`, `fault`, `safety` and `heat` (0..1).
122
+ `host.configureTool(settings)` takes a `Record<string, string>` keyed by the id of the module's own control; the test room applies `condition` and `ammo_quality` and ignores other ids.
122
123
 
123
124
  ## API
124
125
 
package/dist/index.d.ts CHANGED
@@ -37,6 +37,16 @@ type PartShape = {
37
37
  radius: number;
38
38
  };
39
39
  type FluidKind = 'water' | 'fuel' | 'oil' | 'gas' | 'sap' | 'acid' | 'blood' | string;
40
+ /** Seconds from a trigger to the blast: fixed, or `[min, max]` drawn from the world seed. */
41
+ type ChainDelay = number | readonly [number, number];
42
+ /** How an explosive part is set off by what happens around it. See `PartSpec.explodes`. */
43
+ interface ExplosionChain {
44
+ shot?: ChainDelay;
45
+ fire?: ChainDelay;
46
+ explosion?: ChainDelay;
47
+ /** Fraction of the part's hit points a shot must have taken off before it sets the part off (default 0: any hit). */
48
+ shotDamageFraction?: number;
49
+ }
40
50
  interface PartSpec {
41
51
  /** Unique inside the object. */
42
52
  id: string;
@@ -67,6 +77,12 @@ interface PartSpec {
67
77
  energy: number;
68
78
  radius: number;
69
79
  atC?: number;
80
+ /**
81
+ * Chain reaction: what sets the part off before it is destroyed and the seconds from that to the blast (a number, or `[min, max]` drawn
82
+ * from the world seed). `shot`: a bullet hit; `fire`: the part gets to `atC` (default 300 °C) or burns; `explosion`: a blast reaches it.
83
+ * Each part goes off once. Without `chain`, it explodes only when destroyed (or at `atC`).
84
+ */
85
+ chain?: ExplosionChain;
70
86
  };
71
87
  /** Fixed to the world (a wall-mounted cabinet, a tree trunk in the ground). Groups that keep an anchored part stay in place. */
72
88
  anchored?: boolean;
@@ -360,6 +376,14 @@ type DestructionEvent = (EventBase & {
360
376
  energy: number;
361
377
  radius: number;
362
378
  })
379
+ /** An explosive part was set off by a bullet, fire or a nearby blast and will explode `delay` seconds from now (`explodes.chain`). */
380
+ | (EventBase & {
381
+ type: 'primed';
382
+ part: string;
383
+ point: Vec3;
384
+ trigger: 'shot' | 'fire' | 'explosion';
385
+ delay: number;
386
+ })
363
387
  /** A structural part is overloaded (`stress` > 1); throttled to once per 0.5 s per part. Brittle parts also `crack` at stress > 0.8. */
364
388
  | (EventBase & {
365
389
  type: 'stress';
@@ -532,6 +556,53 @@ type Use = {
532
556
  radius: number;
533
557
  seconds: number;
534
558
  };
559
+ /** Explosive id (`m67`, `molotov`, `pg7v`...): the host looks the numbers up in the arsenal. Absent on a plain thrown object. */
560
+ explosive?: string;
561
+ /** What sets it off: `timer` after `fuse` seconds, `impact` on the first hit once armed. */
562
+ trigger?: 'timer' | 'impact';
563
+ /** Metres the projectile must fly before an impact sets it off; closer, it hits without exploding (rocket launchers). */
564
+ armDistance?: number;
565
+ /** Radius in metres of the wounding area (`radius` is the lethal one). */
566
+ injuryRadius?: number;
567
+ /** Seconds the blast stuns whoever is inside the injury radius (flashbang). */
568
+ stunSeconds?: number;
569
+ /** Seconds the thrower held it after pulling the pin: `fuse` is what was left of the full fuse when it left the hand. */
570
+ cooked?: number;
571
+ /** Went off in the thrower's hand (held past the fuse): no velocity, `fuse` is 0 and the blast is at `origin`. */
572
+ inHand?: boolean;
573
+ }
574
+ /** An explosive set down where the trigger is pulled (mine, C4, charge): it waits for its trigger instead of flying. */
575
+ | {
576
+ kind: 'place';
577
+ explosive: string;
578
+ /** `wire`: something trips it; `remote`: the player fires it; `timer`: after `fuse` seconds. */
579
+ triggers: readonly ('timer' | 'wire' | 'remote')[];
580
+ energy: number;
581
+ /** Lethal radius in metres. */
582
+ radius: number;
583
+ injuryRadius: number;
584
+ /** Cone the blast goes through, in front of `direction` (mine); absent for a blast in every direction. */
585
+ cone?: {
586
+ halfAngleDeg: number;
587
+ length: number;
588
+ };
589
+ /** The wire a `wire` charge stretches from where it is set down: metres out, degrees from its facing (counter-clockwise from above) and metres above it. */
590
+ tripwire?: {
591
+ length: number;
592
+ angleDeg: number;
593
+ height: number;
594
+ };
595
+ fuse?: number;
596
+ origin: Vec3;
597
+ direction: Vec3;
598
+ }
599
+ /** The blast behind a rocket launcher: dangerous inside `radius` metres, in the cone around `direction` (pointing away from the target). */
600
+ | {
601
+ kind: 'backblast';
602
+ radius: number;
603
+ halfAngleDeg: number;
604
+ origin: Vec3;
605
+ direction: Vec3;
535
606
  } | {
536
607
  kind: 'tool';
537
608
  tool: string;
@@ -817,9 +888,12 @@ interface ModuleArea {
817
888
  * What the player did with the tool in hand, as the host reports it. Hosts emit what they support; modules ignore what they do not need. The
818
889
  * names are of a tool's cycle, not of a firearm: a nail gun, a chainsaw or a hose has modes, faults and a safety too.
819
890
  */
820
- type HostEvent = {
891
+ type HostEvent =
892
+ /** The tool is put in hand; `duration` is the seconds the rule takes to ready it (absent: no draw time). */
893
+ {
821
894
  type: 'tool';
822
895
  weapon: string;
896
+ duration?: number;
823
897
  }
824
898
  /** `shots` are projectiles (a shotgun shell is many), `rounds` the cartridges spent (a burst is several). */
825
899
  | {
@@ -829,10 +903,20 @@ type HostEvent = {
829
903
  recoil: [number, number];
830
904
  rounds?: number;
831
905
  mode?: string;
832
- } | {
906
+ suppressed?: boolean;
907
+ }
908
+ /** The trigger was pulled, the round was spent and nothing left the tool (a dud cartridge): no projectile, no recoil. */
909
+ | {
910
+ type: 'misfire';
911
+ weapon: string;
912
+ }
913
+ /** `kind` is how the rule reloads (`tactical`, `empty`, `one_by_one`); `duration` the seconds of the whole reload, on `start`. */
914
+ | {
833
915
  type: 'reload';
834
916
  weapon: string;
835
917
  phase: 'start' | 'end';
918
+ kind?: string;
919
+ duration?: number;
836
920
  } | {
837
921
  type: 'empty';
838
922
  weapon: string;
@@ -849,10 +933,79 @@ type HostEvent = {
849
933
  weapon: string;
850
934
  fault: string;
851
935
  phase: 'start' | 'end';
936
+ duration?: number;
852
937
  } | {
853
938
  type: 'safety';
854
939
  weapon: string;
855
940
  on: boolean;
941
+ }
942
+ /** The action works between shots (a pump, a bolt); `duration` is the seconds of the cycle, on `start`. */
943
+ | {
944
+ type: 'cycle';
945
+ weapon: string;
946
+ phase: 'start' | 'end';
947
+ duration?: number;
948
+ }
949
+ /** A step of a longer phase (a reload's magazine out, magazine in, charge), at the moment the rule reaches it. */
950
+ | {
951
+ type: 'stage';
952
+ weapon: string;
953
+ stage: string;
954
+ }
955
+ /** One more round went into the tool while it loads round by round. */
956
+ | {
957
+ type: 'feed';
958
+ weapon: string;
959
+ }
960
+ /**
961
+ * A throwable with a timer fuse is cooked: the trigger pulls the pin (`start`, `fuse` is the whole fuse), releasing throws it with what is left of
962
+ * the fuse (`throw`), and holding past the fuse sets it off in the hand (`burst`, `fuse` 0).
963
+ */
964
+ | {
965
+ type: 'cook';
966
+ weapon: string;
967
+ phase: 'start' | 'throw' | 'burst';
968
+ fuse: number;
969
+ }
970
+ /** An accessory went on (`fit`), came off (`remove`) or broke (`break`) at `mount`; `duration` is the seconds the work takes, on `fit` and `remove`. */
971
+ | {
972
+ type: 'attachment';
973
+ weapon: string;
974
+ action: 'fit' | 'remove' | 'break';
975
+ attachment: string;
976
+ mount: string;
977
+ duration?: number;
978
+ }
979
+ /** A light or a laser was turned on or off. */
980
+ | {
981
+ type: 'toggle';
982
+ weapon: string;
983
+ attachment: string;
984
+ on: boolean;
985
+ }
986
+ /** The stock was folded or slid in (`collapsed`) or opened (`extended`); `kind` is how it moves (`fold`, `retract`), `duration` the seconds it takes. */
987
+ | {
988
+ type: 'stock';
989
+ weapon: string;
990
+ state: 'collapsed' | 'extended';
991
+ kind?: string;
992
+ duration?: number;
993
+ }
994
+ /** The weapon in hand changed between the main weapon and the one under its barrel (`on`: the under-barrel one is in use); `duration` is the seconds of the switch. */
995
+ | {
996
+ type: 'underbarrel';
997
+ weapon: string;
998
+ on: boolean;
999
+ duration?: number;
1000
+ }
1001
+ /** Something blew up: a thrown or launched explosive, a charge, or a part of the world (a fuel barrel); `source` is the weapon or the object, `cause` what set it off. */
1002
+ | {
1003
+ type: 'explosion';
1004
+ source: string;
1005
+ point: Vec3;
1006
+ energy: number;
1007
+ radius: number;
1008
+ cause: string;
856
1009
  };
857
1010
  /** The tool in hand as the host sees it. */
858
1011
  interface HostTool {
@@ -868,6 +1021,52 @@ interface HostTool {
868
1021
  safety?: boolean;
869
1022
  /** 0..1 */
870
1023
  heat?: number;
1024
+ /** What is on the tool (factory parts included): the accessory, where it sits, and whether a light or laser is on. */
1025
+ attachments?: readonly HostToolAttachment[];
1026
+ /** Where the stock is, for a tool with one that folds or slides. */
1027
+ stock?: 'collapsed' | 'extended';
1028
+ /** True while a suppressor is on: the shot sound is the muffled one. */
1029
+ suppressed?: boolean;
1030
+ /** True while the trigger and the reload go to the launcher under the barrel. */
1031
+ underbarrelActive?: boolean;
1032
+ /** Kg: the tool with what is on it. */
1033
+ mass?: number;
1034
+ /** Seconds to bring the tool to the sights, with what is on it. */
1035
+ aimSeconds?: number;
1036
+ }
1037
+ interface HostToolAttachment {
1038
+ readonly id: string;
1039
+ readonly mount: string;
1040
+ readonly integral?: boolean;
1041
+ readonly on?: boolean;
1042
+ }
1043
+ /** What a module asks of the tool in hand (accessories, stock, the launcher under the barrel, how it is held). The work takes the seconds the rule gives it and shows as host events. */
1044
+ type ToolCommand = {
1045
+ readonly type: 'fit';
1046
+ readonly attachment: string;
1047
+ readonly mount?: string;
1048
+ } | {
1049
+ readonly type: 'remove';
1050
+ readonly attachment: string;
1051
+ } | {
1052
+ readonly type: 'toggle';
1053
+ readonly attachment: string;
1054
+ } | {
1055
+ readonly type: 'stock';
1056
+ readonly state: 'collapsed' | 'extended';
1057
+ } | {
1058
+ readonly type: 'underbarrel';
1059
+ readonly on: boolean;
1060
+ } | {
1061
+ readonly type: 'stance';
1062
+ readonly aiming?: boolean;
1063
+ readonly supported?: boolean;
1064
+ };
1065
+ /** `ok`, or why nothing happened (the rule's refusal: `no_mount`, `interface`, `occupied`, `busy`...); `seconds` is how long the work takes. */
1066
+ interface ToolCommandResult {
1067
+ readonly ok: boolean;
1068
+ readonly reason?: string;
1069
+ readonly seconds?: number;
871
1070
  }
872
1071
  /**
873
1072
  * What the host lends a module that brings its own presentation (a weapon in the hands, sounds, ejected casings). `THREE`, `scene` and `camera`
@@ -887,6 +1086,13 @@ interface ModuleHost {
887
1086
  onFrame(update: (dt: number) => void): () => void;
888
1087
  /** Write a line to the host's behaviour log. */
889
1088
  log(text: string, data?: Record<string, unknown>): void;
1089
+ /**
1090
+ * Sets what the host lets a module tune on its tools, keyed by the id of the module's own control (`ModuleControl.id`) with the id of the option as the value; the tool in hand changes at once and the
1091
+ * ones taken later start that way. A host applies the ids it knows and ignores the rest. The test room knows `condition` (how worn the tools are) and `ammo_quality` (how good what they load is).
1092
+ */
1093
+ configureTool?(settings: Readonly<Record<string, string>>): void;
1094
+ /** Asks the tool in hand to do something (see `ToolCommand`). A host without accessories leaves it out. */
1095
+ command?(command: ToolCommand): ToolCommandResult;
890
1096
  /** Lent only to a module that declares controls, layers or metrics. */
891
1097
  bench?: ModuleBench;
892
1098
  }
@@ -909,4 +1115,90 @@ interface TestableModule<Context = unknown> {
909
1115
  attach?(host: ModuleHost): () => void;
910
1116
  }
911
1117
 
912
- export { type ActionControl, type AnimationClipSpec, type AnimationEventSpec, type AnimationKey, type AnimationPose, type AreaPlacement, type BenchChange, type BenchDeclaration, type BenchReport, type BooleanControl, type CheckOptions, type CheckSpecOptions, type ChoiceControl, type ChoiceOption, type ContainerSpec, type ControlPatch, type ControlValue, type Crack, type DamageType, type DebrisKind, type Dent, type DestructibleImpl, type DestructibleSpec, type DestructionEvent, type DestructionEventType, type FluidKind, type FractureStyle, type Fragment, type Hit, type HitResult, type Hole, type HostEvent, type HostTool, IMPACT_CONTRACT, type ImpactContract, type ItemAnimationContract, type ItemUseDef, type JointKind, type JointSpec, type JointState, type LiquidId, type MaterialId, type ModuleArea, type ModuleBench, type ModuleControl, type ModuleHost, type ModuleItemFluid, type ModuleLayer, type ModuleMetric, type ModuleScenario, type NumberControl, type ObjectState, type PartShape, type PartSpec, type PartState, type PartStatus, type Quat, type RayHit, type Shot, type SpecReport, type StructureRole, type TestableItem, type TestableModule, type ToolKind, type Use, type Vec3, checkAnimation, checkBench, checkDestructible, checkHit, checkSpec, shotToHit };
1118
+ /** Clips a tool can bring, one per move of its cycle. A tool brings the ones it has; a missing clip is simply not played. */
1119
+ declare const TOOL_CLIP: {
1120
+ readonly DRAW: "draw";
1121
+ readonly SHOT: "shot";
1122
+ readonly MISFIRE: "misfire";
1123
+ readonly EMPTY: "empty";
1124
+ readonly CYCLE: "cycle";
1125
+ readonly RELOAD: "reload";
1126
+ readonly FEED: "feed";
1127
+ readonly FAULT: "fault";
1128
+ readonly CLEAR: "clear";
1129
+ readonly SAFETY_ON: "safety_on";
1130
+ readonly SAFETY_OFF: "safety_off";
1131
+ readonly MODE: "mode";
1132
+ /** A throwable with the pin out, held until it is thrown. */
1133
+ readonly COOK: "cook";
1134
+ /** The stock folds, unfolds, slides in or out (variants `fold`, `unfold`, `collapse`, `extend`). */
1135
+ readonly STOCK: "stock";
1136
+ /** A light or a laser is switched (variants `on`, `off`). */
1137
+ readonly TOGGLE: "toggle";
1138
+ };
1139
+ type ToolClipId = (typeof TOOL_CLIP)[keyof typeof TOOL_CLIP];
1140
+ /** What a renderer does when a rule event arrives. `variant` picks `<clip>_<variant>` when the tool has it, else `<clip>`. */
1141
+ type ToolAnimationStep = {
1142
+ readonly type: 'play';
1143
+ readonly clip: ToolClipId;
1144
+ readonly variant?: string;
1145
+ readonly duration?: number;
1146
+ readonly repeat?: number;
1147
+ }
1148
+ /** Move the clip that is playing to the marker (a clip event `stage` with `data.stage`) the rule just reached. */
1149
+ | {
1150
+ readonly type: 'sync';
1151
+ readonly stage: string;
1152
+ }
1153
+ /** The rule says the phase is over: the clip jumps to its end if it is still running. */
1154
+ | {
1155
+ readonly type: 'finish';
1156
+ readonly clip: ToolClipId;
1157
+ };
1158
+ /** The move a host event asks of the tool in hand, or null when it has no motion. */
1159
+ declare const toolAnimationStep: (event: HostEvent) => ToolAnimationStep | null;
1160
+ /** Where a renderer puts the poses: every part of the tool by id (the ids the clips move), and the whole tool. Poses are offsets from rest. */
1161
+ interface ToolRig {
1162
+ setPartPose(partId: string, pose: AnimationPose): void;
1163
+ setRootPose?(pose: AnimationPose): void;
1164
+ }
1165
+ /** Clips by id, with the variants as `<clip>_<variant>`. */
1166
+ type ToolClipSet = Readonly<Record<string, AnimationClipSpec>>;
1167
+ /** A renderer's tool animator: feed it the host events and the frame time. */
1168
+ interface ToolAnimator {
1169
+ handle(event: HostEvent): void;
1170
+ update(dt: number): void;
1171
+ /** Ids of the clips playing now. */
1172
+ readonly playing: readonly string[];
1173
+ dispose(): void;
1174
+ }
1175
+ declare const STAGE_EVENT = "stage";
1176
+ /** The clip with its times (keys and events) scaled so that it lasts `duration` seconds. */
1177
+ declare const scaleClip: (clip: AnimationClipSpec, duration: number) => AnimationClipSpec;
1178
+ /** Pose of the whole tool (`partId` undefined) or of one part at `time`, linear between keys; a key that does not name it means rest. */
1179
+ declare const sampleClip: (clip: AnimationClipSpec, partId: string | undefined, time: number) => {
1180
+ position: [number, number, number];
1181
+ rotation: [number, number, number];
1182
+ };
1183
+ /** Problems with a clip set (empty when valid): each key is the clip id (or `<clip>_<variant>`), every clip passes `checkAnimation`, and a `stage` event names its stage. */
1184
+ declare const checkToolClips: (clips: ToolClipSet, partIds: Iterable<string>) => {
1185
+ ok: boolean;
1186
+ problems: string[];
1187
+ };
1188
+ interface ToolClipPlayerOptions {
1189
+ clips: ToolClipSet;
1190
+ rig: ToolRig;
1191
+ /** Called for each clip event as its time passes (`eject_casing`, `drop_magazine`, `stage`...). */
1192
+ onEvent?(event: AnimationEventSpec, clip: string): void;
1193
+ /** Seconds between the rounds of a `repeat` (a burst): default 0.1. */
1194
+ repeatInterval?(): number;
1195
+ }
1196
+ /** The default player of a tool's clips: runs the steps of `toolAnimationStep` against a rig, scaled to the duration the rule gives. */
1197
+ interface ToolClipPlayer {
1198
+ apply(step: ToolAnimationStep): void;
1199
+ update(dt: number): void;
1200
+ readonly playing: readonly string[];
1201
+ }
1202
+ declare const createToolClipPlayer: (options: ToolClipPlayerOptions) => ToolClipPlayer;
1203
+
1204
+ export { type ActionControl, type AnimationClipSpec, type AnimationEventSpec, type AnimationKey, type AnimationPose, type AreaPlacement, type BenchChange, type BenchDeclaration, type BenchReport, type BooleanControl, type ChainDelay, type CheckOptions, type CheckSpecOptions, type ChoiceControl, type ChoiceOption, type ContainerSpec, type ControlPatch, type ControlValue, type Crack, type DamageType, type DebrisKind, type Dent, type DestructibleImpl, type DestructibleSpec, type DestructionEvent, type DestructionEventType, type ExplosionChain, type FluidKind, type FractureStyle, type Fragment, type Hit, type HitResult, type Hole, type HostEvent, type HostTool, type HostToolAttachment, IMPACT_CONTRACT, type ImpactContract, type ItemAnimationContract, type ItemUseDef, type JointKind, type JointSpec, type JointState, type LiquidId, type MaterialId, type ModuleArea, type ModuleBench, type ModuleControl, type ModuleHost, type ModuleItemFluid, type ModuleLayer, type ModuleMetric, type ModuleScenario, type NumberControl, type ObjectState, type PartShape, type PartSpec, type PartState, type PartStatus, type Quat, type RayHit, STAGE_EVENT, type Shot, type SpecReport, type StructureRole, TOOL_CLIP, type TestableItem, type TestableModule, type ToolAnimationStep, type ToolAnimator, type ToolClipId, type ToolClipPlayer, type ToolClipPlayerOptions, type ToolClipSet, type ToolCommand, type ToolCommandResult, type ToolKind, type ToolRig, type Use, type Vec3, checkAnimation, checkBench, checkDestructible, checkHit, checkSpec, checkToolClips, createToolClipPlayer, sampleClip, scaleClip, shotToHit, toolAnimationStep };
@@ -22,12 +22,19 @@ var GameContracts = (() => {
22
22
  var index_exports = {};
23
23
  __export(index_exports, {
24
24
  IMPACT_CONTRACT: () => IMPACT_CONTRACT,
25
+ STAGE_EVENT: () => STAGE_EVENT,
26
+ TOOL_CLIP: () => TOOL_CLIP,
25
27
  checkAnimation: () => checkAnimation,
26
28
  checkBench: () => checkBench,
27
29
  checkDestructible: () => checkDestructible,
28
30
  checkHit: () => checkHit,
29
31
  checkSpec: () => checkSpec,
30
- shotToHit: () => shotToHit
32
+ checkToolClips: () => checkToolClips,
33
+ createToolClipPlayer: () => createToolClipPlayer,
34
+ sampleClip: () => sampleClip,
35
+ scaleClip: () => scaleClip,
36
+ shotToHit: () => shotToHit,
37
+ toolAnimationStep: () => toolAnimationStep
31
38
  });
32
39
 
33
40
  // src/impact.ts
@@ -274,6 +281,184 @@ var GameContracts = (() => {
274
281
  return { ok: problems.length === 0, problems };
275
282
  };
276
283
 
284
+ // src/tool-animation.ts
285
+ var TOOL_CLIP = {
286
+ DRAW: "draw",
287
+ SHOT: "shot",
288
+ MISFIRE: "misfire",
289
+ EMPTY: "empty",
290
+ CYCLE: "cycle",
291
+ RELOAD: "reload",
292
+ FEED: "feed",
293
+ FAULT: "fault",
294
+ CLEAR: "clear",
295
+ SAFETY_ON: "safety_on",
296
+ SAFETY_OFF: "safety_off",
297
+ MODE: "mode",
298
+ /** A throwable with the pin out, held until it is thrown. */
299
+ COOK: "cook",
300
+ /** The stock folds, unfolds, slides in or out (variants `fold`, `unfold`, `collapse`, `extend`). */
301
+ STOCK: "stock",
302
+ /** A light or a laser is switched (variants `on`, `off`). */
303
+ TOGGLE: "toggle"
304
+ };
305
+ var stockVariant = (event) => {
306
+ const isFolding = event.kind === void 0 ? false : event.kind.includes("fold");
307
+ if (event.state === "collapsed") return isFolding ? "fold" : "collapse";
308
+ return isFolding ? "unfold" : "extend";
309
+ };
310
+ var toolAnimationStep = (event) => {
311
+ switch (event.type) {
312
+ case "tool":
313
+ return { type: "play", clip: TOOL_CLIP.DRAW, ...event.duration === void 0 ? {} : { duration: event.duration } };
314
+ case "shot":
315
+ return { type: "play", clip: TOOL_CLIP.SHOT, repeat: Math.max(1, event.rounds ?? 1) };
316
+ case "misfire":
317
+ return { type: "play", clip: TOOL_CLIP.MISFIRE };
318
+ case "empty":
319
+ return { type: "play", clip: TOOL_CLIP.EMPTY };
320
+ case "mode":
321
+ return { type: "play", clip: TOOL_CLIP.MODE, variant: event.mode };
322
+ case "fault":
323
+ return { type: "play", clip: TOOL_CLIP.FAULT, variant: event.fault };
324
+ case "clear":
325
+ if (event.phase === "end") return { type: "finish", clip: TOOL_CLIP.CLEAR };
326
+ return { type: "play", clip: TOOL_CLIP.CLEAR, variant: event.fault, ...event.duration === void 0 ? {} : { duration: event.duration } };
327
+ case "safety":
328
+ return { type: "play", clip: event.on ? TOOL_CLIP.SAFETY_ON : TOOL_CLIP.SAFETY_OFF };
329
+ case "cycle":
330
+ if (event.phase === "end") return { type: "finish", clip: TOOL_CLIP.CYCLE };
331
+ return { type: "play", clip: TOOL_CLIP.CYCLE, ...event.duration === void 0 ? {} : { duration: event.duration } };
332
+ case "reload":
333
+ if (event.phase === "end") return { type: "finish", clip: TOOL_CLIP.RELOAD };
334
+ return { type: "play", clip: TOOL_CLIP.RELOAD, ...event.kind === void 0 ? {} : { variant: event.kind }, ...event.duration === void 0 ? {} : { duration: event.duration } };
335
+ case "stage":
336
+ return { type: "sync", stage: event.stage };
337
+ case "feed":
338
+ return { type: "play", clip: TOOL_CLIP.FEED };
339
+ case "cook":
340
+ if (event.phase === "start") return { type: "play", clip: TOOL_CLIP.COOK, duration: event.fuse };
341
+ return { type: "finish", clip: TOOL_CLIP.COOK };
342
+ case "stock":
343
+ return { type: "play", clip: TOOL_CLIP.STOCK, variant: stockVariant(event), ...event.duration === void 0 ? {} : { duration: event.duration } };
344
+ case "toggle":
345
+ return { type: "play", clip: TOOL_CLIP.TOGGLE, variant: event.on ? "on" : "off" };
346
+ default:
347
+ return null;
348
+ }
349
+ };
350
+ var STAGE_EVENT = "stage";
351
+ var scaleClip = (clip, duration) => {
352
+ const factor = clip.duration > 0 ? duration / clip.duration : 1;
353
+ return {
354
+ ...clip,
355
+ duration,
356
+ keys: clip.keys.map((key) => ({ ...key, t: key.t * factor })),
357
+ ...clip.events ? { events: clip.events.map((event) => ({ ...event, t: event.t * factor })) } : {}
358
+ };
359
+ };
360
+ var ZERO = [0, 0, 0];
361
+ var mix = (from, to, fraction) => [
362
+ (from[0] ?? 0) + ((to[0] ?? 0) - (from[0] ?? 0)) * fraction,
363
+ (from[1] ?? 0) + ((to[1] ?? 0) - (from[1] ?? 0)) * fraction,
364
+ (from[2] ?? 0) + ((to[2] ?? 0) - (from[2] ?? 0)) * fraction
365
+ ];
366
+ var sampleClip = (clip, partId, time) => {
367
+ const points = clip.keys.map((key) => {
368
+ const pose = partId === void 0 ? key : key.parts?.[partId];
369
+ return { t: key.t, position: pose?.position ?? ZERO, rotation: pose?.rotation ?? ZERO };
370
+ });
371
+ const first = points[0];
372
+ if (!first || first.t > 0) points.unshift({ t: 0, position: ZERO, rotation: ZERO });
373
+ const last = points[points.length - 1];
374
+ if (!last) return { position: ZERO, rotation: ZERO };
375
+ if (time >= last.t) return { position: mix(last.position, last.position, 0), rotation: mix(last.rotation, last.rotation, 0) };
376
+ const index = Math.max(0, points.findIndex((point, i) => time >= point.t && time < (points[i + 1]?.t ?? Infinity)));
377
+ const from = points[index];
378
+ const to = points[index + 1];
379
+ if (!from || !to) return { position: ZERO, rotation: ZERO };
380
+ const fraction = to.t > from.t ? (time - from.t) / (to.t - from.t) : 0;
381
+ return { position: mix(from.position, to.position, fraction), rotation: mix(from.rotation, to.rotation, fraction) };
382
+ };
383
+ var checkToolClips = (clips, partIds) => {
384
+ const parts = [...partIds];
385
+ const problems = [];
386
+ for (const [key, clip] of Object.entries(clips)) {
387
+ if (clip.id !== key) problems.push(`clip "${key}" has the id "${clip.id}"`);
388
+ if (!(clip.duration > 0)) problems.push(`clip "${key}" has no duration`);
389
+ problems.push(...checkAnimation(clip, parts).problems);
390
+ for (const event of clip.events ?? []) if (event.type === STAGE_EVENT && typeof event.data?.stage !== "string") problems.push(`clip "${key}" has a stage event without data.stage`);
391
+ }
392
+ return { ok: problems.length === 0, problems };
393
+ };
394
+ var createToolClipPlayer = (options) => {
395
+ const { clips, rig, onEvent } = options;
396
+ let instances = [];
397
+ const find = (clip, variant) => {
398
+ const name = variant !== void 0 && clips[`${clip}_${variant}`] ? `${clip}_${variant}` : clip;
399
+ const found = clips[name];
400
+ return found ? { name, clip: found } : void 0;
401
+ };
402
+ const render = (instance) => {
403
+ const time = Math.min(instance.time, instance.clip.duration);
404
+ const root = sampleClip(instance.clip, void 0, time);
405
+ if (instance.clip.keys.some((key) => key.position || key.rotation)) rig.setRootPose?.(root);
406
+ const touched = new Set(instance.clip.keys.flatMap((key) => Object.keys(key.parts ?? {})));
407
+ for (const id of touched) rig.setPartPose(id, sampleClip(instance.clip, id, time));
408
+ };
409
+ const fireEvents = (instance, upTo) => {
410
+ const events = instance.clip.events ?? [];
411
+ while (instance.fired < events.length && (events[instance.fired]?.t ?? Infinity) <= upTo + 1e-9) {
412
+ const event = events[instance.fired];
413
+ instance.fired += 1;
414
+ if (event) onEvent?.(event, instance.name);
415
+ }
416
+ };
417
+ return {
418
+ apply(step) {
419
+ if (step.type === "sync") {
420
+ for (const instance of instances) {
421
+ const marker = (instance.clip.events ?? []).find((event) => event.type === STAGE_EVENT && event.data?.stage === step.stage);
422
+ if (marker && instance.time < marker.t) instance.time = marker.t;
423
+ }
424
+ return;
425
+ }
426
+ if (step.type === "finish") {
427
+ for (const instance of instances) {
428
+ if (instance.name !== step.clip && !instance.name.startsWith(`${step.clip}_`)) continue;
429
+ fireEvents(instance, instance.clip.duration);
430
+ instance.time = instance.clip.duration;
431
+ }
432
+ return;
433
+ }
434
+ const found = find(step.clip, step.variant);
435
+ if (!found) return;
436
+ const clip = step.duration && step.duration > 0 ? scaleClip(found.clip, step.duration) : found.clip;
437
+ const interval = options.repeatInterval?.() ?? 0.1;
438
+ instances = instances.filter((existing) => existing.name !== found.name || existing.delay > 0);
439
+ for (let round = 0; round < (step.repeat ?? 1); round++) {
440
+ instances.push({ name: found.name, clip, time: 0, delay: round * interval, fired: 0 });
441
+ }
442
+ },
443
+ update(dt) {
444
+ for (const instance of instances) {
445
+ const carried = instance.delay > 0 ? dt - instance.delay : dt;
446
+ if (instance.delay > 0) {
447
+ instance.delay = Math.max(0, instance.delay - dt);
448
+ if (instance.delay > 0) continue;
449
+ }
450
+ instance.time += carried;
451
+ fireEvents(instance, instance.time);
452
+ render(instance);
453
+ }
454
+ instances = instances.filter((instance) => instance.delay > 0 || instance.time < instance.clip.duration);
455
+ },
456
+ get playing() {
457
+ return instances.map((instance) => instance.name);
458
+ }
459
+ };
460
+ };
461
+
277
462
  // src/bench.ts
278
463
  var SNAKE_CASE = /^[a-z][a-z0-9_]*$/;
279
464
  var finite2 = (n) => typeof n === "number" && Number.isFinite(n);