forked from perelman/contra-renderer
261 lines
9.8 KiB
TypeScript
261 lines
9.8 KiB
TypeScript
import { Animation } from "./animation.js";
|
|
import { CoupleRole, DanceRole, DancerIdentity, ExtendedDancerIdentity } from "./danceCommon.js";
|
|
import * as exampleAnimations from "./exampleAnimations.js";
|
|
import { DancerSetPosition, DancersSetPositions, dancerHeight, dancerHeightOffset, leftShoulder, lineDistance, rightShoulder, setDistance, degreesToRadians } from "./rendererConstants.js";
|
|
|
|
|
|
|
|
function hueForDancer(identity: DancerIdentity): number {
|
|
if (identity.coupleRole == CoupleRole.Ones) {
|
|
if (identity.danceRole == DanceRole.Lark) {
|
|
return 0; //red
|
|
} else {
|
|
return 39; //orange
|
|
}
|
|
} else {
|
|
if (identity.danceRole == DanceRole.Lark) {
|
|
return 240; //blue
|
|
} else {
|
|
return 180; //teal
|
|
}
|
|
}
|
|
}
|
|
|
|
const colorForDancerCache = new Map<DancerIdentity, string[]>(
|
|
DancerIdentity.all().map(id => [id,
|
|
[...Array(49).keys()]
|
|
.map(reverseColorCacheKey)
|
|
.map(({relativeSet, relativeLine}) => colorForDancer(id, relativeSet, relativeLine))]));
|
|
|
|
const colorForDancerLabelCache = new Map<DancerIdentity, string[]>(
|
|
DancerIdentity.all().map(id => [id,
|
|
[...Array(49).keys()]
|
|
.map(i => reverseColorCacheKey(i))
|
|
.map(({relativeSet, relativeLine}) => colorForDancerLabel(id, relativeSet, relativeLine))]));
|
|
|
|
function colorCacheKey(relativeSet: number, relativeLine: number) {
|
|
relativeSet = Math.max(-3, Math.min(3, relativeSet)) + 3;
|
|
relativeLine = Math.max(-3, Math.min(3, relativeLine)) + 3;
|
|
|
|
return relativeLine * 7 + relativeSet;
|
|
}
|
|
function reverseColorCacheKey(key: number): { relativeSet: number, relativeLine: number } {
|
|
return {
|
|
relativeSet: (key % 7) - 3,
|
|
relativeLine: Math.floor(key / 7) - 3,
|
|
}
|
|
}
|
|
|
|
function colorForDancer(setIdentity: DancerIdentity, relativeSet: number, relativeLine: number) : string {
|
|
const hue = hueForDancer(setIdentity);
|
|
const sat = 100 - Math.abs(relativeLine * 40);
|
|
const unclampedLum = 50 + relativeSet * 20;
|
|
const lum = unclampedLum < 10 ? 10 : unclampedLum > 90 ? 90 : unclampedLum;
|
|
return `hsl(${hue}, ${sat}%, ${lum}%)`;
|
|
}
|
|
|
|
function colorForDancerCached(setIdentity: DancerIdentity, relativeSet: number, relativeLine: number) : string {
|
|
return colorForDancerCache.get(setIdentity)![colorCacheKey(relativeSet, relativeLine)];
|
|
}
|
|
|
|
function colorForDancerById(identity: ExtendedDancerIdentity) : string {
|
|
return colorForDancer(identity.setIdentity, identity.relativeSet, identity.relativeLine);
|
|
}
|
|
|
|
function colorForDancerLabel(setIdentity: DancerIdentity, relativeSet: number, relativeLine: number) : string {
|
|
const dancerHue = hueForDancer(setIdentity);
|
|
const hue = (dancerHue + 180) % 360;
|
|
const sat = 100;
|
|
const lum = dancerHue === 240 && relativeSet < 2 || relativeSet < 0
|
|
? 100
|
|
: 20 - relativeSet * 40;
|
|
return `hsl(${hue}, ${sat}%, ${lum}%)`;
|
|
}
|
|
|
|
function colorForDancerLabelCached(setIdentity: DancerIdentity, relativeSet: number, relativeLine: number) : string {
|
|
return colorForDancerLabelCache.get(setIdentity)![colorCacheKey(relativeSet, relativeLine)];
|
|
}
|
|
|
|
function colorForDancerLabelById(identity: ExtendedDancerIdentity) : string {
|
|
return colorForDancerLabel(identity.setIdentity, identity.relativeSet, identity.relativeLine);
|
|
}
|
|
|
|
const positiveNumberStrings: string[] = [...Array(10).keys()].map(i => i.toString());
|
|
const negativeNumberStrings: string[] = [...Array(10).keys()].map(i => (-i).toString());
|
|
function cachedToString(smallNumber: number) {
|
|
if (Math.abs(smallNumber) >= 10) return smallNumber.toString();
|
|
if (smallNumber < 0) return negativeNumberStrings[-smallNumber];
|
|
else return positiveNumberStrings[smallNumber];
|
|
}
|
|
|
|
export class Renderer {
|
|
canvas: HTMLCanvasElement;
|
|
ctx: CanvasRenderingContext2D;
|
|
animation?: Animation;
|
|
extraSets?: number;
|
|
extraLines?: number;
|
|
drawDebug: boolean = false;
|
|
|
|
constructor(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
|
|
this.canvas = canvas;
|
|
this.ctx = ctx;
|
|
}
|
|
|
|
drawDancerBody(setIdentity: DancerIdentity, relativeSet: number, relativeLine: number, drawText: boolean) {
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(leftShoulder.x, leftShoulder.y);
|
|
this.ctx.lineTo(rightShoulder.x, rightShoulder.y);
|
|
this.ctx.lineTo(0, dancerHeight-dancerHeightOffset);
|
|
this.ctx.fill();
|
|
|
|
// Draw dot at origin to identify "center" point of dancer.
|
|
const backupFillStyle = this.ctx.fillStyle;
|
|
this.ctx.fillStyle = 'black';
|
|
const pointSize = 0.05;
|
|
this.ctx.fillRect(-pointSize/2, -pointSize/2, pointSize, pointSize);
|
|
|
|
if (drawText) {
|
|
this.ctx.save();
|
|
|
|
this.ctx.scale(-1, 1);
|
|
this.ctx.rotate(Math.PI);
|
|
this.ctx.fillStyle = colorForDancerLabelCached(setIdentity, relativeSet, relativeLine);
|
|
|
|
this.ctx.font = '0.15px sans'
|
|
this.ctx.fillText(setIdentity.danceRole === DanceRole.Lark ? 'L' : 'R', -0.14, +0.04);
|
|
this.ctx.fillText(setIdentity.coupleRole === CoupleRole.Ones ? '1' : '2', +0.04, +0.04);
|
|
|
|
this.ctx.font = '0.1px sans'
|
|
this.ctx.fillText(cachedToString(relativeLine), +0.14, +0.04);
|
|
this.ctx.fillText(cachedToString(relativeSet), -0.22, +0.04);
|
|
|
|
this.ctx.restore();
|
|
}
|
|
|
|
this.ctx.fillStyle = backupFillStyle;
|
|
}
|
|
|
|
drawDancer(position: DancerSetPosition, identity: ExtendedDancerIdentity, offsetSets: number, drawText: boolean, drawDebug: boolean) {
|
|
this.ctx.save();
|
|
|
|
this.ctx.translate(identity.relativeLine * lineDistance,
|
|
identity.relativeSet * setDistance);
|
|
const relativeSet = identity.relativeSet + (offsetSets * (identity.setIdentity.coupleRole === CoupleRole.Ones ? 1 : -1));
|
|
this.ctx.fillStyle = this.ctx.strokeStyle = colorForDancerCached(identity.setIdentity, relativeSet, identity.relativeLine);
|
|
|
|
if (drawDebug) {
|
|
if (this.drawDebug && position.drawDebug) {
|
|
this.ctx.save();
|
|
this.ctx.lineWidth = 0.05;
|
|
position.drawDebug(this.ctx);
|
|
this.ctx.restore();
|
|
}
|
|
} else {
|
|
this.ctx.translate(position.position.x, position.position.y);
|
|
this.ctx.rotate(-degreesToRadians(position.rotation));
|
|
|
|
this.drawDancerBody(identity.setIdentity, relativeSet, identity.relativeLine, drawText);
|
|
|
|
// Draw arms.
|
|
this.ctx.lineWidth = 0.03;
|
|
if (position.leftArmEnd) {
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(leftShoulder.x, leftShoulder.y);
|
|
this.ctx.lineTo(position.leftArmEnd.x, position.leftArmEnd.y);
|
|
this.ctx.stroke();
|
|
}
|
|
if (position.rightArmEnd) {
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(rightShoulder.x, rightShoulder.y);
|
|
this.ctx.lineTo(position.rightArmEnd.x, position.rightArmEnd.y);
|
|
this.ctx.stroke();
|
|
}
|
|
}
|
|
|
|
this.ctx.restore();
|
|
}
|
|
|
|
drawSet(positions: DancersSetPositions, relativeSet: number, relativeLine: number, offsetSets: number, drawText: boolean, drawDebug: boolean) {
|
|
for (const entry of positions.entries()) {
|
|
this.drawDancer(entry[1], { setIdentity: entry[0], relativeLine, relativeSet }, offsetSets, drawText, drawDebug);
|
|
}
|
|
}
|
|
|
|
drawSets(positions: DancersSetPositions, offsetSets?: number, drawText?: boolean, drawDebug?: boolean) {
|
|
const extraSets = this.extraSets ?? 0;
|
|
const extraLines = this.extraLines ?? 0;
|
|
for (var relativeLine = -extraLines; relativeLine <= extraLines; relativeLine++) {
|
|
for (var relativeSet = -extraSets; relativeSet <= extraSets; relativeSet++) {
|
|
this.drawSet(positions, relativeSet, relativeLine, offsetSets ?? 0, drawText ?? true, !!drawDebug);
|
|
}
|
|
}
|
|
}
|
|
|
|
drawSetsAtBeat(beat: number) {
|
|
if (!this.animation) throw new Error("Attempted to render before setting animation.");
|
|
|
|
this.drawSets(this.animation.positionsAtBeat(beat));
|
|
}
|
|
|
|
drawSetsWithTrails(beat: number, progression?: number) {
|
|
if (!this.animation) throw new Error("Attempted to render before setting animation.");
|
|
|
|
this.clear();
|
|
const increments = 7;
|
|
const trailLengthInBeats = 1;
|
|
const incrementLength = trailLengthInBeats / increments;
|
|
progression ??= 0;
|
|
const offsetSets = this.animation.progression.y === 0
|
|
? 0
|
|
: -((progression - (progression % 2)) / 2) / ((this.animation.progression.y * 2) / setDistance);
|
|
for (var i = increments; i >= 0; i--) {
|
|
const beatToDraw = beat - i*incrementLength;
|
|
this.ctx.globalAlpha = i == 0 ? 1 : (1 - i/increments)*0.3;
|
|
const positions = this.animation.positionsAtBeat(beatToDraw, progression % 2);
|
|
if (this.drawDebug) this.drawSets(positions, offsetSets, true, true);
|
|
this.drawSets(positions, offsetSets, i === 0, false);
|
|
}
|
|
}
|
|
|
|
playAnimation(bpm: number, start: number, end: number) {
|
|
const startTime = Date.now();
|
|
const msPerBeat = (60*1000)/bpm;
|
|
const drawSetsWithTrails = (b: number) => this.drawSetsWithTrails(b);
|
|
|
|
function anim() {
|
|
const now = Date.now();
|
|
const msElapsed = now - startTime;
|
|
const beat = start + msElapsed/msPerBeat;
|
|
if (beat > end) {
|
|
drawSetsWithTrails(end);
|
|
} else {
|
|
drawSetsWithTrails(beat);
|
|
requestAnimationFrame(anim);
|
|
}
|
|
}
|
|
anim();
|
|
}
|
|
|
|
clear() {
|
|
// TODO Right way to clear?
|
|
this.ctx.fillStyle = 'white';
|
|
this.ctx.fillRect(-this.canvas.width, -this.canvas.height, 2*this.canvas.width, 2*this.canvas.height);
|
|
|
|
const extraLines = this.extraLines ?? 0;
|
|
const extraSets = this.extraSets ?? 0;
|
|
for (var relativeLine = -extraLines; relativeLine <= extraLines; relativeLine++) {
|
|
for (var relativeSet = -extraSets; relativeSet <= extraSets; relativeSet++) {
|
|
this.ctx.save();
|
|
const hue = (relativeLine + relativeSet) % 2 === 0 ? 60 : 170;
|
|
const sat = 100 - Math.abs(relativeLine * 40);
|
|
const lum = Math.min(98, 90 + Math.abs(relativeSet) * 5);
|
|
this.ctx.fillStyle = `hsl(${hue}, ${sat}%, ${lum}%)`;
|
|
this.ctx.translate(relativeLine * lineDistance,
|
|
relativeSet * setDistance);
|
|
this.ctx.fillRect(-1.975, -1.975, 3.95, 3.95);
|
|
this.ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
}
|