diff --git a/public/ascii/frank.txt b/public/ascii/frank.txt new file mode 100644 index 0000000..02b3be0 --- /dev/null +++ b/public/ascii/frank.txt @@ -0,0 +1,8 @@ + ______ _ + | ____| | | + | |__ _ __ __ _ _ __ | | __ + | __| '__/ _` | '_ \| |/ / + | | | | | (_| | | | | < + |_| |_| \__,_|_| |_|_|\_\ + + was here. sorry about the server. diff --git a/public/ascii/gg.txt b/public/ascii/gg.txt new file mode 100644 index 0000000..7bfaffd --- /dev/null +++ b/public/ascii/gg.txt @@ -0,0 +1,4 @@ + ___ ___ + / __| / __| + | (_ | | (_ | + \___| \___| diff --git a/public/ascii/skull.txt b/public/ascii/skull.txt new file mode 100644 index 0000000..0f73d22 --- /dev/null +++ b/public/ascii/skull.txt @@ -0,0 +1,6 @@ + _____ + / \ + | () () | + \ ^ / + ||||| + ||||| diff --git a/public/ascii/sword.txt b/public/ascii/sword.txt new file mode 100644 index 0000000..d0b5a06 --- /dev/null +++ b/public/ascii/sword.txt @@ -0,0 +1,36 @@ + /\ + //\\ + // \\ + // \\ + |\ /| + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | + __ | | | | __ +/ \____| | | |____/ \ +) ___/ ) ( \___ ( +\__/ \__/ \__/ \__/ + \____/ + ) ( + / /\ \ + ) )( ( + | || | + | || | + | || | + | || | + | )( | + ) \/ ( + \____/ + (____) diff --git a/src/commands/handlers.ts b/src/commands/handlers.ts index 2e792e4..c333ca5 100644 --- a/src/commands/handlers.ts +++ b/src/commands/handlers.ts @@ -1,4 +1,5 @@ import type { LineColor } from '../components/BootSequence'; +import { BOOT_SCREENS } from '../components/MatrixRain'; export interface OutputLine { text: string; @@ -211,6 +212,30 @@ export const handlers: Record = { return [red(`rm: cannot remove '${args[args.length - 1]}': Permission denied`)]; }, + theme: (args) => { + if (args.length === 0) { + const current = localStorage.getItem('stift15-bootscreen') || 'logo'; + return [ + amber('Boot screen themes:'), + ...BOOT_SCREENS.map((s) => + green(` ${s === current ? '▸ ' : ' '}${s}`) + ), + dim(''), + dim(`Usage: theme `), + dim('Run "reboot" to see the new boot screen.'), + ]; + } + const name = args[0].toLowerCase(); + if (!BOOT_SCREENS.includes(name)) { + return [red(`theme: '${name}' not found. Available: ${BOOT_SCREENS.join(', ')}`)]; + } + localStorage.setItem('stift15-bootscreen', name); + return [ + green(`Boot screen set to '${name}'.`), + dim('Run "reboot" to see it.'), + ]; + }, + sudo: (args) => { if (args.length === 0) return [red('sudo: missing command')]; return [ diff --git a/src/components/CommandPrompt.tsx b/src/components/CommandPrompt.tsx index 8fc47c7..bd3f6d9 100644 --- a/src/components/CommandPrompt.tsx +++ b/src/components/CommandPrompt.tsx @@ -1,13 +1,13 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { handlers, type OutputLine } from '../commands/handlers'; +import { useState, useEffect, useRef, useCallback } from "react"; +import { handlers, type OutputLine } from "../commands/handlers"; -const PROMPT = 'stift15@server:~$ '; +const PROMPT = "stift15@server:~$ "; const WELCOME: OutputLine[] = [ - { text: '> Connection restored.', color: 'green' }, - { text: '> Welcome to Stift15 Terminal v1.0', color: 'green' }, - { text: '> Type \'help\' for available commands.', color: 'dim' }, - { text: '', color: 'dim' }, + { text: "> Connection restored.", color: "green" }, + { text: "> Welcome to Stift15 Terminal v1.0", color: "green" }, + { text: "> Type 'help' for available commands.", color: "dim" }, + { text: "", color: "dim" }, ]; interface CommandPromptProps { @@ -17,14 +17,14 @@ interface CommandPromptProps { export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) { const [outputLines, setOutputLines] = useState(WELCOME); - const [input, setInput] = useState(''); + const [input, setInput] = useState(""); const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); const inputRef = useRef(null); const bottomRef = useRef(null); const scrollToBottom = useCallback(() => { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, []); useEffect(scrollToBottom, [outputLines.length, scrollToBottom]); @@ -40,7 +40,7 @@ export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) { const executeCommand = useCallback( (cmdLine: string) => { - const echoLine: OutputLine = { text: PROMPT + cmdLine, color: 'green' }; + const echoLine: OutputLine = { text: PROMPT + cmdLine, color: "green" }; const trimmed = cmdLine.trim(); if (!trimmed) { @@ -52,22 +52,22 @@ export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) { const cmd = parts[0].toLowerCase(); const args = parts.slice(1); - if (cmd === 'clear') { + if (cmd === "clear") { setOutputLines([]); return; } - if (cmd === 'rm' && trimmed.includes('-rf') && trimmed.includes('/')) { + if (cmd === "rm" && trimmed.includes("-rf") && trimmed.includes("/")) { setOutputLines((prev) => [...prev, echoLine]); setTimeout(() => onBrick?.(), 300); return; } - if (cmd === 'reboot') { + if (cmd === "reboot") { setOutputLines((prev) => [ ...prev, echoLine, - { text: 'Rebooting server...', color: 'amber' }, + { text: "Rebooting server...", color: "amber" }, ]); setTimeout(() => onReboot?.(), 800); return; @@ -76,48 +76,61 @@ export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) { const handler = handlers[cmd]; const result = handler ? handler(args) - : [{ text: `${cmd}: command not found. Type 'help' for available commands.`, color: 'red' as const }]; + : [ + { + text: `${cmd}: command not found. Type 'help' for available commands.`, + color: "red" as const, + }, + ]; - setOutputLines((prev) => [...prev, echoLine, ...result, { text: '', color: 'dim' }]); + setOutputLines((prev) => [ + ...prev, + echoLine, + ...result, + { text: "", color: "dim" }, + ]); setHistory((prev) => [...prev, trimmed]); setHistoryIndex(-1); }, - [] + [onBrick, onReboot], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { + if (e.key === "Enter") { executeCommand(input); - setInput(''); + setInput(""); setHistoryIndex(-1); - } else if (e.key === 'ArrowUp') { + } else if (e.key === "ArrowUp") { e.preventDefault(); if (history.length === 0) return; - const newIndex = historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1); + const newIndex = + historyIndex === -1 + ? history.length - 1 + : Math.max(0, historyIndex - 1); setHistoryIndex(newIndex); setInput(history[newIndex]); - } else if (e.key === 'ArrowDown') { + } else if (e.key === "ArrowDown") { e.preventDefault(); if (historyIndex === -1) return; const newIndex = historyIndex + 1; if (newIndex >= history.length) { setHistoryIndex(-1); - setInput(''); + setInput(""); } else { setHistoryIndex(newIndex); setInput(history[newIndex]); } } }, - [input, executeCommand, history, historyIndex] + [input, executeCommand, history, historyIndex], ); const colorClass: Record = { - green: 'text-term-green', - amber: 'text-term-amber', - red: 'text-term-red', - dim: 'text-term-dim', + green: "text-term-green", + amber: "text-term-amber", + red: "text-term-red", + dim: "text-term-dim", }; return ( @@ -130,7 +143,7 @@ export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) { key={i} className={`${colorClass[line.color]} whitespace-pre leading-[1.4]`} > - {line.text || '\u00A0'} + {line.text || "\u00A0"} ))} @@ -140,7 +153,7 @@ export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) { {input} {/* Hidden native input for reliable focus and mobile keyboard support */} void; @@ -11,11 +11,14 @@ interface Column { chars: string[]; } +export const BOOT_SCREENS = ["logo", "skull", "sword", "frank", "gg"]; + const FONT_SIZE = 14; const CHAR_WIDTH = FONT_SIZE * 0.6; const CHAR_HEIGHT = FONT_SIZE * 1.2; const FADE_ALPHA = 0.05; -const RAIN_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789'; +const RAIN_CHARS = + "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789"; function randomChar(): string { return RAIN_CHARS[Math.floor(Math.random() * RAIN_CHARS.length)]; @@ -35,12 +38,14 @@ export function MatrixRain({ onComplete }: MatrixRainProps) { const completedRef = useRef(false); useEffect(() => { - loadAsciiArt('/ascii/logo.txt').then(setArtLines); + const stored = localStorage.getItem("stift15-bootscreen"); + const artFile = stored && BOOT_SCREENS.includes(stored) ? stored : "logo"; + loadAsciiArt(`/ascii/${artFile}.txt`).then(setArtLines); }, []); const startAnimation = useCallback( (canvas: HTMLCanvasElement, art: string[]) => { - const ctx = canvas.getContext('2d')!; + const ctx = canvas.getContext("2d")!; let animId: number; function resize() { @@ -67,7 +72,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) { artGrid[r] = []; for (let c = 0; c < artWidth; c++) { const ch = art[r]?.[c]; - if (ch && ch !== ' ') { + if (ch && ch !== " ") { artGrid[r][c] = ch; } } @@ -75,10 +80,10 @@ export function MatrixRain({ onComplete }: MatrixRainProps) { // Track which art cells have been revealed and when (for glow decay) const revealed: boolean[][] = Array.from({ length: artHeight }, () => - Array(artWidth).fill(false) + Array(artWidth).fill(false), ); const revealTimeMap: number[][] = Array.from({ length: artHeight }, () => - Array(artWidth).fill(0) + Array(artWidth).fill(0), ); const GLOW_DURATION = 1200; // ms for glow to fade out let totalArtChars = 0; @@ -92,11 +97,11 @@ export function MatrixRain({ onComplete }: MatrixRainProps) { // Initialize columns const columns: Column[] = Array.from({ length: totalCols }, () => - createColumn(totalRows) + createColumn(totalRows), ); // Clear to black initially - ctx.fillStyle = '#0a0a0a'; + ctx.fillStyle = "#0a0a0a"; ctx.fillRect(0, 0, canvas.width, canvas.height); function draw() { @@ -105,7 +110,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) { ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.font = `${FONT_SIZE}px "IBM Plex Mono", "Fira Code", "Courier New", monospace`; - ctx.textBaseline = 'top'; + ctx.textBaseline = "top"; for (let col = 0; col < totalCols; col++) { const column = columns[col]; @@ -143,7 +148,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) { if (t === 0) { // Head character — bright white-green - ctx.fillStyle = '#ccffcc'; + ctx.fillStyle = "#ccffcc"; } else { // Trail fades from bright to dim const brightness = Math.max(0, 1 - t / trailLen); @@ -156,11 +161,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) { column.chars[row % column.chars.length] = randomChar(); } - ctx.fillText( - column.chars[row % column.chars.length], - x, - y - ); + ctx.fillText(column.chars[row % column.chars.length], x, y); } // Reset column when it falls off screen @@ -188,9 +189,9 @@ export function MatrixRain({ onComplete }: MatrixRainProps) { ctx.fillStyle = `rgb(${white + 51}, 255, ${white + 51})`; } else { // Settled: normal bright green, no shadow - ctx.shadowColor = 'transparent'; + ctx.shadowColor = "transparent"; ctx.shadowBlur = 0; - ctx.fillStyle = '#33ff33'; + ctx.fillStyle = "#33ff33"; } ctx.fillText(artGrid[r][c]!, x, y); @@ -198,7 +199,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) { } } // Reset shadow state for next frame's rain drawing - ctx.shadowColor = 'transparent'; + ctx.shadowColor = "transparent"; ctx.shadowBlur = 0; // Check completion @@ -223,7 +224,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) { resizeObserver.disconnect(); }; }, - [onComplete] + [onComplete], ); useEffect(() => { @@ -231,10 +232,5 @@ export function MatrixRain({ onComplete }: MatrixRainProps) { return startAnimation(canvasRef.current, artLines); }, [artLines, startAnimation]); - return ( - - ); + return ; }