feat: complete sentences mode with Star Wars effect and line transitions

- Implement two-line layout with Star Wars perspective (top line 80% size)
- Add smooth line transition animation when first line completes
- First line explodes independently, second line grows and moves down
- Fix space handling at line breaks (space included in first line)
- Ship stays centered for sentences mode
- Support punctuation input (commas, periods, exclamation, question marks)
- Reduce font size to 20px for better readability
- Set falling speed to 25 pixels/second for sentences
This commit is contained in:
2025-11-04 17:42:25 +00:00
parent 9ed9a0510c
commit d1de98793a
4 changed files with 185 additions and 53 deletions
+3
View File
@@ -34,6 +34,9 @@ export type FallingLetter = {
// For word/sentence/paragraph modes
fullText?: string; // The complete word/sentence/paragraph
charIndex?: number; // Current character index being typed (0-based)
// For line transitions in sentences/paragraphs
lineTransition?: boolean;
lineTransitionTime?: number;
};
// ============================================================================
+152 -50
View File
@@ -18,8 +18,9 @@ const breakTextIntoLines = (text: string): [string, string] => {
}
}
const line1 = text.substring(0, breakPoint).trim();
const line2 = text.substring(breakPoint).trim();
// 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];
};
@@ -203,10 +204,41 @@ export function useBoard() {
ctx.shadowColor = "#ff0000";
ctx.fillStyle = "#ff4444";
// Draw full text or just current letter
const displayText = letter.fullText || letter.letter;
const textWidth = ctx.measureText(displayText).width;
ctx.fillText(displayText, letterX - textWidth / 2, letter.y);
if (isSentenceMode && (gameMode === 'sentences' || gameMode === 'paragraphs')) {
// For sentences: break into two lines with Star Wars effect
const [line1, line2] = breakTextIntoLines(displayText);
ctx.save();
// Star Wars perspective effect
const perspectiveFactor = 0.8;
const vanishingPointX = CANVAS_WIDTH / 2;
// Inverted: line2 on top, line1 on bottom
const line2Y = letter.y - fontSize * 1.5;
const line1Y = letter.y + fontSize;
// Draw line2 on top with perspective
const line2FontSize = fontSize * perspectiveFactor;
ctx.font = `bold ${line2FontSize}px monospace`;
const line2CenterX = letterX + (vanishingPointX - letterX) * 0.2;
const line2Width = ctx.measureText(line2).width;
ctx.fillText(line2, line2CenterX - line2Width / 2, line2Y);
// Draw line1 on bottom (full size)
ctx.font = `bold ${fontSize}px monospace`;
const line1Width = ctx.measureText(line1).width;
ctx.fillText(line1, letterX - line1Width / 2, line1Y);
ctx.restore();
} else {
// Single line for letters/words
const textWidth = ctx.measureText(displayText).width;
ctx.fillText(displayText, letterX - textWidth / 2, letter.y);
}
ctx.shadowBlur = 0;
} else if (letter.state === 'exploding') {
// Explosion effect
@@ -236,8 +268,29 @@ export function useBoard() {
// Full text fading
ctx.fillStyle = "#ffffff";
const displayText = letter.fullText || letter.letter;
const textWidth = ctx.measureText(displayText).width;
ctx.fillText(displayText, -textWidth / 2, 0);
if (isSentenceMode && (gameMode === 'sentences' || gameMode === 'paragraphs')) {
// For sentences: break into two lines during explosion with perspective
const [line1, line2] = breakTextIntoLines(displayText);
// Star Wars perspective for explosion
const perspectiveFactor = 0.8;
// Draw line2 on top with smaller font
ctx.save();
ctx.font = `bold ${fontSize * perspectiveFactor}px monospace`;
const line2Width = ctx.measureText(line2).width;
ctx.fillText(line2, -line2Width / 2, -fontSize * 1.5);
ctx.restore();
// Draw line1 on bottom (normal size)
const line1Width = ctx.measureText(line1).width;
ctx.fillText(line1, -line1Width / 2, fontSize);
} else {
// Single line for letters/words
const textWidth = ctx.measureText(displayText).width;
ctx.fillText(displayText, -textWidth / 2, 0);
}
ctx.restore();
} else {
@@ -245,49 +298,98 @@ export function useBoard() {
const fullText = letter.fullText || letter.letter;
const charIndex = letter.charIndex ?? 0;
if (isSentenceMode && (gameMode === 'sentences' || gameMode === 'paragraphs')) {
// For sentences/paragraphs: break into two lines
const [line1, line2] = breakTextIntoLines(fullText);
const line1Length = line1.length;
// Line 1 Y position (slightly above center)
const line1Y = letter.y - fontSize;
const line2Y = letter.y + fontSize;
// Render line 1
const line1Width = ctx.measureText(line1).width;
let line1X = letterX - line1Width / 2;
for (let i = 0; i < line1.length; i++) {
const char = line1[i];
if (i < charIndex) {
ctx.fillStyle = "#00ff00"; // Green - completed
} else if (i === charIndex) {
ctx.fillStyle = "#ffff00"; // Yellow - current
} else {
ctx.fillStyle = "#ffffff"; // White - remaining
}
ctx.fillText(char, line1X, line1Y);
line1X += ctx.measureText(char).width;
}
// Render line 2
const line2Width = ctx.measureText(line2).width;
let line2X = letterX - line2Width / 2;
for (let i = 0; i < line2.length; i++) {
const charIdx = line1Length + 1 + i; // Account for space between lines
const char = line2[i];
if (charIdx < charIndex) {
ctx.fillStyle = "#00ff00"; // Green - completed
} else if (charIdx === charIndex) {
ctx.fillStyle = "#ffff00"; // Yellow - current
} else {
ctx.fillStyle = "#ffffff"; // White - remaining
}
ctx.fillText(char, line2X, line2Y);
line2X += ctx.measureText(char).width;
}
if (isSentenceMode && (gameMode === 'sentences' || gameMode === 'paragraphs')) {
// For sentences/paragraphs: break into two lines with Star Wars effect
const [line1, line2] = breakTextIntoLines(fullText);
const line1Length = line1.length;
// Check if we're in a line transition animation
const isTransitioning = letter.lineTransition && letter.lineTransitionTime;
const transitionProgress = isTransitioning
? Math.min((performance.now() - letter.lineTransitionTime) / 500, 1) // 500ms animation
: 0;
// Star Wars perspective effect
const perspectiveFactor = isTransitioning
? 0.8 + (0.2 * transitionProgress) // Grow from 0.8 to 1.0
: 0.8;
const vanishingPointX = CANVAS_WIDTH / 2;
// Positions with animation
const line2Y = letter.y - fontSize * (isTransitioning ? 1.5 - transitionProgress * 0.5 : 1.5);
const line1Y = letter.y + fontSize;
// If transitioning, line1 explodes independently
if (isTransitioning && transitionProgress < 0.6) {
// Draw exploding line1
const explosionProgress = transitionProgress / 0.6; // Complete in 60% of transition
const explosionScale = 1 + explosionProgress * 2;
const explosionOpacity = 1 - explosionProgress;
ctx.save();
ctx.translate(letterX, line1Y);
ctx.scale(explosionScale, explosionScale);
ctx.globalAlpha = explosionOpacity;
ctx.fillStyle = "#ffff00";
const line1Width = ctx.measureText(line1).width;
ctx.fillText(line1, -line1Width / 2, 0);
ctx.restore();
}
// Save context for transformations
ctx.save();
// Render line 2 (with growing effect during transition)
const line2FontSize = fontSize * perspectiveFactor;
ctx.font = `bold ${line2FontSize}px monospace`;
const line2Width = ctx.measureText(line2).width;
// Apply perspective - top line moves toward vanishing point (less during transition)
const perspectiveAmount = isTransitioning ? 0.2 * (1 - transitionProgress) : 0.2;
const line2CenterX = letterX + (vanishingPointX - letterX) * perspectiveAmount;
let line2X = line2CenterX - line2Width / 2;
// Add slight transparency for depth (but make it more opaque during transition)
const topOpacity = isTransitioning ? 0.85 + 0.15 * transitionProgress : 0.85;
for (let i = 0; i < line2.length; i++) {
const charIdx = line1Length + i; // line1 already includes the space
const char = line2[i];
if (charIdx < charIndex) {
ctx.fillStyle = `rgba(0, 255, 0, ${topOpacity})`; // Green - completed
} else if (charIdx === charIndex) {
ctx.fillStyle = `rgba(255, 255, 0, ${topOpacity})`; // Yellow - current
} else {
ctx.fillStyle = `rgba(255, 255, 255, ${topOpacity})`; // White - remaining
}
ctx.fillText(char, line2X, line2Y);
line2X += ctx.measureText(char).width;
}
// Render line 1 only if not transitioning or in early phase of transition
if (!isTransitioning || transitionProgress > 0.6) {
// After transition, don't render line1 anymore
if (!isTransitioning) {
ctx.font = `bold ${fontSize}px monospace`;
const line1Width = ctx.measureText(line1).width;
let line1X = letterX - line1Width / 2;
for (let i = 0; i < line1.length; i++) {
const char = line1[i];
if (i < charIndex) {
ctx.fillStyle = "#00ff00"; // Green - completed
} else if (i === charIndex) {
ctx.fillStyle = "#ffff00"; // Yellow - current
} else {
ctx.fillStyle = "#ffffff"; // White - remaining
}
ctx.fillText(char, line1X, line1Y);
line1X += ctx.measureText(char).width;
}
}
}
ctx.restore();
} else {
// For letters/words: single line as before
const totalWidth = ctx.measureText(fullText).width;
+5 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import type { GameManager, GameOptions, KeyLogEntry, FallingLetter, GameMode } from "../types";
import { useBoard } from "../useBoard";
import { useCursor } from "../useCursor";
@@ -11,6 +11,7 @@ export function useGame(
): GameManager {
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
const [gameMode, setGameModeState] = useState<GameMode>(options?.initialGameMode ?? 'letters');
const gameModeRef = useRef<GameMode>(gameMode);
const boardManager = useBoard();
const cursorManager = useCursor();
@@ -18,7 +19,7 @@ export function useGame(
// Wrap renderBoard to include player position
const renderBoardWrapped = (letters: any = []) => {
boardManager.renderBoard(cursorManager.position(), letters, gameStatusManager.shipState, gameStatusManager.shipExplosionTime, gameMode);
boardManager.renderBoard(cursorManager.position(), letters, gameStatusManager.shipState, gameStatusManager.shipExplosionTime, gameModeRef.current);
};
// Game loop tick handler
@@ -28,7 +29,7 @@ export function useGame(
// For words/letters, auto-track to falling letters
// For sentences/paragraphs, keep ship centered
if (gameMode !== 'sentences' && gameMode !== 'paragraphs') {
if (gameModeRef.current !== 'sentences' && gameModeRef.current !== 'paragraphs') {
if (letters.length > 0) {
const letter = letters[0];
cursorManager.setTargetX(letter.x);
@@ -46,6 +47,7 @@ export function useGame(
// Update gameMode when it changes (without recreating gameStatus state)
useEffect(() => {
gameModeRef.current = gameMode;
gameStatusManager.setGameMode(gameMode);
}, [gameMode]);
+25
View File
@@ -137,6 +137,31 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall
const charIndex = (currentLetter.charIndex ?? 0) + 1;
const fullText = currentLetter.fullText ?? currentLetter.letter;
// For sentences/paragraphs, check if we completed the first line
const isSentenceMode = gameModeRef.current === 'sentences' || gameModeRef.current === 'paragraphs';
if (isSentenceMode && fullText.length > 20) {
// Find the midpoint where text breaks
const midpoint = Math.ceil(fullText.length / 2);
let breakPoint = midpoint;
for (let i = midpoint; i >= midpoint - 10 && i >= 0; i--) {
if (fullText[i] === ' ') {
breakPoint = i;
break;
}
}
// If we just completed the first line (typed the space at position breakPoint)
if (charIndex === breakPoint + 1) {
// Trigger line transition animation
currentLetter.lineTransition = true;
currentLetter.lineTransitionTime = performance.now();
// Continue to next character (first char of line 2)
currentLetter.charIndex = charIndex;
currentLetter.letter = fullText[charIndex];
return;
}
}
// Check if word is complete
if (charIndex >= fullText.length) {