Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Run it once from the Actions tab, then add this to your README, replacing `you`
| `hair` | `#2b1d14` | The player's hair colour, as a hex colour. |
| `hair_style` | `short` | The player's hairstyle: `short`, `long`, or `headband`. |
| `pace` | `1` | How relaxed he plays, from `0.25` (quick) to `4` (very slow). At `1` a shot takes about 3 seconds, so an active year runs several minutes. |
| `max_seconds` | unset | Positive maximum loop duration in seconds. If the requested pace would exceed it, pace is lowered just enough to fit. If even pace `0.25` is too long, it renders at `0.25` and logs a warning. |
| `order` | `oldest-first` | `oldest-first` (starts at the beginning of the year) or `newest-first` (starts at the most recent week beside the hoop and walks backwards). |
| `footer` | contribution summary | Custom text for the bottom line, or `none` to hide it. Long text is scaled to fit the SVG width. |

Expand Down
2 changes: 2 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ inputs:
pace:
description: How slowly he plays, from 0.25 (quick) to 4 (very relaxed). 1 is about 3 seconds per shot.
default: "1"
max_seconds:
description: Maximum animation loop duration in seconds. If pace 0.25 cannot meet it, renders at pace 0.25 and logs a warning.
footer:
description: Custom text for the bottom line, or "none" to hide it. Defaults to the contribution summary.
order:
Expand Down
10 changes: 7 additions & 3 deletions bin/hoops.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
//
// GITHUB_TOKEN=... npx hoops --user octocat [--out dist] [--jersey blue-gold]
// [--skin '#c68b59'] [--hair '#2b1d14'] [--hair-style short]
// [--pace 1] [--order oldest-first] [--footer 'text|none'] [--fixture calendar.json]
// [--pace 1] [--max-seconds 600] [--order oldest-first] [--footer 'text|none'] [--fixture calendar.json]

import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fetchCalendar } from '../src/github.mjs';
import { buildTimeline, render } from '../src/render.mjs';
import { buildTimeline, parseMaxSeconds, parseOrder, parsePace, render } from '../src/render.mjs';

const args = {};
for (let i = 2; i < process.argv.length; i++) {
Expand All @@ -28,14 +28,18 @@ try {
hair: args.hair,
hairStyle: args['hair-style'],
pace: args.pace,
maxSeconds: args['max-seconds'],
order: args.order,
footer: args.footer,
};
for (const k of Object.keys(options)) if (options[k] === undefined) delete options[k];
parsePace(options.pace ?? 1);
parseOrder(options.order);
if (options.maxSeconds !== undefined) options.maxSeconds = parseMaxSeconds(options.maxSeconds);

if (args.debug !== undefined) {
// --debug N prints when shot N happens, for freezing frames while tweaking.
const tl = buildTimeline(calendar.weeks, { pace: Number(options.pace ?? 1), order: options.order });
const tl = buildTimeline(calendar.weeks, { pace: Number(options.pace ?? 1), order: options.order, maxSeconds: options.maxSeconds });
const s = tl.shots[Number(args.debug)];
console.error(JSON.stringify({ loop: +tl.T.toFixed(2), shots: tl.shots.length, ...(s && { grab: s.grab, release: s.release, swish: s.swish, day: s.day.date }) }));
}
Expand Down
4 changes: 3 additions & 1 deletion src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fetchCalendar } from './github.mjs';
import { parseColor, parseHairStyle, parseJersey, parseOrder, parsePace, render } from './render.mjs';
import { parseColor, parseHairStyle, parseJersey, parseMaxSeconds, parseOrder, parsePace, render } from './render.mjs';

const input = (name, fallback = '') => (process.env[`INPUT_${name.toUpperCase()}`] ?? '').trim() || fallback;

Expand All @@ -24,6 +24,7 @@ try {
hair: input('hair', '#2b1d14'),
hairStyle: input('hair_style', 'short'),
pace: input('pace', '1'),
maxSeconds: input('max_seconds') || undefined,
order: input('order', 'oldest-first'),
...(footerInput ? { footer: footerInput } : {}),
};
Expand All @@ -34,6 +35,7 @@ try {
parseHairStyle(options.hairStyle);
parsePace(options.pace);
parseOrder(options.order);
if (options.maxSeconds !== undefined) parseMaxSeconds(options.maxSeconds);

const calendar = await fetchCalendar(user, token);
mkdirSync(outDir, { recursive: true });
Expand Down
43 changes: 41 additions & 2 deletions src/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ export function parsePace(value = 1) {
return n;
}

export function parseMaxSeconds(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) throw new Error(`max_seconds must be a positive number (got "${value}")`);
return n;
}

export const ORDERS = ['oldest-first', 'newest-first'];

export function parseOrder(value = 'oldest-first') {
Expand Down Expand Up @@ -140,7 +146,7 @@ const STEP_IN = 10; // pixels he steps toward the hoop while dribbling
// Longer shots hang in the air longer.
const flightTime = (dist, pace) => (1.0 + Math.min(1, dist / 800) * 0.7) * Math.min(pace, 1.5);

export function buildTimeline(weeks, { pace = 1, order = 'oldest-first' } = {}) {
function buildTimelineAtPace(weeks, pace, order) {
order = parseOrder(order);
const b = Object.fromEntries(Object.entries(BEATS).map(([k, v]) => [k, v * pace]));
const player = []; // [t, x, y]
Expand Down Expand Up @@ -226,6 +232,37 @@ export function buildTimeline(weeks, { pace = 1, order = 'oldest-first' } = {})
return { T, end, rise: b.rise, player, walking, armsUp, holding, shots, dunk: { takeoff, at: peak }, scores, finalScore: score };
}

export function buildTimeline(weeks, { pace = 1, order = 'oldest-first', maxSeconds } = {}) {
pace = parsePace(pace);
order = parseOrder(order);
if (maxSeconds !== undefined) maxSeconds = parseMaxSeconds(maxSeconds);

const requested = buildTimelineAtPace(weeks, pace, order);
const fits = (timeline) => timeline.T <= maxSeconds && Number(timeline.T.toFixed(2)) <= maxSeconds;
if (maxSeconds === undefined || fits(requested)) return requested;

const minPace = 0.25;
let lowTimeline = buildTimelineAtPace(weeks, minPace, order);
if (!fits(lowTimeline)) {
console.warn(`max_seconds ${maxSeconds} cannot be met; rendering at pace 0.25 takes ${lowTimeline.T.toFixed(2)} seconds`);
return lowTimeline;
}

let low = minPace;
let high = pace;
for (let i = 0; i < 40; i++) {
const middle = (low + high) / 2;
const timeline = buildTimelineAtPace(weeks, middle, order);
if (fits(timeline)) {
low = middle;
lowTimeline = timeline;
} else {
high = middle;
}
}
return lowTimeline;
}

// ---------- keyframes ----------

function keyframes(name, frames, T) {
Expand Down Expand Up @@ -296,6 +333,7 @@ export function render(calendar, {
pace = 1,
order = 'oldest-first',
user = '',
maxSeconds,
footer,
} = {}) {
const bestStreak = longestStreak(calendar.weeks);
Expand All @@ -307,9 +345,10 @@ export function render(calendar, {
const style = parseHairStyle(hairStyle);
pace = parsePace(pace);
order = parseOrder(order);
if (maxSeconds !== undefined) maxSeconds = parseMaxSeconds(maxSeconds);

const weeks = calendar.weeks;
const tl = buildTimeline(weeks, { pace, order });
const tl = buildTimeline(weeks, { pace, order, maxSeconds });
const { T } = tl;
const dur = `${T.toFixed(2)}s linear infinite`;
const css = [];
Expand Down
52 changes: 52 additions & 0 deletions test/render.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,58 @@ test('pace stretches the game', () => {
assert.ok(slow > normal * 1.7, `pace 2 should take about twice as long (${slow} vs ${normal})`);
});

test('max_seconds reduces pace using the generated timeline and respects the pace floor', () => {
const requested = buildTimeline(calendar.weeks, { pace: 4 });
const normal = buildTimeline(calendar.weeks, { pace: 1 });
const capped = buildTimeline(calendar.weeks, { pace: 4, maxSeconds: 500 });
const shortest = buildTimeline(calendar.weeks, { pace: 0.25 });
assert.ok(requested.T > 500);
assert.ok(capped.T <= 500);
assert.ok(capped.T > 499.9, 'uses nearly all of the requested limit');
assert.ok(Number(capped.T.toFixed(2)) <= 500, 'rendered CSS duration also stays within the limit');
const linearlyScaledPace = 4 * 500 / requested.T;
assert.ok(buildTimeline(calendar.weeks, { pace: linearlyScaledPace }).T > 500, 'the nonlinear flight time makes proportional scaling miss the limit');
assert.equal(buildTimeline(calendar.weeks, { pace: 4, maxSeconds: requested.T }).T, requested.T, 'pace 4 is preserved at its exact duration limit');
const atMinimum = buildTimeline(calendar.weeks, { pace: 4, maxSeconds: shortest.T });
assert.ok(atMinimum.T <= shortest.T, 'the exact minimum duration is feasible');
assert.ok(atMinimum.T >= shortest.T - 0.01);

const justAbove = buildTimeline(calendar.weeks, { pace: 4, maxSeconds: capped.T + 0.01 });
assert.ok(justAbove.T >= capped.T, 'a looser limit should not slow the animation further');
assert.equal(buildTimeline(calendar.weeks, { pace: 1, maxSeconds: normal.T + 10 }).T, normal.T, 'a loose limit preserves the requested pace');
const originalWarn = console.warn;
const warnings = [];
console.warn = (message) => warnings.push(message);
try {
const impossible = buildTimeline(calendar.weeks, { pace: 4, maxSeconds: shortest.T - 0.01 });
assert.equal(impossible.T, shortest.T, 'an impossible cap renders at the minimum supported pace');
assert.match(warnings[0], /cannot be met; rendering at pace 0\.25/);
assert.equal(buildTimeline(calendar.weeks, { pace: 0.25, maxSeconds: shortest.T - 0.01 }).T, shortest.T);
assert.equal(warnings.length, 2);
} finally {
console.warn = originalWarn;
}
});

test('max_seconds validates positive finite values and render uses the capped loop', () => {
for (const value of [0, -1, 'fast', Infinity]) {
assert.throws(() => buildTimeline(calendar.weeks, { maxSeconds: value }), /max_seconds must be a positive number/);
}
const svg = render(calendar, { pace: 4, maxSeconds: 500 });
assert.match(svg, /animation:[\w-]+ 500\.00s linear infinite/);
const minimumDuration = buildTimeline(calendar.weeks, { pace: 0.25 }).T.toFixed(2);
const originalWarn = console.warn;
const warnings = [];
console.warn = (message) => warnings.push(message);
try {
const impossible = render(calendar, { maxSeconds: 1 });
assert.match(impossible, new RegExp(`animation:[\\w-]+ ${minimumDuration}s linear infinite`));
assert.match(warnings[0], /cannot be met; rendering at pace 0\.25/);
} finally {
console.warn = originalWarn;
}
});

test('every keyframe percentage is between 0 and 100 and never goes backwards', () => {
const svg = render(calendar);
// stride and bounce are short loops of their own, not on the game clock.
Expand Down
Loading