fleeting-garden/src/game-loop/intro-title-agents.ts
2026-05-13 21:07:10 +01:00

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];
};