354 lines
10 KiB
TypeScript
354 lines
10 KiB
TypeScript
import { appConfig } from '../config';
|
|
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
|
|
|
interface IntroTitlePoint {
|
|
x: number;
|
|
y: number;
|
|
tangent: number | null;
|
|
colorIndex: number;
|
|
}
|
|
|
|
interface IntroTitleAgentOptions {
|
|
count: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
const INTRO_TITLE = appConfig.simulation.intro.title;
|
|
|
|
export const createIntroTitleAgents = ({
|
|
count,
|
|
width,
|
|
height,
|
|
}: IntroTitleAgentOptions): Float32Array => {
|
|
if (count <= 0) {
|
|
return new Float32Array();
|
|
}
|
|
|
|
const safeWidth = Math.max(1, width);
|
|
const safeHeight = Math.max(1, height);
|
|
const points = createIntroTitlePoints(safeWidth, safeHeight);
|
|
if (points.length === 0) {
|
|
return new Float32Array();
|
|
}
|
|
|
|
const data = new Float32Array(count * AGENT_FLOAT_COUNT);
|
|
const minSide = Math.min(safeWidth, safeHeight);
|
|
const targetJitter = Math.max(
|
|
appConfig.simulation.intro.minTargetJitterPx,
|
|
minSide * appConfig.simulation.intro.targetJitterSideRatio
|
|
);
|
|
const entryJitter = Math.max(
|
|
appConfig.simulation.intro.minEntryJitterPx,
|
|
minSide * appConfig.simulation.intro.entryJitterSideRatio
|
|
);
|
|
const titleRadius = points.reduce(
|
|
(radius, point) =>
|
|
Math.max(
|
|
radius,
|
|
Math.hypot(
|
|
point.x - safeWidth / 2,
|
|
point.y - safeHeight * appConfig.simulation.intro.verticalAnchor
|
|
)
|
|
),
|
|
0
|
|
);
|
|
const introCircleRadius = Math.min(
|
|
Math.max(
|
|
titleRadius * appConfig.simulation.intro.titleRadiusMultiplier,
|
|
minSide * appConfig.simulation.intro.circleMinSideRatio
|
|
),
|
|
minSide * appConfig.simulation.intro.circleMaxSideRatio
|
|
);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const point = points[Math.floor(Math.random() * points.length)];
|
|
const targetX = Math.max(
|
|
0,
|
|
Math.min(safeWidth - 1, point.x + (Math.random() - 0.5) * targetJitter)
|
|
);
|
|
const targetY = Math.max(
|
|
0,
|
|
Math.min(safeHeight - 1, point.y + (Math.random() - 0.5) * targetJitter)
|
|
);
|
|
const [startX, startY] = getIntroRadialStart(
|
|
targetX,
|
|
targetY,
|
|
safeWidth,
|
|
safeHeight,
|
|
introCircleRadius,
|
|
entryJitter
|
|
);
|
|
const approachAngle = Math.atan2(targetY - startY, targetX - startX);
|
|
let targetAngle = point.tangent ?? approachAngle;
|
|
if (Math.cos(targetAngle - approachAngle) < 0) {
|
|
targetAngle += Math.PI;
|
|
}
|
|
|
|
const distanceFraction =
|
|
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
|
|
const base = i * AGENT_FLOAT_COUNT;
|
|
data[base] = startX;
|
|
data[base + 1] = startY;
|
|
data[base + 2] =
|
|
approachAngle +
|
|
(Math.random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
|
|
data[base + 3] = point.colorIndex;
|
|
data[base + 4] = targetX;
|
|
data[base + 5] = targetY;
|
|
data[base + 6] = targetAngle;
|
|
data[base + 7] = Math.min(
|
|
appConfig.simulation.intro.targetDelayMax,
|
|
distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
|
|
Math.random() * appConfig.simulation.intro.targetDelayRandomMultiplier
|
|
);
|
|
}
|
|
|
|
return data;
|
|
};
|
|
|
|
const getIntroRadialStart = (
|
|
targetX: number,
|
|
targetY: number,
|
|
width: number,
|
|
height: number,
|
|
radius: number,
|
|
jitter: number
|
|
): [number, number] => {
|
|
const centerX = width / 2;
|
|
const centerY = height * appConfig.simulation.intro.verticalAnchor;
|
|
const offsetX = targetX - centerX;
|
|
const offsetY = targetY - centerY;
|
|
const length = Math.hypot(offsetX, offsetY);
|
|
const angle =
|
|
length > 0.001 ? Math.atan2(offsetY, offsetX) : Math.random() * Math.PI * 2;
|
|
const directionX = Math.cos(angle);
|
|
const directionY = Math.sin(angle);
|
|
const tangentX = -directionY;
|
|
const tangentY = directionX;
|
|
const tangentJitter = (Math.random() - 0.5) * jitter;
|
|
const radialJitter =
|
|
(Math.random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
|
|
const startX =
|
|
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
|
|
const startY =
|
|
centerY + directionY * (radius + radialJitter) + tangentY * tangentJitter;
|
|
|
|
return [
|
|
Math.max(0, Math.min(width - 1, startX)),
|
|
Math.max(0, Math.min(height - 1, startY)),
|
|
];
|
|
};
|
|
|
|
const createIntroTitlePoints = (
|
|
width: number,
|
|
height: number
|
|
): Array<IntroTitlePoint> => {
|
|
const maskCanvas = document.createElement('canvas');
|
|
maskCanvas.width = width;
|
|
maskCanvas.height = height;
|
|
const context = maskCanvas.getContext('2d', { willReadFrequently: true });
|
|
if (!context) {
|
|
return [];
|
|
}
|
|
|
|
const fontSize = getIntroTitleFontSize(context, width, height);
|
|
context.clearRect(0, 0, width, height);
|
|
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
|
|
context.textAlign = 'center';
|
|
context.textBaseline = 'middle';
|
|
context.fillStyle = '#fff';
|
|
context.strokeStyle = '#fff';
|
|
context.lineJoin = 'round';
|
|
context.lineWidth = Math.max(
|
|
appConfig.simulation.intro.titleStrokeWidthMinPx,
|
|
fontSize * appConfig.simulation.intro.titleStrokeWidthRatio
|
|
);
|
|
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
|
|
drawIntroTitleText(
|
|
context,
|
|
width / 2,
|
|
height * appConfig.simulation.intro.verticalAnchor,
|
|
letterSpacing,
|
|
'stroke'
|
|
);
|
|
drawIntroTitleText(
|
|
context,
|
|
width / 2,
|
|
height * appConfig.simulation.intro.verticalAnchor,
|
|
letterSpacing,
|
|
'fill'
|
|
);
|
|
|
|
const { data } = context.getImageData(0, 0, width, height);
|
|
const step = Math.max(
|
|
1,
|
|
Math.floor(Math.min(width, height) / appConfig.simulation.intro.maskSampleDensity)
|
|
);
|
|
const points: Array<IntroTitlePoint> = [];
|
|
const characterColorBoundaries = getIntroTitleColorBoundaries(
|
|
context,
|
|
width,
|
|
letterSpacing
|
|
);
|
|
|
|
for (let y = 0; y < height; y += step) {
|
|
for (let x = 0; x < width; x += step) {
|
|
const alpha = getMaskAlpha(data, width, height, x, y);
|
|
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
|
|
continue;
|
|
}
|
|
|
|
points.push({
|
|
x,
|
|
y,
|
|
tangent: estimateMaskTangent(data, width, height, x, y),
|
|
colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries),
|
|
});
|
|
}
|
|
}
|
|
|
|
return points;
|
|
};
|
|
|
|
const getIntroTitleColorBoundaries = (
|
|
context: CanvasRenderingContext2D,
|
|
width: number,
|
|
letterSpacing: number
|
|
): [number, number] => {
|
|
const letters = Array.from(INTRO_TITLE);
|
|
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
|
let x = width / 2 - totalWidth / 2;
|
|
const [firstCutLetter, secondCutLetter] =
|
|
appConfig.simulation.intro.titleColorCutLetters;
|
|
const letterBoxes = letters.map((letter, index) => {
|
|
const letterWidth = context.measureText(letter).width;
|
|
const box = {
|
|
left: x,
|
|
right: x + letterWidth,
|
|
};
|
|
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
|
|
return box;
|
|
});
|
|
|
|
const getBoundaryBetweenLetters = (leftLetterIndex: number) =>
|
|
(letterBoxes[leftLetterIndex].right + letterBoxes[leftLetterIndex + 1].left) / 2;
|
|
|
|
return [
|
|
getBoundaryBetweenLetters(firstCutLetter - 1),
|
|
getBoundaryBetweenLetters(secondCutLetter - 1),
|
|
];
|
|
};
|
|
|
|
const drawIntroTitleText = (
|
|
context: CanvasRenderingContext2D,
|
|
centerX: number,
|
|
centerY: number,
|
|
letterSpacing: number,
|
|
mode: 'fill' | 'stroke'
|
|
): void => {
|
|
const letters = Array.from(INTRO_TITLE);
|
|
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
|
let x = centerX - totalWidth / 2;
|
|
|
|
letters.forEach((letter, index) => {
|
|
const letterWidth = context.measureText(letter).width;
|
|
const drawX = x + letterWidth / 2;
|
|
if (mode === 'fill') {
|
|
context.fillText(letter, drawX, centerY);
|
|
} else {
|
|
context.strokeText(letter, drawX, centerY);
|
|
}
|
|
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
|
|
});
|
|
};
|
|
|
|
const measureIntroTitleText = (
|
|
context: CanvasRenderingContext2D,
|
|
letters: Array<string>,
|
|
letterSpacing: number
|
|
): number => {
|
|
const textWidth = letters.reduce(
|
|
(width, letter) => width + context.measureText(letter).width,
|
|
0
|
|
);
|
|
return textWidth + Math.max(0, letters.length - 1) * letterSpacing;
|
|
};
|
|
|
|
const getIntroTitleColorIndex = (x: number, boundaries: [number, number]): number => {
|
|
if (x < boundaries[0]) {
|
|
return 0;
|
|
}
|
|
|
|
if (x < boundaries[1]) {
|
|
return 1;
|
|
}
|
|
|
|
return 2;
|
|
};
|
|
|
|
const getIntroTitleFontSize = (
|
|
context: CanvasRenderingContext2D,
|
|
width: number,
|
|
height: number
|
|
): number => {
|
|
const maxWidth = width * appConfig.simulation.intro.maxWidthRatio;
|
|
const maxHeight = height * appConfig.simulation.intro.maxHeightRatio;
|
|
let fontSize = Math.floor(
|
|
Math.min(
|
|
height * appConfig.simulation.intro.initialFontHeightRatio,
|
|
width * appConfig.simulation.intro.initialFontWidthRatio
|
|
)
|
|
);
|
|
|
|
while (fontSize > appConfig.simulation.intro.minFontSizePx) {
|
|
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
|
|
const metrics = context.measureText(INTRO_TITLE);
|
|
const measuredHeight =
|
|
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
|
|
|
|
if (metrics.width <= maxWidth && measuredHeight <= maxHeight) {
|
|
return fontSize;
|
|
}
|
|
|
|
fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown);
|
|
}
|
|
|
|
return fontSize;
|
|
};
|
|
|
|
const estimateMaskTangent = (
|
|
data: Uint8ClampedArray,
|
|
width: number,
|
|
height: number,
|
|
x: number,
|
|
y: number
|
|
): number | null => {
|
|
const gradientX =
|
|
getMaskAlpha(data, width, height, x + 1, y) -
|
|
getMaskAlpha(data, width, height, x - 1, y);
|
|
const gradientY =
|
|
getMaskAlpha(data, width, height, x, y + 1) -
|
|
getMaskAlpha(data, width, height, x, y - 1);
|
|
|
|
if (
|
|
Math.abs(gradientX) + Math.abs(gradientY) <
|
|
appConfig.simulation.intro.maskGradientThreshold
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return Math.atan2(gradientX, -gradientY);
|
|
};
|
|
|
|
const getMaskAlpha = (
|
|
data: Uint8ClampedArray,
|
|
width: number,
|
|
height: number,
|
|
x: number,
|
|
y: number
|
|
): number => {
|
|
const clampedX = Math.max(0, Math.min(width - 1, Math.round(x)));
|
|
const clampedY = Math.max(0, Math.min(height - 1, Math.round(y)));
|
|
return data[(clampedY * width + clampedX) * 4 + 3];
|
|
};
|