contra-renderer/www/js/animation.ts

773 lines
30 KiB
TypeScript

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<Hand, Offset>;
}
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<Hand, HandAnimation>;
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<Hand, HandAnimation>;
centerRelativeTo?: number;
}) {
super(beats, startPosition, endPosition);
this.hands = new Map<Hand, HandAnimation>(
[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<Hand, HandAnimation>;
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<Hand, HandAnimation>;
facing?: "Start" | "Forward";
}) {
super(beats, startPosition, endPosition);
this.hands = new Map<Hand, HandAnimation>(
[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<DancerIdentity, AnimationSegment[]>;
public readonly progression : Offset;
public readonly numBeats : number;
public readonly progressionError : string | undefined;
constructor(segments: Map<DancerIdentity, AnimationSegment[]>) {
this.segments = segments;
this.numBeats = Math.max(...[...this.segments.values()].map(el => el.reduce((a, s) => a + s.beats, 0)));
const progressions = new Map<DancerIdentity, Offset>(
[...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<DancerIdentity, DancerSetPosition>();
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;
}
}