Implement dynamic paragraph line splitting and fix line break detection

- Add calculateLineCount() to determine optimal lines based on text length (2-6 lines)
- Add getPerspectiveFactors() for appropriate perspective scaling per line count
- Replace hardcoded 6-line paragraphs with dynamic line calculation in both rendering and game logic
- Fix line break detection in useGameStatus to match rendering calculations exactly
- Extend keybindings to support all special characters: apostrophe, hyphen, colon, semicolon, quotes, parentheses, slash, ampersand, and digits
- Integrate greatFallsParagraphs content as wordList in paragraph mode
- Fix vertical centering to scale properly for variable line counts
This commit is contained in:
2025-11-05 10:26:45 +00:00
parent f8956dde6b
commit 961d86c035
8 changed files with 277 additions and 153 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ export const gameInfo = {
'Letters: Single characters fall one at a time',
'Words: Full words fall and must be typed character by character',
'Sentences: Multi-line sentences with perspective effect',
'Paragraphs: 4-line paragraphs with advanced perspective and line transitions',
'Paragraphs: 6-line paragraphs with advanced perspective and line transitions',
],
},
+125 -97
View File
@@ -5,6 +5,28 @@ const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
const SPACESHIP_Y = CANVAS_HEIGHT - 40;
// Calculate optimal number of lines based on text length
const calculateLineCount = (text: string): number => {
const length = text.length;
if (length < 30) return 2;
if (length < 80) return 3;
if (length < 150) return 4;
if (length < 250) return 5;
return 6;
};
// Get perspective factors for a given number of lines
const getPerspectiveFactors = (numLines: number): number[] => {
const factors: Record<number, number[]> = {
2: [0.7, 1.0],
3: [0.6, 0.8, 1.0],
4: [0.6, 0.75, 0.9, 1.0],
5: [0.5, 0.65, 0.8, 0.9, 1.0],
6: [0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
};
return factors[numLines] || [1.0];
};
// Helper function to break text into multiple lines at good breaking points
const breakTextIntoLines = (text: string, numLines: number = 2): string[] => {
if (numLines === 2) {
@@ -406,28 +428,30 @@ export function useBoard() {
ctx.fillStyle = "#ffffff";
const displayText = letter.fullText || letter.letter;
if (gameMode === 'paragraphs') {
// For paragraphs: 4 lines (first typed at bottom, last typed at top)
const lines = breakTextIntoLines(displayText, 4);
const perspectiveFactors = [0.6, 0.7, 0.85, 1.0]; // Reversed: smallest at top, largest at bottom
const lineSpacing = fontSize * 1.2;
ctx.save();
lines.forEach((line, lineIndex) => {
// Calculate visual position: line 0 at bottom, line 3 at top
const visualIndex = lines.length - 1 - lineIndex;
const lineY = letter.y + (lineSpacing * (visualIndex - 1.5));
const lineFontSize = fontSize * perspectiveFactors[visualIndex];
const convergence = 0.3 * (1 - perspectiveFactors[visualIndex]);
const lineCenterX = letterX + ((CANVAS_WIDTH / 2) - letterX) * convergence;
ctx.font = `bold ${lineFontSize}px monospace`;
const lineWidth = ctx.measureText(line).width;
ctx.fillText(line, lineCenterX - lineWidth / 2, lineY);
});
ctx.restore();
if (gameMode === 'paragraphs') {
// For paragraphs: dynamic lines based on text length
const numLines = calculateLineCount(displayText);
const lines = breakTextIntoLines(displayText, numLines);
const perspectiveFactors = getPerspectiveFactors(numLines);
const lineSpacing = fontSize * 1.2;
ctx.save();
const centerOffset = (numLines - 1) / 2;
lines.forEach((line, lineIndex) => {
// Calculate visual position: line 0 at bottom, line N at top
const visualIndex = lines.length - 1 - lineIndex;
const lineY = letter.y + (lineSpacing * (visualIndex - centerOffset));
const lineFontSize = fontSize * perspectiveFactors[visualIndex];
const convergence = 0.3 * (1 - perspectiveFactors[visualIndex]);
const lineCenterX = letterX + ((CANVAS_WIDTH / 2) - letterX) * convergence;
ctx.font = `bold ${lineFontSize}px monospace`;
const lineWidth = ctx.measureText(line).width;
ctx.fillText(line, lineCenterX - lineWidth / 2, lineY);
});
ctx.restore();
} else if (isSentenceMode && gameMode === 'sentences') {
// For sentences: break into two lines during explosion with perspective
const [line1, line2] = breakTextIntoLines(displayText);
@@ -457,84 +481,88 @@ export function useBoard() {
const fullText = letter.fullText || letter.letter;
const charIndex = letter.charIndex ?? 0;
if (gameMode === 'paragraphs') {
// Paragraphs: break into 4 lines with enhanced Star Wars effect
const lines = breakTextIntoLines(fullText, 4);
// Calculate character positions for line breaks
let totalChars = 0;
const lineStartIndices = [0];
for (let i = 0; i < lines.length - 1; i++) {
totalChars += lines[i].length;
lineStartIndices.push(totalChars);
}
// Check if we're in a line transition
const isTransitioning = letter.lineTransition && letter.lineTransitionTime;
const completedLines = letter.completedLines || [];
const transitionProgress = isTransitioning
? Math.min((performance.now() - letter.lineTransitionTime) / 500, 1) // 500ms animation
: 0;
// Base perspective factors (will be modified during transitions)
const basePerspectiveFactors = [0.6, 0.7, 0.85, 1.0]; // Reversed: smallest at top, largest at bottom
const lineSpacing = fontSize * 1.2;
if (gameMode === 'paragraphs') {
// Paragraphs: break into dynamic lines based on text length
const numLines = calculateLineCount(fullText);
const lines = breakTextIntoLines(fullText, numLines);
// Calculate character positions for line breaks
let totalChars = 0;
const lineStartIndices = [0];
for (let i = 0; i < lines.length - 1; i++) {
totalChars += lines[i].length;
lineStartIndices.push(totalChars);
}
// Check if we're in a line transition
const isTransitioning = letter.lineTransition && letter.lineTransitionTime;
const completedLines = letter.completedLines || [];
const transitionProgress = isTransitioning
? Math.min((performance.now() - letter.lineTransitionTime) / 500, 1) // 500ms animation
: 0;
// Base perspective factors based on line count
const basePerspectiveFactors = getPerspectiveFactors(numLines);
const lineSpacing = fontSize * 1.2;
ctx.save();
// Render each line with perspective and animations
lines.forEach((line, lineIndex) => {
// Skip rendering completed lines that have exploded
if (completedLines.includes(lineIndex) && (!isTransitioning || transitionProgress > 0.6)) {
return;
}
// Calculate visual position: line 0 at bottom, line 3 at top
const visualIndex = lines.length - 1 - lineIndex;
// During transition, adjust positions and sizes
let lineY = letter.y + (lineSpacing * (visualIndex - 1.5));
let lineFontSize = fontSize * basePerspectiveFactors[visualIndex];
let lineOpacity = 1.0 - (visualIndex * 0.1);
// If this line just completed and is exploding
if (isTransitioning && completedLines[completedLines.length - 1] === lineIndex && transitionProgress < 0.6) {
// Explosion effect for the completed line
const explosionProgress = transitionProgress / 0.6;
const explosionScale = 1 + explosionProgress * 2;
const explosionOpacity = 1 - explosionProgress;
ctx.save();
ctx.translate(letterX, lineY);
ctx.scale(explosionScale, explosionScale);
ctx.globalAlpha = explosionOpacity;
ctx.fillStyle = "#ffff00";
ctx.font = `bold ${lineFontSize}px monospace`;
const lineWidth = ctx.measureText(line).width;
ctx.fillText(line, -lineWidth / 2, 0);
ctx.restore();
return; // Skip normal rendering for exploding line
}
// For remaining lines during transition, grow and shift down
if (isTransitioning && !completedLines.includes(lineIndex)) {
const numCompletedLines = completedLines.length;
// Shift lines down to fill the gap
const newVisualIndex = visualIndex + numCompletedLines;
if (newVisualIndex < 4) {
// Animate to new position
const targetY = letter.y + (lineSpacing * (newVisualIndex - 1.5));
lineY = lineY + (targetY - lineY) * transitionProgress;
// Grow to new size
const targetSize = fontSize * basePerspectiveFactors[newVisualIndex];
lineFontSize = lineFontSize + (targetSize - lineFontSize) * transitionProgress;
// Adjust opacity
const targetOpacity = 1.0 - (newVisualIndex * 0.1);
lineOpacity = lineOpacity + (targetOpacity - lineOpacity) * transitionProgress;
}
}
// Render each line with perspective and animations
lines.forEach((line, lineIndex) => {
// Skip rendering completed lines that have exploded
if (completedLines.includes(lineIndex) && (!isTransitioning || transitionProgress > 0.6)) {
return;
}
// Calculate visual position: line 0 at bottom, line N at top
const visualIndex = lines.length - 1 - lineIndex;
// Calculate Y offset based on number of lines (center the text block vertically)
const centerOffset = (numLines - 1) / 2;
// During transition, adjust positions and sizes
let lineY = letter.y + (lineSpacing * (visualIndex - centerOffset));
let lineFontSize = fontSize * basePerspectiveFactors[visualIndex];
let lineOpacity = 1.0 - (visualIndex * 0.1);
// If this line just completed and is exploding
if (isTransitioning && completedLines[completedLines.length - 1] === lineIndex && transitionProgress < 0.6) {
// Explosion effect for the completed line
const explosionProgress = transitionProgress / 0.6;
const explosionScale = 1 + explosionProgress * 2;
const explosionOpacity = 1 - explosionProgress;
ctx.save();
ctx.translate(letterX, lineY);
ctx.scale(explosionScale, explosionScale);
ctx.globalAlpha = explosionOpacity;
ctx.fillStyle = "#ffff00";
ctx.font = `bold ${lineFontSize}px monospace`;
const lineWidth = ctx.measureText(line).width;
ctx.fillText(line, -lineWidth / 2, 0);
ctx.restore();
return; // Skip normal rendering for exploding line
}
// For remaining lines during transition, grow and shift down
if (isTransitioning && !completedLines.includes(lineIndex)) {
const numCompletedLines = completedLines.length;
// Shift lines down to fill the gap
const newVisualIndex = visualIndex + numCompletedLines;
if (newVisualIndex < numLines && newVisualIndex < basePerspectiveFactors.length) {
// Animate to new position
const targetY = letter.y + (lineSpacing * (newVisualIndex - centerOffset));
lineY = lineY + (targetY - lineY) * transitionProgress;
// Grow to new size
const targetSize = fontSize * basePerspectiveFactors[newVisualIndex];
lineFontSize = lineFontSize + (targetSize - lineFontSize) * transitionProgress;
// Adjust opacity
const targetOpacity = 1.0 - (newVisualIndex * 0.1);
lineOpacity = lineOpacity + (targetOpacity - lineOpacity) * transitionProgress;
}
}
// Convergence toward vanishing point
const convergenceFactor = 1 - (lineFontSize / (fontSize * 1.0)); // Based on current size
+66 -16
View File
@@ -15,6 +15,63 @@ const MARGIN_RIGHT = 50; // Safe margin from right edge
const CANVAS_WIDTH = 800;
const SPACESHIP_Y = 600 - 40; // Position of the ship on canvas
// Calculate optimal number of lines based on text length (must match useBoard.ts)
const calculateLineCount = (text: string): number => {
const length = text.length;
if (length < 30) return 2;
if (length < 80) return 3;
if (length < 150) return 4;
if (length < 250) return 5;
return 6;
};
// Helper function to break text into multiple lines at good breaking points (must match useBoard.ts)
const breakTextIntoLines = (text: string, numLines: number = 2): string[] => {
if (numLines === 2) {
const midpoint = Math.ceil(text.length / 2);
// Try to find a space near the midpoint to break naturally
let breakPoint = midpoint;
for (let i = midpoint; i >= midpoint - 10 && i >= 0; i--) {
if (text[i] === ' ') {
breakPoint = i;
break;
}
}
// Keep the space at the end of line1 for typing
const line1 = text.substring(0, breakPoint + 1); // Include the space
const line2 = text.substring(breakPoint + 1).trim(); // Start after the space
return [line1, line2];
}
// For 3+ lines, divide text evenly
const lines: string[] = [];
const charsPerLine = Math.ceil(text.length / numLines);
let startIdx = 0;
for (let i = 0; i < numLines - 1; i++) {
let endIdx = startIdx + charsPerLine;
// Find nearest space to break at
for (let j = endIdx; j >= endIdx - 10 && j >= startIdx; j--) {
if (text[j] === ' ') {
endIdx = j;
break;
}
}
lines.push(text.substring(startIdx, endIdx + 1)); // Include the space
startIdx = endIdx + 1;
}
// Last line gets remaining text
lines.push(text.substring(startIdx).trim());
return lines;
};
export function useGameStatus(_onGameLoopTick?: (deltaTime: number, letters: FallingLetter[], lasers: Laser[]) => void, options: GameOptions = {}) {
const gameMode = options.gameMode ?? 'letters';
const downwardSpeed = options.downwardSpeed;
@@ -222,23 +279,16 @@ export function useGameStatus(_onGameLoopTick?: (deltaTime: number, letters: Fal
// Handle line transitions for sentences and paragraphs
if (gameModeRef.current === 'paragraphs' && fullText.length > 20) {
// Calculate line breaks for 4-line paragraphs
const numLines = 4;
const charsPerLine = Math.ceil(fullText.length / numLines);
const lineBreakPoints: number[] = [];
// Calculate line breaks using dynamic line count (must match rendering)
const numLines = calculateLineCount(fullText);
const lines = breakTextIntoLines(fullText, numLines);
let startIdx = 0;
for (let i = 0; i < numLines - 1; i++) {
let endIdx = startIdx + charsPerLine;
// Find nearest space to break at
for (let j = endIdx; j >= endIdx - 10 && j >= startIdx; j--) {
if (fullText[j] === ' ') {
endIdx = j;
break;
}
}
lineBreakPoints.push(endIdx);
startIdx = endIdx + 1;
// Calculate line break points (end of each line)
const lineBreakPoints: number[] = [];
let totalChars = 0;
for (let i = 0; i < lines.length - 1; i++) {
totalChars += lines[i].length;
lineBreakPoints.push(totalChars - 1); // Point to the last character of the line
}
// Check if we just completed a line