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
Executable
+149
View File
@@ -0,0 +1,149 @@
/* =========================================================
🔧 Controls (tweak these)
========================================================= */
:root {
--snake-cell-size: 32px;
}
/* =========================================================
Container & Board Base
========================================================= */
#snake-board {
position: relative;
background-color: var(--card, #1e2030);
border-radius: 0.5rem;
box-shadow: 0 0 10px rgba(27, 29, 43, 0.6), inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 5%, transparent);
padding: 4px;
}
/* =========================================================
Snake Board Layout
========================================================= */
.snake-row {
display: flex;
}
.snake-cell {
width: var(--snake-cell-size);
height: var(--snake-cell-size);
background-color: var(--muted, #2f334d);
border: 1px solid var(--border, #3b3f5c);
box-sizing: border-box;
transition: background-color 0.1s ease;
}
/* =========================================================
Snake Rendering
========================================================= */
.snake-cell.snake-head {
background-color: var(--accent, #ffc777);
box-shadow:
0 0 8px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
inset 0 0 4px color-mix(in srgb, var(--accent, #ffc777) 90%, transparent);
position: relative;
}
.snake-cell.snake-head.dir-up {
border-radius: 6px 6px 2px 2px;
}
.snake-cell.snake-head.dir-down {
border-radius: 2px 2px 6px 6px;
}
.snake-cell.snake-head.dir-left {
border-radius: 6px 2px 2px 6px;
}
.snake-cell.snake-head.dir-right {
border-radius: 2px 6px 6px 2px;
}
.snake-cell.snake-head::after {
content: "";
position: absolute;
top: 25%;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 30%;
background: radial-gradient(circle, var(--accent-foreground, #1b1d2b) 30%, transparent 60%);
opacity: 0.8;
}
.snake-cell.snake-body {
background-color: var(--accent, #ffc777);
opacity: 0.7;
box-shadow: inset 0 0 3px color-mix(in srgb, var(--accent, #ffc777) 50%, transparent);
}
.snake-cell.snake-tail {
background-color: var(--accent, #ffc777);
opacity: 0.5;
box-shadow: 0 0 4px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent);
}
.snake-cell.snake-tail.dir-up {
border-radius: 6px 6px 2px 2px;
}
.snake-cell.snake-tail.dir-down {
border-radius: 2px 2px 6px 6px;
}
.snake-cell.snake-tail.dir-left {
border-radius: 6px 2px 2px 6px;
}
.snake-cell.snake-tail.dir-right {
border-radius: 2px 6px 6px 2px;
}
/* =========================================================
Collision Effect
========================================================= */
.snake-cell.collision {
background-color: var(--destructive, #c53b53) !important;
box-shadow:
0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 90%, transparent),
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 60%, transparent),
inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent) !important;
animation: collision-pulse 0.5s ease-in-out infinite alternate;
}
@keyframes collision-pulse {
0% {
box-shadow:
0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 90%, transparent),
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 60%, transparent),
inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent);
}
100% {
box-shadow:
0 0 20px color-mix(in srgb, var(--destructive, #c53b53) 100%, transparent),
0 0 40px color-mix(in srgb, var(--destructive, #c53b53) 80%, transparent),
inset 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 90%, transparent);
}
}
/* =========================================================
Food Rendering
========================================================= */
.snake-cell.food {
background-color: var(--chart-2, #c3e88d);
border-radius: 50%;
box-shadow:
0 0 8px color-mix(in srgb, var(--chart-2, #c3e88d) 60%, transparent),
inset 0 0 4px color-mix(in srgb, var(--chart-2, #c3e88d) 80%, transparent);
position: relative;
}
.snake-cell.food::after {
content: "🍎";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
}
+36
View File
@@ -0,0 +1,36 @@
export const gameInfo = {
name: 'Typing Chud',
description: 'TODO: Game description',
controls: {
navigation: [
{ keys: 'hjkl', description: 'TODO: Navigation controls' },
],
game: [
{ keys: 'Space', description: 'Start game' },
{ keys: 'q', description: 'Quit game' },
],
},
rules: {
// TODO: Define game rules
},
scoring: {
formula: 'TODO: Scoring formula',
range: '0 - 1000 points',
penalties: [],
examples: [],
},
gameOver: {
conditions: [
// TODO: Define game over conditions
],
},
objective: 'TODO: Game objective',
winCondition: 'TODO: Win condition',
} as const;
export type GameInfo = typeof gameInfo;
Executable
+8
View File
@@ -0,0 +1,8 @@
export * from './useGame';
export * from './useCursor';
export * from './useGameStatus';
export * from './useBoard';
export * from './useScore';
export * from './types';
export * from './gameInfo';
Executable
+133
View File
@@ -0,0 +1,133 @@
import type { RefObject } from 'react';
// ============================================================================
// Core Game Types
// ============================================================================
export type GameStatus = 'waiting' | 'started' | 'game-won' | 'game-over';
export type GamePhase = 'idle' | 'playing';
export type PlayStatus = 'started' | 'game-over' | 'game-won';
export const isPlaying = (status: GameStatus): status is PlayStatus =>
status === 'started' || status === 'game-over' || status === 'game-won';
export const getGamePhase = (status: GameStatus): GamePhase =>
status === 'waiting' ? 'idle' : 'playing';
// ============================================================================
// Game-Specific Types (to be defined)
// ============================================================================
// TODO: Define game-specific types here
// ============================================================================
// Input & Key Tracking
// ============================================================================
export type KeyLogEntry = { key: string; timestamp: number };
export type GameOptions = {
// TODO: Define game options
};
// ============================================================================
// Manager Interfaces - Board
// ============================================================================
export type BoardManager = {
containerRef: RefObject<HTMLDivElement | null>;
renderBoard: (cols: number, rows: number) => void;
};
// ============================================================================
// Manager Interfaces - Cursor/Snake
// ============================================================================
export type CursorMode = 'normal';
export type Coord = { row: number; col: number };
export type CursorManager = {
position: () => Coord;
mode: () => CursorMode;
// VIM-style motions (required by Unified API)
moveLeft: (count?: number) => void;
moveRight: (count?: number) => void;
moveUp: (count?: number) => void;
moveDown: (count?: number) => void;
moveToStart: () => void;
moveToEnd: () => void;
moveToTop: () => void;
moveToBottom: () => void;
repeatLastMotion: () => void;
};
// TODO: Define game-specific CursorManager extension if needed
// ============================================================================
// Manager Interfaces - Score
// ============================================================================
export type ScoreManager = {
timeValue: number;
totalKeystrokes: number;
};
// ============================================================================
// Manager Interfaces - Game Status
// ============================================================================
export type GameStatusManager = {
gameStatus: GameStatus;
setGameStatus: (status: GameStatus) => void;
startGame: () => void;
quitGame: () => void;
level: number;
score: number;
togglePause: () => void;
};
// ============================================================================
// Manager Interfaces - Key Tracking
// ============================================================================
export type GameKeyManager = {
keyLog: KeyLogEntry[];
clearKeyLog: () => void;
getKeyLog: () => KeyLogEntry[];
};
// ============================================================================
// Unified Game Manager - Main Interface
// ============================================================================
export type GameManager = {
// Required rendering
containerRef: RefObject<HTMLDivElement | null>;
renderBoard: () => void;
// Required managers
cursor: CursorManager;
scoreManager: ScoreManager;
// Required lifecycle
gameStatus: GameStatus;
setGameStatus: (status: GameStatus) => void;
startGame: () => void;
quitGame: () => void;
// Required key tracking
keyLog: KeyLogEntry[];
clearKeyLog: () => void;
getKeyLog: () => KeyLogEntry[];
// TODO: Add game-specific additions
};
// ============================================================================
// Hook Return Types
// ============================================================================
// Forward declarations for hook return types
// These will be imported from their respective modules
+1
View File
@@ -0,0 +1 @@
export * from './useBoard';
+19
View File
@@ -0,0 +1,19 @@
import { useRef } from "react";
export function useBoard() {
const containerRef = useRef<HTMLDivElement | null>(null);
const renderBoard = () => {
const container = containerRef.current;
if (!container) return;
// TODO: Implement board rendering
};
return {
containerRef,
renderBoard,
};
}
export type UseBoardType = ReturnType<typeof useBoard>;
+1
View File
@@ -0,0 +1 @@
export * from './useCursor';
+58
View File
@@ -0,0 +1,58 @@
import { useState } from "react";
import type { Coord } from "../types";
export function useCursor() {
const [position] = useState<Coord>({ row: 0, col: 0 });
const moveLeft = (_count?: number) => {
// TODO: Implement movement
};
const moveRight = (_count?: number) => {
// TODO: Implement movement
};
const moveUp = (_count?: number) => {
// TODO: Implement movement
};
const moveDown = (_count?: number) => {
// TODO: Implement movement
};
const moveToStart = () => {
// TODO: Implement anchor motion
};
const moveToEnd = () => {
// TODO: Implement anchor motion
};
const moveToTop = () => {
// TODO: Implement anchor motion
};
const moveToBottom = () => {
// TODO: Implement anchor motion
};
const repeatLastMotion = () => {
// TODO: Implement repeat
};
return {
position: () => position,
mode: () => 'normal' as const,
moveLeft,
moveRight,
moveUp,
moveDown,
moveToStart,
moveToEnd,
moveToTop,
moveToBottom,
repeatLastMotion,
};
}
export type UseCursorType = ReturnType<typeof useCursor>;
+1
View File
@@ -0,0 +1 @@
export * from './useGame';
+3
View File
@@ -0,0 +1,3 @@
export function useBoard() {
return {};
}
+40
View File
@@ -0,0 +1,40 @@
import { useState } from "react";
import type { GameManager, GameOptions, KeyLogEntry } from "../types";
import { useBoard } from "../useBoard";
import { useCursor } from "../useCursor";
import { useGameStatus } from "../useGameStatus";
import { useScore } from "../useScore";
export function useGame(
_options?: GameOptions,
platformHook?: unknown
): GameManager {
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
const boardManager = useBoard();
const cursorManager = useCursor();
const gameStatusManager = useGameStatus();
const scoreManager = useScore(gameStatusManager);
const gameManager: GameManager = {
containerRef: boardManager.containerRef,
renderBoard: boardManager.renderBoard,
gameStatus: gameStatusManager.gameStatus,
setGameStatus: gameStatusManager.setGameStatus,
startGame: gameStatusManager.startGame,
quitGame: gameStatusManager.quitGame,
cursor: cursorManager,
scoreManager,
keyLog,
clearKeyLog: () => setKeyLog([]),
getKeyLog: () => [...keyLog],
};
if (typeof platformHook === 'function') {
platformHook(gameManager);
}
return gameManager;
}
export type UseGameType = ReturnType<typeof useGame>;
+1
View File
@@ -0,0 +1 @@
export * from './useGameStatus';
+23
View File
@@ -0,0 +1,23 @@
import { useState } from "react";
import type { GameStatus } from "../types";
export function useGameStatus() {
const [gameStatus, setGameStatus] = useState<GameStatus>("waiting");
const startGame = () => {
setGameStatus("started");
};
const quitGame = () => {
setGameStatus("waiting");
};
return {
gameStatus,
setGameStatus,
startGame,
quitGame,
};
}
export type UseGameStatusType = ReturnType<typeof useGameStatus>;
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef, useState } from "react";
export type UseTimerReturn = {
timeValue: number;
startTimer: () => void;
stopTimer: () => void;
resetTimer: () => void;
};
export type UseTimerOptions = {
/**
* Interval (in ms) between updates. Defaults to 100ms.
*/
interval?: number;
};
export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
const { interval = 100 } = options;
const [timeValue, setTimeValue] = useState(0);
const intervalIdRef = useRef<ReturnType<typeof setInterval> | null>(null);
const offsetRef = useRef(0);
const startTimeRef = useRef<number | null>(null);
const update = useCallback(() => {
if (startTimeRef.current === null) {
return;
}
const now = performance.now();
setTimeValue(offsetRef.current + (now - startTimeRef.current));
}, []);
const startTimer = useCallback(() => {
if (intervalIdRef.current !== null) {
return;
}
if (startTimeRef.current === null) {
startTimeRef.current = performance.now();
}
update();
intervalIdRef.current = setInterval(update, Math.max(16, interval));
}, [interval, update]);
const stopTimer = useCallback(() => {
if (intervalIdRef.current !== null) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
}
if (startTimeRef.current !== null) {
update();
offsetRef.current += performance.now() - startTimeRef.current;
startTimeRef.current = null;
}
}, [update]);
const resetTimer = useCallback(() => {
if (intervalIdRef.current !== null) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
}
startTimeRef.current = null;
offsetRef.current = 0;
setTimeValue(0);
}, []);
useEffect(() => {
return () => {
if (intervalIdRef.current !== null) {
clearInterval(intervalIdRef.current);
}
};
}, []);
return {
timeValue,
startTimer,
stopTimer,
resetTimer,
};
}
+1
View File
@@ -0,0 +1 @@
export * from './useScore';
+23
View File
@@ -0,0 +1,23 @@
import { useState, useEffect } from "react";
import type { ScoreManager } from "../types";
export function useScore(gameStatusManager: any): ScoreManager {
const [timeValue, setTimeValue] = useState(0);
useEffect(() => {
if (gameStatusManager.gameStatus !== 'started') return;
const interval = setInterval(() => {
setTimeValue(prev => prev + 100);
}, 100);
return () => clearInterval(interval);
}, [gameStatusManager.gameStatus]);
return {
timeValue,
totalKeystrokes: 0, // TODO: Track keystrokes
};
}
export type UseScoreType = ReturnType<typeof useScore>;
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useRef } from "react";
import type { UseTimerReturn } from "./hooks/useTimer";
import type { GameStatus } from "../types";
type UseScoreTimeParams = {
gameStatus: GameStatus;
timer: UseTimerReturn;
};
export function useScoreTime({ gameStatus, timer }: UseScoreTimeParams) {
const prevStatusRef = useRef<GameStatus | null>(null);
const { startTimer, stopTimer, resetTimer } = timer;
useEffect(() => {
prevStatusRef.current = gameStatus;
switch (gameStatus) {
case "started":
resetTimer();
startTimer();
break;
case "game-over":
case "game-won":
case "waiting":
stopTimer();
break;
}
}, [gameStatus, startTimer, stopTimer, resetTimer]);
}