Create a new contra dance instruction (schema, animator, UI). Use when the user asks to add a new instruction, figure, or move to the dance system.
This skill creates a new atomic instruction for the contra dance animation system.
Every instruction requires changes to 5 locations. Follow each step in order.
src/instructions/<name>.tsimport { z } from "zod";
import { BeatsSchema, RoleSchema, /* ... */ } from "../contraCore";
import { instructionBaseSchemaFields } from "./_base";
export const FooInstructionSchema = z.object({
...instructionBaseSchemaFields, // { beats: integer }
type: z.literal("foo"),
// instruction-specific fields here
});
export type FooInstruction = z.infer<typeof FooInstructionSchema>;
Some instructions are always instantaneous. To overwrite beats:
export const FooInstructionSchema = z.object({
...instructionBaseSchemaFields,
beats: z.literal(0),
type: z.literal("foo"),
// ...
});
The segment animator is a curried function: (instr) => (init, who) => Segment[].
import { type SegmentAnimator } from "./_segment";
export const fooSegments =
(instr: FooInstruction): SegmentAnimator =>
(init, who) => {
// Optional: pre-compute partner lookups, assert formation validity
return [
{
dur: instr.beats,
position: ..., // omit = stay put
facing: ..., // omit = keep current facing
hands: ..., // omit = leave hands unchanged
labels: ..., // omit = leave labels unchanged
},
];
};
| Need | Import from |
|---|---|
ellipsePosition, PI, TWO_PI, revolve, getDir | ../geometry |
getRole, isLark, otherHand, otherRole, ProtoId, Hand, RoleSchema | ../contraCore |
Dancer, DancerHandPointer | ../worldState |
resolveCalledIdentifier, findDancerInCalledDirection, CalledIdentifierSchema | ./_base |
arc, orbit, linearTo, lerpFacingTo, rotateFacingBy, hold, holdByRole, holdUntil, disconnect | ./_segment |
must | ../utils |
arc(cid, { semiMinor, phi }) — Elliptical path from dancer to partner. phi: PI = swap positions. semiMinor sign controls curve direction (typically ±0.25).orbit(cid, { radians }) — Circular orbit around midpoint with partner.linearTo(targetFn) — Straight-line interpolation to target position.(id, frac, segInit) => Vector — full control over position.lerpFacingTo(targetFn) — Shortest-arc interpolation to target facing.rotateFacingBy(radiansFn) — Rotate by fixed radians. radiansFn receives id, can vary by role.hold(hand, cid, theirHand) — Hold one hand pair throughout.holdByRole({ lark: [h, cid, th], robin: [h, cid, th] }) — Role-dependent hands.holdUntil(threshold, hand, cid, theirHand) — Hold until frac, then disconnect.disconnect() — Remove all hand connections.(frac) => Partial<Record<Hand, DancerHandPointer | undefined>> — return hand pointers directly (e.g. { left: { theirId: matchId, theirHand: "left" } }).When you need role-filtered or direction-based partner lookups (which can't use the arc/hold primitives that take a cid), resolve them in the plan function from the Dancer:
export function planFoo(dancer: Dancer): DancerSegment[] {
const partner = dancer.findDancerInDirection(
dancer.resolvePureDirection("on_right"),
{ roles: "different" },
);
if (!partner) throw new SnazzyError([{ dancerId: dancer.id }, " has no partner for foo"]);
return [{
dur: instr.beats,
position: (frac) => lerpVectors(dancer.pos, partner.pos, frac),
hands: () => ({ right: { theirId: partner.id, theirHand: "left" } }),
}];
}
src/instructions/_atomic.tsAdd three things:
// 1. Import
import { FooInstructionSchema, fooSegments } from "./foo";
// 2. Add to AtomicInstructionSchema discriminated union (alphabetical order)
export const AtomicInstructionSchema = z.discriminatedUnion("type", [
// ...existing...
FooInstructionSchema,
// ...existing...
]);
// 3. Add case to makeAtomicInstructionSegments switch (alphabetical order)
export function makeAtomicInstructionSegments(...) {
switch (instr.type) {
// ...existing...
case "foo":
return fooSegments(instr, init, who);
// ...existing...
}
}
src/components/fields/FooFields.tsximport type { AtomicInstruction } from "../../instructions/_atomic";
import { InstructionSchema } from "../../instructions/index";
import type { SubFormProps } from "../fieldUtils";
export function FooFields({
instruction,
onChange,
onInvalid,
}: SubFormProps & {
instruction: Extract<AtomicInstruction, { type: "foo" }>;
}) {
const { id } = instruction;
function tryCommit(overrides: Record<string, unknown>) {
const raw = {
id,
type: "foo",
beats: instruction.beats,
// spread all instruction-specific fields as defaults
...overrides,
};
const result = InstructionSchema.safeParse(raw);
if (result.success) onChange(result.data);
else onInvalid?.();
}
return (
<>
{/* Use <InlineDropdown> and <InlineNumber> for editable fields */}
{/* Use <CalledIdentifierDropdown> for cid fields */}
{/* Use <CardinalDirectionDropdown> for endFacing fields */}
</>
);
}
Available UI components: InlineDropdown (from ../InlineDropdown), InlineNumber (from ../InlineNumber), CalledIdentifierDropdown (from ../CalledIdentifierDropdown), CardinalDirectionDropdown (from ../CardinalDirectionDropdown).
Common field option constants from ../fieldUtils: ROLE_OPTIONS, HAND_OPTIONS, TAKE_HAND_OPTIONS.
src/components/CommandPane.tsxFour changes:
import { FooFields } from "./fields/FooFields";
ACTION_OPTIONS array and ACTION_LABELS objectconst ACTION_OPTIONS: ActionOptionType[] = [
// ...existing...
"foo",
// ...
];
const ACTION_LABELS: Record<string, string> = {
// ...existing...
foo: "foo",
};
doesRequireBeatsInput switchReturn true if the user should be able to edit beats in the UI, false for zero-beat instructions like relabel or drop_hands.
case "foo":
return <FooFields {...common} instruction={instruction} />;
src/components/fieldUtils.tsAdd a case to makeDefaultInstruction:
case "foo":
return {
id,
type: "foo",
// provide sensible defaults for all fields
};
After all changes:
npx tsc --noEmit — must pass with no errorsnpm run test — must pass with no regressionsnpm run format && npm run lint -- --fix && npm run typecheck