master piece
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(pnpm build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>claude-test</title>
|
||||
<title>503 - Stift15</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+7
-1
@@ -10,14 +10,20 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"three": "^0.183.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.183.1",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
|
||||
Generated
+765
-62
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
_____ _______ _____ ______ _______ __ _____
|
||||
/ ____|__ __|_ _| ____|__ __/_ | ____|
|
||||
| (___ | | | | | |__ | | | | |__
|
||||
\___ \ | | | | | __| | | | |___ \
|
||||
____) | | | _| |_| | | | | |___) |
|
||||
|_____/ |_| |_____|_| |_| |_|____/
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
+40
-113
@@ -1,120 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Terminal } from './components/Terminal'
|
||||
import { MatrixRain } from './components/MatrixRain'
|
||||
import { CommandPrompt } from './components/CommandPrompt'
|
||||
import { BrickTransition } from './components/BrickTransition'
|
||||
import { Bricked } from './components/Bricked'
|
||||
import { CrtOverlay } from './components/CrtOverlay'
|
||||
|
||||
type AppPhase = 'boot' | 'matrix' | 'prompt' | 'brick-transition' | 'bricked';
|
||||
|
||||
function getInitialPhase(): AppPhase {
|
||||
if (localStorage.getItem('stift15-bricked') === 'true') return 'bricked';
|
||||
return 'boot';
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const [phase, setPhase] = useState<AppPhase>(getInitialPhase);
|
||||
|
||||
const advancePhase = useCallback(() => {
|
||||
setPhase((p) => {
|
||||
if (p === 'boot') return 'matrix';
|
||||
if (p === 'matrix') return 'prompt';
|
||||
return p;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reboot = useCallback(() => {
|
||||
setPhase('matrix');
|
||||
}, []);
|
||||
|
||||
const brick = useCallback(() => {
|
||||
localStorage.setItem('stift15-bricked', 'true');
|
||||
setPhase('brick-transition');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
<div className="relative h-screen w-screen overflow-hidden bg-term-bg">
|
||||
{phase === 'boot' && <Terminal onComplete={advancePhase} />}
|
||||
{phase === 'matrix' && <MatrixRain onComplete={advancePhase} />}
|
||||
{phase === 'prompt' && <CommandPrompt onReboot={reboot} onBrick={brick} />}
|
||||
{phase === 'brick-transition' && <BrickTransition onComplete={() => setPhase('bricked')} />}
|
||||
{phase === 'bricked' && <Bricked />}
|
||||
<CrtOverlay />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { LineColor } from '../components/BootSequence';
|
||||
|
||||
export interface OutputLine {
|
||||
text: string;
|
||||
color: LineColor;
|
||||
}
|
||||
|
||||
type CommandHandler = (args: string[]) => OutputLine[];
|
||||
|
||||
function green(text: string): OutputLine {
|
||||
return { text, color: 'green' };
|
||||
}
|
||||
function amber(text: string): OutputLine {
|
||||
return { text, color: 'amber' };
|
||||
}
|
||||
function dim(text: string): OutputLine {
|
||||
return { text, color: 'dim' };
|
||||
}
|
||||
function red(text: string): OutputLine {
|
||||
return { text, color: 'red' };
|
||||
}
|
||||
|
||||
const FS_LISTING = [
|
||||
'drwxr-xr-x frank staff games/',
|
||||
'drwxr-xr-x frank staff mods/',
|
||||
'drwxr-xr-x frank staff frank_broke_this/',
|
||||
'drwxr-xr-x frank staff backups_that_dont_work/',
|
||||
'-rw-r--r-- root staff todo.txt',
|
||||
'-rw-r--r-- root staff server_rules.txt',
|
||||
'-rw-r--r-- frank staff definitely_not_a_virus.exe',
|
||||
'-rwx------ root staff fix_franks_mess.sh',
|
||||
'-rw-r--r-- frank staff iou_hosting_fees.txt',
|
||||
'drwxrwxrwx frank staff node_modules/ (2.1 GB)',
|
||||
];
|
||||
|
||||
const FILE_CONTENTS: Record<string, string[]> = {
|
||||
'todo.txt': [
|
||||
'1. Fix Frank\'s mess',
|
||||
'2. Update the server (postponed since 2019)',
|
||||
'3. Actually play games instead of fixing the server',
|
||||
'4. Collect hosting fees from Frank',
|
||||
'5. Give up',
|
||||
],
|
||||
'server_rules.txt': [
|
||||
'STIFT15 SERVER RULES:',
|
||||
'=====================',
|
||||
'1. Do not let Frank push to main',
|
||||
'2. Do NOT let Frank push to main',
|
||||
'3. Seriously, Frank, stop pushing to main',
|
||||
'4. No mining crypto on the game server',
|
||||
'5. Frank, rule 4 applies to you specifically',
|
||||
'6. Whoever keeps downloading texture packs: stop (Frank)',
|
||||
'7. The server admin is always right',
|
||||
'8. If the server is down, it\'s Frank\'s fault',
|
||||
],
|
||||
'iou_hosting_fees.txt': [
|
||||
'Frank owes:',
|
||||
' January .... $15.00',
|
||||
' February ... $15.00',
|
||||
' March ...... $15.00',
|
||||
' April ...... $15.00 + $47.00 (emergency server recovery)',
|
||||
' May ........ $15.00',
|
||||
' Total ...... $122.00',
|
||||
'',
|
||||
'Status: "I\'ll get you next month" (since January)',
|
||||
],
|
||||
'definitely_not_a_virus.exe': [
|
||||
'Nice try. This is a Linux server.',
|
||||
],
|
||||
};
|
||||
|
||||
const NEOFETCH = [
|
||||
' _______ stift15@server',
|
||||
' / _____| ---------------',
|
||||
' | (___ OS: Linux 6.9.0-gamer-generic',
|
||||
' \\___ \\ Host: Stift15 Gaming HQ',
|
||||
' ____) | Kernel: 6.9.0-gamer',
|
||||
' |_____/ Uptime: 4h 20m 69s',
|
||||
' ____ _____ _____ Packages: 847 (node_modules)',
|
||||
' |____|_____|_____| Shell: stift15-sh 1.0',
|
||||
' CPU: AMD Ryzen 9 9950X @ 5.7GHz',
|
||||
' S T I F T 1 5 GPU: NVIDIA RTX 5090 "Mortgage Ed."',
|
||||
' Memory: 63420 MB / 65536 MB',
|
||||
' Disk: 98% used (node_modules)',
|
||||
' RGB: SYNCED (mood: aggressive)',
|
||||
];
|
||||
|
||||
export const handlers: Record<string, CommandHandler> = {
|
||||
help: () => [
|
||||
amber('Available commands:'),
|
||||
green(' help - Show this help message'),
|
||||
green(' ls - List files'),
|
||||
green(' cat <file> - Read a file'),
|
||||
green(' cd <dir> - Change directory'),
|
||||
green(' whoami - Who are you?'),
|
||||
green(' nano - Text editor'),
|
||||
green(' neofetch - System info'),
|
||||
green(' clear - Clear terminal'),
|
||||
green(' blame - Find who\'s responsible'),
|
||||
green(' uptime - Server uptime'),
|
||||
green(' ping <target> - Ping a target'),
|
||||
green(' rm <file> - Remove a file'),
|
||||
green(' sudo <cmd> - Run as root'),
|
||||
green(' reboot - Reboot the server'),
|
||||
],
|
||||
|
||||
ls: () => FS_LISTING.map((line) => dim(line)),
|
||||
|
||||
cat: (args) => {
|
||||
const file = args[0];
|
||||
if (!file) return [red('cat: missing file operand')];
|
||||
const contents = FILE_CONTENTS[file];
|
||||
if (!contents) return [red(`cat: ${file}: No such file or directory`)];
|
||||
return contents.map((line) => green(line));
|
||||
},
|
||||
|
||||
cd: (args) => {
|
||||
const dir = args[0];
|
||||
if (!dir) return [dim('~')];
|
||||
if (dir === 'node_modules') return [red('cd: node_modules: event horizon detected, cannot enter')];
|
||||
if (dir === 'frank_broke_this') return [red('cd: frank_broke_this: directory too corrupted to enter')];
|
||||
if (dir === 'games') return [amber('cd: games: you should be fixing the server, not playing games')];
|
||||
if (dir === 'backups_that_dont_work') return [red('cd: backups_that_dont_work: Permission denied (also they don\'t work)')];
|
||||
return [red(`cd: ${dir}: No such file or directory`)];
|
||||
},
|
||||
|
||||
whoami: () => [
|
||||
green('guest'),
|
||||
dim('(you thought you were root? lol)'),
|
||||
],
|
||||
|
||||
nano: () => [
|
||||
red('nano: command disabled.'),
|
||||
dim('Frank deleted it to "save disk space."'),
|
||||
dim('He then installed 47 texture packs.'),
|
||||
],
|
||||
|
||||
neofetch: () => NEOFETCH.map((line) => green(line)),
|
||||
|
||||
blame: () => [
|
||||
green('Analyzing git history...'),
|
||||
green('Checking commit logs...'),
|
||||
green('Cross-referencing with incident reports...'),
|
||||
green(''),
|
||||
amber('Result: It was Frank.'),
|
||||
dim('(confidence: 99.7%)'),
|
||||
dim('(remaining 0.3%: also Frank, using an alt account)'),
|
||||
],
|
||||
|
||||
uptime: () => [
|
||||
green(' 03:14:15 up 4:20, 69 users, load average: 4.04, 0.00, 0.00'),
|
||||
dim('(new personal record!)'),
|
||||
],
|
||||
|
||||
ping: (args) => {
|
||||
const target = args[0];
|
||||
if (!target) return [red('ping: missing target')];
|
||||
const lower = target.toLowerCase();
|
||||
if (lower === 'frank') {
|
||||
return [
|
||||
green(`PING frank (192.168.1.???) 56(84) bytes of data.`),
|
||||
amber('Request timeout. Frank is AFK again.'),
|
||||
amber('Request timeout. Frank is "getting food."'),
|
||||
amber('Request timeout. Frank disconnected.'),
|
||||
red('--- frank ping statistics ---'),
|
||||
red('3 packets transmitted, 0 received, 100% packet loss'),
|
||||
dim('(as usual)'),
|
||||
];
|
||||
}
|
||||
if (lower === 'franks.mom' || lower === 'frank.mom' || lower === 'franksmom') {
|
||||
return [
|
||||
green(`PING franks.mom (192.168.1.1) 56(84) bytes of data.`),
|
||||
dim('...'),
|
||||
dim('...'),
|
||||
dim('...'),
|
||||
amber('Request timeout.'),
|
||||
amber('Request timeout.'),
|
||||
amber('Request timeout.'),
|
||||
red('--- franks.mom ping statistics ---'),
|
||||
red('3 packets transmitted, 0 received, 100% packet loss'),
|
||||
dim(''),
|
||||
dim('franks.mom is unreachable.'),
|
||||
dim('She blocked this server after the 3 AM incident.'),
|
||||
dim('Frank says "it wasn\'t that loud" but we have witnesses.'),
|
||||
];
|
||||
}
|
||||
if (lower === 'google.com' || lower === 'google') {
|
||||
return [
|
||||
green(`PING google.com (142.250.80.46) 56(84) bytes of data.`),
|
||||
green('64 bytes: icmp_seq=1 ttl=118 time=4.20ms'),
|
||||
green('64 bytes: icmp_seq=2 ttl=118 time=6.90ms'),
|
||||
dim('--- google.com ping statistics ---'),
|
||||
dim('2 packets transmitted, 2 received, 0% packet loss'),
|
||||
dim('(at least something works)'),
|
||||
];
|
||||
}
|
||||
return [
|
||||
green(`PING ${target} 56(84) bytes of data.`),
|
||||
green(`64 bytes: icmp_seq=1 ttl=64 time=${(Math.random() * 100).toFixed(1)}ms`),
|
||||
green(`64 bytes: icmp_seq=2 ttl=64 time=${(Math.random() * 100).toFixed(1)}ms`),
|
||||
];
|
||||
},
|
||||
|
||||
rm: (args) => {
|
||||
const joined = args.join(' ');
|
||||
if (joined.includes('-rf') && joined.includes('/')) {
|
||||
// Special case handled in CommandPrompt
|
||||
return [];
|
||||
}
|
||||
if (args.length === 0) return [red('rm: missing operand')];
|
||||
return [red(`rm: cannot remove '${args[args.length - 1]}': Permission denied`)];
|
||||
},
|
||||
|
||||
sudo: (args) => {
|
||||
if (args.length === 0) return [red('sudo: missing command')];
|
||||
return [
|
||||
red('Nice try.'),
|
||||
dim(`[sudo] incident reported to the server admin.`),
|
||||
dim(`[sudo] also reported to Frank's mom.`),
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
export type LineColor = 'green' | 'amber' | 'red' | 'dim';
|
||||
|
||||
export interface BootLine {
|
||||
text: string;
|
||||
color: LineColor;
|
||||
delay: number; // ms before this line appears after the previous
|
||||
typewriter?: boolean;
|
||||
}
|
||||
|
||||
export const BOOT_SEQUENCE: BootLine[] = [
|
||||
// Phase 1 - BIOS POST
|
||||
{ text: 'AWARD BIOS v6.9.420 (c) 2024 Stift15 Systems Ltd.', color: 'dim', delay: 300 },
|
||||
{ text: 'Performing Power-On Self Test...', color: 'dim', delay: 200 },
|
||||
{ text: '', color: 'dim', delay: 100 },
|
||||
{ text: 'CPU: AMD Ryzen 9 9950X "GamerFuel Edition" @ 5.7 GHz .......... OK', color: 'green', delay: 150 },
|
||||
{ text: 'RAM: 65536 MB DDR5-6000 ....................................... OK', color: 'green', delay: 120 },
|
||||
{ text: 'GPU: NVIDIA RTX 5090 "Mortgage Edition" ........................ OK', color: 'green', delay: 120 },
|
||||
{ text: 'RGB Subsystem .................................................. SYNCED (mood: aggressive)', color: 'green', delay: 100 },
|
||||
{ text: 'Gamer Chair Lumbar Support ..................................... RECLINED', color: 'green', delay: 100 },
|
||||
{ text: '', color: 'dim', delay: 80 },
|
||||
{ text: 'Detecting boot devices...', color: 'dim', delay: 400 },
|
||||
{ text: 'Boot device: /dev/sda1 (labeled "DO_NOT_DELETE_MOM")', color: 'dim', delay: 300 },
|
||||
{ text: '', color: 'dim', delay: 200 },
|
||||
|
||||
// Phase 2 - Kernel Boot
|
||||
{ text: 'Loading GRUB 2.12 ...', color: 'green', delay: 300 },
|
||||
{ text: 'kernel: Linux 6.9.0-gamer-generic loading...', color: 'green', delay: 200 },
|
||||
{ text: '[ 0.000000] Command line: root=/dev/sda1 ro quiet splash=off fps_unlock=true', color: 'dim', delay: 80 },
|
||||
{ text: '[ 0.004200] Initializing gaming subsystems...', color: 'dim', delay: 80 },
|
||||
{ text: '[ 0.013370] RGB controller mapped to /dev/rgb0', color: 'dim', delay: 60 },
|
||||
{ text: '[ 0.024601] Registering thermal zone: "CPU Package" (target: yes)', color: 'dim', delay: 60 },
|
||||
{ text: '[ 0.031337] Loading module: gamer_posture.ko', color: 'dim', delay: 60 },
|
||||
{ text: '[ 0.042069] Mounting /dev/fridge (read-only, sadness mode)', color: 'dim', delay: 60 },
|
||||
{ text: '', color: 'dim', delay: 200 },
|
||||
|
||||
// Phase 3 - Services Starting
|
||||
{ text: '[ OK ] Started Gamer Posture Reminder Service', color: 'green', delay: 150 },
|
||||
{ text: '[ OK ] Started Mountain Dew Level Monitor', color: 'green', delay: 120 },
|
||||
{ text: '[ OK ] Started Discord Rich Presence Daemon', color: 'green', delay: 120 },
|
||||
{ text: '[ OK ] Started Mechanical Keyboard Click Amplifier', color: 'green', delay: 100 },
|
||||
{ text: '[ OK ] Started Rage Quit Prevention System (v0.1-beta)', color: 'green', delay: 100 },
|
||||
{ text: '[WARN] Hot Pocket Proximity Sensor returned NaN', color: 'amber', delay: 300 },
|
||||
{ text: '[ OK ] Started Frank\'s "Temporary" Minecraft Server (uptime: 847 days)', color: 'green', delay: 150 },
|
||||
{ text: '[ OK ] Started nginx web server on port 80', color: 'green', delay: 100 },
|
||||
{ text: '[ OK ] Started Node.js application server on port 3000', color: 'green', delay: 100 },
|
||||
{ text: '[WARN] server.js has mass of 2.1 GB (node_modules singularity detected)', color: 'amber', delay: 400 },
|
||||
{ text: '[WARN] Gravitational pull from node_modules is affecting nearby files', color: 'amber', delay: 200 },
|
||||
{ text: '', color: 'dim', delay: 300 },
|
||||
|
||||
// Phase 4 - The Crash
|
||||
{ text: '[FAIL] HTTP Health Check: GET /api/status returned... nothing', color: 'red', delay: 600 },
|
||||
{ text: '[FAIL] The backend is not responding.', color: 'red', delay: 400 },
|
||||
{ text: '[FAIL] The backend has never responded. To anything. Ever.', color: 'red', delay: 500 },
|
||||
{ text: '', color: 'dim', delay: 200 },
|
||||
{ text: '[WARN] Attempting emergency restart...', color: 'amber', delay: 800 },
|
||||
{ text: '[ 4.040404] Process \'server.js\' invoked OOM killer', color: 'red', delay: 300 },
|
||||
{ text: '[ 4.040405] OOM killer selected process \'stift15-game-server\' (adj 1000)', color: 'red', delay: 150 },
|
||||
{ text: '[ 4.040406] Out of memory: Killed process 1337 (stift15-game-server)', color: 'red', delay: 150 },
|
||||
{ text: '[ 4.040407] Reason: Frank downloaded the entire Steam Workshop into /tmp', color: 'red', delay: 300 },
|
||||
{ text: '', color: 'dim', delay: 200 },
|
||||
{ text: '[FAIL] Emergency restart failed. Exit code: 418 (I\'m a teapot)', color: 'red', delay: 600 },
|
||||
{ text: '', color: 'dim', delay: 800 },
|
||||
|
||||
// Phase 5 - The 503 Error (typewriter lines)
|
||||
{ text: '============================================================', color: 'red', delay: 400, typewriter: true },
|
||||
{ text: ' ERROR 503 - SERVICE UNAVAILABLE', color: 'red', delay: 100, typewriter: true },
|
||||
{ text: '============================================================', color: 'red', delay: 100, typewriter: true },
|
||||
{ text: '', color: 'dim', delay: 200 },
|
||||
{ text: ' The server hosting the Stift15 gaming group has encountered', color: 'green', delay: 60, typewriter: true },
|
||||
{ text: ' a fatal error and is currently taking a rage-quit break.', color: 'green', delay: 60, typewriter: true },
|
||||
{ text: '', color: 'dim', delay: 300 },
|
||||
{ text: ' DIAGNOSTIC SUMMARY:', color: 'amber', delay: 200, typewriter: true },
|
||||
{ text: ' - Last stable connection: "lol"', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: ' - Uptime before crash: 4h 20m 69s', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: ' - Packets lost: all of them', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: ' - Root cause: someone git pushed node_modules to prod', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: ' - Secondary cause: Frank', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: ' - Tertiary cause: also Frank', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: ' - Frank\'s response: "works on my machine"', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: '', color: 'dim', delay: 300 },
|
||||
{ text: ' RECOMMENDED ACTIONS:', color: 'amber', delay: 200, typewriter: true },
|
||||
{ text: ' 1. Touch grass (estimated ETA: never)', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: ' 2. Check if Frank pushed to main again', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: ' 3. Blame the lag', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: ' 4. Alt+F4 your expectations', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: ' 5. Have you tried turning Frank off and on again?', color: 'green', delay: 80, typewriter: true },
|
||||
{ text: '', color: 'dim', delay: 300 },
|
||||
{ text: ' If this error persists, please scream into the void or', color: 'green', delay: 60, typewriter: true },
|
||||
{ text: ' message the server admin, who is also screaming into the void.', color: 'green', delay: 60, typewriter: true },
|
||||
{ text: '', color: 'dim', delay: 400 },
|
||||
{ text: '============================================================', color: 'red', delay: 100, typewriter: true },
|
||||
{ text: ' SERVER ADMIN NOTE:', color: 'amber', delay: 200, typewriter: true },
|
||||
{ text: ' No, I will not fix this at 3 AM. I have work tomorrow.', color: 'green', delay: 60, typewriter: true },
|
||||
{ text: ' The server can stay dead. It builds character.', color: 'green', delay: 60, typewriter: true },
|
||||
{ text: ' Also Frank still owes me for last month\'s hosting.', color: 'green', delay: 60, typewriter: true },
|
||||
{ text: '============================================================', color: 'red', delay: 100, typewriter: true },
|
||||
{ text: '', color: 'dim', delay: 1500 },
|
||||
|
||||
// Phase 6 - Reconnect
|
||||
{ text: '> Reconnecting in 3 seconds...', color: 'dim', delay: 0 },
|
||||
{ text: '> ...', color: 'dim', delay: 1000 },
|
||||
{ text: '> ...', color: 'dim', delay: 1000 },
|
||||
{ text: '> CONNECTION ESTABLISHED', color: 'green', delay: 1000 },
|
||||
];
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface BrickTransitionProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
const DELETE_PATHS = [
|
||||
'/bin',
|
||||
'/boot',
|
||||
'/dev',
|
||||
'/etc',
|
||||
'/home',
|
||||
'/lib',
|
||||
'/media',
|
||||
'/mnt',
|
||||
'/opt',
|
||||
'/proc',
|
||||
'/root',
|
||||
'/sbin',
|
||||
'/srv',
|
||||
'/sys',
|
||||
'/tmp',
|
||||
'/usr',
|
||||
'/usr/lib',
|
||||
'/usr/share',
|
||||
'/usr/bin',
|
||||
'/usr/local/games',
|
||||
'/usr/local/node_modules (2.1 GB)',
|
||||
'/var',
|
||||
'/var/log',
|
||||
'/var/frank_was_here',
|
||||
];
|
||||
|
||||
type Phase = 'deleting' | 'kernel-panic' | 'glitch' | 'black';
|
||||
|
||||
export function BrickTransition({ onComplete }: BrickTransitionProps) {
|
||||
const [phase, setPhase] = useState<Phase>('deleting');
|
||||
const [deletedCount, setDeletedCount] = useState(0);
|
||||
const [glitchOpacity, setGlitchOpacity] = useState(1);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Phase 1: Rapid-fire file deletion
|
||||
useEffect(() => {
|
||||
if (phase !== 'deleting') return;
|
||||
let i = 0;
|
||||
intervalRef.current = setInterval(() => {
|
||||
i++;
|
||||
setDeletedCount(i);
|
||||
if (i >= DELETE_PATHS.length) {
|
||||
clearInterval(intervalRef.current);
|
||||
setTimeout(() => setPhase('kernel-panic'), 400);
|
||||
}
|
||||
}, 80);
|
||||
return () => clearInterval(intervalRef.current);
|
||||
}, [phase]);
|
||||
|
||||
// Phase 2: Kernel panic text, then glitch
|
||||
useEffect(() => {
|
||||
if (phase !== 'kernel-panic') return;
|
||||
const timer = setTimeout(() => setPhase('glitch'), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [phase]);
|
||||
|
||||
// Phase 3: Screen glitch — flicker and dissolve
|
||||
useEffect(() => {
|
||||
if (phase !== 'glitch') return;
|
||||
let frame = 0;
|
||||
const totalFrames = 30;
|
||||
intervalRef.current = setInterval(() => {
|
||||
frame++;
|
||||
// Flicker between visible and dark, trending toward black
|
||||
const progress = frame / totalFrames;
|
||||
if (Math.random() < progress) {
|
||||
setGlitchOpacity(0);
|
||||
} else {
|
||||
setGlitchOpacity(Math.random() * 0.5 + 0.3);
|
||||
}
|
||||
if (frame >= totalFrames) {
|
||||
clearInterval(intervalRef.current);
|
||||
setPhase('black');
|
||||
}
|
||||
}, 60);
|
||||
return () => clearInterval(intervalRef.current);
|
||||
}, [phase]);
|
||||
|
||||
// Phase 4: Black screen, then transition to bricked
|
||||
useEffect(() => {
|
||||
if (phase !== 'black') return;
|
||||
const timer = setTimeout(onComplete, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [phase, onComplete]);
|
||||
|
||||
if (phase === 'black') {
|
||||
return <div className="absolute inset-0 z-0 bg-term-bg" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-0 overflow-hidden p-4 font-mono text-sm bg-term-bg"
|
||||
style={{ opacity: phase === 'glitch' ? glitchOpacity : 1 }}
|
||||
>
|
||||
{/* Deletion log */}
|
||||
<div className="text-term-red leading-[1.4]">
|
||||
rm: deleting entire filesystem...
|
||||
</div>
|
||||
{DELETE_PATHS.slice(0, deletedCount).map((path, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-term-red whitespace-pre leading-[1.4]"
|
||||
style={phase === 'glitch' ? {
|
||||
transform: `translateX(${(Math.random() - 0.5) * 40}px)`,
|
||||
opacity: Math.random() * 0.7 + 0.3,
|
||||
} : undefined}
|
||||
>
|
||||
rm: {path} ... <span className="text-term-amber">deleted</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Kernel panic */}
|
||||
{(phase === 'kernel-panic' || phase === 'glitch') && (
|
||||
<div className="mt-4">
|
||||
<div className="text-term-red leading-[1.4]"> </div>
|
||||
<div className="text-term-red leading-[1.4] font-bold">
|
||||
Kernel panic - not syncing: Attempted to kill init!
|
||||
</div>
|
||||
<div className="text-term-red leading-[1.4]">
|
||||
exitcode=0x00000009
|
||||
</div>
|
||||
<div className="text-term-dim leading-[1.4]"> </div>
|
||||
<div className="text-term-dim leading-[1.4]">
|
||||
[ 9.999999] ---[ end Kernel panic - not syncing ]---
|
||||
</div>
|
||||
<div className="text-term-dim leading-[1.4]"> </div>
|
||||
<div className="text-term-amber leading-[1.4]">
|
||||
You absolute legend. You actually did it.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const LINES = [
|
||||
{ text: 'AWARD BIOS v6.9.420 (c) 2024 Stift15 Systems Ltd.', delay: 300 },
|
||||
{ text: 'Performing Power-On Self Test...', delay: 200 },
|
||||
{ text: '', delay: 100 },
|
||||
{ text: 'CPU: AMD Ryzen 9 9950X "GamerFuel Edition" @ 5.7 GHz .......... OK', delay: 150 },
|
||||
{ text: 'RAM: 65536 MB DDR5-6000 ....................................... OK', delay: 120 },
|
||||
{ text: 'GPU: NVIDIA RTX 5090 "Mortgage Edition" ........................ OK', delay: 120 },
|
||||
{ text: '', delay: 200 },
|
||||
{ text: 'Detecting boot devices...', delay: 400 },
|
||||
{ text: 'Boot device: /dev/sda1 (labeled "DO_NOT_DELETE_MOM")', delay: 300 },
|
||||
{ text: '', delay: 200 },
|
||||
{ text: 'Loading GRUB 2.12 ...', delay: 300 },
|
||||
{ text: '', delay: 500 },
|
||||
{ text: 'error: no such partition.', delay: 400 },
|
||||
{ text: 'error: file \'/boot/vmlinuz-6.9.0-gamer-generic\' not found.', delay: 300 },
|
||||
{ text: 'error: you need to load the kernel first.', delay: 300 },
|
||||
{ text: '', delay: 600 },
|
||||
{ text: 'GRUB rescue>', delay: 200 },
|
||||
{ text: '', delay: 1000 },
|
||||
{ text: '============================================================', delay: 400 },
|
||||
{ text: ' FATAL: NO BOOTABLE FILESYSTEM FOUND', delay: 200 },
|
||||
{ text: '============================================================', delay: 200 },
|
||||
{ text: '', delay: 300 },
|
||||
{ text: ' Someone ran rm -rf / on this server.', delay: 200 },
|
||||
{ text: ' The entire filesystem has been deleted.', delay: 200 },
|
||||
{ text: ' There is nothing left.', delay: 200 },
|
||||
{ text: '', delay: 300 },
|
||||
{ text: ' The server admin has been notified.', delay: 200 },
|
||||
{ text: ' The server admin is crying.', delay: 300 },
|
||||
{ text: '', delay: 500 },
|
||||
{ text: ' This server is now a very expensive paperweight.', delay: 200 },
|
||||
{ text: ' Good job.', delay: 300 },
|
||||
{ text: '', delay: 800 },
|
||||
{ text: ' Press F to pay respects.', delay: 0 },
|
||||
];
|
||||
|
||||
export function Bricked() {
|
||||
const [visibleCount, setVisibleCount] = useState(0);
|
||||
const [unbricked, setUnbricked] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleCount >= LINES.length) return;
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setVisibleCount((c) => c + 1);
|
||||
}, LINES[visibleCount].delay);
|
||||
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };
|
||||
}, [visibleCount]);
|
||||
|
||||
useEffect(() => {
|
||||
containerRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'f' || e.key === 'F') {
|
||||
localStorage.removeItem('stift15-bricked');
|
||||
setUnbricked(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (unbricked) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-0 flex items-center justify-center font-mono text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-term-green leading-[1.4]">Respects paid. Server restored.</div>
|
||||
<div className="text-term-dim leading-[1.4] mt-2">Reload the page to reconnect.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visible = LINES.slice(0, visibleCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-0 overflow-y-auto p-4 font-mono text-sm outline-none"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => containerRef.current?.focus()}
|
||||
>
|
||||
{visible.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${i >= 12 && i <= 14 ? 'text-term-red' : i >= 18 ? 'text-term-red' : 'text-term-dim'} whitespace-pre leading-[1.4]`}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
</div>
|
||||
))}
|
||||
{visibleCount >= LINES.length && (
|
||||
<div className="text-term-dim whitespace-pre leading-[1.4] mt-2">
|
||||
<span
|
||||
className="inline-block w-[0.6em] h-[1em] bg-term-dim ml-0.5 align-middle"
|
||||
style={{ animation: 'blink 1s step-end infinite' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { handlers, type OutputLine } from '../commands/handlers';
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
interface CommandPromptProps {
|
||||
onReboot?: () => void;
|
||||
onBrick?: () => void;
|
||||
}
|
||||
|
||||
export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) {
|
||||
const [outputLines, setOutputLines] = useState<OutputLine[]>(WELCOME);
|
||||
const [input, setInput] = useState('');
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
useEffect(scrollToBottom, [outputLines.length, scrollToBottom]);
|
||||
|
||||
// Focus container on mount
|
||||
useEffect(() => {
|
||||
containerRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
(cmdLine: string) => {
|
||||
const echoLine: OutputLine = { text: PROMPT + cmdLine, color: 'green' };
|
||||
const trimmed = cmdLine.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
setOutputLines((prev) => [...prev, echoLine]);
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const cmd = parts[0].toLowerCase();
|
||||
const args = parts.slice(1);
|
||||
|
||||
if (cmd === 'clear') {
|
||||
setOutputLines([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'rm' && trimmed.includes('-rf') && trimmed.includes('/')) {
|
||||
setOutputLines((prev) => [...prev, echoLine]);
|
||||
setTimeout(() => onBrick?.(), 300);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'reboot') {
|
||||
setOutputLines((prev) => [
|
||||
...prev,
|
||||
echoLine,
|
||||
{ text: 'Rebooting server...', color: 'amber' },
|
||||
]);
|
||||
setTimeout(() => onReboot?.(), 800);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = handlers[cmd];
|
||||
const result = handler
|
||||
? handler(args)
|
||||
: [{ text: `${cmd}: command not found. Type 'help' for available commands.`, color: 'red' as const }];
|
||||
|
||||
setOutputLines((prev) => [...prev, echoLine, ...result, { text: '', color: 'dim' }]);
|
||||
setHistory((prev) => [...prev, trimmed]);
|
||||
setHistoryIndex(-1);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
executeCommand(input);
|
||||
setInput('');
|
||||
} else if (e.key === 'Backspace') {
|
||||
setInput((prev) => prev.slice(0, -1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (history.length === 0) return;
|
||||
const newIndex = historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1);
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(history[newIndex]);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (historyIndex === -1) return;
|
||||
const newIndex = historyIndex + 1;
|
||||
if (newIndex >= history.length) {
|
||||
setHistoryIndex(-1);
|
||||
setInput('');
|
||||
} else {
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(history[newIndex]);
|
||||
}
|
||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||||
setInput((prev) => prev + e.key);
|
||||
}
|
||||
},
|
||||
[input, executeCommand, history, historyIndex]
|
||||
);
|
||||
|
||||
const colorClass: Record<string, string> = {
|
||||
green: 'text-term-green',
|
||||
amber: 'text-term-amber',
|
||||
red: 'text-term-red',
|
||||
dim: 'text-term-dim',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-0 overflow-y-auto p-4 font-mono text-sm outline-none"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => containerRef.current?.focus()}
|
||||
>
|
||||
{outputLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${colorClass[line.color]} whitespace-pre leading-[1.4]`}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Input line */}
|
||||
<div className="text-term-green whitespace-pre leading-[1.4]">
|
||||
{PROMPT}
|
||||
{input}
|
||||
<span
|
||||
className="inline-block w-[0.6em] h-[1em] bg-term-green ml-0.5 align-middle"
|
||||
style={{ animation: 'blink 1s step-end infinite' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import { crtVertexShader } from '../shaders/crt.vert';
|
||||
import { crtFragmentShader } from '../shaders/crt.frag';
|
||||
|
||||
function CrtMesh() {
|
||||
const materialRef = useRef<THREE.ShaderMaterial>(null);
|
||||
|
||||
const uniforms = useMemo(
|
||||
() => ({
|
||||
uTime: { value: 0 },
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (materialRef.current) {
|
||||
materialRef.current.uniforms.uTime.value = clock.getElapsedTime();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh>
|
||||
<planeGeometry args={[2, 2]} />
|
||||
<shaderMaterial
|
||||
ref={materialRef}
|
||||
vertexShader={crtVertexShader}
|
||||
fragmentShader={crtFragmentShader}
|
||||
uniforms={uniforms}
|
||||
transparent
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export function CrtOverlay() {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 pointer-events-none">
|
||||
<Canvas
|
||||
orthographic
|
||||
camera={{ zoom: 1, position: [0, 0, 1] }}
|
||||
gl={{ alpha: true }}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<CrtMesh />
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { loadAsciiArt } from '../utils/loadAsciiArt';
|
||||
|
||||
interface MatrixRainProps {
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
interface Column {
|
||||
y: number;
|
||||
speed: number;
|
||||
chars: string[];
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
function randomChar(): string {
|
||||
return RAIN_CHARS[Math.floor(Math.random() * RAIN_CHARS.length)];
|
||||
}
|
||||
|
||||
function createColumn(totalRows: number): Column {
|
||||
return {
|
||||
y: Math.floor(Math.random() * -totalRows),
|
||||
speed: 0.3 + Math.random() * 0.7,
|
||||
chars: Array.from({ length: totalRows }, () => randomChar()),
|
||||
};
|
||||
}
|
||||
|
||||
export function MatrixRain({ onComplete }: MatrixRainProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [artLines, setArtLines] = useState<string[] | null>(null);
|
||||
const completedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadAsciiArt('/ascii/logo.txt').then(setArtLines);
|
||||
}, []);
|
||||
|
||||
const startAnimation = useCallback(
|
||||
(canvas: HTMLCanvasElement, art: string[]) => {
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
let animId: number;
|
||||
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(canvas.parentElement!);
|
||||
|
||||
const totalCols = Math.floor(canvas.width / CHAR_WIDTH);
|
||||
const totalRows = Math.floor(canvas.height / CHAR_HEIGHT);
|
||||
|
||||
// Center the art on screen
|
||||
const artWidth = Math.max(...art.map((l) => l.length));
|
||||
const artHeight = art.length;
|
||||
const artOffsetCol = Math.floor((totalCols - artWidth) / 2);
|
||||
const artOffsetRow = Math.floor((totalRows - artHeight) / 2);
|
||||
|
||||
// Build art grid lookup: artGrid[row][col] = character or undefined
|
||||
const artGrid: (string | undefined)[][] = [];
|
||||
for (let r = 0; r < artHeight; r++) {
|
||||
artGrid[r] = [];
|
||||
for (let c = 0; c < artWidth; c++) {
|
||||
const ch = art[r]?.[c];
|
||||
if (ch && ch !== ' ') {
|
||||
artGrid[r][c] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track which art cells have been revealed and when (for glow decay)
|
||||
const revealed: boolean[][] = Array.from({ length: artHeight }, () =>
|
||||
Array(artWidth).fill(false)
|
||||
);
|
||||
const revealTimeMap: number[][] = Array.from({ length: artHeight }, () =>
|
||||
Array(artWidth).fill(0)
|
||||
);
|
||||
const GLOW_DURATION = 1200; // ms for glow to fade out
|
||||
let totalArtChars = 0;
|
||||
for (let r = 0; r < artHeight; r++) {
|
||||
for (let c = 0; c < artWidth; c++) {
|
||||
if (artGrid[r][c]) totalArtChars++;
|
||||
}
|
||||
}
|
||||
let revealedCount = 0;
|
||||
let revealCompleteTime = 0;
|
||||
|
||||
// Initialize columns
|
||||
const columns: Column[] = Array.from({ length: totalCols }, () =>
|
||||
createColumn(totalRows)
|
||||
);
|
||||
|
||||
// Clear to black initially
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
function draw() {
|
||||
// Fade existing content
|
||||
ctx.fillStyle = `rgba(10, 10, 10, ${FADE_ALPHA})`;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.font = `${FONT_SIZE}px "IBM Plex Mono", "Fira Code", "Courier New", monospace`;
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
for (let col = 0; col < totalCols; col++) {
|
||||
const column = columns[col];
|
||||
column.y += column.speed;
|
||||
|
||||
const headRow = Math.floor(column.y);
|
||||
|
||||
// Draw rain trail (a few characters behind the head)
|
||||
const trailLen = 15;
|
||||
for (let t = 0; t < trailLen; t++) {
|
||||
const row = headRow - t;
|
||||
if (row < 0 || row >= totalRows) continue;
|
||||
|
||||
const x = col * CHAR_WIDTH;
|
||||
const y = row * CHAR_HEIGHT;
|
||||
|
||||
// Check if this position is an art cell
|
||||
const artRow = row - artOffsetRow;
|
||||
const artCol = col - artOffsetCol;
|
||||
const isArtCell =
|
||||
artRow >= 0 &&
|
||||
artRow < artHeight &&
|
||||
artCol >= 0 &&
|
||||
artCol < artWidth &&
|
||||
artGrid[artRow]?.[artCol];
|
||||
|
||||
if (isArtCell && t === 0) {
|
||||
// Head just passed through — reveal this art character
|
||||
if (!revealed[artRow][artCol]) {
|
||||
revealed[artRow][artCol] = true;
|
||||
revealTimeMap[artRow][artCol] = performance.now();
|
||||
revealedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (t === 0) {
|
||||
// Head character — bright white-green
|
||||
ctx.fillStyle = '#ccffcc';
|
||||
} else {
|
||||
// Trail fades from bright to dim
|
||||
const brightness = Math.max(0, 1 - t / trailLen);
|
||||
const g = Math.floor(100 + 155 * brightness);
|
||||
ctx.fillStyle = `rgb(0, ${g}, 0)`;
|
||||
}
|
||||
|
||||
// Randomly change trail characters
|
||||
if (Math.random() < 0.05) {
|
||||
column.chars[row % column.chars.length] = randomChar();
|
||||
}
|
||||
|
||||
ctx.fillText(
|
||||
column.chars[row % column.chars.length],
|
||||
x,
|
||||
y
|
||||
);
|
||||
}
|
||||
|
||||
// Reset column when it falls off screen
|
||||
if (headRow - 15 > totalRows) {
|
||||
columns[col] = createColumn(totalRows);
|
||||
columns[col].y = Math.floor(Math.random() * -10);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw revealed art characters on top with glow effect
|
||||
const now = performance.now();
|
||||
for (let r = 0; r < artHeight; r++) {
|
||||
for (let c = 0; c < artWidth; c++) {
|
||||
if (revealed[r][c] && artGrid[r][c]) {
|
||||
const x = (artOffsetCol + c) * CHAR_WIDTH;
|
||||
const y = (artOffsetRow + r) * CHAR_HEIGHT;
|
||||
const elapsed = now - revealTimeMap[r][c];
|
||||
const glowIntensity = Math.max(0, 1 - elapsed / GLOW_DURATION);
|
||||
|
||||
if (glowIntensity > 0) {
|
||||
// Active glow: bright white core fading to green, with blur
|
||||
ctx.shadowColor = `rgba(51, 255, 51, ${glowIntensity})`;
|
||||
ctx.shadowBlur = 12 + glowIntensity * 16;
|
||||
const white = Math.floor(glowIntensity * 200);
|
||||
ctx.fillStyle = `rgb(${white + 51}, 255, ${white + 51})`;
|
||||
} else {
|
||||
// Settled: normal bright green, no shadow
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = '#33ff33';
|
||||
}
|
||||
|
||||
ctx.fillText(artGrid[r][c]!, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset shadow state for next frame's rain drawing
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Check completion
|
||||
if (revealedCount >= totalArtChars && !completedRef.current) {
|
||||
if (revealCompleteTime === 0) {
|
||||
revealCompleteTime = performance.now();
|
||||
} else if (performance.now() - revealCompleteTime > 2000) {
|
||||
completedRef.current = true;
|
||||
cancelAnimationFrame(animId);
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
},
|
||||
[onComplete]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !artLines) return;
|
||||
return startAnimation(canvasRef.current, artLines);
|
||||
}, [artLines, startAnimation]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 z-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { BOOT_SEQUENCE } from './BootSequence';
|
||||
import { TerminalLine } from './TerminalLine';
|
||||
import { useTerminalSequence } from '../hooks/useTerminalSequence';
|
||||
|
||||
interface TerminalProps {
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function Terminal({ onComplete }: TerminalProps) {
|
||||
const { visibleLines } = useTerminalSequence(BOOT_SEQUENCE, onComplete);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [visibleLines.length]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-0 overflow-y-auto p-4 font-mono text-sm">
|
||||
{visibleLines.map((line, i) => (
|
||||
<TerminalLine
|
||||
key={i}
|
||||
text={line.text}
|
||||
color={line.color}
|
||||
typewriter={line.typewriter}
|
||||
isLast={i === visibleLines.length - 1}
|
||||
/>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTypewriter } from '../hooks/useTypewriter';
|
||||
import type { LineColor } from './BootSequence';
|
||||
|
||||
const colorClass: Record<LineColor, string> = {
|
||||
green: 'text-term-green',
|
||||
amber: 'text-term-amber',
|
||||
red: 'text-term-red',
|
||||
dim: 'text-term-dim',
|
||||
};
|
||||
|
||||
interface TerminalLineProps {
|
||||
text: string;
|
||||
color: LineColor;
|
||||
typewriter?: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
function TypewriterText({ text }: { text: string }) {
|
||||
const revealed = useTypewriter(text, 2, 16);
|
||||
return <>{revealed}</>;
|
||||
}
|
||||
|
||||
export function TerminalLine({ text, color, typewriter, isLast }: TerminalLineProps) {
|
||||
if (text === '') {
|
||||
return <div className="h-[1.2em]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${colorClass[color]} whitespace-pre leading-[1.4]`}>
|
||||
{typewriter ? <TypewriterText text={text} /> : text}
|
||||
{isLast && (
|
||||
<span
|
||||
className="inline-block w-[0.6em] h-[1em] bg-term-green ml-0.5 align-middle"
|
||||
style={{ animation: 'blink 1s step-end infinite' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { BootLine } from '../components/BootSequence';
|
||||
|
||||
export function useTerminalSequence(lines: BootLine[], onComplete?: () => void) {
|
||||
const [visibleCount, setVisibleCount] = useState(0);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const completedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleCount >= lines.length) {
|
||||
if (!completedRef.current && onComplete) {
|
||||
completedRef.current = true;
|
||||
timeoutRef.current = setTimeout(onComplete, 800);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLine = lines[visibleCount];
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setVisibleCount((c) => c + 1);
|
||||
}, nextLine.delay);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [visibleCount, lines, onComplete]);
|
||||
|
||||
return {
|
||||
visibleLines: lines.slice(0, visibleCount),
|
||||
isComplete: visibleCount >= lines.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export function useTypewriter(text: string, charsPerTick = 2, tickMs = 16) {
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setCharCount(0);
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCharCount((c) => {
|
||||
const next = c + charsPerTick;
|
||||
if (next >= text.length) {
|
||||
clearInterval(intervalRef.current);
|
||||
return text.length;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, tickMs);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [text, charsPerTick, tickMs]);
|
||||
|
||||
return text.slice(0, charCount);
|
||||
}
|
||||
+16
-104
@@ -1,111 +1,23 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
@import "tailwindcss";
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
@theme {
|
||||
--color-term-green: #33ff33;
|
||||
--color-term-amber: #ffbf00;
|
||||
--color-term-red: #ff3333;
|
||||
--color-term-dim: #338833;
|
||||
--color-term-bg: #0a0a0a;
|
||||
--font-mono: "IBM Plex Mono", "Fira Code", "Courier New", monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--color-term-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export const crtFragmentShader = /* glsl */ `
|
||||
uniform float uTime;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
|
||||
// Scanlines - thin horizontal dark bars scrolling down
|
||||
float scanline = sin(uv.y * 800.0 + uTime * 2.0) * 0.04;
|
||||
|
||||
// Vignette - darken edges to simulate CRT curvature
|
||||
vec2 center = uv - 0.5;
|
||||
float vignette = 1.0 - dot(center, center) * 1.5;
|
||||
vignette = clamp(vignette, 0.0, 1.0);
|
||||
|
||||
// Flicker - subtle brightness oscillation
|
||||
float flicker = sin(uTime * 8.0) * 0.008 + sin(uTime * 13.0) * 0.005;
|
||||
|
||||
// Combine: mostly transparent, with subtle darkening
|
||||
float darkness = scanline + (1.0 - vignette) * 0.3 - flicker;
|
||||
gl_FragColor = vec4(0.0, 0.0, 0.0, clamp(darkness, 0.0, 0.5));
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,7 @@
|
||||
export const crtVertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,5 @@
|
||||
export async function loadAsciiArt(path: string): Promise<string[]> {
|
||||
const res = await fetch(path);
|
||||
const text = await res.text();
|
||||
return text.split('\n');
|
||||
}
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user