forked from perelman/contra-renderer
773 lines
30 KiB
TypeScript
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;
|
|
}
|
|
} |