WIP
This commit is contained in:
parent
34ac200437
commit
39b0160064
136 changed files with 7129 additions and 1950 deletions
354
src/game-loop/intro-title-agents.ts
Normal file
354
src/game-loop/intro-title-agents.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
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];
|
||||
};
|
||||
Loading…
Reference in a new issue