352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
export const proceduralBrowserRuntime = `
|
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
const avatarInstanceId = () => typeof globalThis.crypto?.randomUUID === 'function'
|
|
? globalThis.crypto.randomUUID()
|
|
: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
|
const clamp01 = value => Math.max(0, Math.min(1, value));
|
|
const easeProgress = (progress, transition) => transition === 'smooth'
|
|
? progress * progress * (3 - 2 * progress)
|
|
: transition === 'snappy'
|
|
? 1 - (1 - progress) ** 3
|
|
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress);
|
|
const nearestAngle = (target, current) => {
|
|
let resolved = target;
|
|
while (resolved - current > 180) resolved -= 360;
|
|
while (resolved - current < -180) resolved += 360;
|
|
return resolved;
|
|
};
|
|
const resolvedTargetExpression = (target, current) => ({
|
|
...target,
|
|
headX: nearestAngle(target.headX, current.headX),
|
|
headY: nearestAngle(target.headY, current.headY),
|
|
headZ: nearestAngle(target.headZ, current.headZ),
|
|
leftAngle: nearestAngle(target.leftAngle, current.leftAngle),
|
|
rightAngle: nearestAngle(target.rightAngle, current.rightAngle),
|
|
});
|
|
const colorChannels = color => {
|
|
const value = color.replace('#', '');
|
|
const hex = value.length === 3 ? value.split('').map(channel => channel + channel).join('') : value;
|
|
const numeric = Number.parseInt(hex, 16);
|
|
return [(numeric >> 16) & 255, (numeric >> 8) & 255, numeric & 255];
|
|
};
|
|
const interpolateColor = (from, to, progress) => {
|
|
const left = colorChannels(from);
|
|
const right = colorChannels(to);
|
|
const value = left.map((channel, index) => Math.round(channel + (right[index] - channel) * progress));
|
|
return '#' + value.map(channel => channel.toString(16).padStart(2, '0')).join('');
|
|
};
|
|
const resolveColors = expression => ({
|
|
body: expression.bodyColor || DATA.avatar.colors.body,
|
|
eyes: expression.eyeColor || DATA.avatar.colors.eyes,
|
|
});
|
|
const svgElement = name => document.createElementNS(SVG_NS, name);
|
|
|
|
function mountAvatar(target, options = {}) {
|
|
const host = typeof target === 'string' ? document.querySelector(target) : target;
|
|
if (!host) throw new Error('Avatar target was not found.');
|
|
const animationNames = Object.keys(DATA.animations);
|
|
if (!animationNames.length) throw new Error('The avatar export contains no animations.');
|
|
const instanceId = avatarInstanceId();
|
|
const clipId = 'avatar-procedural-clip-' + instanceId;
|
|
const svg = svgElement('svg');
|
|
svg.setAttribute('viewBox', '-150 -150 300 300');
|
|
svg.setAttribute('role', 'img');
|
|
svg.setAttribute('aria-label', DATA.avatar.name);
|
|
svg.style.width = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
|
|
svg.style.height = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
|
|
svg.style.display = 'block';
|
|
svg.style.overflow = 'visible';
|
|
const defs = svgElement('defs');
|
|
const clipPath = svgElement('clipPath');
|
|
const clipHead = svgElement('path');
|
|
clipPath.id = clipId;
|
|
clipPath.append(clipHead);
|
|
defs.append(clipPath);
|
|
svg.append(defs);
|
|
const motionLayer = svgElement('g');
|
|
const backLayer = svgElement('g');
|
|
const head = svgElement('path');
|
|
const eyesLayer = svgElement('g');
|
|
const leftEye = svgElement('path');
|
|
const rightEye = svgElement('path');
|
|
const frontLayer = svgElement('g');
|
|
eyesLayer.setAttribute('clip-path', 'url(#' + clipId + ')');
|
|
eyesLayer.append(leftEye, rightEye);
|
|
motionLayer.append(backLayer, head, eyesLayer, frontLayer);
|
|
svg.append(motionLayer);
|
|
host.replaceChildren(svg);
|
|
|
|
const ensurePaths = (group, paths, fill) => {
|
|
while (group.children.length < paths.length) group.append(svgElement('path'));
|
|
while (group.children.length > paths.length) group.lastElementChild.remove();
|
|
paths.forEach((path, index) => {
|
|
group.children[index].setAttribute('d', path);
|
|
group.children[index].setAttribute('fill', fill);
|
|
});
|
|
};
|
|
let currentAnimation = options.animation && DATA.animations[options.animation] ? options.animation : animationNames[0];
|
|
const initialStep = DATA.animations[currentAnimation].steps[0];
|
|
const initialExpression = DATA.expressions[initialStep.expressionId];
|
|
let currentPose = AvatarProceduralEngine.poseFromExpression(initialExpression);
|
|
let currentColors = resolveColors(initialExpression);
|
|
let blinkAmount = 1;
|
|
let transitionState = null;
|
|
let blinkState = null;
|
|
let frameRequest = null;
|
|
let stepTimer = null;
|
|
let blinkTimer = null;
|
|
let blinkDueAt = null;
|
|
let stepIndex = 0;
|
|
let direction = 1;
|
|
let playing = false;
|
|
let paused = false;
|
|
let pausedRemainingMs = 0;
|
|
let pausedTransition = null;
|
|
let pausedBlink = null;
|
|
let pausedBlinkDelay = 0;
|
|
let stepDueAt = null;
|
|
let ambientStartedAt = performance.now();
|
|
let ambientSignature = initialExpression.eyeMotion + ':' + initialExpression.bodyMotion;
|
|
let lastAmbientFrame = 0;
|
|
|
|
const applyMotion = expression => {
|
|
const signature = expression.eyeMotion + ':' + expression.bodyMotion;
|
|
if (signature !== ambientSignature) {
|
|
ambientSignature = signature;
|
|
ambientStartedAt = performance.now();
|
|
}
|
|
};
|
|
const render = (time = performance.now()) => {
|
|
const elapsed = time - ambientStartedAt;
|
|
const expression = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression)
|
|
? AvatarProceduralEngine.applyAmbientMotion(currentPose.expression, elapsed)
|
|
: currentPose.expression;
|
|
const renderedPose = AvatarProceduralEngine.poseFromExpression(expression);
|
|
const geometry = AvatarProceduralEngine.renderAvatar(renderedPose, DATA.avatar.surface, blinkAmount, {
|
|
includeWire: false,
|
|
bodyNodes: DATA.avatar.bodyNodes,
|
|
});
|
|
const offset = AvatarProceduralEngine.ambientBodyOffset(currentPose.expression, elapsed);
|
|
motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')');
|
|
ensurePaths(backLayer, geometry.backPaths, currentColors.body);
|
|
ensurePaths(frontLayer, geometry.frontPaths, currentColors.body);
|
|
head.setAttribute('d', geometry.headPath);
|
|
head.setAttribute('fill', currentColors.body);
|
|
clipHead.setAttribute('d', geometry.headPath);
|
|
leftEye.setAttribute('d', geometry.leftPath);
|
|
rightEye.setAttribute('d', geometry.rightPath);
|
|
leftEye.setAttribute('fill', currentColors.eyes);
|
|
rightEye.setAttribute('fill', currentColors.eyes);
|
|
leftEye.style.display = geometry.leftVisible ? '' : 'none';
|
|
rightEye.style.display = geometry.rightVisible ? '' : 'none';
|
|
};
|
|
const tick = time => {
|
|
frameRequest = null;
|
|
if (transitionState) {
|
|
const linear = clamp01((time - transitionState.startedAt) / transitionState.durationMs);
|
|
const eased = easeProgress(linear, transitionState.transition);
|
|
const expression = { ...transitionState.fromPose.expression };
|
|
AvatarProceduralEngine.expressionFields.forEach(field => {
|
|
expression[field] = transitionState.fromPose.expression[field] +
|
|
(transitionState.toPose.expression[field] - transitionState.fromPose.expression[field]) * eased;
|
|
});
|
|
expression.eyeMotion = transitionState.toPose.expression.eyeMotion;
|
|
expression.bodyMotion = transitionState.toPose.expression.bodyMotion;
|
|
currentPose = AvatarProceduralEngine.poseFromExpression(expression);
|
|
currentColors = {
|
|
body: interpolateColor(transitionState.fromColors.body, transitionState.toColors.body, clamp01(eased)),
|
|
eyes: interpolateColor(transitionState.fromColors.eyes, transitionState.toColors.eyes, clamp01(eased)),
|
|
};
|
|
if (linear >= 1) {
|
|
currentPose = transitionState.toPose;
|
|
currentColors = transitionState.toColors;
|
|
transitionState = null;
|
|
}
|
|
}
|
|
if (blinkState) {
|
|
const progress = clamp01((time - blinkState.startedAt) / blinkState.durationMs);
|
|
if (progress <= 0.42) {
|
|
const closeProgress = progress / 0.42;
|
|
blinkAmount = 1 - closeProgress * closeProgress;
|
|
} else {
|
|
const openProgress = (progress - 0.42) / 0.58;
|
|
blinkAmount = 1 - (1 - openProgress) ** 2;
|
|
}
|
|
if (progress >= 1) {
|
|
blinkAmount = 1;
|
|
blinkState = null;
|
|
}
|
|
}
|
|
const ambientActive = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression);
|
|
if (transitionState || blinkState || !ambientActive || time - lastAmbientFrame >= 1000 / 30) {
|
|
render(time);
|
|
if (ambientActive) lastAmbientFrame = time;
|
|
}
|
|
if (transitionState || blinkState || ambientActive) frameRequest = requestAnimationFrame(tick);
|
|
};
|
|
const requestTick = () => {
|
|
if (frameRequest === null) frameRequest = requestAnimationFrame(tick);
|
|
};
|
|
const animateTo = (expressionId, durationMs, transition) => {
|
|
const target = DATA.expressions[expressionId];
|
|
if (!target) return;
|
|
applyMotion(target);
|
|
const resolved = resolvedTargetExpression(target, currentPose.expression);
|
|
const targetPose = AvatarProceduralEngine.poseFromExpression(resolved);
|
|
const targetColors = resolveColors(target);
|
|
if (durationMs <= 0) {
|
|
transitionState = null;
|
|
currentPose = targetPose;
|
|
currentColors = targetColors;
|
|
render();
|
|
if (AvatarProceduralEngine.hasAmbientMotion(currentPose.expression)) requestTick();
|
|
return;
|
|
}
|
|
transitionState = {
|
|
fromPose: currentPose,
|
|
toPose: targetPose,
|
|
fromColors: currentColors,
|
|
toColors: targetColors,
|
|
startedAt: performance.now(),
|
|
durationMs,
|
|
transition,
|
|
expressionId,
|
|
};
|
|
requestTick();
|
|
};
|
|
const clearSchedule = () => {
|
|
if (stepTimer !== null) clearTimeout(stepTimer);
|
|
if (blinkTimer !== null) clearTimeout(blinkTimer);
|
|
stepTimer = null;
|
|
blinkTimer = null;
|
|
blinkDueAt = null;
|
|
stepDueAt = null;
|
|
};
|
|
const scheduleBlink = (animation, delay) => {
|
|
if (!animation.blink.enabled) return;
|
|
blinkDueAt = performance.now() + delay;
|
|
blinkTimer = setTimeout(() => {
|
|
blinkDueAt = null;
|
|
blinkState = { startedAt: performance.now(), durationMs: animation.blink.durationMs };
|
|
requestTick();
|
|
const range = animation.blink.maxIntervalMs - animation.blink.minIntervalMs;
|
|
scheduleBlink(animation, animation.blink.durationMs + animation.blink.minIntervalMs + Math.random() * range);
|
|
}, delay);
|
|
};
|
|
const advance = animation => {
|
|
const last = animation.steps.length - 1;
|
|
const playbackMode = options.loop === true ? 'loop' : options.loop === false ? 'once' : animation.playbackMode;
|
|
if (playbackMode === 'once' && stepIndex >= last) {
|
|
playing = false;
|
|
options.onAnimationEnd?.(currentAnimation);
|
|
return;
|
|
}
|
|
if (playbackMode === 'pingPong' && last > 0) {
|
|
if (stepIndex >= last) direction = -1;
|
|
else if (stepIndex <= 0) direction = 1;
|
|
stepIndex += direction;
|
|
} else stepIndex = (stepIndex + 1) % (last + 1);
|
|
runStep(animation);
|
|
};
|
|
const runStep = animation => {
|
|
if (!playing || !animation.steps.length) return;
|
|
const step = animation.steps[stepIndex];
|
|
animateTo(step.expressionId, step.transitionMs, step.transition);
|
|
const duration = step.transitionMs + step.holdMs;
|
|
stepDueAt = performance.now() + duration;
|
|
stepTimer = setTimeout(() => advance(animation), duration);
|
|
};
|
|
const api = {
|
|
element: svg,
|
|
get animation() { return currentAnimation; },
|
|
get playing() { return playing; },
|
|
play(animationName) {
|
|
animationName = animationName || currentAnimation;
|
|
if (!DATA.animations[animationName]) throw new Error('Unknown animation: ' + animationName);
|
|
clearSchedule();
|
|
if (animationName === currentAnimation && paused) {
|
|
paused = false;
|
|
playing = true;
|
|
if (pausedTransition) animateTo(pausedTransition.expressionId, pausedTransition.durationMs, pausedTransition.transition);
|
|
if (pausedBlink) {
|
|
blinkState = {
|
|
startedAt: performance.now() - pausedBlink.progress * pausedBlink.durationMs,
|
|
durationMs: pausedBlink.durationMs,
|
|
};
|
|
requestTick();
|
|
}
|
|
stepDueAt = performance.now() + pausedRemainingMs;
|
|
stepTimer = setTimeout(() => advance(DATA.animations[currentAnimation]), pausedRemainingMs);
|
|
scheduleBlink(
|
|
DATA.animations[currentAnimation],
|
|
pausedBlinkDelay || DATA.animations[currentAnimation].blink.minIntervalMs
|
|
);
|
|
pausedTransition = null;
|
|
pausedBlink = null;
|
|
pausedBlinkDelay = 0;
|
|
return api;
|
|
}
|
|
currentAnimation = animationName;
|
|
stepIndex = 0;
|
|
direction = 1;
|
|
paused = false;
|
|
playing = true;
|
|
runStep(DATA.animations[currentAnimation]);
|
|
scheduleBlink(DATA.animations[currentAnimation], DATA.animations[currentAnimation].blink.initialDelayMs);
|
|
return api;
|
|
},
|
|
pause() {
|
|
const now = performance.now();
|
|
if (playing && stepDueAt !== null) pausedRemainingMs = Math.max(stepDueAt - now, 0);
|
|
pausedBlinkDelay = blinkDueAt === null ? 0 : Math.max(blinkDueAt - now, 0);
|
|
if (transitionState) {
|
|
const elapsed = now - transitionState.startedAt;
|
|
pausedTransition = {
|
|
expressionId: transitionState.expressionId,
|
|
durationMs: Math.max(transitionState.durationMs - elapsed, 0),
|
|
transition: transitionState.transition,
|
|
};
|
|
}
|
|
if (blinkState) {
|
|
pausedBlink = {
|
|
progress: clamp01((now - blinkState.startedAt) / blinkState.durationMs),
|
|
durationMs: blinkState.durationMs,
|
|
};
|
|
}
|
|
clearSchedule();
|
|
transitionState = null;
|
|
blinkState = null;
|
|
paused = true;
|
|
playing = false;
|
|
render();
|
|
return api;
|
|
},
|
|
stop() {
|
|
clearSchedule();
|
|
transitionState = null;
|
|
blinkState = null;
|
|
blinkAmount = 1;
|
|
pausedBlink = null;
|
|
pausedBlinkDelay = 0;
|
|
paused = false;
|
|
playing = false;
|
|
stepIndex = 0;
|
|
direction = 1;
|
|
const first = DATA.animations[currentAnimation].steps[0];
|
|
if (first) animateTo(first.expressionId, 0, first.transition);
|
|
return api;
|
|
},
|
|
destroy() {
|
|
clearSchedule();
|
|
if (frameRequest !== null) cancelAnimationFrame(frameRequest);
|
|
svg.remove();
|
|
},
|
|
};
|
|
applyMotion(initialExpression);
|
|
render();
|
|
if (AvatarProceduralEngine.hasAmbientMotion(initialExpression)) requestTick();
|
|
if (options.autoplay !== false) api.play(currentAnimation);
|
|
return api;
|
|
}
|
|
`
|