chore: initialize typing-chud project with Unified API structure

- Copy vim-snake structure stripped of game-specific logic
- Remove finalScore calculation (raw data only)
- Setup example app with keybinding state management
- Empty board renders and waits for game logic
This commit is contained in:
2025-11-03 21:36:09 +00:00
commit 80f7aa3f8a
40 changed files with 3987 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useRef } from "react";
import { useGame } from "@vimazing/typing-chud";
import "@vimazing/typing-chud/game.css";
import type { GameManager } from "@vimazing/typing-chud";
import { useKeyBindings } from "./useKeyBindings";
function App() {
const gameManager = useGame({}, useKeyBindings);
const { containerRef, gameStatus, scoreManager, renderBoard } = gameManager;
const gameManagerRef = useRef<GameManager>(gameManager);
useEffect(() => {
gameManagerRef.current = gameManager;
}, [gameManager]);
useEffect(() => {
renderBoard();
}, [renderBoard]);
const formatTime = (ms: number) => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
};
return (
<div className="relative mx-auto my-4 w-fit space-y-4">
<h1 className="text-2xl font-bold text-center">Typing Chud</h1>
<div className="flex gap-4 justify-center text-sm font-mono">
<div className="px-3 py-1 bg-muted rounded">
Time: {formatTime(scoreManager.timeValue)}
</div>
<div className="px-3 py-1 bg-muted rounded">
Keystrokes: {scoreManager.totalKeystrokes}
</div>
</div>
<div ref={containerRef} className="relative" />
<div className="text-center text-sm text-muted-foreground">
{gameStatus === "waiting" && <p>Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to start</p>}
{gameStatus === "started" && <p>Game running...</p>}
{gameStatus === "game-over" && <p>Game Over! Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to restart</p>}
{gameStatus === "game-won" && <p>You Won! Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to restart</p>}
</div>
</div>
);
}
export default App;
+111
View File
@@ -0,0 +1,111 @@
import type { GameStatus } from "@vimazing/vim-maze";
import type { UseScoreType } from "@vimazing/vim-maze";
import { useIsMobile } from "./useIsMobile";
import { MobileGameOverlay } from "./MobileGameOverlay";
type GameOverlayProps = {
gameStatus: GameStatus;
scoreManager: UseScoreType;
};
export const GameOverlay = ({ gameStatus, scoreManager }: GameOverlayProps) => {
const isMobile = useIsMobile();
if (isMobile) return <MobileGameOverlay gameStatus={gameStatus} scoreManager={scoreManager} />
// 🧠 Only show overlay when the game has ended
if (['started', 'hasKey'].includes(gameStatus)) return null;
// console.log('gameStatus:', gameStatus)
return (
<GameOverlayContainer>
{gameStatus === "waiting" && <GameWaitingContent />}
{gameStatus === "game-over" && <GameOverContent />}
{gameStatus === "game-won" && <GameWonContent scoreManager={scoreManager} />}
</GameOverlayContainer>
);
};
export const GameWaitingContent = () => {
return (
<div
className="text-[#c8d3f5] text-[2em] font-mono text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
<div className="flex flex-col gap-10">
<p className="text-[3rem]">Press <strong className="text-[#ffc777]">i</strong> to play...</p>
<p><strong className="text-[#ffc777]">h</strong>,<strong className="text-[#ffc777]">j</strong>,<strong className="text-[#ffc777]">k</strong>,<strong className="text-[#ffc777]">l</strong> to move around</p>
<p>try other vim motions <br /> you're familiar with</p>
<p>
<strong className="text-[#ffc777]">$</strong>,<strong className="text-[#ffc777]">^</strong>,<strong className="text-[#ffc777]">0</strong>,<strong className="text-[#ffc777]">G</strong>,<strong className="text-[#ffc777]">gg</strong> only valid if <br />
the edges are reachable
</p>
</div>
</div>
);
};
export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) => {
const finalScore = scoreManager.finalScore ?? 0;
return (
<div
className="text-[#c8d3f5] text-[2em] grid gap-4 font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
<p className="text-[#c3e88d]">Congratulations!</p>
<p>Final Score:</p>
<p className="text-[#82aaff] text-[2.5em]">{finalScore.toLocaleString()}</p>
<p className="text-[#828bb8] text-[0.65em] normal-case">
<span className="text-[#c099ff]">min</span>
<span className="text-[#c8d3f5]">(</span>
<span className="text-[#ff966c]">100000</span>
<span className="text-[#c8d3f5]">, (</span>
<span className="text-[#82aaff]">optimalTime</span>
<span className="text-[#c8d3f5]"> / </span>
<span className="text-[#82aaff]">actualTime</span>
<span className="text-[#c8d3f5]">) * </span>
<span className="text-[#ff966c]">100000</span>
<span className="text-[#c8d3f5]">)</span>
</p>
<p className="text-[#828bb8]">Press <strong className="text-[#ffc777] normal-case">i</strong> to play again</p>
</div>
);
};
export const GameOverContent = () => {
return (
<div
className="text-[#ff757f] font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
<p className="text-[5em]">Game Over</p>
<p className="text-[2em] text-[#828bb8]">Press <strong className="text-[#ffc777] normal-case">i</strong> to play again</p>
</div>
);
};
export const GameOverlayContainer = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<div
className="
absolute inset-0
flex justify-center items-center
z-40
pointer-events-none
"
style={{
backgroundColor: 'rgba(27, 29, 43, 0.75)',
backdropFilter: 'blur(1px)'
}}
>
{children}
</div>
);
};
+99
View File
@@ -0,0 +1,99 @@
import type { GameStatus } from "@vimazing/vim-maze";
import type { UseScoreType } from "@vimazing/vim-maze";
type MobileGameOverlayProps = {
gameStatus: GameStatus;
scoreManager: UseScoreType;
};
export const MobileGameOverlay = ({ gameStatus, scoreManager }: MobileGameOverlayProps) => {
// 🧠 Only show overlay when the game has ended
if (['started', 'hasKey'].includes(gameStatus)) return null;
// console.log('gameStatus:', gameStatus)
return (
<GameOverlayContainer>
{gameStatus === "waiting" && <GameWaitingContent />}
{gameStatus === "game-over" && <GameOverContent />}
{gameStatus === "game-won" && <GameWonContent scoreManager={scoreManager} />}
</GameOverlayContainer>
);
};
export const GameWaitingContent = () => {
return (
<div
className="text-[#c8d3f5] text-[1em] font-mono text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
<div className="flex flex-col gap-3">
<p className="text-[1.5rem]">Press here to play</p>
<p>all the vim motions <br /> available on desktop</p>
</div>
</div>
);
};
export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) => {
const finalScore = scoreManager.finalScore ?? 0;
return (
<div
className="text-[#c8d3f5] text-[1em] grid gap-4 font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
<p className="text-[#c3e88d]">Congratulations!</p>
<p>Final Score:</p>
<p className="text-[#82aaff] text-[2.5em]">{finalScore.toLocaleString()}</p>
<p className="text-[#828bb8] text-[0.65em] normal-case">
<span className="text-[#c099ff]">min</span>
<span className="text-[#c8d3f5]">(</span>
<span className="text-[#ff966c]">100000</span>
<span className="text-[#c8d3f5]">, (</span>
<span className="text-[#82aaff]">optimalTime</span>
<span className="text-[#c8d3f5]"> / </span>
<span className="text-[#82aaff]">actualTime</span>
<span className="text-[#c8d3f5]">) * </span>
<span className="text-[#ff966c]">100000</span>
<span className="text-[#c8d3f5]">)</span>
</p>
<p className="text-[#828bb8]">Press to play again</p>
</div>
);
};
export const GameOverContent = () => {
return (
<div
className="text-[#ff757f] text-[5em] font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
Game Over
</div>
);
};
export const GameOverlayContainer = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<div
className="
absolute inset-0
flex justify-center items-center
z-40
pointer-events-none
"
style={{
backgroundColor: 'rgba(27, 29, 43, 0.75)',
backdropFilter: 'blur(1px)'
}}
>
{children}
</div>
);
};
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+30
View File
@@ -0,0 +1,30 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean>(
typeof window !== "undefined"
? window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches
: false
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = (event: MediaQueryListEvent) => {
setIsMobile(event.matches);
};
// Set initial value
setIsMobile(mql.matches);
// Add listener
mql.addEventListener("change", onChange);
// Cleanup
return () => mql.removeEventListener("change", onChange);
}, []);
return isMobile;
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect } from "react";
import type { GameManager } from "../../src";
export const useKeyBindings = (gameManager: GameManager) => {
const { gameStatus, renderBoard, startGame, quitGame, clearKeyLog } = gameManager;
useEffect(() => {
const handler = (ev: KeyboardEvent) => {
// start game
if (ev.code === "Space") {
if (["waiting", "game-over", "game-won"].includes(gameStatus)) {
clearKeyLog();
renderBoard();
if (gameStatus === "game-over" || gameStatus === "game-won") {
quitGame();
setTimeout(() => startGame(), 0);
} else {
startGame();
}
return;
}
}
// ignore other keys while waiting
if (gameStatus === "waiting") return;
// quit
if (ev.key === "q" || ev.key === "Q") {
quitGame();
return;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [gameStatus]);
};
+11
View File
@@ -0,0 +1,11 @@
import { useState, useEffect } from 'react';
export const useMounted = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted;
};