From 8d40f4f62c74f020db34a6432e03075b574e33a8 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Thu, 16 Apr 2026 17:55:52 -0700 Subject: add warping space indicator on last word if necessary really not recommended to make lines long but this is like a fallback --- src/app/game/page.tsx | 100 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 8 deletions(-) diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx index aa953a7..b87fb00 100644 --- a/src/app/game/page.tsx +++ b/src/app/game/page.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, + useLayoutEffect, useMemo, useReducer, useRef, @@ -100,6 +101,9 @@ function GameInner() { const [clearShowing, setClearShowing] = useState(false); const [comboAnimKey, setComboAnimKey] = useState(0); const [countdown, setCountdown] = useState(0); + const charRowRef = useRef(null); + const charRefs = useRef<(HTMLSpanElement | null)[]>([]); + const [wrapSpaceIndicators, setWrapSpaceIndicators] = useState([]); const countdownIntervalRef = useRef(null); const [g, dispatch] = useReducer(gReducer, initialGState); @@ -121,6 +125,58 @@ function GameInner() { elapsedMs > 0 ? Math.round(g.totalCorrect / 5 / (elapsedMs / 60000)) : 0; const gRef = useRef(g); + const currentLineContent = + g.displayedLineIdx >= 0 ? gameLines[g.displayedLineIdx]?.content ?? "" : ""; + + useEffect(() => { + charRefs.current = []; + }, [currentLineContent]); + + useLayoutEffect(() => { + if (!charRowRef.current) return; + let frame = 0; + const text = currentLineContent.toLowerCase(); + + const recompute = () => { + const nodes = charRefs.current; + const indicators = new Array(text.length).fill(false); + for (let i = 0; i < text.length - 1; i += 1) { + if (text[i] !== " ") continue; + const curr = nodes[i]; + const next = nodes[i + 1]; + if (!curr || !next) continue; + const currRect = curr.getBoundingClientRect(); + const nextRect = next.getBoundingClientRect(); + if (nextRect.top - currRect.top > 1) { + indicators[i] = true; + } + } + setWrapSpaceIndicators(indicators); + }; + + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(recompute); + }); + }; + + schedule(); + + if (document.fonts?.ready) { + document.fonts.ready.then(schedule); + } + + const observer = new ResizeObserver(schedule); + observer.observe(charRowRef.current); + window.addEventListener("resize", schedule); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", schedule); + cancelAnimationFrame(frame); + }; + }, [currentLineContent]); useEffect(() => { gRef.current = g; }, [g]); @@ -381,6 +437,17 @@ function GameInner() { useEffect(() => { if (phase !== "playing") return; const handler = (e: KeyboardEvent) => { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + const audio = audioRef.current; + if (!audio) return; + e.preventDefault(); + const direction = e.key === "ArrowRight" ? 1 : -1; + const seekSeconds = 5; + const target = audio.currentTime + direction * seekSeconds; + const duration = Number.isFinite(audio.duration) ? audio.duration : target; + audio.currentTime = Math.min(Math.max(0, target), duration); + return; + } if (e.key.length === 1) { e.preventDefault(); handleKeyPress(e.key); @@ -526,12 +593,11 @@ function GameInner() { - {g.score.toLocaleString()} + {g.score.toLocaleString()} Score 0} key={`combo-${comboAnimKey}`} > @@ -540,7 +606,7 @@ function GameInner() { Combo - {accuracy}% + {accuracy}% Accuracy @@ -548,7 +614,7 @@ function GameInner() { WPM - {g.totalMiss} + {g.totalMiss} Misses @@ -586,10 +652,10 @@ function GameInner() { - + {(() => { - const text = - gameLines[g.displayedLineIdx].content.toLowerCase(); + const rawText = gameLines[g.displayedLineIdx].content; + const text = rawText.toLowerCase(); const tokens = text.split(/(\s+)/).filter(Boolean); let renderIndex = 0; return tokens.flatMap((token, tokenIdx) => { @@ -600,12 +666,26 @@ function GameInner() { else if (renderIndex === g.typedCount) state = wrongChar ? "wrong" : "active"; else state = "pending"; + const charIndex = renderIndex; + const showIndicator = + ch === " " && + wrapSpaceIndicators[charIndex] && + state !== "typed"; + const displayChar = + ch === " " + ? showIndicator + ? "␣" + : "\u00A0" + : ch; const element = ( { + charRefs.current[charIndex] = el; + }} > - {ch === " " ? "\u00A0" : ch} + {displayChar} ); renderIndex += 1; @@ -619,10 +699,14 @@ function GameInner() { else if (renderIndex === g.typedCount) state = wrongChar ? "wrong" : "active"; else state = "pending"; + const charIndex = renderIndex; const element = ( { + charRefs.current[charIndex] = el; + }} > {ch} -- cgit v1.2.3