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:
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user