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
@@ -51,7 +51,7 @@ Full words fall from the top. Type each character of the word sequentially.
Multi-line sentences with perspective effect. Continue typing through line breaks.
### Paragraphs
4-line paragraphs with advanced perspective and smooth line transitions.
6-line paragraphs with advanced perspective and smooth line transitions.
## API
+27 -20
View File
@@ -1,14 +1,21 @@
import { useState } from "react";
import { useGame, type GameMode } from "@vimazing/the-last-startyper";
import { useGame, type GameMode, type GameOptions } from "@vimazing/the-last-startyper";
import "@vimazing/the-last-startyper/game.css";
import { usePlatformHook, useStateMachine, getScenario, getScenarioNames } from "./usePlatformHook";
import { greatFallsParagraphs } from "./usePlatformHook/greatFallsContent";
function App() {
const [scenarioId, setScenarioId] = useState<string>("progressive");
const scenario = getScenario(scenarioId);
const [gameMode, setGameMode] = useState<GameMode>(scenario.initialMode);
const gameManager = useGame({ gameMode }, usePlatformHook);
const { containerRef, gameStatus, scoreManager } = gameManager;
const [scenarioId, setScenarioId] = useState<string>("progressive");
const scenario = getScenario(scenarioId);
const [gameMode, setGameMode] = useState<GameMode>(scenario.initialMode);
const getGameOptions = (mode: GameMode): GameOptions => ({
gameMode: mode,
...(mode === 'paragraphs' && { wordList: greatFallsParagraphs }),
});
const gameManager = useGame(getGameOptions(gameMode), usePlatformHook);
const { containerRef, gameStatus, scoreManager } = gameManager;
useStateMachine({
gameManager,
@@ -34,12 +41,12 @@ function App() {
<label className="text-xs text-muted-foreground">Test Scenario:</label>
<select
value={scenarioId}
onChange={(e) => {
setScenarioId(e.target.value);
const newScenario = getScenario(e.target.value);
setGameMode(newScenario.initialMode);
gameManager.changeGameMode({ gameMode: newScenario.initialMode });
}}
onChange={(e) => {
setScenarioId(e.target.value);
const newScenario = getScenario(e.target.value);
setGameMode(newScenario.initialMode);
gameManager.changeGameMode(getGameOptions(newScenario.initialMode));
}}
className="w-full px-2 py-1 rounded text-sm bg-muted text-foreground border border-muted-foreground"
>
{getScenarioNames().map(({ id, name }) => (
@@ -54,14 +61,14 @@ function App() {
<div className="text-center">
<div className="text-xs text-muted-foreground mb-2">Mode Selection:</div>
<div className="flex gap-2 justify-center text-sm">
{(['letters', 'words', 'sentences', 'paragraphs'] as const).map((mode) => (
<button
key={mode}
onClick={(e) => {
setGameMode(mode);
gameManager.changeGameMode({ gameMode: mode });
e.currentTarget.blur();
}}
{(['letters', 'words', 'sentences', 'paragraphs'] as const).map((mode) => (
<button
key={mode}
onClick={(e) => {
setGameMode(mode);
gameManager.changeGameMode(getGameOptions(mode));
e.currentTarget.blur();
}}
className={`px-3 py-1 rounded capitalize font-medium transition ${gameMode === mode
? 'bg-blue-600 text-white'
: 'bg-muted text-foreground hover:bg-blue-500 hover:text-white'
@@ -0,0 +1,33 @@
export const greatFallsParagraphs = [
"It takes ten years for the culture of a great company to fall apart once the CEO seat is given to someone without an engineering or product background. That's been the story of Boeing, Intel, and now Apple.",
"Legendary American companies that all got lost when a bean counter, marketing man, or logistics hand took over.",
"Boeing's troubles started when they were taken over by McDonnell Douglas in 1997, but really accelerated after 2005 when they installed their first CEO with no aerospace background.",
"The result, after ten years of cost-cutting and outsourcing, was the 737 MAX MCAS tragedies, and an organization gutted of ambition and engineering pride.",
"Intel did the same thing, and almost at the same time. In 2005, they too installed their first CEO without an engineering background.",
"Ten years later, they were stumbling with delayed nodes, stalled progress, and no answers on mobile. Now the entire business is teetering.",
"Finally, Apple. Steve Jobs handed the reins to Tim Cook in 2011, but such was the strength of the product pipeline and culture that Jobs left behind, that it initially looked like Cook could break the spell.",
"Show that it was possible for a logistics man to steer one of the great ships of American ingenuity and tech supremacy.",
"But now the ten-year curse is hitting Apple with an eerily familiar thud. They wasted a decade chasing a self-driving dream without direction, and ended up with the worst possible car interface to show for it.",
"They completely missed the boat on AI, and embarrassed themselves with Genmoji and vaporware ads. And the Vision pro has been an expensive tech demo that nobody actually wanted to wear three months after they bought it.",
"The profits still gush from glories past, and the tollbooth operation on the App Store, but the soul has left the machine.",
"While these three stories are different, they're drawn from the same archetype: Great companies need bold, hands-on leaders who live and breathe the stuff they make or they'll eventually hollow out.",
"It's tempting for boards of public companies to think that all care and competence around product can be delegated down the org chart. That someone who can hit the numbers is all you need at the top.",
"But it's not.",
"You need an Andy Grove, Phil Condit, or Scott Forstall. You need someone so professionally invested in the work and the culture that they'll refuse to let the search for surface-level efficiencies drain the foundation of its strength.",
"You need an engineer or a product person as CEO."
];
@@ -30,12 +30,12 @@ export const usePlatformHook = (gameManager: GameManager) => {
return;
}
// handle letter and punctuation typing during game
if (gameManager.gameStatus === "started") {
if (ev.key.length === 1 && /[a-zA-Z.,!? ]/.test(ev.key)) {
gameManager.handleTypedLetter(ev.key);
}
}
// handle letter and punctuation typing during game
if (gameManager.gameStatus === "started") {
if (ev.key.length === 1 && /[a-zA-Z0-9.,!?;:'"()\-/& ]/.test(ev.key)) {
gameManager.handleTypedLetter(ev.key);
}
}
};
window.addEventListener("keydown", handler);
+18 -12
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import type { GameMode, GameManager } from "@vimazing/the-last-startyper";
import type { GameMode, GameManager, GameOptions } from "@vimazing/the-last-startyper";
import type { TestScenario } from "./testStateMachines";
import { greatFallsParagraphs } from "./greatFallsContent";
interface UseStateMachineProps {
gameManager: GameManager;
@@ -9,6 +10,11 @@ interface UseStateMachineProps {
onModeChange: (newMode: GameMode) => void;
}
const getGameOptionsForMode = (mode: GameMode): GameOptions => ({
gameMode: mode,
...(mode === 'paragraphs' && { wordList: greatFallsParagraphs }),
});
export const useStateMachine = ({
gameManager,
gameMode,
@@ -32,11 +38,11 @@ export const useStateMachine = ({
(t) => currentScore >= t.scoreThreshold && !triggeredScoreTransitionsRef.current.has(t.scoreThreshold)
);
if (nextTransition) {
triggeredScoreTransitionsRef.current.add(nextTransition.scoreThreshold);
onModeChange(nextTransition.targetMode);
gameManager.changeGameMode({ gameMode: nextTransition.targetMode });
}
if (nextTransition) {
triggeredScoreTransitionsRef.current.add(nextTransition.scoreThreshold);
onModeChange(nextTransition.targetMode);
gameManager.changeGameMode(getGameOptionsForMode(nextTransition.targetMode));
}
return;
}
@@ -57,12 +63,12 @@ export const useStateMachine = ({
const currentTransition = scenario.transitions[currentTransitionIdx];
const currentCount = scoreManager.currentCount;
if (currentCount >= currentTransition.threshold) {
lastTransitionIndexRef.current = currentTransitionIdx;
lastTriggeredModeRef.current = gameMode;
onModeChange(currentTransition.targetMode);
gameManager.changeGameMode({ gameMode: currentTransition.targetMode });
}
if (currentCount >= currentTransition.threshold) {
lastTransitionIndexRef.current = currentTransitionIdx;
lastTriggeredModeRef.current = gameMode;
onModeChange(currentTransition.targetMode);
gameManager.changeGameMode(getGameOptionsForMode(currentTransition.targetMode));
}
}, [gameManager.scoreManager.currentCount, gameMode, gameManager, scenario, gameManager.gameStatus, gameManager.scoreManager.score]);
useEffect(() => {
+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