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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user