import { CoupleRole, DancerIdentity, Rotation, normalizeRotation } from "./danceCommon.js"; import { DancerSetPosition, DancersSetPositions, Hand, OffsetDistance, OffsetEquals, OffsetMinus, OffsetPlus, OffsetRotate, OffsetTimes, OffsetTranspose, offsetZero, setHeight } from "./rendererConstants.js"; import { Offset, leftShoulder, rightShoulder, degreesToRadians, radiansToDegrees } from "./rendererConstants.js"; export enum AnimationKind { Linear = "Linear", Rotation = "Rotation", } export interface RotationAround { center: Offset, width: number, height: number, }; export abstract class AnimationSegment { public readonly beats: number; // Start position is redundant... but allows AnimationSegments to be defined indepent of their surroundings. public readonly startPosition: DancerSetPosition; public readonly endPosition: DancerSetPosition; protected constructor(beats: number, startPosition: DancerSetPosition, endPosition: DancerSetPosition) { this.beats = beats; this.startPosition = startPosition; this.endPosition = endPosition; } abstract interpolateOffset(progress: number): Offset; // TODO Not sure this interpolation is actually right for arms... interpolateHandOffset(progress: number, hand: Hand) : Offset | undefined { let prev = hand === Hand.Left ? this.startPosition.leftArmEnd : this.startPosition.rightArmEnd; let next = hand === Hand.Left ? this.endPosition.leftArmEnd : this.endPosition.rightArmEnd; if (!prev && !next) return undefined; prev ??= hand.shoulderPosition(); next ??= hand.shoulderPosition(); return interpolateLinearOffset(progress, prev, next); } interpolateRotation(progress: number) { return interpolateLinear(progress, this.startPosition.rotation, this.endPosition.rotation); } abstract drawDebug(ctx: CanvasRenderingContext2D, progress: number); positionAtFraction(progress: number) : DancerSetPosition { return { position: this.interpolateOffset(progress), rotation: this.interpolateRotation(progress), leftArmEnd: this.interpolateHandOffset(progress, Hand.Left), rightArmEnd: this.interpolateHandOffset(progress, Hand.Right), drawDebug: (ctx) => this.drawDebug(ctx, progress), } } } function interpolateLinear(progress: number, prev: number, next: number): number { return next * progress + prev * (1 - progress); } function interpolateLinearOffset(progress: number, prev: Offset, next: Offset): Offset { return { x: interpolateLinear(progress, prev.x, next.x), y: interpolateLinear(progress, prev.y, next.y) }; } export class LinearAnimationSegment extends AnimationSegment { constructor({ beats, startPosition, endPosition }: { beats: number; startPosition: DancerSetPosition; endPosition: DancerSetPosition; }) { super(beats, startPosition, endPosition); } public static standStill(startAndEndPosition: DancerSetPosition, beats?: number) { return new LinearAnimationSegment({ beats: beats ?? 0, startPosition: startAndEndPosition, endPosition: startAndEndPosition }); } override interpolateOffset(progress: number): Offset { return interpolateLinearOffset(progress, this.startPosition.position, this.endPosition.position); } override drawDebug(ctx: CanvasRenderingContext2D, progress: number) { ctx.beginPath(); ctx.moveTo(this.startPosition.position.x, this.startPosition.position.y); ctx.lineTo(this.endPosition.position.x, this.endPosition.position.y); ctx.stroke(); } } export interface AnimationTransitionFlags { rotation?: boolean; rotationDuring?: "Actual" | "Start" | "End"; rotationDirection?: Hand; hands?: boolean; handsDuring?: "Actual" | "None" | "Start" | "End" | Map; } export class TransitionAnimationSegment extends AnimationSegment { private readonly actualAnimation: AnimationSegment; private readonly flags: AnimationTransitionFlags; private readonly startTransitionProgress: number; private readonly endTransitionProgress: number; private readonly startRotation: number; private readonly endRotation: number; constructor({ actualAnimation, flags, startTransitionBeats, endTransitionBeats }: { actualAnimation: AnimationSegment; flags: AnimationTransitionFlags; startTransitionBeats: number; endTransitionBeats?: number; }) { super(actualAnimation.beats, actualAnimation.startPosition, actualAnimation.endPosition); this.actualAnimation = actualAnimation; this.flags = flags; this.startTransitionProgress = startTransitionBeats / actualAnimation.beats; this.endTransitionProgress = endTransitionBeats === undefined ? this.startTransitionProgress : endTransitionBeats / actualAnimation.beats; this.startRotation = this.startPosition.rotation; this.endRotation = this.endPosition.rotation; if (this.flags.rotation) { let rotationDirection = flags.rotationDirection; if (!flags.rotationDirection) { const actualStart = this.actualAnimation.interpolateRotation(0); const actualEnd = this.actualAnimation.interpolateRotation(1); if (actualEnd > actualStart) { rotationDirection = Hand.Right; } else if (actualEnd < actualStart) { rotationDirection = Hand.Left; } } const transitionStart = this.actualAnimation.interpolateRotation(this.startTransitionProgress); const transitionEnd = this.actualAnimation.interpolateRotation(1 - this.endTransitionProgress); if (rotationDirection === Hand.Right) { while (transitionStart <= this.startRotation - 180) { this.startRotation -= 360; } while (transitionEnd >= this.endRotation + 180) { this.endRotation += 360; } } else if (rotationDirection === Hand.Left) { while (transitionStart >= this.startRotation + 180) { this.startRotation += 360; } while (transitionEnd <= this.endRotation - 180) { this.endRotation -= 360; } } if (!this.flags.rotationDirection) { // Transitions should be short adjustments, not spins... // ... unless a direction is explicitly specified. while (transitionStart - this.startRotation < -180) { this.startRotation -= 360; } while (transitionStart - this.startRotation > 180) { this.startRotation += 360; } while (transitionEnd - this.endRotation < -180) { this.endRotation -= 360; } while (transitionEnd - this.endRotation > 180) { this.endRotation += 360; } } } } override interpolateOffset(progress: number): Offset { return this.actualAnimation.interpolateOffset(progress); } override interpolateRotation(progress: number): number { switch (this.flags.rotationDuring) { case undefined: case "Actual": const actualRotation = this.actualAnimation.interpolateRotation(progress); if (this.flags.rotation) { if (progress < this.startTransitionProgress) { return interpolateLinear(progress / this.startTransitionProgress, this.startRotation, actualRotation); } else if ((1 - progress) < this.endTransitionProgress) { return interpolateLinear((1 - progress) / this.endTransitionProgress, this.endRotation, actualRotation); } } return actualRotation; case "Start": if ((1 - progress) < this.endTransitionProgress) { return interpolateLinear((1 - progress) / this.endTransitionProgress, this.endRotation, this.startRotation); } else { return this.startRotation; } case "End": if (progress < this.startTransitionProgress) { return interpolateLinear(progress / this.startTransitionProgress, this.startRotation, this.endRotation); } else { return this.endRotation; } } } override interpolateHandOffset(progress: number, hand: Hand): Offset | undefined { const startHand = hand === Hand.Left ? this.startPosition.leftArmEnd ?? leftShoulder : this.startPosition.rightArmEnd ?? rightShoulder; const endHand = hand === Hand.Left ? this.endPosition.leftArmEnd ?? leftShoulder : this.endPosition.rightArmEnd ?? rightShoulder; const actualHandOffset = this.flags.handsDuring === undefined || this.flags.handsDuring === "Actual" ? this.actualAnimation.interpolateHandOffset(progress, hand) : this.flags.handsDuring === "None" ? hand.shoulderPosition() : this.flags.handsDuring === "Start" ? startHand : this.flags.handsDuring === "End" ? endHand : this.flags.handsDuring instanceof Map ? this.flags.handsDuring.get(hand) : (() => { throw "Unexpected handsDuring: " + this.flags.handsDuring; })(); if (this.flags.hands) { if (progress < this.startTransitionProgress) { const startHand = hand === Hand.Left ? this.startPosition.leftArmEnd ?? leftShoulder : this.startPosition.rightArmEnd ?? rightShoulder; return interpolateLinearOffset(progress / this.startTransitionProgress, startHand, actualHandOffset ?? hand.shoulderPosition()); } else if (1 - progress < this.endTransitionProgress) { const endHand = hand === Hand.Left ? this.endPosition.leftArmEnd ?? leftShoulder : this.endPosition.rightArmEnd ?? rightShoulder; return interpolateLinearOffset((1 - progress) / this.endTransitionProgress, endHand, actualHandOffset ?? hand.shoulderPosition()); } } return actualHandOffset; } override drawDebug(ctx: CanvasRenderingContext2D, progress: number) { // TODO better way to display transition? if (progress < this.startTransitionProgress) { ctx.beginPath(); ctx.moveTo(this.startPosition.position.x, this.startPosition.position.y); const transitionStart = this.actualAnimation.interpolateOffset(this.startTransitionProgress); ctx.lineTo(transitionStart.x, transitionStart.y); ctx.stroke(); } else if (progress > 1 - this.endTransitionProgress) { ctx.beginPath(); const transitionEnd = this.actualAnimation.interpolateOffset(this.endTransitionProgress); ctx.moveTo(transitionEnd.x, transitionEnd.y); ctx.lineTo(this.endPosition.position.x, this.endPosition.position.y); ctx.stroke(); } this.actualAnimation.drawDebug(ctx, progress); } } // TODO Not sure this really belongs here instead of on some Semantic* type. export enum RotationAnimationFacing { Linear = "Linear", // Default, linearly interpolate. Center = "Center", // Always face the center. CenterRelative = "CenterRelative", CenterRelativeOffset = "CenterRelativeOffset", Forward = "Forward", // Always face the direction of the rotation. Backward = "Backward", // Opposite of forward. Start = "Start", // Stay facing the same direction as at the beginning. } export interface CloserDuringRotation { minDistance: number, transitionBeats: number, } interface Closer { middleDistance: number, transitionProgress: number, } export interface HandAnimation { kind: "Linear" | "Center" | "CenterUntilPassed" | "None" | "Start" | "End" } export class RotationAnimationSegment extends AnimationSegment { private readonly xRadius: number; private readonly yRadius: number; private readonly startDistance: number; private readonly endDistance: number; private readonly startRotation: number; private readonly endRotation: number; private readonly center: Offset; private readonly facing: RotationAnimationFacing; private readonly startFacing: Rotation; private readonly closer?: Closer; private readonly hands: Map; private readonly centerRelativeTo: number; constructor({ beats, startPosition, endPosition, rotation, around, facing, closer, hands, centerRelativeTo }: { beats: number; startPosition: DancerSetPosition; endPosition: DancerSetPosition; rotation: number; around: RotationAround; facing: RotationAnimationFacing; closer?: CloserDuringRotation; hands?: Map; centerRelativeTo?: number; }) { super(beats, startPosition, endPosition); this.hands = new Map( [Hand.Left, Hand.Right].map(h => [h, hands?.get(h) ?? { kind: "Linear" }])); this.facing = facing; this.xRadius = around.width / 2; this.yRadius = around.height / 2; this.center = around.center; let positionToDegrees = (pos: Offset): number => { const radians = Math.atan2( (pos.y - around.center.y) / this.yRadius, (pos.x - around.center.x) / this.xRadius ); // Canvas rotation is backwards of what we expect, so take the negative here. return -radiansToDegrees(radians); } this.startRotation = positionToDegrees(startPosition.position); this.startFacing = startPosition.rotation; this.centerRelativeTo = centerRelativeTo ?? this.startFacing; const actualRotation = normalizeRotation(positionToDegrees(endPosition.position) - this.startRotation, rotation); this.endRotation = this.startRotation + actualRotation; let positionToNormalizedDistance = (pos: Offset): number => { return Math.sqrt(Math.pow((pos.y - around.center.y) / this.yRadius, 2) + Math.pow((pos.x - around.center.x) / this.xRadius, 2)); } this.startDistance = positionToNormalizedDistance(startPosition.position); this.endDistance = positionToNormalizedDistance(endPosition.position); if (closer && (this.startDistance > closer.minDistance || this.endDistance > closer.minDistance)) { this.closer = { middleDistance: closer.minDistance, transitionProgress: closer.transitionBeats / beats, }; } } override interpolateOffset(progress: number): Offset { const radians = -degreesToRadians(interpolateLinear(progress, this.startRotation, this.endRotation)); let distance: number; if (!this.closer) { distance = interpolateLinear(progress, this.startDistance, this.endDistance); } else { if (progress <= this.closer.transitionProgress) { distance = interpolateLinear(progress / this.closer.transitionProgress, this.startDistance, this.closer.middleDistance); } else if ((1 - progress) <= this.closer.transitionProgress) { distance = interpolateLinear((1 - progress) / this.closer.transitionProgress, this.endDistance, this.closer.middleDistance); } else { distance = this.closer.middleDistance; } } return { x: this.center.x + this.xRadius * Math.cos(radians) * distance, y: this.center.y + this.yRadius * Math.sin(radians) * distance, } } override interpolateRotation(progress: number): number { if (this.facing === RotationAnimationFacing.Linear) { return super.interpolateRotation(progress); } else { const degrees = interpolateLinear(progress, this.startRotation, this.endRotation); switch (this.facing) { case RotationAnimationFacing.Start: return this.startFacing; case RotationAnimationFacing.Center: return degrees - 90; case RotationAnimationFacing.CenterRelativeOffset: return degrees - 90 + this.centerRelativeTo; case RotationAnimationFacing.CenterRelative: return degrees - this.startRotation + this.centerRelativeTo; case RotationAnimationFacing.Forward: case RotationAnimationFacing.Backward: return (this.endRotation > this.startRotation === (this.facing === RotationAnimationFacing.Forward)) ? degrees + 180 : degrees; } } } override interpolateHandOffset(progress: number, hand: Hand): Offset | undefined { const handAnim : HandAnimation = this.hands.get(hand)!; switch (handAnim.kind) { case "Start": return hand === Hand.Left ? this.startPosition.leftArmEnd : this.startPosition.rightArmEnd; case "End": return hand === Hand.Left ? this.endPosition.leftArmEnd : this.endPosition.rightArmEnd; case "Linear": return super.interpolateHandOffset(progress, hand); case "Center": const position = this.interpolateOffset(progress); const offset = { x: position.x - this.center.x, y: position.y - this.center.y, }; const rotation = degreesToRadians(this.interpolateRotation(progress)); const centerPos = { x: -Math.cos(rotation) * offset.x + Math.sin(rotation) * offset.y, y: -Math.sin(rotation) * offset.x - Math.cos(rotation) * offset.y, } return centerPos; case "None": return hand === Hand.Left ? leftShoulder : rightShoulder; } } override drawDebug(ctx: CanvasRenderingContext2D, progress: number) { ctx.beginPath(); const distance = this.closer?.middleDistance ?? this.endDistance; let start: number, end: number; if (Math.abs(this.endRotation - this.startRotation) >= 360) { start = 0; end = 2 * Math.PI; } else { start = degreesToRadians(this.startRotation); end = degreesToRadians(this.endRotation); } ctx.ellipse(this.center.x, this.center.y, this.xRadius * distance, this.yRadius * distance, 0, start, end, this.endRotation < this.startRotation); ctx.stroke(); const startPos = this.interpolateOffset(0); const endPos = this.interpolateOffset(1); ctx.beginPath(); ctx.ellipse(startPos.x, startPos.y, 0.1, 0.1, 0, 0, 2*Math.PI); ctx.stroke(); const endSize = 0.05; ctx.fillRect(endPos.x - endSize, endPos.y - endSize, endSize*2, endSize*2); } } // For "PassBy" and related moves where stepping straight forward would walk into the // other dancer, so instead move in thin oval export class StepWideLinearAnimationSegment extends AnimationSegment { private readonly distanceAtMidpoint: number; private readonly sidewaysVector: Offset; private readonly movementAngle: number; private readonly handTransitionProgress: number; private readonly progressCenter?: number; private readonly actualCenter: Offset; private readonly hands: Map; private readonly facing: "Start" | "Forward"; private readonly midPoint: Offset; constructor({ beats, startPosition, endPosition, distanceAtMidpoint, otherPath, hands, facing }: { beats: number; startPosition: DancerSetPosition; endPosition: DancerSetPosition; distanceAtMidpoint: number; otherPath?: { start: Offset, end: Offset }; hands?: Map; facing?: "Start" | "Forward"; }) { super(beats, startPosition, endPosition); this.hands = new Map( [Hand.Left, Hand.Right].map(h => [h, hands?.get(h) ?? { kind: "Linear" }])); this.facing = facing ?? "Start"; this.distanceAtMidpoint = distanceAtMidpoint; const vector = OffsetMinus(this.endPosition.position, this.startPosition.position); const norm = vector.x * vector.x + vector.y * vector.y; const distance = Math.sqrt(norm); const sideways = OffsetRotate(vector, Rotation.Left); const normalizedVector = OffsetTimes(sideways, 1/distance); this.sidewaysVector = normalizedVector; this.movementAngle = radiansToDegrees(-Math.atan2(vector.y, vector.x)) + 90; // Center is the point where the dancer is closest to the other dancer. // If omitted, it's assumed the movement is symmetrical, so that happens halfway through the move. // Otherwise, find the point on the line between the start and end closest to the center. this.actualCenter = OffsetPlus(this.startPosition.position, OffsetTimes(vector, 0.5)); if (otherPath) { /* (xa(t), ya(t)) (xb(t), yb(t)) (xa_0 + xa_m*t, ya_0, ya_m*t) (xb_0 + xb_m*t, yb_0, yb_m*t) // can omit the sqrt MIN[((xa_0 + xa_m*t) - (xb_0 + xb_m*t))**2 + ((ya_0, ya_m*t) - (yb_0, yb_m*t))**2] MIN[(xa_0-xb_0 + (xa_m-xb_m)*t)**2 + (ya_0-yb_0 + (ya_m-yb_m)*t)**2] MIN[(x_0 + x_m*t)**2 + (y_0 + y_m*t)**2] MIN[(x_0**2 + 2x_0*x_m*t + x_m**2*t**2) + (y_0**2 + 2y_0*y_m*t + y_m**2*t**2)] MIN[(x_0**2+y_0**2 + (2x_0*x_m+2y_0*y_m)*t + (x_m**2+y_m**2)*t**2)] a = x_m**2+y_m**2 = (xa_m-xb_m)**2 + (ya_m-yb_m)**2 b = (2x_0*x_m+2y_0*y_m) = 2*((xa_0-xb_0)*(xa_m-xb_m) + (ya_0-yb_0)*(ya_m-yb_m)) t = -b/2a */ const otherVector = OffsetMinus(otherPath.end, otherPath.start); const m = OffsetMinus(otherVector, vector); const i = OffsetMinus(otherPath.start, this.startPosition.position); const a = m.x*m.x + m.y*m.y; const b = 2*(i.x*m.x + i.y*m.y); const tMin = -b/(2*a); const t = tMin >= 0 && tMin <= 1 ? tMin : undefined; if (t !== undefined) { this.progressCenter = t; this.actualCenter = interpolateLinearOffset(t, this.startPosition.position, this.endPosition.position); const otherCenter = interpolateLinearOffset(t, otherPath.start, otherPath.end); const otherSideways = OffsetMinus(this.actualCenter, otherCenter); const sidewaysLinedUp = Math.sign(otherSideways.x) === Math.sign(sideways.x) && Math.sign(otherSideways.y) === Math.sign(sideways.y); const distanceAdjustment = OffsetDistance(this.actualCenter, otherCenter) * (sidewaysLinedUp ? +1 : -1); if (this.distanceAtMidpoint > 0) { this.distanceAtMidpoint = Math.max(0, this.distanceAtMidpoint - distanceAdjustment); } else { this.distanceAtMidpoint = Math.min(0, this.distanceAtMidpoint + distanceAdjustment); } } } this.handTransitionProgress = 0.5 / beats; this.midPoint = this.interpolateOffset(this.progressCenter ?? 0.5); } interpolateOffset(progress: number): Offset { let sidewaysProgress = progress; if (this.progressCenter) { if (progress === this.progressCenter) { sidewaysProgress = 0.5; } else if (progress < this.progressCenter) { sidewaysProgress = progress / this.progressCenter / 2; } else /* progress => this.progressCenter */ { sidewaysProgress = ((progress - this.progressCenter) / (1 - this.progressCenter)) / 2 + 0.5; } } return OffsetPlus(interpolateLinearOffset(progress, this.startPosition.position, this.endPosition.position), OffsetTimes(this.sidewaysVector, this.distanceAtMidpoint * Math.sin(sidewaysProgress * Math.PI))); } interpolateRotation(progress: number): number { if (this.facing === "Forward") { return this.movementAngle; } else /* "Start" */ { return this.startPosition.rotation; } } interpolateHandOffset(progress: number, hand: Hand): Offset | undefined { const progressCenter = this.progressCenter ?? 0.5; const handAnim : HandAnimation = this.hands.get(hand)!; switch (handAnim.kind) { case "Start": return hand === Hand.Left ? this.startPosition.leftArmEnd : this.startPosition.rightArmEnd; case "End": return hand === Hand.Left ? this.endPosition.leftArmEnd : this.endPosition.rightArmEnd; case "Linear": return super.interpolateHandOffset(progress, hand); case "CenterUntilPassed": if (progress > progressCenter) { return hand.shoulderPosition(); } case "Center": const position = this.interpolateOffset(progress); const offset = { x: position.x - this.actualCenter.x, y: position.y - this.actualCenter.y, }; const rotation = degreesToRadians(this.interpolateRotation(progress)); const centerPos = { x: -Math.cos(rotation) * offset.x + Math.sin(rotation) * offset.y, y: -Math.sin(rotation) * offset.x - Math.cos(rotation) * offset.y, } if (handAnim.kind === "CenterUntilPassed" && progress + this.handTransitionProgress > progressCenter) { return interpolateLinearOffset((progressCenter - progress) / this.handTransitionProgress, hand.shoulderPosition(), centerPos); } return centerPos; case "None": return hand.shoulderPosition(); } } override drawDebug(ctx: CanvasRenderingContext2D, progress: number) { ctx.beginPath(); ctx.moveTo(this.startPosition.position.x, this.startPosition.position.y); ctx.lineTo(this.midPoint.x, this.midPoint.y); ctx.lineTo(this.endPosition.position.x, this.endPosition.position.y); ctx.stroke(); ctx.beginPath(); ctx.ellipse(this.actualCenter.x, this.actualCenter.y, 0.075, 0.075, 0, 0, 2*Math.PI); ctx.stroke(); } } export class SlideAnimationSegment extends AnimationSegment { private readonly withoutSlide: AnimationSegment[]; private readonly slideAmount: Offset; constructor(beats:number, withoutSlide:AnimationSegment[], slideAmount:Offset) { super( beats, withoutSlide[0].startPosition, { ...withoutSlide.at(-1)!.endPosition, position: OffsetPlus(withoutSlide.at(-1)!.endPosition.position, slideAmount) }); this.withoutSlide = withoutSlide; this.slideAmount = slideAmount; } private selectSegment(progress: number): { segment: AnimationSegment, segmentProgress: number } { let beat = progress * this.beats; for (const segment of this.withoutSlide) { if (beat <= segment.beats) { return { segment, segmentProgress: beat / segment.beats }; } else { beat -= segment.beats; } } return { segment: this.withoutSlide.at(-1)!, segmentProgress: 1 }; } interpolateOffset(progress: number): Offset { const { segment, segmentProgress } = this.selectSegment(progress); return OffsetPlus(segment.interpolateOffset(segmentProgress), OffsetTimes(this.slideAmount, progress)); } interpolateHandOffset(progress: number, hand: Hand): Offset | undefined { const { segment, segmentProgress } = this.selectSegment(progress); return segment.interpolateHandOffset(segmentProgress, hand); } interpolateRotation(progress: number): number { const { segment, segmentProgress } = this.selectSegment(progress); return segment.interpolateRotation(segmentProgress); } override drawDebug(ctx: CanvasRenderingContext2D, progress: number) { // TODO Right way to handle the slide part? const slide = OffsetTimes(this.slideAmount, progress); ctx.translate(slide.x, slide.y); const { segment, segmentProgress } = this.selectSegment(progress); segment.drawDebug(ctx, segmentProgress); } } export class Animation { private readonly segments : Map; public readonly progression : Offset; public readonly numBeats : number; public readonly progressionError : string | undefined; constructor(segments: Map) { this.segments = segments; this.numBeats = Math.max(...[...this.segments.values()].map(el => el.reduce((a, s) => a + s.beats, 0))); const progressions = new Map( [...this.segments.entries()] .map(([id, a]) => [ id, OffsetMinus( a.at(-1)!.endPosition.position, a.at(0)!.startPosition.position)])); this.progression = progressions.get(DancerIdentity.OnesLark)!; if (this.progression.x !== 0) { this.progressionError = "Progressing to different line is unsupported."; } if (this.progression.y === 0) { this.progressionError = "Dance does not progress."; } if (!OffsetEquals(progressions.get(DancerIdentity.OnesLark)!, progressions.get(DancerIdentity.OnesRobin)!)) { this.progressionError = "Ones are not progressing the same as each other."; } if (!OffsetEquals(progressions.get(DancerIdentity.TwosLark)!, progressions.get(DancerIdentity.TwosRobin)!)) { this.progressionError = "Twos are not progressing the same as each other."; } if (!OffsetEquals(progressions.get(DancerIdentity.OnesLark)!, OffsetMinus(offsetZero, progressions.get(DancerIdentity.TwosLark)!))) { this.progressionError = "Ones and Twos are not progressing opposite each other."; } if (this.progression.y % setHeight / 2 !== 0) { this.progressionError = "Progression is not an integer number of places."; } } positionsAtBeat(beat: number, progression?: number): DancersSetPositions { function positionAtBeat(animation: AnimationSegment[], beat: number): DancerSetPosition { let lastFrame: AnimationSegment = animation[0]; let lastFrameEndBeat = 0; for (const frame of animation) { let currentFrameEndBeat = lastFrameEndBeat + frame.beats; if (currentFrameEndBeat < beat) { lastFrame = frame; lastFrameEndBeat = currentFrameEndBeat; continue; } else if (currentFrameEndBeat === beat) { return frame.endPosition; } else { const progress = (beat - lastFrameEndBeat) / (currentFrameEndBeat - lastFrameEndBeat); return frame.positionAtFraction(progress); } } // fallthrough return lastFrame.endPosition; } progression ??= 0; while (beat < 0) { beat += this.numBeats; progression -= 1; } while (beat > this.numBeats) { beat -= this.numBeats; progression += 1; } const res = new Map(); for (const [id, animationSegments] of this.segments.entries()) { const basePosition = positionAtBeat(animationSegments, beat); if (progression !== 0) { const progressionOffset = OffsetTimes( this.progression, progression * (id.coupleRole === CoupleRole.Ones ? 1 : -1)); res.set(id, { ...basePosition, position: OffsetPlus( basePosition.position, progressionOffset), drawDebug: basePosition.drawDebug ? (ctx) => { ctx.translate(progressionOffset.x, progressionOffset.y); basePosition.drawDebug!(ctx); } : undefined }); } else { res.set(id, basePosition); } } return res; } }