diff --git a/.gitignore b/.gitignore
index 38fb9fc..7c5203b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,119 +1,34 @@
-# Logs
-logs
-*.log
+# Node modules
+node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-# Diagnostic reports (https://nodejs.org/api/report.html)
-report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+# OS files
+.DS_Store
+Thumbs.db
+*.swp
+*.swo
+*~
-# Runtime data
-pids
-*.pid
-*.seed
-*.pid.lock
+# IDE
+.vscode/
+.idea/
+*.sublime-project
+*.sublime-workspace
-# Directory for instrumented libs generated by jscoverage/JSCover
-lib-cov
+# Build outputs
+dist/
+build/
-# Coverage directory used by tools like istanbul
-coverage
-*.lcov
-
-# nyc test coverage
-.nyc_output
-
-# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
-.grunt
-
-# Bower dependency directory (https://bower.io/)
-bower_components
-
-# node-waf configuration
-.lock-wscript
-
-# Compiled binary addons (https://nodejs.org/api/addons.html)
-build/Release
-
-# Dependency directories
-node_modules/
-jspm_packages/
-
-# Snowpack dependency directory (https://snowpack.dev/)
-web_modules/
-
-# TypeScript cache
-*.tsbuildinfo
-
-# Optional npm cache directory
-.npm
-
-# Optional eslint cache
-.eslintcache
-
-# Optional stylelint cache
-.stylelintcache
-
-# Microbundle cache
-.rpt2_cache/
-.rts2_cache_cjs/
-.rts2_cache_es/
-.rts2_cache_umd/
-
-# Optional REPL history
-.node_repl_history
-
-# Output of 'npm pack'
-*.tgz
-
-# Yarn Integrity file
-.yarn-integrity
-
-# dotenv environment variables file
+# Environment variables
.env
-.env.development.local
-.env.test.local
-.env.production.local
.env.local
-# parcel-bundler cache files
-.cache
-.parcel-cache
+# Logs
+*.log
+logs/
-# Next.js build output
-.next
-out
-
-# Nuxt.js build output
-.nuxt
-dist
-
-# Gatsby files
-.cache/
-# Comment in the public line in if your project uses Gatsby and not Next.js
-# https://nextjs.org/blog/next-9-1#public-directory-support
-# public
-
-# vuepress build output
-.vuepress/dist
-
-# vuepress v2.x temp files
-.temp
-
-# Docusaurus cache and generated files
-.docusaurus
-
-# SvelteKit build output
-.svelte-kit
-build
-
-# Remix build output
-.cache
-build/
-public/build/
-
-# Vitest coverage directory
-coverage/
+# Temporary files
+*.tmp
+temp/
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index f673a71..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "liveServer.settings.port": 5502
-}
\ No newline at end of file
diff --git a/README.md b/README.md
index ef6ea2a..1cd41a2 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,122 @@
-# Litruv
+# lit.ruv.wtf
-Check out the live site: **[lit.ruv.wtf](https://lit.ruv.wtf)**
+Interactive terminal interface for lit.ruv.wtf - A retro-styled command line experience with chat, documentation, and more.
-Dev/Tech Artist at MatesMedia. Follow for coding, game development, and creative projects.
\ No newline at end of file
+## Features
+
+- **Classic CRT Aesthetics**: Green phosphor terminal with authentic CRT effects including scanlines and subtle flicker
+- **Interactive Chat**: Matrix-powered chat integration with real-time messaging
+- **Keyboard Navigation**: Full keyboard support with arrow keys, history navigation, and standard terminal shortcuts
+- **Mouse Support**: Click to position cursor, scroll through history, and interact with links
+- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
+- **Multiple Color Schemes**: Switch between green, amber, blue, and white phosphor themes
+- **Command History**: Navigate through previous commands using up/down arrows
+- **Bluesky Integration**: Fetch and display recent posts from Bluesky
+- **Quick Links**: Easy access to documentation, GitHub, and social media
+
+## Getting Started
+
+### Quick Start
+
+1. Open `index.html` directly in your browser, or
+2. Serve with a local web server:
+
+```bash
+# Using Python 3
+python3 -m http.server 8000
+
+# Or using npm script
+npm start
+
+# Or using Node.js http-server
+npx http-server -p 8000
+```
+
+3. Open `http://localhost:8000` in your browser
+
+## Available Commands
+
+- `help` - Display available commands
+- `about` - Information about this terminal
+- `clear` - Clear the terminal screen
+- `echo [message]` - Echo back your message
+- `date` - Display current date and time
+- `whoami` - Display current user information
+- `history` - Show command history
+- `color [scheme]` - Change terminal color scheme (green, amber, blue, white)
+- `banner` - Display welcome banner
+- `bluesky [count]` - Fetch recent posts from Bluesky (default: 5)
+- `chat` - Enter interactive chat (type /quit to exit)
+- `github` - Visit GitHub repository
+- `contact` - Display contact information
+- `privacy` - Display privacy policy
+
+## Chat Commands
+
+While in chat mode (after running `chat`):
+
+- `/help` - Show chat commands
+- `/nick [name]` - Change your display name
+- `/quit` or `/exit` - Exit chat mode
+
+## Keyboard Shortcuts
+
+- `↑/↓` - Navigate command history
+- `←/→` - Move cursor within current line
+- `Home` - Move cursor to start of line
+- `End` - Move cursor to end of line
+- `Ctrl+C` - Cancel current input
+- `Ctrl+L` - Clear screen (keeping current input)
+- `Enter` - Execute command
+- `Backspace` - Delete character
+
+## Mouse Support
+
+- Click anywhere in the terminal to focus
+- Scroll to view terminal history
+- Click on links to open them
+
+## Customization
+
+### Colors
+
+Change the color scheme by editing the theme in `terminal.js` or use the `color` command at runtime:
+
+```
+$ color amber
+$ color blue
+$ color white
+$ color green
+```
+
+### Commands
+
+Add new commands by extending the `commands` object in `terminal.js`:
+
+```javascript
+commands.mycommand = {
+ description: 'My custom command',
+ execute: (args) => {
+ return 'Output from my command';
+ }
+};
+```
+
+## Technologies
+
+- **xterm.js** - Terminal emulator for the web
+- **xterm-addon-fit** - Responsive terminal sizing
+- **xterm-addon-web-links** - Clickable URL support
+- **Pure CSS** - Classic CRT effects and animations
+
+## Browser Support
+
+Works in all modern browsers:
+- Chrome/Edge 90+
+- Firefox 88+
+- Safari 14+
+- Opera 76+
+
+## License
+
+MIT
diff --git a/build.js b/build.js
deleted file mode 100644
index cac81fb..0000000
--- a/build.js
+++ /dev/null
@@ -1,176 +0,0 @@
-const fs = require('fs').promises;
-const path = require('path');
-
-// Helper function to extract content between delimeters
-function extractBlock(content, startDelimiter, endDelimiter) {
- const startIndex = content.indexOf(startDelimiter);
- if (startIndex === -1) return { blockContent: '', remainingContent: content };
- const endIndex = content.indexOf(endDelimiter, startIndex + startDelimiter.length);
- if (endIndex === -1) return { blockContent: '', remainingContent: content }; // Or throw error
-
- const block = content.substring(startIndex + startDelimiter.length, endIndex).trim();
- const remaining = content.substring(0, startIndex) + content.substring(endIndex + endDelimiter.length);
- return { blockContent: block, remainingContent: remaining.trim() };
-}
-
-// Helper function to copy files recursively
-async function copyWebsiteFiles() {
- const websiteDir = path.join(__dirname, 'website');
- const buildDir = path.join(__dirname, 'build');
-
- try {
- const websiteExists = await fs.access(websiteDir).then(() => true).catch(() => false);
- if (!websiteExists) {
- console.log("No website directory found, skipping copy.");
- return;
- }
-
- async function copyRecursive(src, dest) {
- const stats = await fs.stat(src);
-
- if (stats.isDirectory()) {
- await fs.mkdir(dest, { recursive: true });
- const items = await fs.readdir(src);
-
- for (const item of items) {
- const srcPath = path.join(src, item);
- const destPath = path.join(dest, item);
- await copyRecursive(srcPath, destPath);
- }
- } else {
- await fs.copyFile(src, dest);
- }
- }
-
- await copyRecursive(websiteDir, buildDir);
- console.log("Website files copied successfully.");
- } catch (error) {
- console.error('Error copying website files:', error);
- throw error;
- }
-}
-
-async function buildPage(pageName, outputName) {
- const layoutPath = path.join(__dirname, 'templates', '_layout.html');
- const commonHeaderPath = path.join(__dirname, 'templates', '_header.html');
- const commonFooterPath = path.join(__dirname, 'templates', '_footer.html');
- const contentPath = path.join(__dirname, 'src', `${pageName}.html`);
- const outputDir = path.join(__dirname, 'build');
- const outputPath = path.join(outputDir, `${outputName}.html`);
-
- try {
- // Ensure the build directory exists
- await fs.mkdir(outputDir, { recursive: true });
-
- let layoutContent = await fs.readFile(layoutPath, 'utf-8');
- const srcFileContent = await fs.readFile(contentPath, 'utf-8');
-
- // Extract metadata
- const metadataRegex = //;
- const metadataMatch = srcFileContent.match(metadataRegex);
- let metadata = {};
- let mainPageContent = srcFileContent;
-
- if (metadataMatch && metadataMatch[1]) {
- try {
- metadata = JSON.parse(metadataMatch[1].trim());
- } catch (e) {
- console.error(`Error parsing metadata JSON for ${pageName}.html:`, e);
- }
- mainPageContent = srcFileContent.replace(metadataRegex, '').trim();
- }
-
- // Defaults for metadata
- const defaults = {
- title: "Litruv",
- metaDescription: "Litruv's personal website.",
- csp: "script-src 'self' 'unsafe-inline';",
- ogTitle: "Litruv",
- ogDescription: "Litruv's personal website.",
- ogUrl: "https://lit.ruv.wtf",
- twitterTitle: "Litruv",
- twitterDescription: "Litruv's personal website.",
- twitterUrl: "https://lit.ruv.wtf",
- pageSpecificStyles: "",
- pageSpecificScriptsDefer: "",
- pageSpecificScriptsBodyEnd: "",
- useCommonHeader: true,
- useCommonFooter: true
- };
-
- const finalMetadata = { ...defaults, ...metadata };
-
- // Extract page-specific styles
- const stylesExtraction = extractBlock(mainPageContent, '', '');
- finalMetadata.pageSpecificStyles = stylesExtraction.blockContent;
- mainPageContent = stylesExtraction.remainingContent;
-
- // Extract page-specific defer scripts
- const deferScriptsExtraction = extractBlock(mainPageContent, '', '');
- finalMetadata.pageSpecificScriptsDefer = deferScriptsExtraction.blockContent;
- mainPageContent = deferScriptsExtraction.remainingContent;
-
- // Extract page-specific body-end scripts
- const bodyEndScriptsExtraction = extractBlock(mainPageContent, '', '');
- finalMetadata.pageSpecificScriptsBodyEnd = bodyEndScriptsExtraction.blockContent;
- mainPageContent = bodyEndScriptsExtraction.remainingContent;
-
- // Replace metadata placeholders in layout
- layoutContent = layoutContent.replace(//g, finalMetadata.title)
- .replace(//g, finalMetadata.metaDescription)
- .replace(//g, finalMetadata.csp)
- .replace(//g, finalMetadata.ogTitle)
- .replace(//g, finalMetadata.ogDescription)
- .replace(//g, finalMetadata.ogUrl)
- .replace(//g, finalMetadata.twitterTitle)
- .replace(//g, finalMetadata.twitterDescription)
- .replace(//g, finalMetadata.twitterUrl)
- .replace(//g, finalMetadata.pageSpecificStyles)
- .replace(//g, finalMetadata.pageSpecificScriptsDefer)
- .replace(//g, finalMetadata.pageSpecificScriptsBodyEnd);
-
- // Handle common header and footer
- const headerContentToInject = finalMetadata.useCommonHeader ? await fs.readFile(commonHeaderPath, 'utf-8') : '';
- const footerContentToInject = finalMetadata.useCommonFooter ? await fs.readFile(commonFooterPath, 'utf-8') : '';
-
- layoutContent = layoutContent.replace('', headerContentToInject);
- layoutContent = layoutContent.replace('', footerContentToInject);
- layoutContent = layoutContent.replace('', mainPageContent);
-
- await fs.writeFile(outputPath, layoutContent, 'utf-8');
- console.log(`Successfully built ${outputPath}`);
- } catch (error) {
- console.error(`Error building page ${pageName}:`, error);
- throw error; // Re-throw to fail the build process if needed
- }
-}
-
-async function main() {
- try {
- // Copy website files first
- await copyWebsiteFiles();
-
- const srcDir = path.join(__dirname, 'src');
- const files = await fs.readdir(srcDir);
-
- const htmlFiles = files.filter(file => file.endsWith('.html'));
-
- if (htmlFiles.length === 0) {
- console.log("No HTML files found in src directory to build.");
- return;
- }
-
- for (const file of htmlFiles) {
- const pageName = path.parse(file).name;
- const outputName = pageName;
- await buildPage(pageName, outputName);
- }
-
- console.log("All pages built successfully.");
- } catch (error) {
- console.error('Build process failed:', error);
- process.exit(1);
- }
-}
-
-main();
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5311ec7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "lit.ruv.wtf",
+ "version": "1.0.0",
+ "description": "Interactive terminal interface for lit.ruv.wtf - A retro-styled command line experience with chat, documentation, and more.",
+ "main": "index.html",
+ "scripts": {
+ "start": "python3 -m http.server 8000",
+ "start-alt": "python -m SimpleHTTPServer 8000"
+ },
+ "keywords": [
+ "terminal",
+ "xterm",
+ "retro",
+ "cli",
+ "web-terminal",
+ "matrix",
+ "chat",
+ "interactive"
+ ],
+ "author": "Max Litruv Boonzaayer",
+ "license": "MIT",
+ "dependencies": {},
+ "devDependencies": {},
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/litruv/lit.ruv.wtf"
+ },
+ "homepage": "https://lit.ruv.wtf"
+}
diff --git a/src/index.html b/src/index.html
deleted file mode 100644
index 6da8072..0000000
--- a/src/index.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
diff --git a/src/mining.html b/src/mining.html
deleted file mode 100644
index 49540c2..0000000
--- a/src/mining.html
+++ /dev/null
@@ -1,349 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
[ ]
-
Shop
-
-
-
-
-
-
-
-
-
diff --git a/src/numbermatch.html b/src/numbermatch.html
deleted file mode 100644
index 4705587..0000000
--- a/src/numbermatch.html
+++ /dev/null
@@ -1,498 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- New Game
-
-
-
-
-
- Matches cleared: 0
-
-
- Tiles remaining: 0
-
-
- Add Numbers used: 0/3
-
-
- Hints used: 0
-
-
-
-
-
-
- +
- 3
- Add numbers
-
-
- ?
- Hint
-
-
-
-
- How To Play
- Select two tiles that are either identical (like 8 and 8) or sum to ten (for example, 3 and 7). Clear every number to win.
- Match Rules
-
- Paths can run horizontally, vertically, diagonally, or wrap in reading order.
- Every cell between selections must be empty along the chosen path.
- Wrap-around allows the end of a row to connect to the start of the next when the path is clear.
-
- Strategy & Tips
-
- Scan rows and columns for classic pairs like 9-1, 8-2, 7-3, 6-4, and 5-5.
- Use diagonal and wrap-around matches to reach blocked numbers.
- Add Numbers unlocks fresh tiles in three batches—save it for when the board runs dry.
-
- Frequently Asked Questions
- Which numbers appear? Numbers 1-9 are distributed to encourage plenty of identical and ten-sum pairs.
- What counts as wrap-around? Reading order connections across a row break, provided every intervening cell is empty.
-
-
-
-
-
diff --git a/src/privacy.html b/src/privacy.html
deleted file mode 100644
index 5efd162..0000000
--- a/src/privacy.html
+++ /dev/null
@@ -1,97 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Privacy Policy
-
-
I respect your privacy.
-
-
Apps
-
My apps do not collect, store, or share any personal information. No analytics, no tracking, no funny business.
-
-
Website
-
This site may collect anonymous usage data (such as page views or general browser information) to help improve functionality and performance. This data is non-personal and cannot be used to identify you. I do not sell or share this data with third parties.
-
Additionally, this site uses Cloudflare for performance and security. Cloudflare may collect its own analytics and statistical data, subject to their privacy policy .
-
-
By using this site, you agree to this policy.
-
diff --git a/templates/_footer.html b/templates/_footer.html
deleted file mode 100644
index d338ab5..0000000
--- a/templates/_footer.html
+++ /dev/null
@@ -1,7 +0,0 @@
-
diff --git a/templates/_header.html b/templates/_header.html
deleted file mode 100644
index c42c020..0000000
--- a/templates/_header.html
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
- Litruv
- Dev/Tech Artist @
MatesMedia
-
-
-
-
-
-
diff --git a/templates/_layout.html b/templates/_layout.html
deleted file mode 100644
index 5426d78..0000000
--- a/templates/_layout.html
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/website/dynamic-rotation.js b/website/dynamic-rotation.js
deleted file mode 100644
index f10ecff..0000000
--- a/website/dynamic-rotation.js
+++ /dev/null
@@ -1,55 +0,0 @@
-(function() {
- function updatePostRotations() {
- const posts = document.querySelectorAll('.post');
-
- posts.forEach(post => {
- const height = post.offsetHeight;
-
- // Calculate rotation: 3deg for 200px posts, 0.5deg for 500px+ posts
- const minRotation = 0.5;
- const maxRotation = 2.5;
- const minHeight = 200;
- const maxHeight = 600;
-
- // Linear interpolation between min and max rotation based on height
- let rotation = maxRotation - ((height - minHeight) / (maxHeight - minHeight)) * (maxRotation - minRotation);
-
- // Clamp between min and max
- rotation = Math.max(minRotation, Math.min(maxRotation, rotation));
-
- // Apply alternating direction
- const isOdd = Array.from(posts).indexOf(post) % 2 === 0;
- const finalRotation = isOdd ? -rotation : rotation;
-
- // Set CSS custom property
- post.style.setProperty('--dynamic-rotation', `${finalRotation}deg`);
- post.style.transform = `rotate(var(--dynamic-rotation, ${finalRotation}deg))`;
- });
- }
-
- // Update rotations when DOM is ready
- function init() {
- // Wait for images to load to get accurate heights
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', () => {
- setTimeout(updatePostRotations, 100);
- });
- } else {
- setTimeout(updatePostRotations, 100);
- }
-
- // Update after window load for images
- window.addEventListener('load', () => {
- setTimeout(updatePostRotations, 200);
- });
-
- // Update on resize with debouncing
- let resizeTimeout;
- window.addEventListener('resize', () => {
- clearTimeout(resizeTimeout);
- resizeTimeout = setTimeout(updatePostRotations, 300);
- });
- }
-
- init();
-})();
diff --git a/website/index.html b/website/index.html
new file mode 100644
index 0000000..34bde8d
--- /dev/null
+++ b/website/index.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+ Lit.ruv.wtf Terminal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ready
+ Type 'help' for available commands
+
+
+
+
+
+
+
+
+
diff --git a/website/main.js b/website/main.js
deleted file mode 100644
index 339d78f..0000000
--- a/website/main.js
+++ /dev/null
@@ -1,674 +0,0 @@
-function formatDate(t) {
- return new Date(t).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit"
- });
-}
-
-function getRandomRotation() {
- return 3 * Math.random() - 1.5;
-}
-
-const postItColors = [
- '#E6F3FF', // faded blue
- '#FFF9C4', // yellow
- '#E8F5E8', // green
- '#FFE4B5', // orange
- '#FFE1E6' // pink
-];
-
-function getRandomPostItColor() {
- return postItColors[Math.floor(Math.random() * postItColors.length)];
-}
-
-function getPostItColorByDate(dateString) {
- let hash = 0;
- for (let i = 0; i < dateString.length; i++) {
- hash = ((hash << 5) - hash) + dateString.charCodeAt(i);
- hash = hash & hash;
- }
- const colorIndex = Math.abs(hash) % postItColors.length;
- return postItColors[colorIndex];
-}
-
-let lastMediaVolume = 1;
-
-// Define attachHoverListeners function
-function attachHoverListeners(postElement) {
- const baseRotation = postElement.style.getPropertyValue('--post-rotation');
-
- postElement.addEventListener('mouseenter', function () {
- postElement.style.zIndex = 100;
- postElement.style.transition = "transform 0.22s cubic-bezier(.5,1.5,.5,1), box-shadow 0.22s cubic-bezier(.5,1.5,.5,1)";
- postElement.style.transform = `rotate(${getRandomRotation()}deg) scale(1.1)`;
- });
-
- postElement.addEventListener('mouseleave', function () {
- postElement.style.transition = "transform 0.18s cubic-bezier(.5,0,.5,1), box-shadow 0.18s cubic-bezier(.5,0,.5,1)";
- postElement.style.transform = `rotate(${baseRotation || '0deg'}) scale(1)`;
- postElement.style.zIndex = 1;
- });
-}
-
-function createPost(t) {
- const post = document.createElement("div");
- post.className = "post";
- post.style.transform = `rotate(${getRandomRotation()}deg)`;
- post.style.backgroundColor = getPostItColorByDate(t.createdAt);
-
- const link = document.createElement("a");
- link.href = t.url;
- link.target = "_blank";
- link.style.textDecoration = "none";
-
- let embedHtml = "";
- if (t.embed && t.embed.length > 0) {
- embedHtml = t.embed.map((embed, idx) => {
- if (embed.type === "image") {
- return `
-
-
-
- `;
- } else if (embed.type === "video") {
- const isM3u8 = embed.playlist && embed.playlist.endsWith('.m3u8');
- return `
-
-
- Sorry, your browser doesn't support embedded videos.
-
-
- `;
- }
- return "";
- }).join("");
- }
-
- function linkifyText(text, highlightColor) {
- text = text.replace(/(^|[\s.,;:!?])#([a-zA-Z0-9_]{2,50})\b/g, (m, pre, tag) =>
- `${pre}#${tag} `
- );
- text = text.replace(/(^|[\s.,;:!?])@([a-zA-Z0-9_.-]+\.[a-zA-Z0-9_.-]+)/g, (m, pre, handle) =>
- `${pre}@${handle} `
- );
- return text;
- }
-
- function getHighlighterColor(cardColor) {
- if (cardColor === '#E6F3FF') return '#ffb7ce';
- if (cardColor === '#FFF9C4') return '#b7e0fd';
- if (cardColor === '#E8F5E8') return '#fff89a';
- if (cardColor === '#FFE4B5') return '#b6fcb6';
- if (cardColor === '#FFE1E6') return '#ffd59e';
- return '#fff89a';
- }
-
- const cardColor = getPostItColorByDate(t.createdAt);
- const highlightColor = getHighlighterColor(cardColor);
-
- const textHtml = linkifyText(t.text, highlightColor).replace(/\n/g, " ");
-
- post.innerHTML = `
-
- ${textHtml}
- ${embedHtml}
-
- ♥ ${t.likes}
- ⟲ ${t.reposts}
-
-
- `;
-
- setTimeout(() => {
- const imgs = post.querySelectorAll('img');
- imgs.forEach(img => {
- img.addEventListener('click', e => {
- e.preventDefault();
- e.stopPropagation();
- createShadowbox(post, img);
- });
- });
-
- post.querySelectorAll('.video-embed-container').forEach(container => {
- const video = container.querySelector('video');
- if (!video) return;
-
- video.volume = lastMediaVolume;
-
- video.addEventListener('mousedown', function (e) {
- e.stopPropagation();
- });
-
- video.addEventListener('volumechange', function () {
- if (!isNaN(video.volume)) {
- lastMediaVolume = video.volume;
- document.querySelectorAll('video.video-embed').forEach(v => {
- if (v !== video && Math.abs(v.volume - lastMediaVolume) > 0.01) {
- v.volume = lastMediaVolume;
- }
- });
- }
- });
-
- video.addEventListener('play', function () {
- document.querySelectorAll('video.video-embed').forEach(v => {
- if (v !== video && !v.paused) {
- v.pause();
- }
- });
- });
-
- const observer = new IntersectionObserver((entries) => {
- entries.forEach(entry => {
- if (!entry.isIntersecting && !video.paused) {
- video.pause();
- }
- });
- }, {
- threshold: 0.25
- });
- observer.observe(video);
- });
-
- const hlsVideos = post.querySelectorAll('video[data-hls-src]');
- if (hlsVideos.length > 0 && window.Hls) {
- hlsVideos.forEach(video => {
- const src = video.getAttribute('data-hls-src');
- if (video.canPlayType('application/vnd.apple.mpegurl')) {
- video.src = src;
- } else if (window.Hls.isSupported()) {
- const hls = new window.Hls();
- hls.loadSource(src);
- hls.attachMedia(video);
- } else {
- video.outerHTML = 'Video format not supported
';
- }
- });
- }
- }, 0);
-
- link.href = t.url;
- link.appendChild(post);
-
- return link;
-}
-
-function createShadowbox(postEl, imgEl) {
- const existing = document.getElementById('shadowbox-modal');
- if (existing) existing.remove();
-
- const modal = document.createElement('div');
- modal.id = 'shadowbox-modal';
- modal.className = 'shadowbox-modal';
-
- const postClone = postEl.cloneNode(true);
- postClone.className += ' shadowbox-post';
- postClone.style.background = postEl.style.backgroundColor || '#fff';
-
- const tapeElements = postClone.querySelectorAll('.tape');
- tapeElements.forEach(tape => tape.remove());
-
- const imgs = postClone.querySelectorAll('img');
- const originalImgs = postEl.querySelectorAll('img');
- let focusedImg = null;
- let clickedImageIndex = -1;
-
- originalImgs.forEach((originalImg, idx) => {
- if (originalImg === imgEl) {
- clickedImageIndex = idx;
- }
- });
-
- imgs.forEach((cloneImg, idx) => {
- if (cloneImg.classList.contains('image-placeholder')) {
- const fullSizeUrl = getFullSizeImageUrl(cloneImg.src);
- cloneImg.src = fullSizeUrl;
- cloneImg.className += ' shadowbox-image';
- }
- if (idx === clickedImageIndex && cloneImg.classList.contains('image-placeholder')) {
- focusedImg = cloneImg;
- }
- });
-
- const closeBtn = document.createElement('button');
- closeBtn.textContent = '×';
- closeBtn.className = 'shadowbox-close';
- closeBtn.addEventListener('click', e => {
- e.stopPropagation();
- closeShadowbox();
- });
- postClone.appendChild(closeBtn);
-
- postClone.addEventListener('click', e => e.stopPropagation());
- modal.appendChild(postClone);
- modal.addEventListener('click', closeShadowbox);
- document.body.appendChild(modal);
-
- setTimeout(() => {
- modal.classList.add('visible');
- postClone.classList.add('visible');
- }, 10);
-
- setTimeout(() => {
- if (focusedImg) {
- focusedImg.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
- }, 400);
-
- function closeShadowbox() {
- modal.classList.remove('visible');
- postClone.classList.remove('visible');
- setTimeout(() => {
- modal.remove();
- }, 350);
- }
-
- function escListener(e) {
- if (e.key === 'Escape') {
- closeShadowbox();
- window.removeEventListener('keydown', escListener);
- }
- }
- window.addEventListener('keydown', escListener);
-
- let lastFocusedIdx = Array.from(imgs).findIndex(cloneImg => cloneImg === focusedImg);
- let snapTimeout = null;
- let isInitialScroll = true;
-
- setTimeout(() => {
- isInitialScroll = false;
- }, 800);
-
- postClone.addEventListener('scroll', () => {
- if (isInitialScroll) return;
-
- let modalRect = postClone.getBoundingClientRect();
- let modalCenter = modalRect.top + modalRect.height / 2;
-
- let candidates = [];
- imgs.forEach((img, idx) => {
- const rect = img.getBoundingClientRect();
- const center = rect.top + rect.height / 2;
- const dist = Math.abs(center - modalCenter);
- candidates.push({ idx, dist, center, rect });
- });
-
- candidates.sort((a, b) => a.dist - b.dist);
-
- let best = candidates[0];
- if (
- best.idx !== lastFocusedIdx &&
- best.dist < best.rect.height * 0.75
- ) {
- lastFocusedIdx = best.idx;
- if (snapTimeout) clearTimeout(snapTimeout);
- snapTimeout = setTimeout(() => {
- imgs[lastFocusedIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
- }, 60);
- }
- });
-}
-
-function getFullSizeImageUrl(thumbUrl) {
- if (!thumbUrl) return thumbUrl;
- return thumbUrl.replace(/@jpeg$/, '').replace(/\?.*$/, '');
-}
-
-function filterOriginalPosts(feed) {
- return feed.filter(item => {
- const isRepost = item?.reason?.$type === "app.bsky.feed.defs#reasonRepost";
- const isReply = item?.post?.record?.reply;
- return !isRepost && !isReply;
- });
-}
-
-let postElementsCache = null;
-let layoutColumns = null;
-let postQueue = [];
-let isProcessingQueue = false;
-let currentNumColumns = 0; // Variable to store the current number of columns
-
-function createColumns(num) {
- const postsContainer = document.getElementById("posts");
- if (!postsContainer) return [];
- postsContainer.innerHTML = "";
- const columns = [];
- for (let i = 0; i < num; i++) {
- const col = document.createElement("div");
- col.className = "masonry-col";
- postsContainer.appendChild(col);
- columns.push(col);
- }
- return columns;
-}
-
-function getNumColumns() {
- if (window.innerWidth >= 1200) return 3;
- if (window.innerWidth >= 800) return 2;
- return 1;
-}
-
-function getShortestColumn(columns) {
- let minHeight = Infinity;
- let shortestColumns = [];
-
- // Find all columns with the minimum height
- for (const col of columns) {
- const height = col.offsetHeight;
- if (height < minHeight) {
- minHeight = height;
- shortestColumns = [col];
- } else if (height === minHeight) {
- shortestColumns.push(col);
- }
- }
-
- // If multiple columns have the same height, prefer the middle one
- if (shortestColumns.length > 1) {
- const middleIndex = Math.floor(columns.length / 2);
- const middleColumn = columns[middleIndex];
- if (shortestColumns.includes(middleColumn)) {
- return middleColumn;
- }
- // If middle column isn't shortest, return the one closest to middle
- let closestToMiddle = shortestColumns[0];
- let minDistance = Math.abs(columns.indexOf(closestToMiddle) - middleIndex);
-
- for (const col of shortestColumns) {
- const distance = Math.abs(columns.indexOf(col) - middleIndex);
- if (distance < minDistance) {
- minDistance = distance;
- closestToMiddle = col;
- }
- }
- return closestToMiddle;
- }
-
- return shortestColumns[0];
-}
-
-function addPostToLayout(postEl, zIndex) {
- if (!layoutColumns) return;
-
- const col = getShortestColumn(layoutColumns);
- const postDiv = postEl.firstElementChild;
-
- const currentTransform = postDiv.style.transform;
- const rotationMatch = currentTransform.match(/rotate\(([-\d.]+deg)\)/);
- const rotation = rotationMatch ? rotationMatch[1] : '0deg';
-
- postDiv.style.setProperty('--post-rotation', rotation);
- postDiv.style.zIndex = zIndex;
-
- postDiv.classList.add('loading');
-
- col.appendChild(postEl);
-
- setTimeout(() => {
- postDiv.classList.remove('loading');
- attachHoverListeners(postDiv);
- }, 600);
-
- setTimeout(() => {
- const imgs = postEl.querySelectorAll('img.image-placeholder');
- if (imgs.length > 0 && window.addTapeToPost) {
- window.addTapeToPost(postEl);
- }
- }, 50);
-}
-
-async function processPostQueue() {
- if (isProcessingQueue || postQueue.length === 0) return;
-
- isProcessingQueue = true;
-
- while (postQueue.length > 0) {
- const { postEl, zIndex } = postQueue.shift();
-
- // Create promises for this post's media
- const promises = [];
-
- // Preload images
- const imgs = postEl.querySelectorAll("img");
- if (imgs.length) {
- promises.push(...Array.from(imgs).map(img => {
- if (img.complete) return Promise.resolve();
- return new Promise(res => {
- img.onload = img.onerror = res;
- });
- }));
- }
-
- // Preload video metadata
- const videos = postEl.querySelectorAll("video");
- if (videos.length) {
- promises.push(...Array.from(videos).map(video => {
- if (video.readyState >= 1) return Promise.resolve();
- return new Promise(res => {
- video.onloadedmetadata = video.onerror = res;
- });
- }));
- }
-
- // Wait for this post's media to load before adding to layout
- if (promises.length > 0) {
- await Promise.all(promises);
- }
-
- // Add post to layout
- addPostToLayout(postEl, zIndex);
-
- // Small delay between posts for visual effect
- await new Promise(resolve => setTimeout(resolve, 100));
- }
-
- isProcessingQueue = false;
-
- // Replace loading indicator with "view more" button when all posts are processed
- hideLoadingIndicator();
- showViewMoreButton();
-}
-
-function layoutPosts(postElements) {
- if (document.fullscreenElement && document.fullscreenElement.tagName === "VIDEO") {
- return;
- }
- const numCols = getNumColumns();
- const columns = createColumns(numCols);
- postElements.forEach((postEl, index) => {
- const col = getShortestColumn(columns);
- postEl.firstElementChild.style.zIndex = postElements.length - index;
- col.appendChild(postEl);
- });
-
- setTimeout(() => {
- if (window.addTapeToImages) {
- window.addTapeToImages();
- } else {
- console.error('[Main] addTapeToImages function not available');
- }
- }, 200);
-}
-
-function showLoadingIndicator() {
- const postsContainer = document.getElementById("posts");
- if (!postsContainer) return;
-
- const loadingDiv = document.createElement("div");
- loadingDiv.id = "loading-posts";
- loadingDiv.className = "loading-posts";
- loadingDiv.innerHTML = `
-
- `;
- postsContainer.appendChild(loadingDiv);
-}
-
-function hideLoadingIndicator() {
- const loadingDiv = document.getElementById("loading-posts");
- if (loadingDiv) {
- loadingDiv.remove();
- }
-}
-
-function showViewMoreButton() {
- const postsContainer = document.getElementById("posts");
-
- // Check if button already exists
- if (document.getElementById("view-more-button")) {
- return;
- }
-
- const viewMoreDiv = document.createElement("a");
- viewMoreDiv.id = "view-more-button";
- viewMoreDiv.href = "https://bsky.app/profile/lit.mates.dev";
- viewMoreDiv.target = "_blank";
- viewMoreDiv.className = "view-more-posts";
- viewMoreDiv.innerHTML = `
-
- View more on Bluesky →
- Follow @lit.mates.dev for more posts
-
- `;
-
- // Insert after the posts container but before footer
- postsContainer.insertAdjacentElement('afterend', viewMoreDiv);
-}
-
-async function fetchPosts() {
- // Show loading indicator
- showLoadingIndicator();
-
- try {
- const res = await fetch(
- "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=lit.mates.dev&limit=20&filter=posts_no_replies"
- );
- if (!res.ok) throw new Error("Failed to fetch posts");
- const data = await res.json();
-
- const posts = filterOriginalPosts(data.feed).map(item => {
- let embeds = [];
- const embedObj = item.post.embed;
- if (embedObj) {
- if (embedObj.$type === "app.bsky.embed.images#view" && Array.isArray(embedObj.images)) {
- embeds = embedObj.images.map(img => ({
- type: "image",
- url: img.thumb,
- alt: img.alt || "",
- fullres: img.fullsize || img.full || img.thumb
- }));
- } else if (embedObj.$type === "app.bsky.embed.video#view") {
- embeds = [{
- type: "video",
- playlist: embedObj.playlist,
- thumbnail: embedObj.thumbnail,
- alt: embedObj.alt || "",
- aspectRatio: embedObj.aspectRatio || null
- }];
- }
- }
-
- return {
- author: item.post.author.displayName,
- handle: item.post.author.handle,
- avatar: item.post.author.avatar,
- createdAt: item.post.record.createdAt,
- text: item.post.record.text,
- likes: item.post.likeCount || 0,
- reposts: item.post.repostCount || 0,
- comments: item.post.replyCount || 0,
- url: `https://bsky.app/profile/${item.post.author.handle}/post/${item.post.uri.split("/").pop()}`,
- embed: embeds
- };
- });
-
- // Initialize layout columns
- const numCols = getNumColumns();
- layoutColumns = createColumns(numCols);
- currentNumColumns = numCols; // Initialize currentNumColumns
-
- // Create all post elements
- postElementsCache = posts.map(post => createPost(post));
-
- // Add posts to queue in order
- postQueue = postElementsCache.map((postEl, index) => ({
- postEl,
- zIndex: postElementsCache.length - index
- }));
-
- // Start processing the queue
- processPostQueue();
-
- } catch (err) {
- console.error("Error fetching posts:", err);
- // Hide loading indicator on error
- hideLoadingIndicator();
-
- // Show error message
- const postsContainer = document.getElementById("posts");
- if (postsContainer) {
- const errorDiv = document.createElement("div");
- errorDiv.className = "loading-posts";
- errorDiv.innerHTML = `
-
- ⚠ Failed to load posts. Please refresh the page.
-
- `;
- postsContainer.appendChild(errorDiv);
- }
- }
-}
-
-window.addEventListener("resize", () => {
- if (document.fullscreenElement && document.fullscreenElement.tagName === "VIDEO") {
- return;
- }
-
- const newNumCols = getNumColumns();
-
- if (postElementsCache && layoutColumns && newNumCols !== currentNumColumns) {
- currentNumColumns = newNumCols; // Update the current number of columns
- // Re-layout all existing posts
- layoutColumns = createColumns(newNumCols);
- postElementsCache.forEach((postEl, index) => {
- if (postEl.parentNode) { // Only re-layout posts that are already added
- const zIndex = postElementsCache.length - index;
- const col = getShortestColumn(layoutColumns);
- const postDiv = postEl.firstElementChild;
- postDiv.style.zIndex = zIndex;
- col.appendChild(postEl);
- }
- });
- // Re-add tape after layout changes using the full refresh
- setTimeout(() => {
- if (window.refreshTape) {
- window.refreshTape();
- }
- }, 100);
- }
-});
-
-if (document.getElementById("posts")) {
- fetchPosts();
-}
\ No newline at end of file
diff --git a/website/masonry-smooth.js b/website/masonry-smooth.js
deleted file mode 100644
index 4b97b7e..0000000
--- a/website/masonry-smooth.js
+++ /dev/null
@@ -1,58 +0,0 @@
-(function() {
- let currentLayout = null;
- let posts = [];
- let columns = [];
- let originalPositions = new Map();
-
- function initMasonry() {
- const postsContainer = document.getElementById('posts');
- if (!postsContainer) return;
-
- posts = Array.from(postsContainer.querySelectorAll('.post'));
- columns = Array.from(postsContainer.querySelectorAll('.masonry-col'));
-
- if (posts.length === 0 || columns.length === 0) return;
-
- // Assign z-index based on post order (newer posts first)
- posts.forEach((post, index) => {
- post.style.zIndex = 100 - index;
- });
-
- // Store original positions of posts
- posts.forEach((post, index) => {
- const rect = post.getBoundingClientRect();
- originalPositions.set(post, {
- originalParent: post.parentNode,
- originalIndex: index,
- originalRect: rect
- });
- });
-
- checkLayout();
- }
-
- function checkLayout() {
- const isMobile = window.innerWidth <= 799;
- const newLayout = isMobile ? 'mobile' : 'desktop';
-
- if (newLayout !== currentLayout) {
- currentLayout = newLayout;
- // Don't redistribute - just let CSS media queries handle it
- // The layout change is handled purely by CSS
- }
- }
-
- // Initialize when DOM is ready
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', initMasonry);
- } else {
- initMasonry();
- }
-
- // Listen for resize events but don't do anything that affects layout
- let resizeTimeout;
- window.addEventListener('resize', () => {
- clearTimeout(resizeTimeout);
- resizeTimeout = setTimeout(checkLayout, 150);
- });
-})();
diff --git a/website/mining.js b/website/mining.js
deleted file mode 100644
index b667e98..0000000
--- a/website/mining.js
+++ /dev/null
@@ -1,2402 +0,0 @@
-document.addEventListener('DOMContentLoaded', () => {
- const canvas = document.getElementById('game-canvas');
- const depthDisplay = document.getElementById('depth-counter');
- const scoreDisplay = document.getElementById('score-counter');
-
- if (!canvas) return;
-
- // --- Configuration ---
- let GRID_SIZE = 5; // 5x5 grid (Dynamic)
- let VISIBLE_LAYERS = 7; // How many layers deep we render (Dynamic)
- const BLOCK_SIZE = 1;
-
- const SELL_PRICES = { coal: 10, iron: 20, gold: 50, diamond: 200, emerald: 500 };
- const UPGRADE_COSTS = {
- speed: (level) => Math.floor(100 * Math.pow(1.5, level)),
- luck: (level) => Math.floor(500 * Math.pow(2, level)),
- mining_area: (level) => Math.floor(1000 * Math.pow(3, level)), // Expensive!
- vein_miner: (level) => Math.floor(2000 * Math.pow(2, level)),
- tnt_frequency: (level) => Math.floor(5000 * Math.pow(1.5, level)),
- tnt_power: (level) => Math.floor(10000 * Math.pow(2, level)),
- pickup_range: (level) => Math.floor(300 * Math.pow(1.5, level)),
- grid_size: (level) => Math.floor(10000 * Math.pow(4, level)) // Very Expensive!
- };
-
- // --- State ---
- let depth = 0;
- let score = 0;
- let isAnimating = false;
- let cameraOffsetY = 0;
- let upgrades = { speed: 0, luck: 0, mining_area: 0, vein_miner: 0, tnt_frequency: 0, tnt_power: 0, pickup_range: 0, grid_size: 0 };
- let tntTimer = 0;
- const tntProjectiles = [];
-
- // Store blocks: blocks[layerIndex][x][z]
- // layerIndex 0 is the top-most visible layer.
- let layers = [];
- let extraBlocks = []; // Blocks above the ground (trees, etc)
-
- // --- Three.js Setup ---
- const scene = new THREE.Scene();
- scene.background = new THREE.Color(0x87CEEB); // Sky blue background
-
- // Orthographic Camera for Isometric view
- const aspect = canvas.clientWidth / canvas.clientHeight;
- const d = 5;
- const camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000);
-
- // Isometric angle
- camera.position.set(20, 14, 20);
- camera.lookAt(scene.position); // Look at 0,0,0
- camera.updateProjectionMatrix();
-
- const renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: false, antialias: true });
- renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
- renderer.setPixelRatio(window.devicePixelRatio);
- // renderer.outputEncoding = THREE.sRGBEncoding; // We use GammaCorrectionShader instead
- renderer.shadowMap.enabled = true;
- renderer.shadowMap.type = THREE.PCFShadowMap;
-
- // --- Post Processing (SSAO) ---
- const composer = new THREE.EffectComposer(renderer);
- const renderPass = new THREE.RenderPass(scene, camera);
- composer.addPass(renderPass);
-
- const ssaoPass = new THREE.SSAOPass(scene, camera, canvas.clientWidth, canvas.clientHeight);
- ssaoPass.kernelRadius = 16;
- ssaoPass.minDistance = 0.001;
- ssaoPass.maxDistance = 0.1;
- composer.addPass(ssaoPass);
-
- const gammaCorrectionPass = new THREE.ShaderPass(THREE.GammaCorrectionShader);
- composer.addPass(gammaCorrectionPass);
-
- // Lighting
- const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6);
- hemiLight.position.set(0, 50, 0);
- scene.add(hemiLight);
-
- const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
- dirLight.position.set(-30, 50, 30);
- dirLight.castShadow = true;
- dirLight.shadow.mapSize.width = 2048;
- dirLight.shadow.mapSize.height = 2048;
- const dLight = 10;
- dirLight.shadow.camera.left = -dLight;
- dirLight.shadow.camera.right = dLight;
- dirLight.shadow.camera.top = dLight;
- dirLight.shadow.camera.bottom = -dLight;
- scene.add(dirLight);
-
- // --- Textures ---
- const textureLoader = new THREE.TextureLoader();
- const loadTexture = (name) => {
- const tex = textureLoader.load(`blocks/${name}`);
- tex.magFilter = THREE.NearestFilter;
- tex.minFilter = THREE.NearestFilter;
- tex.encoding = THREE.sRGBEncoding;
- return tex;
- };
-
- const loadItemTexture = (name) => {
- const tex = textureLoader.load(`items/${name}`);
- tex.magFilter = THREE.NearestFilter;
- tex.minFilter = THREE.NearestFilter;
- tex.encoding = THREE.sRGBEncoding;
- return tex;
- };
-
- const textures = {
- stone: loadTexture('stone.png'),
- dirt: loadTexture('dirt.png'),
- cobblestone: loadTexture('cobblestone.png'),
- coal: loadTexture('coal_ore.png'),
- iron: loadTexture('iron_ore.png'),
- gold: loadTexture('gold_ore.png'),
- diamond: loadTexture('diamond_ore.png'),
- emerald: loadTexture('emerald_ore.png'),
- bedrock: loadTexture('bedrock.png'),
- grass_top: loadTexture('grass_top.png'),
- grass_side: loadTexture('grass_side.png'),
- dirt: loadTexture('dirt.png'),
- log_side: loadTexture('log_oak.png'),
- log_top: loadTexture('log_oak_top.png'),
- leaves: loadTexture('leaves_oak.png'),
- apple: loadItemTexture('apple.png'),
- stick: loadItemTexture('stick.png'),
- sapling: loadTexture('sapling_oak.png'),
- planks: loadTexture('planks_oak.png'),
- wood_pickaxe: loadItemTexture('wood_pickaxe.png'),
- stone_pickaxe: loadItemTexture('stone_pickaxe.png'),
- iron_pickaxe: loadItemTexture('iron_pickaxe.png'),
- gold_pickaxe: loadItemTexture('gold_pickaxe.png'),
- diamond_pickaxe: loadItemTexture('diamond_pickaxe.png'),
- tnt_side: loadTexture('tnt_side.png'),
- tnt_top: loadTexture('tnt_top.png'),
- tnt_bottom: loadTexture('tnt_bottom.png'),
- particle: (() => {
- const t = new THREE.TextureLoader().load('particle/particles.png');
- t.magFilter = THREE.NearestFilter;
- t.minFilter = THREE.NearestFilter;
- return t;
- })()
- };
-
- // Load destroy stages
- const destroyTextures = [];
- for (let i = 0; i < 10; i++) {
- const tex = loadTexture(`destroy_stage_${i}.png`);
- tex.magFilter = THREE.NearestFilter;
- tex.minFilter = THREE.NearestFilter;
- destroyTextures.push(tex);
- }
-
- // Materials cache
- const materials = {};
- const getMaterial = (type) => {
- if (materials[type]) return materials[type];
-
- let map = textures.stone;
- if (textures[type]) map = textures[type];
-
- // Special case for grass block to look nice
- if (type === 'grass') {
- const mat = [
- new THREE.MeshLambertMaterial({ map: textures.grass_side }), // px
- new THREE.MeshLambertMaterial({ map: textures.grass_side }), // nx
- new THREE.MeshLambertMaterial({ map: textures.grass_top, color: 0x79C05A }), // py (top) - Tinted green
- new THREE.MeshLambertMaterial({ map: textures.dirt }), // ny (bottom)
- new THREE.MeshLambertMaterial({ map: textures.grass_side }), // pz
- new THREE.MeshLambertMaterial({ map: textures.grass_side }) // nz
- ];
- materials[type] = mat;
- return mat;
- }
-
- if (type === 'log') {
- const mat = [
- new THREE.MeshLambertMaterial({ map: textures.log_side }), // px
- new THREE.MeshLambertMaterial({ map: textures.log_side }), // nx
- new THREE.MeshLambertMaterial({ map: textures.log_top }), // py (top)
- new THREE.MeshLambertMaterial({ map: textures.log_top }), // ny (bottom)
- new THREE.MeshLambertMaterial({ map: textures.log_side }), // pz
- new THREE.MeshLambertMaterial({ map: textures.log_side }) // nz
- ];
- materials[type] = mat;
- return mat;
- }
-
- if (type === 'leaves') {
- const mat = new THREE.MeshLambertMaterial({ map: textures.leaves, transparent: true, alphaTest: 0.5, color: 0x79C05A });
- materials[type] = mat;
- return mat;
- }
-
- if (type === 'apple') {
- const mat = new THREE.MeshLambertMaterial({ map: textures.apple, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide });
- materials[type] = mat;
- return mat;
- }
- if (type === 'stick') {
- const mat = new THREE.MeshLambertMaterial({ map: textures.stick, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide });
- materials[type] = mat;
- return mat;
- }
- if (type === 'sapling') {
- const mat = new THREE.MeshLambertMaterial({ map: textures.sapling, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide });
- materials[type] = mat;
- return mat;
- }
-
- const mat = new THREE.MeshLambertMaterial({ map: map });
- materials[type] = mat;
- return mat;
- };
-
- // Damage Overlay
- const damageMaterial = new THREE.MeshBasicMaterial({
- map: destroyTextures[0],
- color: 0x808080,
- transparent: true,
- depthWrite: false,
- polygonOffset: true,
- polygonOffsetFactor: -1,
- polygonOffsetUnits: -1,
- blending: THREE.CustomBlending,
- blendEquation: THREE.ReverseSubtractEquation,
- blendSrc: THREE.SrcAlphaFactor,
- blendDst: THREE.OneFactor
- });
- const damageGeometry = new THREE.BoxGeometry(BLOCK_SIZE * 1.002, BLOCK_SIZE * 1.002, BLOCK_SIZE * 1.002);
- const damageOverlay = new THREE.Mesh(damageGeometry, damageMaterial);
- damageOverlay.visible = false;
- scene.add(damageOverlay);
-
- // --- Game Logic ---
-
- // Hardness reference: Stone (1.5) takes 4 seconds (4,000 ms)
- // Multiplier = 4,000 / 1.5 = 2666.66 ms per hardness unit
- const HARDNESS_MULTIPLIER = 2666.66;
-
- const BLOCK_HEALTH = {
- grass: 0.3 * HARDNESS_MULTIPLIER,
- dirt: 0.2 * HARDNESS_MULTIPLIER,
- stone: 1.5 * HARDNESS_MULTIPLIER,
- cobblestone: 2 * HARDNESS_MULTIPLIER,
- coal: 3 * HARDNESS_MULTIPLIER,
- iron: 3 * HARDNESS_MULTIPLIER,
- gold: 3 * HARDNESS_MULTIPLIER,
- diamond: 3 * HARDNESS_MULTIPLIER,
- emerald: 3 * HARDNESS_MULTIPLIER,
- log: 2.0 * HARDNESS_MULTIPLIER,
- leaves: 0.05 * HARDNESS_MULTIPLIER,
- bedrock: Infinity
- };
-
- function createBlock(x, y, z, type) {
- const geometry = new THREE.BoxGeometry(BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
- const material = getMaterial(type);
- const cube = new THREE.Mesh(geometry, material);
- cube.castShadow = true;
- cube.receiveShadow = true;
-
- // Center the grid
- // GRID_SIZE is dynamic now
- const offset = (GRID_SIZE * BLOCK_SIZE) / 2 - (BLOCK_SIZE / 2);
-
- cube.position.set(
- x * BLOCK_SIZE - offset,
- y * BLOCK_SIZE, // Y is vertical
- z * BLOCK_SIZE - offset
- );
-
- cube.userData = { gridX: x, gridY: y, gridZ: z, type: type };
- scene.add(cube);
- return cube;
- }
-
- function getBlockTypeForDepth(d) {
- // Helper to reuse generation logic
- if (d === 0) return 'grass';
- if (d === 1) return 'dirt';
- if (d === 2) return 'dirt';
-
- // Bedrock at bottom? No, infinite mining.
-
- const rand = Math.random();
-
- // Ore probabilities based on depth
- let coalProb = 0.05;
- let ironProb = 0.0;
- let goldProb = 0.0;
- let diamondProb = 0.0;
- let emeraldProb = 0.0;
-
- if (d > 5) ironProb = 0.03;
- if (d > 10) goldProb = 0.02;
- if (d > 20) diamondProb = 0.01;
- if (d > 50) emeraldProb = 0.005;
-
- // Increase probs as we go deeper
- if (d > 30) {
- coalProb = 0.08;
- ironProb = 0.05;
- }
- if (d > 60) {
- goldProb = 0.04;
- diamondProb = 0.02;
- }
-
- if (rand < emeraldProb) return 'emerald';
- if (rand < emeraldProb + diamondProb) return 'diamond';
- if (rand < emeraldProb + diamondProb + goldProb) return 'gold';
- if (rand < emeraldProb + diamondProb + goldProb + ironProb) return 'iron';
- if (rand < emeraldProb + diamondProb + goldProb + ironProb + coalProb) return 'coal';
-
- if (d < 5) return 'dirt';
- if (d < 10 && Math.random() < 0.5) return 'dirt';
-
- return Math.random() < 0.8 ? 'stone' : 'cobblestone';
- }
-
- function generateLayer(layerIndex, absoluteDepth) {
- const layer = [];
- for (let x = 0; x < GRID_SIZE; x++) {
- layer[x] = [];
- for (let z = 0; z < GRID_SIZE; z++) {
- // layerIndex corresponds to Y position.
- // 0 is top, -1 is below, etc.
- const type = getBlockTypeForDepth(absoluteDepth);
- const block = createBlock(x, -layerIndex, z, type);
- layer[x][z] = block;
- }
- }
- return layer;
- }
-
- const STRUCTURES = {
- tree: []
- };
-
- // Init Tree Structure
- (() => {
- const s = STRUCTURES.tree;
- for(let y=0; y<3; y++) s.push({x:0, y:y, z:0, type:'log'});
- for(let x=-2; x<=2; x++) {
- for(let y=2; y<=3; y++) {
- for(let z=-2; z<=2; z++) {
- if(Math.abs(x)===2 && Math.abs(z)===2) continue;
- if(x===0 && z===0 && y===2) continue;
- s.push({x, y, z, type:'leaves'});
- }
- }
- }
- for(let x=-1; x<=1; x++) {
- for(let z=-1; z<=1; z++) {
- if(x===0 && z===0) continue;
- s.push({x, y:4, z, type:'leaves'});
- }
- }
- s.push({x:0, y:5, z:0, type:'leaves'});
- })();
-
- function placeStructure(gx, gy, gz, structureName) {
- const structure = STRUCTURES[structureName];
- if (!structure) return;
-
- structure.forEach(block => {
- spawnBlock(gx + block.x, gy + block.y, gz + block.z, block.type);
- });
- }
-
- function generateTree() {
- const cx = Math.floor(GRID_SIZE / 2);
- const cz = Math.floor(GRID_SIZE / 2);
- placeStructure(cx, 1, cz, 'tree');
- }
-
- function saveGame() {
- const saveData = {
- depth,
- score,
- inventory,
- upgrades,
- layers: layers.map(layer => layer.map(row => row.map(block => block ? block.userData.type : null))),
- extraBlocks: extraBlocks.map(b => ({
- type: b.userData.type,
- gridX: b.userData.gridX,
- gridZ: b.userData.gridZ,
- y: Math.round(b.position.y / BLOCK_SIZE),
- isSapling: b.userData.isSapling,
- growthTimer: b.userData.growthTimer
- })),
- drops: drops.map(d => ({
- type: d.userData.type,
- count: d.userData.count || 1,
- x: d.position.x,
- y: d.position.y,
- z: d.position.z,
- vx: d.userData.velocity.x,
- vy: d.userData.velocity.y,
- vz: d.userData.velocity.z,
- life: d.userData.life,
- autoCollect: d.userData.autoCollect
- }))
- };
- localStorage.setItem('miningGameSave', JSON.stringify(saveData));
- }
-
- function loadGame() {
- const dataStr = localStorage.getItem('miningGameSave');
- if (!dataStr) return false;
-
- try {
- const data = JSON.parse(dataStr);
- depth = data.depth;
- score = data.score;
- inventory = Object.assign({}, data.inventory);
- if (data.upgrades) {
- upgrades = Object.assign(upgrades, data.upgrades);
- // Ensure new upgrades are initialized if loading old save
- if (upgrades.mining_area === undefined) upgrades.mining_area = 0;
- if (upgrades.vein_miner === undefined) upgrades.vein_miner = 0;
- if (upgrades.tnt_frequency === undefined) upgrades.tnt_frequency = 0;
- if (upgrades.tnt_power === undefined) upgrades.tnt_power = 0;
- if (upgrades.pickup_range === undefined) upgrades.pickup_range = 0;
- if (upgrades.grid_size === undefined) upgrades.grid_size = 0;
-
- // Update GRID_SIZE based on loaded upgrade
- GRID_SIZE = 5 + (upgrades.grid_size * 2);
- VISIBLE_LAYERS = 7 + upgrades.grid_size;
-
- // Adjust camera for larger grids
- if (upgrades.grid_size > 0) {
- const d = 5 + upgrades.grid_size;
- camera.left = -d * aspect;
- camera.right = d * aspect;
- camera.top = d;
- camera.bottom = -d;
- camera.updateProjectionMatrix();
- }
- }
-
- depthDisplay.innerText = depth;
- scoreDisplay.innerText = score;
- updateInventoryUI();
-
- // Rebuild layers
- layers = [];
- data.layers.forEach((savedLayer, layerIdx) => {
- const layer = [];
- // Handle grid size mismatch if save is old
- const savedSize = savedLayer.length;
- const offsetDiff = (GRID_SIZE - savedSize) / 2;
-
- for(let x=0; x= 0 && oldX < savedSize && oldZ >= 0 && oldZ < savedSize) {
- if (savedLayer[oldX] && savedLayer[oldX][oldZ]) {
- type = savedLayer[oldX][oldZ];
- }
- } else {
- // New outer block for expanded grid
- // Only if we are expanding. If shrinking (not possible yet), we just clip.
- // But wait, if we load an old save with smaller grid, we should probably fill the new space?
- // Or just leave it empty?
- // If we leave it empty, checkLayerCleared might trigger.
- // So we should probably generate blocks for the new area.
- if (GRID_SIZE > savedSize) {
- type = getBlockTypeForDepth(depth + layerIdx);
- }
- }
-
- if (type) {
- const block = createBlock(x, -layerIdx, z, type);
- layer[x][z] = block;
- scene.add(block); // Ensure we add to scene!
- } else {
- layer[x][z] = null;
- }
- }
- }
- layers.push(layer);
- });
-
- // Fill missing layers if VISIBLE_LAYERS increased
- while (layers.length < VISIBLE_LAYERS) {
- const layerIdx = layers.length;
- const absDepth = depth + layerIdx;
- layers.push(generateLayer(layerIdx, absDepth));
- }
-
- // Rebuild extraBlocks
- extraBlocks = [];
- data.extraBlocks.forEach(bData => {
- if (bData.isSapling) {
- spawnSapling(bData.gridX, bData.y, bData.gridZ, bData.growthTimer);
- } else {
- spawnBlock(bData.gridX, bData.y, bData.gridZ, bData.type);
- }
- });
-
- // Rebuild drops
- if (data.drops) {
- data.drops.forEach(dData => {
- const pos = new THREE.Vector3(dData.x, dData.y, dData.z);
- spawnDrop(pos, dData.type, dData.autoCollect);
- // The last added drop is at drops[drops.length-1]
- const drop = drops[drops.length-1];
- if (drop) {
- drop.userData.velocity.set(dData.vx, dData.vy, dData.vz);
- drop.userData.life = dData.life || 0;
- drop.userData.physicsPos.copy(pos);
- drop.userData.count = dData.count || 1;
-
- // Apply scale based on count
- const count = drop.userData.count;
- const newScale = Math.min(2.0, 0.6 + (count * 0.1));
- if (drop.userData.isExtruded) {
- drop.scale.set(newScale, newScale, newScale);
- } else {
- const blockScale = Math.min(1.5, 1.0 + (count * 0.1));
- drop.scale.set(blockScale, blockScale, blockScale);
- }
- }
- });
- }
-
- return true;
- } catch (e) {
- console.error("Failed to load save", e);
- return false;
- }
- }
-
- function initGame() {
- if (loadGame()) {
- console.log("Game loaded from save.");
- } else {
- // Generate initial layers
- for (let i = 0; i < VISIBLE_LAYERS; i++) {
- layers.push(generateLayer(i, depth + i));
- }
- generateTree();
- }
-
- // Set initial camera position for tree
- if (extraBlocks.length > 0) {
- cameraOffsetY = 3.5;
- camera.position.set(20, 14 + cameraOffsetY, 20);
- camera.lookAt(0, cameraOffsetY, 0);
- }
-
- // Auto-save
- setInterval(saveGame, 5000);
- }
-
- function checkLayerCleared() {
- // Check the top layer (index 0)
- const topLayer = layers[0];
- let isEmpty = true;
-
- for (let x = 0; x < GRID_SIZE; x++) {
- for (let z = 0; z < GRID_SIZE; z++) {
- if (topLayer[x][z] !== null) {
- isEmpty = false;
- break;
- }
- }
- if (!isEmpty) break;
- }
-
- if (isEmpty) {
- advanceLevel();
- }
- }
-
- function advanceLevel() {
- if (isAnimating) return;
- isAnimating = true;
-
- // Remove empty top layer from array
- layers.shift();
-
- // Increment depth
- depth++;
- depthDisplay.innerText = depth;
-
- // Generate new layer at the bottom
- const newLayerDepth = depth + VISIBLE_LAYERS - 1;
-
- // Create it one step lower (VISIBLE_LAYERS instead of VISIBLE_LAYERS - 1)
- // so it can animate up into place
- const newLayer = generateLayer(VISIBLE_LAYERS, newLayerDepth);
-
- layers.push(newLayer);
-
- // Animate all blocks moving up
- const duration = 500; // ms
- const start = Date.now();
-
- const startPositions = [];
- const allBlocks = [];
-
- layers.forEach(layer => {
- layer.forEach(row => {
- row.forEach(block => {
- if (block) {
- allBlocks.push(block);
- startPositions.push(block.position.y);
- }
- });
- });
- });
-
- extraBlocks.forEach(block => {
- allBlocks.push(block);
- startPositions.push(block.position.y);
- });
-
- // Also animate drops
- const dropStartPositions = [];
- drops.forEach(drop => {
- dropStartPositions.push(drop.userData.physicsPos.y);
- });
-
- function animate() {
- const now = Date.now();
- const progress = Math.min((now - start) / duration, 1);
- const ease = 1 - Math.pow(1 - progress, 3); // Cubic ease out
- const offset = ease * BLOCK_SIZE;
-
- allBlocks.forEach((b, i) => {
- b.position.y = startPositions[i] + offset;
- });
-
- drops.forEach((drop, i) => {
- drop.userData.physicsPos.y = dropStartPositions[i] + offset;
- drop.position.y = drop.userData.physicsPos.y; // Visual update handled in updateDrops but we force it here for smoothness
- });
-
- if (progress < 1) {
- requestAnimationFrame(animate);
- } else {
- isAnimating = false;
- // Correct positions exactly to avoid float drift
- layers.forEach((layer, layerIdx) => {
- layer.forEach(row => {
- row.forEach(block => {
- if (block) {
- // The top layer (index 0) should now be at Y=0
- block.position.y = -layerIdx * BLOCK_SIZE;
- }
- });
- });
- });
- // Extra blocks just stay where they ended up (shifted up by 1)
-
- // Finalize drop positions
- drops.forEach((drop, i) => {
- drop.userData.physicsPos.y = dropStartPositions[i] + BLOCK_SIZE;
- });
-
- saveGame();
- checkLayerCleared(); // Check if the next layer is also empty
- }
- }
-
- animate();
- }
-
- // --- Inventory ---
- let inventory = {};
- let selectedItem = null;
- const inventoryDisplay = document.getElementById('inventory-display');
-
- function isPlaceable(type) {
- const placeables = ['log', 'planks', 'cobblestone', 'dirt', 'stone', 'leaves', 'sapling', 'grass'];
- return placeables.includes(type);
- }
-
- function addToInventory(type) {
- if (!inventory[type]) inventory[type] = 0;
- inventory[type]++;
- updateInventoryUI();
- }
-
- function updateInventoryUI() {
- if (!inventoryDisplay) return;
- inventoryDisplay.innerHTML = '';
- for (const [type, count] of Object.entries(inventory)) {
- if (count <= 0) continue; // Skip items with 0 count
-
- const slot = document.createElement('div');
- slot.className = 'inventory-slot';
- slot.dataset.type = type; // For animation targeting
-
- if (selectedItem === type) {
- slot.style.borderColor = '#ffff00';
- slot.style.borderWidth = '3px';
- slot.style.transform = 'scale(1.1)';
- }
-
- slot.onclick = () => {
- if (selectedItem === type) {
- selectedItem = null;
- } else {
- if (isPlaceable(type)) {
- selectedItem = type;
- }
- }
- updateInventoryUI();
- };
-
- const img = document.createElement('img');
- let iconName = type;
- if (type === 'coal') iconName = 'coal_ore';
- if (type === 'iron') iconName = 'iron_ore';
- if (type === 'gold') iconName = 'gold_ore';
- if (type === 'diamond') iconName = 'diamond_ore';
- if (type === 'emerald') iconName = 'emerald_ore';
- if (type === 'log') iconName = 'log_oak';
- if (type === 'leaves') iconName = 'leaves_oak';
- if (type === 'grass') iconName = 'grass_side';
- if (type === 'stick') iconName = 'stick';
- if (type === 'sapling') iconName = 'sapling_oak';
- if (type === 'apple') iconName = 'apple';
- if (type === 'planks') iconName = 'planks_oak';
- if (type === 'wood_pickaxe') iconName = 'wood_pickaxe';
- if (type === 'stone_pickaxe') iconName = 'stone_pickaxe';
- if (type === 'iron_pickaxe') iconName = 'iron_pickaxe';
- if (type === 'gold_pickaxe') iconName = 'gold_pickaxe';
- if (type === 'diamond_pickaxe') iconName = 'diamond_pickaxe';
-
- if (['apple', 'stick', 'wood_pickaxe', 'stone_pickaxe', 'iron_pickaxe', 'gold_pickaxe', 'diamond_pickaxe'].includes(type)) {
- img.src = `items/${iconName}.png`;
- } else {
- img.src = `blocks/${iconName}.png`;
- }
-
- // Special handling for non-block items to tint them?
- if (type === 'stick') {
- img.style.transform = 'scale(0.8)';
- }
- if (type === 'sapling') {
- img.style.transform = 'scale(0.8)';
- }
-
- const countDiv = document.createElement('div');
- countDiv.className = 'inventory-count';
- countDiv.innerText = count;
-
- slot.appendChild(img);
- slot.appendChild(countDiv);
- inventoryDisplay.appendChild(slot);
- }
- updateCraftingUI();
- }
-
- // --- Crafting ---
- const craftingDisplay = document.getElementById('crafting-display');
- const RECIPES = [
- {
- output: 'planks',
- count: 4,
- inputs: { 'log': 1 }
- },
- {
- output: 'stick',
- count: 4,
- inputs: { 'planks': 2 }
- },
- {
- output: 'wood_pickaxe',
- count: 1,
- inputs: { 'planks': 3, 'stick': 2 }
- },
- {
- output: 'stone_pickaxe',
- count: 1,
- inputs: { 'cobblestone': 3, 'stick': 2 }
- },
- {
- output: 'iron_pickaxe',
- count: 1,
- inputs: { 'iron': 3, 'stick': 2 }
- },
- {
- output: 'gold_pickaxe',
- count: 1,
- inputs: { 'gold': 3, 'stick': 2 }
- },
- {
- output: 'diamond_pickaxe',
- count: 1,
- inputs: { 'diamond': 3, 'stick': 2 }
- }
- ];
-
- function canCraft(recipe) {
- for (const [item, count] of Object.entries(recipe.inputs)) {
- if (!inventory[item] || inventory[item] < count) return false;
- }
- return true;
- }
-
- function craft(recipe) {
- if (!canCraft(recipe)) return;
-
- // Deduct inputs
- for (const [item, count] of Object.entries(recipe.inputs)) {
- inventory[item] -= count;
- if (inventory[item] <= 0) delete inventory[item];
- }
-
- // Add output
- if (!inventory[recipe.output]) inventory[recipe.output] = 0;
- inventory[recipe.output] += recipe.count;
-
- updateInventoryUI();
- // updateCraftingUI called by updateInventoryUI
- }
-
- function updateCraftingUI() {
- if (!craftingDisplay) return;
- craftingDisplay.innerHTML = '';
-
- RECIPES.forEach(recipe => {
- const slot = document.createElement('div');
- slot.className = 'crafting-slot';
- if (canCraft(recipe)) {
- slot.classList.add('can-craft');
- slot.onclick = () => craft(recipe);
- } else {
- slot.style.opacity = '0.5';
- slot.style.cursor = 'default';
- }
-
- const img = document.createElement('img');
- let iconName = recipe.output;
- if (iconName === 'planks') iconName = 'planks_oak';
-
- if (['wood_pickaxe', 'stone_pickaxe', 'iron_pickaxe', 'gold_pickaxe', 'diamond_pickaxe', 'stick'].includes(recipe.output)) {
- img.src = `items/${iconName}.png`;
- } else {
- img.src = `blocks/${iconName}.png`;
- }
-
- // Tooltip
- const tooltip = document.createElement('div');
- tooltip.className = 'crafting-tooltip';
- let costText = [];
- for (const [item, count] of Object.entries(recipe.inputs)) {
- costText.push(`${count} ${item}`);
- }
- tooltip.innerText = `${recipe.output} (${costText.join(', ')})`;
-
- slot.appendChild(img);
- slot.appendChild(tooltip);
- craftingDisplay.appendChild(slot);
- });
- }
-
- // --- Drops ---
- const drops = [];
- const dropGeometry = new THREE.BoxGeometry(0.25, 0.25, 0.25);
-
- // Shadow Texture
- const shadowCanvas = document.createElement('canvas');
- shadowCanvas.width = 64;
- shadowCanvas.height = 64;
- const shadowCtx = shadowCanvas.getContext('2d');
- const shadowGradient = shadowCtx.createRadialGradient(32, 32, 0, 32, 32, 32);
- shadowGradient.addColorStop(0, 'rgba(0, 0, 0, 0.5)');
- shadowGradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
- shadowCtx.fillStyle = shadowGradient;
- shadowCtx.fillRect(0, 0, 64, 64);
- const shadowTexture = new THREE.CanvasTexture(shadowCanvas);
- const shadowMaterial = new THREE.MeshBasicMaterial({ map: shadowTexture, transparent: true, depthWrite: false });
- const shadowGeometry = new THREE.PlaneGeometry(0.4, 0.4);
-
- function spawnDrop(position, type, autoCollect = false) {
- // Check for existing drops to clump with
- for (const existingDrop of drops) {
- if (existingDrop.userData.type === type && !existingDrop.userData.collecting) {
- const dist = existingDrop.userData.physicsPos.distanceTo(position);
- if (dist < 1.0) { // Within 1 block radius
- existingDrop.userData.count = (existingDrop.userData.count || 1) + 1;
-
- // Visual feedback for clumping (scale up slightly, max 2x)
- const newScale = Math.min(2.0, 0.6 + (existingDrop.userData.count * 0.1));
- if (existingDrop.userData.isExtruded) {
- existingDrop.scale.set(newScale, newScale, newScale);
- } else {
- // Standard blocks start at 1.0
- const blockScale = Math.min(1.5, 1.0 + (existingDrop.userData.count * 0.1));
- existingDrop.scale.set(blockScale, blockScale, blockScale);
- }
-
- // Reset life to keep it around longer
- existingDrop.userData.life = 0;
- return; // Merged, don't spawn new
- }
- }
- }
-
- const material = getMaterial(type);
-
- // Adjust geometry based on type
- let geometry = dropGeometry;
- let isExtruded = false;
-
- if (type === 'apple' || type === 'stick' || type === 'sapling') {
- if (geometryCache[type]) {
- geometry = geometryCache[type];
- isExtruded = true;
- } else {
- // Try to generate
- const tex = textures[type];
- if (tex && tex.image && tex.image.complete && tex.image.width > 0) {
- geometry = generateExtrudedGeometry(tex.image);
- geometryCache[type] = geometry;
- isExtruded = true;
- } else if (tex && tex.image) {
- // Load later
- tex.image.onload = () => {
- const geo = generateExtrudedGeometry(tex.image);
- geometryCache[type] = geo;
- // Update existing drops of this type?
- // For simplicity, just next drops will be correct.
- // Or we can update this drop:
- if (drop.parent) { // If still in scene
- drop.geometry = geo;
- }
- };
- }
- }
- }
-
- const drop = new THREE.Mesh(geometry, material);
-
- drop.castShadow = false;
- drop.receiveShadow = false;
-
- // Physics state
- drop.userData.physicsPos = position.clone();
- drop.userData.velocity = new THREE.Vector3(
- (Math.random() - 0.5) * 0.025, // Slower spread
- 0.025 + Math.random() * 0.025, // Lower pop
- (Math.random() - 0.5) * 0.025
- );
- drop.userData.life = 0;
- drop.userData.bobOffset = Math.random() * Math.PI * 2; // Random start phase
- drop.userData.type = type;
- drop.userData.count = 1; // Start with 1 item
- drop.userData.autoCollect = autoCollect;
- drop.userData.autoCollectTimer = 0;
- drop.userData.isExtruded = isExtruded;
-
- if (isExtruded) {
- drop.scale.set(0.6, 0.6, 0.6);
- drop.userData.radius = 0.15; // 0.25 * 0.6
- } else {
- drop.userData.radius = 0.125;
- }
-
- // Shadow
- const shadow = new THREE.Mesh(shadowGeometry, shadowMaterial);
- shadow.rotation.x = -Math.PI / 2;
- shadow.position.copy(position);
- shadow.position.y = -100; // Hide initially
-
- drop.userData.shadow = shadow;
-
- scene.add(drop);
- scene.add(shadow);
- drops.push(drop);
- }
-
- function updateDrops() {
- for (let i = drops.length - 1; i >= 0; i--) {
- const drop = drops[i];
-
- // Collecting animation
- if (drop.userData.collecting) {
- // Calculate screen position
- const vector = drop.position.clone();
- vector.project(camera);
-
- const canvas = renderer.domElement;
- // Use relative coordinates for the container
- const x = (vector.x + 1) / 2 * canvas.clientWidth;
- const y = -(vector.y - 1) / 2 * canvas.clientHeight;
-
- // Create 2D floating item
- createFloatingItem(drop.userData.type, x - 16, y - 16, drop.userData.count || 1);
-
- // Remove 3D drop immediately
- scene.remove(drop);
- scene.remove(drop.userData.shadow);
- drops.splice(i, 1);
- continue;
- }
-
- const vel = drop.userData.velocity;
- const pos = drop.userData.physicsPos;
-
- // Gravity (Slower)
- vel.y -= 0.002;
-
- // Move Physics Position
- pos.add(vel);
-
- // Floor Collision Logic (using pos)
- const gx = Math.round(pos.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
- const gz = Math.round(pos.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
-
- let floorY = -Infinity;
-
- // Check layers
- for (let l = 0; l < layers.length; l++) {
- if (gx >= 0 && gx < GRID_SIZE && gz >= 0 && gz < GRID_SIZE) {
- const block = layers[l][gx][gz];
- if (block) {
- const by = block.position.y;
- if (by < pos.y && by > floorY) {
- floorY = by;
- }
- }
- }
- }
- // Check extra blocks
- extraBlocks.forEach(b => {
- if (Math.round(b.position.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5)) === gx &&
- Math.round(b.position.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5)) === gz) {
- if (b.position.y < pos.y && b.position.y > floorY) {
- floorY = b.position.y;
- }
- }
- });
-
- // Collision check
- // Top of block is floorY + 0.5
- // Bottom of drop is pos.y - radius
- const radius = drop.userData.radius || 0.125;
- const bobAmplitude = 0.05;
- if (floorY !== -Infinity) {
- // Ensure we sit high enough so the bob doesn't clip
- if (pos.y - radius - bobAmplitude < floorY + 0.5) {
- pos.y = floorY + 0.5 + radius + bobAmplitude;
- vel.y = 0; // No bounce
- vel.x *= 0.7; // Friction
- vel.z *= 0.7;
- }
- } else {
- if (pos.y < -10) {
- scene.remove(drop);
- scene.remove(drop.userData.shadow);
- drops.splice(i, 1);
- continue;
- }
- }
-
- // Wall Collision
- const limit = (GRID_SIZE * BLOCK_SIZE) / 2 - 0.2;
- if (pos.x > limit) { pos.x = limit; vel.x *= -0.5; }
- if (pos.x < -limit) { pos.x = -limit; vel.x *= -0.5; }
- if (pos.z > limit) { pos.z = limit; vel.z *= -0.5; }
- if (pos.z < -limit) { pos.z = -limit; vel.z *= -0.5; }
-
- // Update Visuals
- drop.userData.life++;
-
- // Rotation (Yaw)
- drop.rotation.y += 0.01; // Slow rotation
-
- // Bobbing
- const bob = Math.sin(drop.userData.life * 0.02 + drop.userData.bobOffset) * 0.05;
-
- drop.position.copy(pos);
- drop.position.y += bob;
-
- // Shadow Update
- const shadow = drop.userData.shadow;
- if (floorY !== -Infinity) {
- shadow.position.set(pos.x, floorY + 0.5 + 0.01, pos.z);
- shadow.visible = true;
- } else {
- shadow.visible = false;
- }
-
- // Despawn logic
- // Removed auto-despawn. Items stay until collected.
- if (pos.y < -10) {
- scene.remove(drop);
- scene.remove(shadow);
- drops.splice(i, 1);
- }
- }
- }
-
- // --- Interaction ---
- const raycaster = new THREE.Raycaster();
- const mouse = new THREE.Vector2();
-
- let isMining = false;
- let miningBlock = null;
- let miningStartTime = 0;
- let miningDuration = 0;
-
- function updateMouse(event) {
- const rect = canvas.getBoundingClientRect();
- const clientX = event.clientX || (event.touches && event.touches[0].clientX);
- const clientY = event.clientY || (event.touches && event.touches[0].clientY);
-
- if (clientX === undefined || clientY === undefined) return;
-
- mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
- mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
- }
-
- function getIntersection() {
- raycaster.setFromCamera(mouse, camera);
-
- const meshes = [];
- layers.forEach(layer => {
- layer.forEach(row => {
- row.forEach(block => {
- if (block) meshes.push(block);
- });
- });
- });
- extraBlocks.forEach(block => meshes.push(block));
-
- const intersects = raycaster.intersectObjects(meshes);
- if (intersects.length > 0) {
- return intersects[0];
- }
- return null;
- }
-
- function getBlockUnderMouse() {
- const intersect = getIntersection();
- return intersect ? intersect.object : null;
- }
-
- function checkCollection() {
- // Base radius in NDC (screen space -1 to 1)
- // 0.15 is roughly 7.5% of screen width/height
- let pickupRadius = 0.15 + (upgrades.pickup_range * 0.05);
-
- drops.forEach(drop => {
- if (drop.userData.collecting) return;
-
- const vector = drop.position.clone();
- vector.project(camera); // vector is now in NDC
-
- // Check if in front of camera
- if (vector.z > 1) return;
-
- const dx = vector.x - mouse.x;
- const dy = vector.y - mouse.y;
- const dist = Math.sqrt(dx*dx + dy*dy);
-
- if (dist < pickupRadius) {
- drop.userData.collecting = true;
- if (drop.userData.shadow) drop.userData.shadow.visible = false;
- }
- });
- }
-
- function getBlockAt(x, y, z) {
- // Check layers
- if (y <= 0) {
- const layerIdx = -y;
- if (layerIdx < layers.length) {
- if (x >= 0 && x < GRID_SIZE && z >= 0 && z < GRID_SIZE) {
- return layers[layerIdx][x][z];
- }
- }
- }
- // Check extra blocks
- return extraBlocks.find(b =>
- b.userData.gridX === x &&
- Math.round(b.position.y / BLOCK_SIZE) === y &&
- b.userData.gridZ === z
- );
- }
-
- function getMiningSpeedMultiplier() {
- let multiplier = 1;
- if (inventory['wood_pickaxe']) multiplier = 2;
- if (inventory['stone_pickaxe']) multiplier = 4;
- if (inventory['iron_pickaxe']) multiplier = 6;
- if (inventory['gold_pickaxe']) multiplier = 12;
- if (inventory['diamond_pickaxe']) multiplier = 8;
-
- // Apply speed upgrade
- multiplier *= (1 + (upgrades.speed * 0.2));
-
- return multiplier;
- }
-
- function startMining(block) {
- if (miningBlock === block) return; // Already mining this block
-
- cancelMining(); // Stop mining previous block
-
- if (!block) return;
-
- // Check if block is mineable (exposed)
- const gx = block.userData.gridX;
- const gz = block.userData.gridZ;
- const gy = Math.round(block.position.y / BLOCK_SIZE);
-
- // Check if there is a block above
- const blockAbove = getBlockAt(gx, gy + 1, gz);
- if (blockAbove) return;
-
- miningBlock = block;
- miningStartTime = performance.now();
- const type = block.userData.type;
-
- let baseDuration = BLOCK_HEALTH[type] || 1000;
- miningDuration = baseDuration / getMiningSpeedMultiplier();
-
- damageOverlay.position.copy(block.position);
- damageOverlay.visible = true;
- damageOverlay.material.map = destroyTextures[0];
- }
-
- function cancelMining() {
- miningBlock = null;
- damageOverlay.visible = false;
- }
-
- function processMining() {
- if (!isMining || !miningBlock) return;
-
- const elapsed = performance.now() - miningStartTime;
- const progress = elapsed / miningDuration;
-
- if (progress >= 1) {
- breakBlock(miningBlock);
- cancelMining();
-
- // Continuous mining: check if mouse is still down and over a block
- if (isMining) {
- const nextBlock = getBlockUnderMouse();
- if (nextBlock) {
- startMining(nextBlock);
- }
- }
- } else {
- const stage = Math.floor(progress * 10);
- if (stage >= 0 && stage < 10) {
- damageOverlay.material.map = destroyTextures[stage];
- }
- }
- }
-
- function breakBlock(block, isChainReaction = false) {
- if (!block) return;
-
- const gx = block.userData.gridX;
- const gz = block.userData.gridZ;
- const type = block.userData.type;
- const loot = getLoot(type);
-
- // Handle drops
- loot.forEach(itemType => {
- spawnDrop(block.position, itemType, false);
- });
-
- // Check if it's in extra blocks
- const extraIdx = extraBlocks.indexOf(block);
- if (extraIdx !== -1) {
- scene.remove(block);
- extraBlocks.splice(extraIdx, 1);
-
- // Score
- let points = 1;
- if (type === 'log') points = 5;
- if (type === 'leaves') points = 1;
- score += points;
- scoreDisplay.innerText = score;
- return;
- }
-
- // Find layer index
- let foundLayerIdx = -1;
- for(let l=0; l 0 && ['coal', 'iron', 'gold', 'diamond', 'emerald'].includes(type)) {
- const maxVein = 2 + upgrades.vein_miner * 2;
- mineVein(gx, foundLayerIdx, gz, type, maxVein);
- }
-
- // Area Mining (only for non-ores usually, but let's do it for stone/dirt/grass)
- if (upgrades.mining_area > 0 && ['stone', 'dirt', 'grass', 'cobblestone'].includes(type)) {
- const radius = upgrades.mining_area; // 1 = 3x3, 2 = 5x5
- mineArea(gx, foundLayerIdx, gz, radius);
- }
-
- // Check for layer clear AFTER all chain reactions are done
- // We check layer 0 specifically because that's the one that triggers advancement
- checkLayerCleared();
- }
- }
- }
-
- function mineVein(gx, layerIdx, gz, type, maxBlocks) {
- let minedCount = 0;
- const queue = [{x: gx, z: gz, l: layerIdx}];
- const visited = new Set();
- visited.add(`${gx},${layerIdx},${gz}`);
-
- while (queue.length > 0 && minedCount < maxBlocks) {
- const current = queue.shift();
-
- // Check neighbors (up, down, left, right, forward, back)
- const neighbors = [
- {x: current.x+1, z: current.z, l: current.l},
- {x: current.x-1, z: current.z, l: current.l},
- {x: current.x, z: current.z+1, l: current.l},
- {x: current.x, z: current.z-1, l: current.l},
- // Same layer only for now to keep it simple, or maybe check adjacent layers?
- // Let's stick to same layer for simplicity first, or +/- 1 layer
- ];
-
- for (const n of neighbors) {
- if (n.x < 0 || n.x >= GRID_SIZE || n.z < 0 || n.z >= GRID_SIZE) continue;
-
- const key = `${n.x},${n.l},${n.z}`;
- if (visited.has(key)) continue;
-
- const block = layers[n.l][n.x][n.z];
- if (block && block.userData.type === type) {
- visited.add(key);
- queue.push(n);
- breakBlock(block, true); // Chain reaction
- minedCount++;
- if (minedCount >= maxBlocks) break;
- }
- }
- }
- }
-
- function mineArea(gx, layerIdx, gz, radius) {
- for (let x = gx - radius; x <= gx + radius; x++) {
- for (let z = gz - radius; z <= gz + radius; z++) {
- if (x === gx && z === gz) continue; // Already mined
- if (x < 0 || x >= GRID_SIZE || z < 0 || z >= GRID_SIZE) continue;
-
- const block = layers[layerIdx][x][z];
- if (block) {
- // Only mine common blocks with area miner
- if (['stone', 'dirt', 'grass', 'cobblestone'].includes(block.userData.type)) {
- breakBlock(block, true);
- }
- }
- }
- }
- }
-
- function spawnSapling(gx, gy, gz, growthTimer = 0) {
- const type = 'sapling';
- const geometry = new THREE.PlaneGeometry(0.7, 0.7);
- const mat = new THREE.MeshLambertMaterial({
- map: textures.sapling,
- transparent: true,
- side: THREE.DoubleSide,
- alphaTest: 0.5
- });
-
- const plane1 = new THREE.Mesh(geometry, mat);
- plane1.rotation.y = Math.PI / 4;
-
- const plane2 = new THREE.Mesh(geometry, mat);
- plane2.rotation.y = -Math.PI / 4;
-
- const saplingGroup = new THREE.Group();
- saplingGroup.add(plane1);
- saplingGroup.add(plane2);
-
- saplingGroup.position.set(
- (gx - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE,
- gy * BLOCK_SIZE, // Center of block
- (gz - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE
- );
-
- // Adjust height. 0.7 height. Center is at 0.
- // We want bottom at -0.5 relative to block center.
- // So center should be at -0.5 + 0.35 = -0.15
- plane1.position.y = -0.15;
- plane2.position.y = -0.15;
-
- saplingGroup.userData = {
- type: type,
- gridX: gx,
- gridZ: gz,
- isSapling: true,
- growthTimer: growthTimer,
- maxGrowthTime: 600 // 10 seconds at 60fps
- };
-
- // Progress Bar
- const barBg = new THREE.Mesh(
- new THREE.PlaneGeometry(0.8, 0.1),
- new THREE.MeshBasicMaterial({ color: 0x000000 })
- );
- barBg.position.set(0, 0.5, 0);
- // Billboard
- barBg.userData.isBillboard = true;
-
- const barFg = new THREE.Mesh(
- new THREE.PlaneGeometry(0.78, 0.08),
- new THREE.MeshBasicMaterial({ color: 0x00ff00 })
- );
- barFg.position.z = 0.01;
- barFg.scale.x = 0;
-
- barBg.add(barFg);
- saplingGroup.add(barBg);
- saplingGroup.userData.progressBar = barFg;
- saplingGroup.userData.progressBarBg = barBg;
-
- scene.add(saplingGroup);
- extraBlocks.push(saplingGroup);
- }
-
- function placeBlock(gx, gy, gz, type) {
- // Deduct from inventory
- inventory[type]--;
- if (inventory[type] <= 0) {
- delete inventory[type];
- selectedItem = null;
- }
- updateInventoryUI();
-
- // Create Mesh
- if (type === 'sapling') {
- spawnSapling(gx, gy, gz);
- return;
- }
-
- let geometry = new THREE.BoxGeometry(BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
- let material = getMaterial(type);
-
- const block = new THREE.Mesh(geometry, material);
- block.position.set(
- (gx - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE,
- gy * BLOCK_SIZE,
- (gz - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE
- );
-
- block.castShadow = true;
- block.receiveShadow = true;
- block.userData = { type: type, gridX: gx, gridZ: gz }; // gridX/Z might be off if not in layer structure
-
- scene.add(block);
- extraBlocks.push(block);
- }
-
- function onMouseDown(event) {
- if (isAnimating) return;
- updateMouse(event);
-
- if (selectedItem) {
- const intersect = getIntersection();
- if (intersect) {
- const block = intersect.object;
- const face = intersect.face;
-
- const p = intersect.point.clone().add(face.normal.clone().multiplyScalar(BLOCK_SIZE * 0.5));
-
- const nx = Math.round(p.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
- const ny = Math.round(p.y / BLOCK_SIZE);
- const nz = Math.round(p.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
-
- if (nx >= 0 && nx < GRID_SIZE && nz >= 0 && nz < GRID_SIZE) {
- if (!getBlockAt(nx, ny, nz)) {
- placeBlock(nx, ny, nz, selectedItem);
- return;
- }
- }
- }
- }
-
- isMining = true;
- const block = getBlockUnderMouse();
- if (block) startMining(block);
- }
-
- function onMouseUp(event) {
- isMining = false;
- cancelMining();
- }
-
- function onMouseMove(event) {
- updateMouse(event);
- checkCollection(); // Check if hovering over drops
-
- if (isMining) {
- const block = getBlockUnderMouse();
- if (block !== miningBlock) {
- if (block) {
- startMining(block);
- } else {
- cancelMining();
- }
- }
- }
- }
-
- canvas.addEventListener('mousedown', onMouseDown);
- canvas.addEventListener('mouseup', onMouseUp);
- canvas.addEventListener('mouseleave', onMouseUp);
- canvas.addEventListener('mousemove', onMouseMove);
-
- // Touch support
- canvas.addEventListener('touchstart', (e) => {
- e.preventDefault();
- if (e.touches.length > 0) {
- onMouseDown(e);
- }
- }, { passive: false });
-
- canvas.addEventListener('touchend', (e) => {
- e.preventDefault();
- onMouseUp(e);
- });
-
- canvas.addEventListener('touchmove', (e) => {
- e.preventDefault();
- if (isMining && e.touches.length > 0) {
- onMouseMove(e);
- }
- }, { passive: false });
-
-
- function spawnBlock(gx, gy, gz, type) {
- // Check bounds
- if (gx < 0 || gx >= GRID_SIZE || gz < 0 || gz >= GRID_SIZE) return;
-
- // Helper to spawn block without inventory
- let geometry = new THREE.BoxGeometry(BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
- let material = getMaterial(type);
- const block = new THREE.Mesh(geometry, material);
- block.position.set(
- (gx - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE,
- gy * BLOCK_SIZE,
- (gz - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE
- );
- block.castShadow = true;
- block.receiveShadow = true;
- block.userData = { type: type, gridX: gx, gridZ: gz };
- scene.add(block);
- extraBlocks.push(block);
- }
-
- function updateSaplings() {
- for (let i = extraBlocks.length - 1; i >= 0; i--) {
- const block = extraBlocks[i];
- if (block.userData.isSapling) {
- block.userData.growthTimer++;
-
- // Update Billboard
- if (block.userData.progressBarBg) {
- block.userData.progressBarBg.lookAt(camera.position);
- }
-
- // Update Progress
- const progress = block.userData.growthTimer / block.userData.maxGrowthTime;
- if (block.userData.progressBar) {
- block.userData.progressBar.scale.x = progress;
- // To scale from left, we need to adjust position?
- // Default plane geometry is centered.
- // If we scale x, it scales from center.
- // We can just update scale.
- }
-
- if (block.userData.growthTimer >= block.userData.maxGrowthTime) {
- // Grow!
- const { gridX, gridY, gridZ } = block.userData;
- // Wait, we didn't save gridY in userData in placeBlock for sapling?
- // We saved gridX, gridZ.
- // We can calculate gridY from position.
- const gy = Math.round(block.position.y / BLOCK_SIZE);
-
- scene.remove(block);
- extraBlocks.splice(i, 1);
-
- placeStructure(block.userData.gridX, gy, block.userData.gridZ, 'tree');
- }
- }
- }
- }
-
- // --- Particle System ---
- let particles = [];
-
- function spawnExplosionParticles(pos) {
- if (!textures.particle) return;
-
- const particleCount = 30;
- const material = new THREE.SpriteMaterial({
- map: textures.particle,
- color: 0xffffff,
- transparent: true,
- blending: THREE.AdditiveBlending
- });
-
- for (let i = 0; i < particleCount; i++) {
- const sprite = new THREE.Sprite(material);
- sprite.position.copy(pos);
- // Add some randomness to start position
- sprite.position.x += (Math.random() - 0.5) * 0.5;
- sprite.position.y += (Math.random() - 0.5) * 0.5;
- sprite.position.z += (Math.random() - 0.5) * 0.5;
-
- // Random velocity
- const speed = 0.1 + Math.random() * 0.3;
- sprite.userData.velocity = new THREE.Vector3(
- (Math.random() - 0.5) * speed,
- (Math.random() - 0.5) * speed,
- (Math.random() - 0.5) * speed
- );
-
- sprite.userData.life = 1.0;
- const size = 0.3 + Math.random() * 0.4;
- sprite.scale.set(size, size, size);
-
- scene.add(sprite);
- particles.push(sprite);
- }
- }
-
- function updateParticles() {
- for (let i = particles.length - 1; i >= 0; i--) {
- const p = particles[i];
- p.position.add(p.userData.velocity);
- p.userData.velocity.y -= 0.005; // Slight gravity
- p.userData.velocity.multiplyScalar(0.95); // Drag
- p.userData.life -= 0.02;
-
- p.material.opacity = Math.max(0, p.userData.life);
-
- if (p.userData.life <= 0) {
- scene.remove(p);
- particles.splice(i, 1);
- }
- }
- }
-
- // --- Animation Loop ---
- let lastTime = 0;
- const fpsInterval = 1000 / 60;
-
- function animate(currentTime) {
- requestAnimationFrame(animate);
-
- if (!currentTime) currentTime = performance.now();
- const elapsed = currentTime - lastTime;
-
- if (elapsed > fpsInterval) {
- lastTime = currentTime - (elapsed % fpsInterval);
-
- processMining();
- updateDrops();
- updateSaplings();
- updateTNT();
- updateParticles();
- checkCollection();
-
- // Camera offset logic
- const targetOffset = extraBlocks.length > 0 ? 3.5 : 0;
- if (Math.abs(cameraOffsetY - targetOffset) > 0.01) {
- cameraOffsetY += (targetOffset - cameraOffsetY) * 0.05;
- camera.position.set(20, 14 + cameraOffsetY, 20);
- camera.lookAt(0, cameraOffsetY, 0);
- } else if (cameraOffsetY !== targetOffset) {
- cameraOffsetY = targetOffset;
- camera.position.set(20, 14 + cameraOffsetY, 20);
- camera.lookAt(0, cameraOffsetY, 0);
- }
-
- composer.render();
- }
- }
-
- // Handle resize
- const resizeObserver = new ResizeObserver(() => {
- const width = canvas.clientWidth;
- const height = canvas.clientHeight;
-
- if (width === 0 || height === 0) return;
-
- const aspect = width / height;
- // Dynamic view size based on grid size
- let d = 5 + (upgrades.grid_size || 0);
-
- // Adjust for portrait mode to ensure width fits
- if (aspect < 1) {
- d = d / aspect;
- }
-
- camera.left = -d * aspect;
- camera.right = d * aspect;
- camera.top = d;
- camera.bottom = -d;
- camera.updateProjectionMatrix();
-
- renderer.setPixelRatio(window.devicePixelRatio);
- renderer.setSize(width, height, false);
- composer.setSize(width, height);
- if (ssaoPass) ssaoPass.setSize(width, height);
- });
- resizeObserver.observe(canvas);
-
- // Start
- initGame();
- animate();
-
- // --- Loot Table ---
- function getLoot(blockType) {
- if (blockType === 'leaves') {
- const drops = [];
- if (Math.random() < 0.05) drops.push('apple');
- if (Math.random() < 0.05) drops.push('stick');
- if (Math.random() < 0.05) drops.push('sapling');
- return drops;
- }
- if (blockType === 'stone') return ['cobblestone'];
- if (blockType === 'grass') return ['dirt'];
- if (blockType === 'coal') return ['coal'];
- if (blockType === 'iron') return ['iron'];
- if (blockType === 'gold') return ['gold'];
- if (blockType === 'diamond') return ['diamond'];
- if (blockType === 'emerald') return ['emerald'];
- if (blockType === 'log') return ['log'];
- if (blockType === 'dirt') return ['dirt'];
- if (blockType === 'cobblestone') return ['cobblestone'];
-
- return [blockType];
- }
-
- // --- Extrusion Logic ---
- const geometryCache = {};
- const extrusionCanvas = document.createElement('canvas');
- const extrusionCtx = extrusionCanvas.getContext('2d');
-
- function generateExtrudedGeometry(image, depth = 0.0625) { // 1/16
- const w = image.width;
- const h = image.height;
- extrusionCanvas.width = w;
- extrusionCanvas.height = h;
- extrusionCtx.drawImage(image, 0, 0);
- const data = extrusionCtx.getImageData(0, 0, w, h).data;
-
- const positions = [];
- const uvs = [];
- const normals = [];
- const indices = [];
- let indexOffset = 0;
-
- const addFace = (v1, v2, v3, v4, n, u1, u2, u3, u4) => {
- positions.push(...v1, ...v2, ...v3, ...v4);
- normals.push(...n, ...n, ...n, ...n);
- uvs.push(...u1, ...u2, ...u3, ...u4);
- indices.push(indexOffset, indexOffset + 1, indexOffset + 2);
- indices.push(indexOffset, indexOffset + 2, indexOffset + 3);
- indexOffset += 4;
- };
-
- const isOpaque = (x, y) => {
- if (x < 0 || x >= w || y < 0 || y >= h) return false;
- return data[(y * w + x) * 4 + 3] > 10;
- };
-
- const px = 1 / w;
- const py = 1 / h;
- const d = depth / 2;
-
- // Scale factor to fit in 0.5x0.5 world unit
- const scale = 0.5;
- const sx = scale / w;
- const sy = scale / h;
- const ox = -scale / 2;
- const oy = scale / 2;
-
- for (let y = 0; y < h; y++) {
- for (let x = 0; x < w; x++) {
- if (isOpaque(x, y)) {
- const x0 = ox + x * sx;
- const x1 = ox + (x + 1) * sx;
- const y0 = oy - y * sy;
- const y1 = oy - (y + 1) * sy;
-
- const u0 = x * px;
- const u1 = (x + 1) * px;
- const v0 = 1 - y * py;
- const v1 = 1 - (y + 1) * py;
-
- // Front
- addFace(
- [x0, y0, d], [x0, y1, d], [x1, y1, d], [x1, y0, d],
- [0, 0, 1],
- [u0, v1], [u0, v0], [u1, v0], [u1, v1]
- );
-
- // Back
- addFace(
- [x1, y0, -d], [x1, y1, -d], [x0, y1, -d], [x0, y0, -d],
- [0, 0, -1],
- [u1, v1], [u1, v0], [u0, v0], [u0, v1]
- );
-
- // Top
- if (!isOpaque(x, y - 1)) {
- addFace(
- [x0, y0, -d], [x0, y0, d], [x1, y0, d], [x1, y0, -d],
- [0, 1, 0],
- [u0, v1], [u0, v1], [u1, v1], [u1, v1] // Map to pixel top edge
- );
- }
- // Bottom
- if (!isOpaque(x, y + 1)) {
- addFace(
- [x0, y1, d], [x0, y1, -d], [x1, y1, -d], [x1, y1, d],
- [0, -1, 0],
- [u0, v0], [u0, v0], [u1, v0], [u1, v0] // Map to pixel bottom edge
- );
- }
- // Left
- if (!isOpaque(x - 1, y)) {
- addFace(
- [x0, y0, -d], [x0, y1, -d], [x0, y1, d], [x0, y0, d],
- [-1, 0, 0],
- [u0, v1], [u0, v0], [u0, v0], [u0, v1] // Map to pixel left edge
- );
- }
- // Right
- if (!isOpaque(x + 1, y)) {
- addFace(
- [x1, y0, d], [x1, y1, d], [x1, y1, -d], [x1, y0, -d],
- [1, 0, 0],
- [u1, v1], [u1, v0], [u1, v0], [u1, v1] // Map to pixel right edge
- );
- }
- }
- }
- }
-
- const geometry = new THREE.BufferGeometry();
- geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
- geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
- geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
- geometry.setIndex(indices);
- return geometry;
- }
-
- // Fullscreen
- window.toggleFullscreen = function() {
- const elem = document.querySelector('.mining-container');
- const doc = document;
-
- const isNativeFullscreen = doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement;
- const isPseudoFullscreen = elem.classList.contains('pseudo-fullscreen');
-
- if (!isNativeFullscreen && !isPseudoFullscreen) {
- // Enter Fullscreen
- if (elem.requestFullscreen) {
- elem.requestFullscreen().catch(err => {
- console.log("Native fullscreen failed, using fallback.");
- elem.classList.add('pseudo-fullscreen');
- });
- } else if (elem.webkitRequestFullscreen) {
- elem.webkitRequestFullscreen();
- } else if (elem.msRequestFullscreen) {
- elem.msRequestFullscreen();
- } else {
- // No API support (e.g. iOS Safari)
- elem.classList.add('pseudo-fullscreen');
- }
- } else {
- // Exit Fullscreen
- if (isNativeFullscreen) {
- if (doc.exitFullscreen) {
- doc.exitFullscreen();
- } else if (doc.webkitExitFullscreen) {
- doc.webkitExitFullscreen();
- } else if (doc.mozCancelFullScreen) {
- doc.mozCancelFullScreen();
- } else if (doc.msExitFullscreen) {
- doc.msExitFullscreen();
- }
- }
- // Always remove pseudo class
- elem.classList.remove('pseudo-fullscreen');
- }
- };
-
- // Shop System
- window.toggleShop = function() {
- const shop = document.getElementById('shop-modal');
- if (shop.style.display === 'none' || !shop.style.display) {
- shop.style.display = 'flex';
- updateShopUI();
- } else {
- shop.style.display = 'none';
- }
- };
-
- function updateShopUI() {
- const content = document.getElementById('shop-content');
- content.innerHTML = 'Shop ';
-
- // Sell Section
- const sellDiv = document.createElement('div');
- sellDiv.className = 'shop-section';
- sellDiv.innerHTML = 'Sell Ores ';
-
- Object.entries(SELL_PRICES).forEach(([type, price]) => {
- const count = inventory[type] || 0;
- if (count > 0) {
- const btn = document.createElement('button');
- btn.className = 'shop-btn';
- btn.innerHTML = `Sell ${type} (${count}) - $${price * count}`;
- btn.onclick = () => sellOre(type);
- sellDiv.appendChild(btn);
- }
- });
- content.appendChild(sellDiv);
-
- // Upgrade Section
- const upgradeDiv = document.createElement('div');
- upgradeDiv.className = 'shop-section';
- upgradeDiv.innerHTML = 'Upgrades ';
-
- Object.entries(UPGRADE_COSTS).forEach(([type, costFunc]) => {
- const currentLevel = upgrades[type];
- const cost = costFunc(currentLevel);
-
- const btn = document.createElement('button');
- btn.className = 'shop-btn';
- btn.innerHTML = `Upgrade ${type} (Lvl ${currentLevel}) - $${cost}`;
- if (score < cost) btn.disabled = true;
- btn.onclick = () => buyUpgrade(type);
- upgradeDiv.appendChild(btn);
- });
- content.appendChild(upgradeDiv);
-
- // Close button
- const closeBtn = document.createElement('button');
- closeBtn.className = 'shop-btn close-btn';
- closeBtn.innerText = 'Close';
- closeBtn.style.marginTop = '20px';
- closeBtn.onclick = window.toggleShop;
- content.appendChild(closeBtn);
- }
-
- function sellOre(type) {
- const count = inventory[type] || 0;
- if (count > 0) {
- const price = SELL_PRICES[type];
- score += price * count;
- inventory[type] = 0;
- scoreDisplay.innerText = score;
- updateInventoryUI();
- updateShopUI();
- saveGame();
- }
- }
-
- function buyUpgrade(type) {
- const costFunc = UPGRADE_COSTS[type];
- const currentLevel = upgrades[type];
- const cost = costFunc(currentLevel);
-
- if (score >= cost) {
- score -= cost;
- upgrades[type]++;
-
- if (type === 'grid_size') {
- expandGrid();
- }
-
- scoreDisplay.innerText = score;
- updateShopUI();
- saveGame();
- }
- }
-
- function expandGrid() {
- const oldSize = GRID_SIZE;
- const oldVisibleLayers = VISIBLE_LAYERS;
-
- GRID_SIZE = 5 + (upgrades.grid_size * 2);
- VISIBLE_LAYERS = 7 + upgrades.grid_size;
-
- const offsetDiff = (GRID_SIZE - oldSize) / 2;
-
- // Adjust camera
- const d = 5 + upgrades.grid_size;
- camera.left = -d * aspect;
- camera.right = d * aspect;
- camera.top = d;
- camera.bottom = -d;
- camera.updateProjectionMatrix();
-
- // Migrate layers
- const newLayers = [];
- layers.forEach((oldLayer, layerIdx) => {
- const newLayer = [];
- for (let x = 0; x < GRID_SIZE; x++) {
- newLayer[x] = [];
- for (let z = 0; z < GRID_SIZE; z++) {
- // Map old coordinates to new centered coordinates
- const oldX = x - offsetDiff;
- const oldZ = z - offsetDiff;
-
- if (oldX >= 0 && oldX < oldSize && oldZ >= 0 && oldZ < oldSize) {
- // Existing block
- const block = oldLayer[oldX][oldZ];
- newLayer[x][z] = block;
- if (block) {
- // Update position
- const offset = (GRID_SIZE * BLOCK_SIZE) / 2 - (BLOCK_SIZE / 2);
- block.position.x = x * BLOCK_SIZE - offset;
- block.position.z = z * BLOCK_SIZE - offset;
- }
- } else {
- // New outer block
- // Generate block based on depth
- // We use the global 'depth' variable + layerIdx
- const absDepth = depth + layerIdx;
- const type = getBlockTypeForDepth(absDepth);
-
- const block = createBlock(x, -layerIdx, z, type);
- newLayer[x][z] = block;
- scene.add(block);
- }
-
- }
- }
- newLayers.push(newLayer);
- });
- layers = newLayers;
-
- // Add new layers at the bottom if VISIBLE_LAYERS increased
- while (layers.length < VISIBLE_LAYERS) {
- const layerIdx = layers.length;
- const absDepth = depth + layerIdx;
- layers.push(generateLayer(layerIdx, absDepth));
- }
- }
-
- // --- TNT System ---
- function updateTNT() {
- // Spawning
- if (upgrades.tnt_frequency > 0) {
- // Base interval 60s, decreases by 5s per level, min 5s
- const interval = Math.max(5000, 60000 - (upgrades.tnt_frequency * 5000));
-
- // Use performance.now() for smoother timing if needed, but simple counter is fine for now
- // Actually, let's use a delta time approach if we had one, but we don't.
- // Assuming 60fps, 16.6ms per frame.
- tntTimer += 16.6;
-
- if (tntTimer >= interval) {
- tntTimer = 0;
- spawnTNT();
- }
- }
-
- // Physics
- for (let i = tntProjectiles.length - 1; i >= 0; i--) {
- const tnt = tntProjectiles[i];
-
- // Initialize fuse if not present (for existing TNTs)
- if (tnt.userData.fuse === undefined) tnt.userData.fuse = 120;
- if (tnt.userData.landed === undefined) tnt.userData.landed = false;
-
- // Flashing effect
- tnt.userData.flashTimer += 1;
- // Flash faster as fuse runs out
- const flashInterval = tnt.userData.fuse < 60 ? 5 : 10;
-
- if (tnt.userData.flashTimer % flashInterval === 0) {
- tnt.userData.isFlashing = !tnt.userData.isFlashing;
- if (tnt.userData.isFlashing) {
- if (!tnt.userData.whiteMaterial) {
- tnt.userData.whiteMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
- }
- tnt.material = tnt.userData.whiteMaterial;
- } else {
- tnt.material = tnt.userData.originalMaterials;
- }
- }
-
- if (!tnt.userData.landed) {
- tnt.position.y -= 0.1; // Fall speed
-
- const gx = Math.round(tnt.position.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
- const gz = Math.round(tnt.position.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
- const gy = Math.round(tnt.position.y / BLOCK_SIZE);
-
- // Check if we hit a block
- const block = getBlockAt(gx, gy, gz);
-
- if (block) {
- // Land on top
- tnt.userData.landed = true;
- tnt.position.y = block.position.y + BLOCK_SIZE;
- // Snap to grid
- tnt.position.x = (gx - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE;
- tnt.position.z = (gz - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE;
- } else if (gy < -depth - 5) {
- // Fell too far
- tntProjectiles.splice(i, 1);
- scene.remove(tnt);
- }
- } else {
- // Landed logic
- // Check if block below still exists
- const gx = Math.round(tnt.position.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
- const gz = Math.round(tnt.position.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
- const gyBelow = Math.round((tnt.position.y - BLOCK_SIZE) / BLOCK_SIZE);
-
- const blockBelow = getBlockAt(gx, gyBelow, gz);
- if (!blockBelow && tnt.position.y > -depth - 1) {
- tnt.userData.landed = false; // Start falling again
- }
-
- tnt.userData.fuse--;
- if (tnt.userData.fuse <= 0) {
- explodeTNT(tnt);
- tntProjectiles.splice(i, 1);
- }
- }
- }
- }
-
- function spawnTNT() {
- const gx = Math.floor(Math.random() * GRID_SIZE);
- const gz = Math.floor(Math.random() * GRID_SIZE);
-
- const geometry = new THREE.BoxGeometry(0.6, 0.6, 0.6);
-
- const materials = [
- new THREE.MeshLambertMaterial({ map: textures.tnt_side }), // px
- new THREE.MeshLambertMaterial({ map: textures.tnt_side }), // nx
- new THREE.MeshLambertMaterial({ map: textures.tnt_top }), // py (top)
- new THREE.MeshLambertMaterial({ map: textures.tnt_bottom }), // ny (bottom)
- new THREE.MeshLambertMaterial({ map: textures.tnt_side }), // pz
- new THREE.MeshLambertMaterial({ map: textures.tnt_side }) // nz
- ];
-
- const tnt = new THREE.Mesh(geometry, materials);
-
- tnt.position.set(
- (gx - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE,
- 10, // Start high up
- (gz - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE
- );
-
- tnt.userData = {
- flashTimer: 0,
- isFlashing: false,
- originalMaterials: materials,
- fuse: 120, // 2 seconds at 60fps
- landed: false
- };
-
- scene.add(tnt);
- tntProjectiles.push(tnt);
- }
-
- function explodeTNT(tnt) {
- spawnExplosionParticles(tnt.position);
- scene.remove(tnt);
-
- // Explosion effect (simple)
- // Radius based on power: 0 -> 1 (3x3), 1 -> 2 (5x5)
- const radius = 1 + upgrades.tnt_power;
-
- const gx = Math.round(tnt.position.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
- const gz = Math.round(tnt.position.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
-
- // Find the layer we hit. Since TNT falls, it likely hit the top-most block at gx,gz.
- // But we might have hit a specific Y.
- // Let's just explode around the impact point.
- // We need to find the layer index corresponding to the impact Y.
- // tnt.position.y is roughly the block Y.
-
- const impactY = Math.round(tnt.position.y / BLOCK_SIZE);
-
- // Find layer index
- // layers[0] is at y = -depth
- // layers[1] is at y = -depth - 1
- // So y = -depth - layerIdx
- // layerIdx = -depth - y
-
- // Wait, layers are generated at `depth + i`.
- // `createBlock` uses `y = -layerIdx`.
- // But `generateLayer` calls `createBlock` with `y`?
- // No, `generateLayer` returns a 2D array of types.
- // `loadGame` rebuilds layers: `createBlock(x, -layerIdx, z, type)`.
- // So visually, layer 0 is at Y=0?
-
- // Ah, `createBlock` sets `block.position.y = y * BLOCK_SIZE`.
- // In `loadGame`, `y` is passed as `-layerIdx`.
- // So layer 0 is at Y=0. Layer 1 is at Y=-1.
- // But `advanceLevel` shifts layers. `layers.shift()`.
- // And `animate` moves blocks up.
- // `allBlocks.forEach((b, i) => { b.position.y = startPositions[i] + offset; });`
- // So the visual Y position changes!
-
- // This makes `getBlockAt` tricky if it relies on `layers` array index matching Y.
- // `getBlockAt`:
- // `if (y <= 0) { const layerIdx = -y; ... }`
- // This assumes layer 0 is at Y=0, layer 1 at Y=-1.
- // But if we advanced a level, the blocks moved UP.
- // So the block that was at Y=-1 is now at Y=0.
- // And it is now in `layers[0]`.
- // So `getBlockAt` logic holds: `layers[0]` is always at Y=0 (visually, after animation).
-
- // So `impactY` should correspond to `-layerIdx`.
- // `layerIdx = -impactY`.
-
- const centerLayerIdx = -impactY;
-
- // Explode in radius
- // We want a sphere or cylinder explosion?
- // Let's do a simple cube/cylinder explosion across layers.
-
- for (let l = centerLayerIdx - radius; l <= centerLayerIdx + radius; l++) {
- if (l < 0 || l >= layers.length) continue;
-
- for (let x = gx - radius; x <= gx + radius; x++) {
- for (let z = gz - radius; z <= gz + radius; z++) {
- if (x < 0 || x >= GRID_SIZE || z < 0 || z >= GRID_SIZE) continue;
-
- // Distance check for round explosion
- const dx = x - gx;
- const dz = z - gz;
- const dl = l - centerLayerIdx;
- if (dx*dx + dz*dz + dl*dl > radius*radius) continue;
-
- const block = layers[l][x][z];
- if (block) {
- breakBlock(block, true);
- }
- }
- }
- }
-
- // Also check extra blocks (trees)
- for (let i = extraBlocks.length - 1; i >= 0; i--) {
- const b = extraBlocks[i];
- const bx = b.userData.gridX;
- const bz = b.userData.gridZ;
- const by = Math.round(b.position.y / BLOCK_SIZE);
-
- const dx = bx - gx;
- const dz = bz - gz;
- const dy = by - impactY;
-
- if (dx*dx + dz*dz + dy*dy <= radius*radius) {
- breakBlock(b, true);
- }
- }
-
- checkLayerCleared();
- }
-
- // Floating Item Animation
- function createFloatingItem(type, startX, startY, count) {
- const img = document.createElement('img');
- let iconName = type;
- if (type === 'coal') iconName = 'coal_ore';
- if (type === 'iron') iconName = 'iron_ore';
- if (type === 'gold') iconName = 'gold_ore';
- if (type === 'diamond') iconName = 'diamond_ore';
- if (type === 'emerald') iconName = 'emerald_ore';
- if (type === 'log') iconName = 'log_oak';
- if (type === 'leaves') iconName = 'leaves_oak';
- if (type === 'grass') iconName = 'grass_side';
- if (type === 'stick') iconName = 'stick';
- if (type === 'sapling') iconName = 'sapling_oak';
- if (type === 'apple') iconName = 'apple';
- if (type === 'planks') iconName = 'planks_oak';
- if (type === 'wood_pickaxe') iconName = 'wood_pickaxe';
- if (type === 'stone_pickaxe') iconName = 'stone_pickaxe';
- if (type === 'iron_pickaxe') iconName = 'iron_pickaxe';
- if (type === 'gold_pickaxe') iconName = 'gold_pickaxe';
- if (type === 'diamond_pickaxe') iconName = 'diamond_pickaxe';
-
- if (['apple', 'stick', 'wood_pickaxe', 'stone_pickaxe', 'iron_pickaxe', 'gold_pickaxe', 'diamond_pickaxe'].includes(type)) {
- img.src = `items/${iconName}.png`;
- } else {
- img.src = `blocks/${iconName}.png`;
- }
-
- img.style.position = 'absolute';
- img.style.left = `${startX}px`;
- img.style.top = `${startY}px`;
- img.style.width = '32px';
- img.style.height = '32px';
- img.style.pointerEvents = 'none';
- img.style.zIndex = '1000';
-
- const container = document.querySelector('.mining-container');
- container.appendChild(img);
-
- // Find target relative to container
- const containerRect = container.getBoundingClientRect();
-
- // Default target (center bottom if slot not found)
- let targetX = containerRect.width / 2;
- let targetY = containerRect.height - 50;
-
- const slot = document.querySelector(`.inventory-slot[data-type="${type}"]`);
- if (slot) {
- const rect = slot.getBoundingClientRect();
- targetX = rect.left - containerRect.left + rect.width / 2 - 16;
- targetY = rect.top - containerRect.top + rect.height / 2 - 16;
- }
-
- // Control point for Bezier curve
- // "Fly away from inv bar" -> Fly UP first
- // We want a point that is higher (lower Y) than both start and end
- const cpX = (startX + targetX) / 2 + (Math.random() * 100 - 50);
- const cpY = Math.min(startY, targetY) - 150 - (Math.random() * 50);
-
- const startTime = Date.now();
- const duration = 600; // ms
-
- function animate() {
- const now = Date.now();
- const progress = Math.min((now - startTime) / duration, 1);
-
- // Quadratic Bezier
- // B(t) = (1-t)^2 * P0 + 2(1-t)t * P1 + t^2 * P2
- const t = progress;
- const invT = 1 - t;
-
- const x = (invT * invT * startX) + (2 * invT * t * cpX) + (t * t * targetX);
- const y = (invT * invT * startY) + (2 * invT * t * cpY) + (t * t * targetY);
-
- img.style.left = `${x}px`;
- img.style.top = `${y}px`;
-
- // Scale and opacity effects
- if (progress > 0.8) {
- img.style.opacity = `${1 - (progress - 0.8) * 5}`; // Fade out in last 20%
- img.style.transform = `scale(${1 - (progress - 0.8) * 2.5})`; // Shrink in last 20%
- }
-
- if (progress < 1) {
- requestAnimationFrame(animate);
- } else {
- if (img.parentNode) {
- img.parentNode.removeChild(img);
- }
- addToInventory(type, count);
- }
- }
-
- requestAnimationFrame(animate);
-
- }
-});
diff --git a/website/numbermatch.html b/website/numbermatch.html
deleted file mode 100644
index 1f57868..0000000
--- a/website/numbermatch.html
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
- Number Match
-
-
-
-
-
-
-
- New Game
-
-
-
-
-
- Matches cleared: 0
-
-
- Tiles remaining: 0
-
-
- Add Numbers used: 0/3
-
-
- Hints used: 0
-
-
-
-
-
-
- +
- 3
- Add numbers
-
-
- ?
- Hint
-
-
-
-
- How To Play
- Select two tiles that are either identical (for example, 8 and 8) or sum to ten (like 3 and 7). Clear every number to win.
- Match Rules
-
- Paths can run horizontally, vertically, diagonally, or wrap in reading order.
- Every cell between selections must be empty along the chosen path.
- Wrap-around allows the end of a row to connect to the start of the next when the path is clear.
-
- Strategy & Tips
-
- Scan rows and columns first for obvious pairs like 9-1, 8-2, 7-3, 6-4, and 5-5.
- Use diagonal and wrap-around matches to reach blocked numbers.
- Add Numbers unlocks fresh tiles in up to three batches—save it for when the board runs dry.
-
- Frequently Asked Questions
- Which numbers appear? Numbers 1-9 are distributed to encourage plenty of identical and ten-sum pairs.
- What counts as wrap-around? Reading order connections across a row break, provided every intervening cell is empty.
-
-
-
-
-
diff --git a/website/numbermatch.js b/website/numbermatch.js
deleted file mode 100644
index 2ac255a..0000000
--- a/website/numbermatch.js
+++ /dev/null
@@ -1,958 +0,0 @@
-/**
- * Shuffle an array in place using the Fisher-Yates algorithm.
- * @template T
- * @param {T[]} array Array to shuffle.
- * @returns {T[]} The shuffled array.
- */
-function shuffle(array) {
- for (let i = array.length - 1; i > 0; i -= 1) {
- const j = Math.floor(Math.random() * (i + 1));
- const temp = array[i];
- array[i] = array[j];
- array[j] = temp;
- }
- return array;
-}
-
-/**
- * Core game state manager for Number Match logic.
- */
-class NumberMatchGame {
- /**
- * @param {{ width?: number, rows?: number }} [options] Configuration object.
- */
- constructor(options = {}) {
- const { width = 9, rows = 6 } = options;
- this.width = width;
- this.initialRows = rows;
- const initialState = NumberMatchGame.generateInitialState(width, rows);
- this.tiles = initialState.boardTiles;
- this.reserveTiles = initialState.reserveTiles;
- }
-
- /**
- * Regenerate the board with a fresh puzzle.
- * @returns {void}
- */
- reset() {
- const initialState = NumberMatchGame.generateInitialState(this.width, this.initialRows);
- this.tiles = initialState.boardTiles;
- this.reserveTiles = initialState.reserveTiles;
- }
-
- /**
- * Get a shallow copy of the current tiles.
- * @returns {(number|null)[]} Current tile values.
- */
- getTiles() {
- return [...this.tiles];
- }
-
- /**
- * Count the remaining non-null tiles.
- * @returns {number} Number of tiles left on the board.
- */
- getRemainingCount() {
- const boardCount = this.tiles.filter((tile) => tile !== null).length;
- return boardCount;
- }
-
- /**
- * Determine whether the board has been cleared.
- * @returns {boolean} True when all tiles have been removed.
- */
- isComplete() {
- return this.tiles.every((tile) => tile === null);
- }
-
- /**
- * Attempt to match and remove two indices.
- * @param {number} firstIndex Index of the first tile.
- * @param {number} secondIndex Index of the second tile.
- * @returns {boolean} True if the match succeeded.
- */
- selectPair(firstIndex, secondIndex) {
- if (!this.canPair(firstIndex, secondIndex)) {
- return false;
- }
-
- this.tiles[firstIndex] = null;
- this.tiles[secondIndex] = null;
- this.compactEmptyRows();
- return true;
- }
-
- /**
- * Check whether the two indices can form a valid pair.
- * @param {number} firstIndex Index of the first tile.
- * @param {number} secondIndex Index of the second tile.
- * @returns {boolean} True if the tiles satisfy match rules and path constraints.
- */
- canPair(firstIndex, secondIndex) {
- if (firstIndex === secondIndex) {
- return false;
- }
-
- if (!NumberMatchGame.isValidIndex(firstIndex, this.tiles) || !NumberMatchGame.isValidIndex(secondIndex, this.tiles)) {
- return false;
- }
-
- const valueA = this.tiles[firstIndex];
- const valueB = this.tiles[secondIndex];
-
- if (valueA === null || valueB === null) {
- return false;
- }
-
- if (!(valueA === valueB || valueA + valueB === 10)) {
- return false;
- }
-
- return this.hasClearPath(firstIndex, secondIndex);
- }
-
- /**
- * Append remaining numbers to the end of the grid, respecting Add Numbers behavior.
- * @returns {boolean} True when new numbers were appended.
- */
- addNumbers(usesRemaining = 1) {
- this.compactEmptyRows();
- return this.appendRemainingTiles();
- }
-
- /**
- * Collect active tiles in reading order.
- * @returns {number[]} Non-null tile values.
- */
- getActiveTiles() {
- return this.tiles.filter((tile) => tile !== null);
- }
-
- /**
- * Append remaining tiles in reading order after exhausting reserve uses.
- * @returns {boolean} True when tiles were appended.
- */
- appendRemainingTiles() {
- const remaining = this.getActiveTiles();
- if (remaining.length === 0) {
- return false;
- }
-
- this.trimTrailingEmptySlots();
- this.tiles.push(...remaining);
- return true;
- }
-
- /**
- * Remove trailing null placeholders from the board.
- * @returns {void}
- */
- trimTrailingEmptySlots() {
- while (this.tiles.length > 0 && this.tiles[this.tiles.length - 1] === null) {
- this.tiles.pop();
- }
- }
-
- /**
- * Verify that a straight path between two indices is unobstructed.
- * @param {number} firstIndex One tile index.
- * @param {number} secondIndex The other tile index.
- * @returns {boolean} True if any allowed path between the indices is clear.
- */
- hasClearPath(firstIndex, secondIndex) {
- const start = Math.min(firstIndex, secondIndex);
- const end = Math.max(firstIndex, secondIndex);
-
- const rowStart = Math.floor(start / this.width);
- const rowEnd = Math.floor(end / this.width);
- const colStart = start % this.width;
- const colEnd = end % this.width;
-
- if (rowStart === rowEnd && this.isSegmentClear(start, end, 1)) {
- return true;
- }
-
- const diff = end - start;
- if (colStart === colEnd && diff % this.width === 0 && this.isSegmentClear(start, end, this.width)) {
- return true;
- }
-
- const rowDelta = rowEnd - rowStart;
- const colDelta = colEnd - colStart;
-
- if (rowDelta === colDelta && rowDelta > 0) {
- if (colStart + rowDelta < this.width && this.isSegmentClear(start, end, this.width + 1)) {
- return true;
- }
- }
-
- if (rowDelta === -colDelta && rowDelta > 0) {
- if (colStart - rowDelta >= 0 && this.isSegmentClear(start, end, this.width - 1)) {
- return true;
- }
- }
-
- return this.isSegmentClear(start, end, 1);
- }
-
- /**
- * Remove any fully empty rows from the board to keep the grid compact.
- * @returns {void}
- */
- compactEmptyRows() {
- let index = 0;
- while (index < this.tiles.length) {
- const remaining = this.tiles.length - index;
- const span = Math.min(this.width, remaining);
- const slice = this.tiles.slice(index, index + span);
- const isEmptyRow = slice.every((value) => value === null);
- if (isEmptyRow) {
- this.tiles.splice(index, span);
- continue;
- }
- index += this.width;
- }
- }
-
- /**
- * Verify that the indices along a stepped path are empty.
- * @param {number} start Starting index (inclusive).
- * @param {number} end Ending index (inclusive).
- * @param {number} step Increment applied each iteration.
- * @returns {boolean} True if no non-null tiles exist between start and end.
- */
- isSegmentClear(start, end, step) {
- for (let current = start + step; current < end; current += step) {
- if (this.tiles[current] !== null) {
- return false;
- }
- }
- return true;
- }
-
- /**
- * Determine whether the provided index is valid for the current tile array.
- * @param {number} index Index to evaluate.
- * @param {(number|null)[]} tiles Tile array.
- * @returns {boolean} True when the index is within bounds.
- */
- static isValidIndex(index, tiles) {
- return index >= 0 && index < tiles.length;
- }
-
- /**
- * Create a randomized board configuration split between visible tiles and a reserve pile.
- * @param {number} width Number of columns.
- * @param {number} rows Number of initial rows.
- * @returns {{ boardTiles: number[], reserveTiles: number[] }} Generated tile sets.
- */
- static generateInitialState(width, rows) {
- const boardCapacity = width * rows;
- const maxBoardMatches = Math.min(6, Math.floor(boardCapacity / 2));
- const forcedPairs = NumberMatchGame.getForcedPairs(maxBoardMatches);
- const forcedAssignments = new Map();
-
- const horizontalStarts = [];
- for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
- for (let colIndex = 0; colIndex < width - 1; colIndex += 1) {
- horizontalStarts.push(rowIndex * width + colIndex);
- }
- }
-
- const shuffledStarts = shuffle(horizontalStarts);
- const reserved = new Set();
- let startCursor = 0;
-
- forcedPairs.forEach((pair) => {
- let startIndex = null;
- while (startCursor < shuffledStarts.length) {
- const candidate = shuffledStarts[startCursor];
- startCursor += 1;
- if (reserved.has(candidate) || reserved.has(candidate + 1)) {
- continue;
- }
- startIndex = candidate;
- break;
- }
-
- if (startIndex === null) {
- return;
- }
-
- forcedAssignments.set(startIndex, pair[0]);
- forcedAssignments.set(startIndex + 1, pair[1]);
- reserved.add(startIndex);
- reserved.add(startIndex + 1);
- });
-
- const boardTiles = new Array(boardCapacity).fill(null);
-
- for (let index = 0; index < boardCapacity; index += 1) {
- const forcedValue = forcedAssignments.get(index);
- if (typeof forcedValue === "number") {
- boardTiles[index] = forcedValue;
- continue;
- }
-
- const rawNextForced = forcedAssignments.get(index + 1);
- const nextForcedValue = typeof rawNextForced === "number" ? rawNextForced : null;
-
- boardTiles[index] = NumberMatchGame.pickSafeFillerValue(boardTiles, width, index, nextForcedValue);
- }
-
- const reserveTiles = NumberMatchGame.buildReserveTiles(boardTiles, boardCapacity);
-
- return {
- boardTiles,
- reserveTiles
- };
- }
-
- /**
- * Produce a random pair of values that form a legal match.
- * @returns {[number, number]} Two values that either match or sum to ten.
- */
- static generatePair() {
- const complementPairs = [
- [1, 9],
- [2, 8],
- [3, 7],
- [4, 6],
- [5, 5]
- ];
- const identicalValues = [1, 2, 3, 4, 5, 6, 7, 8, 9];
-
- if (Math.random() < 0.5) {
- const value = identicalValues[Math.floor(Math.random() * identicalValues.length)];
- return [value, value];
- }
-
- const pair = complementPairs[Math.floor(Math.random() * complementPairs.length)];
- return [...pair];
- }
-
- /**
- * Create the predetermined matches that appear on the initial board.
- * @param {number} target Total matches to prepare.
- * @returns {Array<[number, number]>} Ordered list of forced pairs.
- */
- static getForcedPairs(target) {
- const essentialPairs = [[5, 5]];
- const complementPool = shuffle([
- [1, 9],
- [2, 8],
- [3, 7],
- [4, 6],
- [9, 1],
- [8, 2],
- [7, 3],
- [6, 4]
- ]);
- const pairs = [];
-
- for (let i = 0; i < essentialPairs.length && pairs.length < target; i += 1) {
- pairs.push(essentialPairs[i]);
- }
-
- let complementIndex = 0;
- while (pairs.length < target && complementIndex < complementPool.length) {
- pairs.push(complementPool[complementIndex]);
- complementIndex += 1;
- }
-
- while (pairs.length < target) {
- pairs.push(NumberMatchGame.generatePair());
- }
-
- return shuffle(pairs);
- }
-
- /**
- * Pick a filler value that will not immediately create an accessible match.
- * @param {Array} tiles Current partially filled board.
- * @param {number} width Board width.
- * @param {number} index Index to populate.
- * @param {number|null} nextForcedValue Upcoming forced value, if one exists.
- * @returns {number} Safe filler digit.
- */
- static pickSafeFillerValue(tiles, width, index, nextForcedValue) {
- const attempt = (respectNext) => {
- const forbidden = NumberMatchGame.collectForbiddenValues(tiles, width, index, respectNext ? nextForcedValue : null);
- const candidates = [];
- for (let value = 1; value <= 9; value += 1) {
- if (!forbidden.has(value)) {
- candidates.push(value);
- }
- }
- return candidates;
- };
-
- let candidates = attempt(true);
- if (candidates.length === 0) {
- candidates = attempt(false);
- }
-
- if (candidates.length === 0) {
- return Math.floor(Math.random() * 9) + 1;
- }
-
- return candidates[Math.floor(Math.random() * candidates.length)];
- }
-
- /**
- * Collect forbidden values based on already placed neighbors and optional future constraints.
- * @param {Array} tiles Current board state.
- * @param {number} width Board width.
- * @param {number} index Target index.
- * @param {number|null} nextForcedValue Forced value to appear next in sequence, if any.
- * @returns {Set} Values that should not be used at the index.
- */
- static collectForbiddenValues(tiles, width, index, nextForcedValue) {
- const forbidden = new Set();
- const register = NumberMatchGame.registerForbiddenValue;
-
- if (index > 0) {
- register(forbidden, tiles[index - 1]);
- }
-
- const row = Math.floor(index / width);
- const col = index % width;
-
- if (row > 0) {
- register(forbidden, tiles[index - width]);
- if (col > 0) {
- register(forbidden, tiles[index - width - 1]);
- }
- if (col < width - 1) {
- register(forbidden, tiles[index - width + 1]);
- }
- }
-
- if (typeof nextForcedValue === "number") {
- register(forbidden, nextForcedValue);
- }
-
- return forbidden;
- }
-
- /**
- * Register a value and its complement as forbidden for immediate placement.
- * @param {Set} forbidden Collection of disallowed values.
- * @param {number|null|undefined} value Value to mark as forbidden.
- * @returns {void}
- */
- static registerForbiddenValue(forbidden, value) {
- if (typeof value !== "number") {
- return;
- }
-
- forbidden.add(value);
- const complement = NumberMatchGame.getComplement(value);
- forbidden.add(complement);
- }
-
- /**
- * Compute the complement digit that forms a sum-to-ten relationship.
- * @param {number} value Value between 1 and 9.
- * @returns {number} Complement digit.
- */
- static getComplement(value) {
- if (value === 5) {
- return 5;
- }
- const complement = 10 - value;
- return Math.min(9, Math.max(1, complement));
- }
-
- /**
- * Build the reserve pile so that complements exist for non-matching board values.
- * @param {Array} boardTiles Generated board.
- * @param {number} boardCapacity Total board slots.
- * @returns {number[]} Reserve tiles array.
- */
- static buildReserveTiles(boardTiles, boardCapacity) {
- const reserve = [];
-
- boardTiles.forEach((value) => {
- if (typeof value !== "number") {
- return;
- }
- reserve.push(NumberMatchGame.getComplement(value));
- });
-
- while (reserve.length < Math.ceil(boardCapacity * 1.1)) {
- const [first, second] = NumberMatchGame.generatePair();
- reserve.push(first, second);
- }
-
- return shuffle(reserve);
- }
-}
-
-/**
- * UI controller responsible for rendering and interactions.
- */
-class NumberMatchUI {
- /**
- * @param {NumberMatchGame} game Game state instance.
- */
- constructor(game) {
- this.game = game;
- this.boardElement = /** @type {HTMLElement} */ (document.getElementById("board"));
- this.messageElement = /** @type {HTMLElement} */ (document.getElementById("message"));
- this.matchesElement = /** @type {HTMLElement} */ (document.getElementById("matchesCount"));
- this.tilesRemainingElement = /** @type {HTMLElement} */ (document.getElementById("tilesRemaining"));
- this.addNumbersElement = /** @type {HTMLElement} */ (document.getElementById("addNumbersUsed"));
- this.addNumbersBadgeElement = /** @type {HTMLElement} */ (document.getElementById("addNumbersCounter"));
- this.hintsElement = /** @type {HTMLElement} */ (document.getElementById("hintsUsed"));
- this.addNumbersButton = /** @type {HTMLButtonElement} */ (document.getElementById("addNumbersButton"));
- this.hintButton = /** @type {HTMLButtonElement} */ (document.getElementById("hintButton"));
- this.newGameButton = /** @type {HTMLButtonElement} */ (document.getElementById("newGameButton"));
- this.newGameDialogBackdrop = /** @type {HTMLElement | null} */ (document.getElementById("newGameDialogBackdrop"));
- this.newGameDialog = /** @type {HTMLElement | null} */ (document.getElementById("newGameDialog"));
- this.newGameConfirmButton = /** @type {HTMLButtonElement | null} */ (document.getElementById("confirmNewGameButton"));
- this.newGameCancelButton = /** @type {HTMLButtonElement | null} */ (document.getElementById("cancelNewGameButton"));
- this.previouslyFocusedElement = null;
-
- this.selectedIndices = [];
- this.matchesCleared = 0;
- this.addNumbersUsed = 0;
- this.hintsUsed = 0;
- this.highlightedIndices = [];
- }
-
- /**
- * Initialize event listeners and render the starting board.
- * @returns {void}
- */
- init() {
- this.bindControlEvents();
- const restored = this.restoreState();
- this.renderBoard();
- this.updateMetrics();
- this.hintButton.disabled = this.game.isComplete();
- if (restored) {
- this.showMessage("Welcome back! Your last board was restored.", "success");
- } else {
- this.showMessage("Select two tiles that match or sum to ten.");
- }
- this.persistState();
- }
-
- /**
- * Attach event listeners to control buttons.
- * @returns {void}
- */
- bindControlEvents() {
- this.addNumbersButton.addEventListener("click", () => {
- const remainingUses = NumberMatchUI.ADD_NUMBERS_LIMIT - this.addNumbersUsed;
- const appended = this.game.addNumbers(remainingUses);
- if (appended) {
- if (remainingUses > 0) {
- this.addNumbersUsed += 1;
- }
- this.showMessage("Numbers duplicated to the end of the grid.", "success");
- this.selectedIndices = [];
- this.clearHighlights();
- this.renderBoard();
- this.updateMetrics();
- this.persistState();
- } else {
- this.showMessage("No numbers remain to duplicate.", "error");
- this.updateAddNumbersPrompt();
- }
- });
-
- this.hintButton.addEventListener("click", () => {
- const hint = this.findHintPair();
- if (!hint) {
- this.showMessage("No matches available. Try adding numbers.", "error");
- this.updateAddNumbersPrompt();
- return;
- }
- this.selectedIndices = [];
- this.highlightedIndices = hint;
- this.hintsUsed += 1;
- this.renderBoard();
- this.updateMetrics();
- this.showMessage("Try matching the highlighted tiles.");
- this.persistState();
- });
-
- this.newGameButton.addEventListener("click", () => {
- this.openNewGameDialog();
- });
-
- if (this.newGameDialogBackdrop && this.newGameConfirmButton && this.newGameCancelButton) {
- this.newGameConfirmButton.addEventListener("click", () => {
- this.closeNewGameDialog();
- this.startNewGame();
- });
-
- this.newGameCancelButton.addEventListener("click", () => {
- this.closeNewGameDialog();
- });
-
- this.newGameDialogBackdrop.addEventListener("mousedown", (event) => {
- if (event.target === this.newGameDialogBackdrop) {
- this.closeNewGameDialog();
- }
- });
-
- this.newGameDialogBackdrop.addEventListener("keydown", (event) => {
- if (event.key === "Escape") {
- event.preventDefault();
- this.closeNewGameDialog();
- }
- });
- }
- }
-
- /**
- * Handle tile clicks and manage selection/matching lifecycle.
- * @param {number} index Tile index that was clicked.
- * @returns {void}
- */
- handleTileClick(index) {
- const tiles = this.game.getTiles();
- this.clearHighlights();
- if (tiles[index] === null) {
- return;
- }
-
- if (this.selectedIndices.includes(index)) {
- this.selectedIndices = this.selectedIndices.filter((item) => item !== index);
- this.renderBoard();
- return;
- }
-
- this.selectedIndices.push(index);
-
- if (this.selectedIndices.length < 2) {
- this.renderBoard();
- return;
- }
-
- this.renderBoard();
- this.clearHighlights();
- const [first, second] = this.selectedIndices;
- const didMatch = this.game.selectPair(first, second);
- if (didMatch) {
- this.matchesCleared += 1;
- this.selectedIndices = [];
- this.renderBoard();
- this.updateMetrics();
- this.showMessage("Great match!", "success");
- if (this.game.isComplete()) {
- this.showMessage("Board cleared! Start a new game when you are ready.", "success");
- this.addNumbersButton.disabled = true;
- this.hintButton.disabled = true;
- this.clearHighlights();
- }
- this.persistState();
- return;
- }
-
- this.selectedIndices = [second];
- this.renderBoard();
- }
-
- /**
- * Render the board based on the current game state.
- * @returns {void}
- */
- renderBoard() {
- const tiles = this.game.getTiles();
- this.boardElement.innerHTML = "";
- this.boardElement.style.gridTemplateColumns = `repeat(${this.game.width}, minmax(0, 1fr))`;
- tiles.forEach((value, index) => {
- const tile = document.createElement("button");
- tile.type = "button";
- tile.className = "tile";
- tile.dataset.index = index.toString();
- if (value === null) {
- tile.classList.add("tile--empty");
- tile.disabled = true;
- tile.setAttribute("aria-hidden", "true");
- } else {
- tile.textContent = String(value);
- tile.addEventListener("click", () => this.handleTileClick(index));
- }
- if (this.selectedIndices.includes(index)) {
- tile.classList.add("tile--selected");
- }
- if (this.highlightedIndices.includes(index)) {
- tile.classList.add("tile--hinted");
- }
- this.boardElement.appendChild(tile);
- });
- this.updateAddNumbersPrompt();
- }
-
- /**
- * Update score-related UI metrics.
- * @returns {void}
- */
- updateMetrics() {
- this.matchesElement.textContent = String(this.matchesCleared);
- this.tilesRemainingElement.textContent = String(this.game.getRemainingCount());
- this.addNumbersElement.textContent = `${this.addNumbersUsed}/${NumberMatchUI.ADD_NUMBERS_LIMIT}`;
- this.hintsElement.textContent = String(this.hintsUsed);
- this.updateAddNumbersBadge();
- }
-
- /**
- * Reflect remaining Add Numbers uses in the badge.
- * @returns {void}
- */
- updateAddNumbersBadge() {
- const remaining = Math.max(0, NumberMatchUI.ADD_NUMBERS_LIMIT - this.addNumbersUsed);
- if (this.addNumbersBadgeElement) {
- this.addNumbersBadgeElement.textContent = String(remaining);
- this.addNumbersBadgeElement.classList.toggle("board-action__badge--empty", remaining === 0);
- }
- }
-
- /**
- * Clear any highlighted tile indices.
- * @returns {void}
- */
- clearHighlights() {
- this.highlightedIndices = [];
- }
-
- /**
- * Display a status message with optional styling.
- * @param {string} message Message to display.
- * @param {"success"|"error"|"info"} [type="info"] Message type for styling.
- * @returns {void}
- */
- showMessage(message, type = "info") {
- this.messageElement.textContent = message;
- this.messageElement.classList.remove("status__message--error", "status__message--success");
- if (type === "success") {
- this.messageElement.classList.add("status__message--success");
- } else if (type === "error") {
- this.messageElement.classList.add("status__message--error");
- }
- }
-
- /**
- * Reset state and begin a new puzzle.
- * @returns {void}
- */
- startNewGame() {
- this.game.reset();
- this.matchesCleared = 0;
- this.addNumbersUsed = 0;
- this.hintsUsed = 0;
- this.selectedIndices = [];
- this.addNumbersButton.disabled = false;
- this.hintButton.disabled = false;
- this.clearHighlights();
- this.renderBoard();
- this.updateMetrics();
- this.showMessage("New puzzle loaded. Good luck!", "success");
- this.hintButton.disabled = false;
- this.persistState();
- }
-
- /**
- * Display the custom new game confirmation dialog.
- * @returns {void}
- */
- openNewGameDialog() {
- if (!this.newGameDialogBackdrop || !this.newGameDialog || !this.newGameConfirmButton) {
- this.startNewGame();
- return;
- }
-
- this.previouslyFocusedElement = document.activeElement;
- this.newGameDialogBackdrop.hidden = false;
- requestAnimationFrame(() => {
- this.newGameDialogBackdrop.classList.add("is-visible");
- this.newGameDialogBackdrop.setAttribute("aria-hidden", "false");
- this.newGameDialog.focus({ preventScroll: true });
- this.newGameConfirmButton.focus({ preventScroll: true });
- });
- }
-
- /**
- * Hide the custom new game dialog and restore focus.
- * @returns {void}
- */
- closeNewGameDialog() {
- if (!this.newGameDialogBackdrop) {
- return;
- }
- this.newGameDialogBackdrop.classList.remove("is-visible");
- this.newGameDialogBackdrop.setAttribute("aria-hidden", "true");
- setTimeout(() => {
- if (this.newGameDialogBackdrop) {
- this.newGameDialogBackdrop.hidden = true;
- }
- }, 180);
-
- if (this.previouslyFocusedElement instanceof HTMLElement) {
- this.previouslyFocusedElement.focus({ preventScroll: true });
- }
- }
-
- /**
- * Determine whether any valid matches remain on the board.
- * @returns {boolean} True if at least one valid pair exists.
- */
- hasAvailableMoves() {
- return NumberMatchUI.findFirstValidPair(this.game) !== null;
- }
-
- /**
- * Update Add Numbers control state, including glow cues and disabling logic.
- * @returns {void}
- */
- updateAddNumbersPrompt() {
- const hasNumbersRemaining = this.game.getRemainingCount() > 0;
- const activeAction = hasNumbersRemaining && !this.game.isComplete();
- this.addNumbersButton.disabled = !activeAction;
-
- const noMoves = !this.hasAvailableMoves();
- const shouldGlow = noMoves && hasNumbersRemaining && activeAction;
- this.addNumbersButton.classList.toggle("board-action--attention", shouldGlow);
- this.updateAddNumbersBadge();
- }
-
- /**
- * Persist the current board and score metrics to localStorage.
- * @returns {void}
- */
- persistState() {
- try {
- const storage = window.localStorage;
- if (!storage) {
- return;
- }
- const state = {
- version: NumberMatchUI.STORAGE_VERSION,
- width: this.game.width,
- tiles: this.game.getTiles(),
- reserveTiles: Array.isArray(this.game.reserveTiles) ? [...this.game.reserveTiles] : undefined,
- matchesCleared: this.matchesCleared,
- addNumbersUsed: this.addNumbersUsed,
- hintsUsed: this.hintsUsed,
- timestamp: Date.now()
- };
- storage.setItem(NumberMatchUI.STORAGE_KEY, JSON.stringify(state));
- } catch (error) {
- console.error("[NumberMatch] Failed to persist state", error);
- }
- }
-
- /**
- * Attempt to restore board and metrics from localStorage.
- * @returns {boolean} True when a valid state was restored.
- */
- restoreState() {
- try {
- const storage = window.localStorage;
- if (!storage) {
- return false;
- }
- const raw = storage.getItem(NumberMatchUI.STORAGE_KEY);
- if (!raw) {
- return false;
- }
- const parsed = JSON.parse(raw);
- if (!parsed || parsed.version !== NumberMatchUI.STORAGE_VERSION) {
- return false;
- }
- if (parsed.width !== this.game.width || !Array.isArray(parsed.tiles)) {
- return false;
- }
-
- const tiles = parsed.tiles.map((value) => {
- if (value === null) {
- return null;
- }
- const numeric = Number(value);
- return Number.isFinite(numeric) ? numeric : null;
- });
-
- if (tiles.length === 0 || tiles.every((value) => value === undefined)) {
- return false;
- }
-
- this.game.tiles = tiles;
- if (Array.isArray(parsed.reserveTiles)) {
- this.game.reserveTiles = parsed.reserveTiles
- .map((value) => Number(value))
- .filter((value) => Number.isFinite(value));
- }
-
- const numericOrDefault = (value, fallback, clampMin = null, clampMax = null) => {
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) {
- return fallback;
- }
- let result = numeric;
- if (clampMin !== null) {
- result = Math.max(clampMin, result);
- }
- if (clampMax !== null) {
- result = Math.min(clampMax, result);
- }
- return result;
- };
-
- this.matchesCleared = numericOrDefault(parsed.matchesCleared, 0, 0, Number.MAX_SAFE_INTEGER);
- this.addNumbersUsed = numericOrDefault(parsed.addNumbersUsed, 0, 0, NumberMatchUI.ADD_NUMBERS_LIMIT);
- this.hintsUsed = numericOrDefault(parsed.hintsUsed, 0, 0, Number.MAX_SAFE_INTEGER);
- this.selectedIndices = [];
- this.highlightedIndices = [];
- return true;
- } catch (error) {
- console.error("[NumberMatch] Failed to restore state", error);
- return false;
- }
- }
-
- /**
- * Find a valid pair of tiles for hint functionality.
-NumberMatchUI.STORAGE_KEY = "numbermatch-state-v1";
-NumberMatchUI.STORAGE_VERSION = 1;
- * @returns {[number, number] | null} Tuple of indices or null when no match exists.
- */
- findHintPair() {
- return NumberMatchUI.findFirstValidPair(this.game);
- }
-
- /**
- * Compute the first valid pair of tiles available.
- * @param {NumberMatchGame} game Game state instance to inspect.
- * @returns {[number, number] | null} Indices representing a valid pair or null.
- */
- static findFirstValidPair(game) {
- const tiles = game.getTiles();
- for (let i = 0; i < tiles.length; i += 1) {
- if (tiles[i] === null) {
- continue;
- }
- for (let j = i + 1; j < tiles.length; j += 1) {
- if (tiles[j] === null) {
- continue;
- }
- if (game.canPair(i, j)) {
- return [i, j];
- }
- }
- }
- return null;
- }
-}
-
-NumberMatchUI.ADD_NUMBERS_LIMIT = 3;
-
-window.addEventListener("DOMContentLoaded", () => {
- const game = new NumberMatchGame({ width: 9, rows: 6 });
- const ui = new NumberMatchUI(game);
- ui.init();
-});
diff --git a/website/numbermatchstyle.css b/website/numbermatchstyle.css
deleted file mode 100644
index c698b2a..0000000
--- a/website/numbermatchstyle.css
+++ /dev/null
@@ -1,313 +0,0 @@
-* {
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
- font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
- background-color: #0f172a;
- color: #e2e8f0;
- min-height: 100vh;
- display: flex;
- justify-content: center;
- align-items: stretch;
- padding: 1.5rem;
-}
-
-.sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- border: 0;
-}
-
-.app {
- display: grid;
- gap: 2rem;
- grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
- width: min(1100px, 100%);
-}
-
-.app__game,
-.app__info {
- background-color: #1e293b;
- border-radius: 1rem;
- padding: 1.5rem;
- box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
-}
-
-.app__title {
- margin: 0;
- font-size: 2rem;
- font-weight: 700;
-}
-
-.app__subtitle {
- margin: 0.25rem 0 0;
- color: #94a3b8;
-}
-
-.controls {
- display: flex;
- flex-wrap: wrap;
- gap: 0.75rem;
- margin: 1.5rem 0;
-}
-
-.control-button {
- background: linear-gradient(135deg, #22d3ee, #2563eb);
- border: none;
- border-radius: 999px;
- color: #0f172a;
- cursor: pointer;
- font-size: 1rem;
- font-weight: 600;
- padding: 0.75rem 1.5rem;
- transition: transform 120ms ease, box-shadow 120ms ease;
-}
-
-.control-button:hover,
-.control-button:focus {
- transform: translateY(-1px);
- box-shadow: 0 10px 25px rgba(37, 99, 235, 0.45);
-}
-
-.control-button:active {
- transform: translateY(0);
- box-shadow: none;
-}
-
-.control-button--secondary {
- background: #334155;
- color: #e2e8f0;
-}
-
-.status {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
-}
-
-.status__message {
- min-height: 1.5rem;
- margin: 0;
- color: #f8fafc;
- font-weight: 600;
-}
-
-.status__message.status__message--error {
- color: #f87171;
-}
-
-.status__message.status__message--success {
- color: #4ade80;
-}
-
-.status__metrics {
- display: flex;
- flex-wrap: wrap;
- gap: 1rem;
- color: #cbd5f5;
-}
-
-.status__metric {
- font-size: 0.95rem;
-}
-
-.board {
- margin-top: 1.5rem;
- display: grid;
- gap: 0.5rem;
- width: 100%;
-}
-
-.board-actions {
- margin-top: 1.25rem;
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 0.75rem;
-}
-
-.board-action {
- width: 3.5rem;
- height: 3.5rem;
- border-radius: 50%;
- border: none;
- display: inline-flex;
- justify-content: center;
- align-items: center;
- font-size: 2rem;
- font-weight: 700;
- cursor: pointer;
- color: #0f172a;
- transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
- position: relative;
-}
-
-.board-action:hover,
-.board-action:focus {
- transform: translateY(-2px);
- box-shadow: 0 16px 30px rgba(15, 23, 42, 0.35);
-}
-
-.board-action:active {
- transform: translateY(0);
- box-shadow: none;
-}
-
-.board-action--primary {
- background: linear-gradient(135deg, #22d3ee, #2563eb);
-}
-
-.board-action--secondary {
- background: #e2e8f0;
- color: #0f172a;
-}
-
-.board-action__icon {
- line-height: 1;
-}
-
-.board-action__badge {
- position: absolute;
- top: -0.35rem;
- right: -0.35rem;
- min-width: 1.5rem;
- padding: 0.15rem 0.4rem;
- border-radius: 999px;
- background: #0ea5e9;
- color: #0f172a;
- font-size: 0.85rem;
- font-weight: 700;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 6px 14px rgba(14, 165, 233, 0.45);
-}
-
-.board-action__badge.board-action__badge--empty {
- background: #cbd5f5;
- color: #475569;
- box-shadow: none;
-}
-
-.board-action--attention {
- box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.4), 0 18px 36px rgba(8, 145, 178, 0.45);
- animation: board-action-pulse 0.9s ease-in-out infinite alternate;
-}
-
-@keyframes board-action-pulse {
- from {
- transform: translateY(-2px) scale(1);
- }
- to {
- transform: translateY(-3px) scale(1.05);
- }
-}
-
-.tile {
- width: 100%;
- aspect-ratio: 1 / 1;
- border-radius: 0.75rem;
- border: none;
- font-size: clamp(1.125rem, 4vw, 1.5rem);
- font-weight: 700;
- color: #0f172a;
- background: linear-gradient(145deg, #f1f5f9, #cbd5f5);
- cursor: pointer;
- transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
-.tile:hover,
-.tile:focus {
- transform: translateY(-2px);
- box-shadow: 0 14px 25px rgba(15, 23, 42, 0.35);
-}
-
-.tile--selected {
- background: linear-gradient(145deg, #fde68a, #f97316);
- box-shadow: 0 16px 35px rgba(251, 191, 36, 0.4);
-}
-
-.tile--hinted:not(.tile--selected) {
- background: linear-gradient(145deg, #bfdbfe, #93c5fd);
- box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.5), 0 12px 24px rgba(37, 99, 235, 0.35);
-}
-
-.tile--empty {
- background: rgba(148, 163, 184, 0.25);
- border: 1px dashed rgba(148, 163, 184, 0.45);
- cursor: default;
- box-shadow: none;
-}
-
-.tile--empty:hover,
-.tile--empty:focus {
- transform: none;
-}
-
-.app__info h2,
-.app__info h3 {
- margin-top: 0;
- color: #f8fafc;
-}
-
-.app__info ul {
- padding-left: 1.25rem;
- margin: 0.75rem 0 1rem;
-}
-
-.app__info li {
- margin-bottom: 0.5rem;
-}
-
-.app__info p {
- color: #cbd5f5;
- line-height: 1.5;
-}
-
-@media (max-width: 960px) {
- body {
- padding: 0;
- }
-
- .app {
- grid-template-columns: minmax(0, 1fr);
- gap: 1.25rem;
- }
-
- .app__info {
- order: 1;
- margin-top: 0;
- }
-
- .board {
- gap: 0.35rem;
- }
-
- .board-actions {
- margin-top: 1rem;
- gap: 0.5rem;
- }
-
- .board-action {
- width: 3.1rem;
- height: 3.1rem;
- font-size: 1.75rem;
- }
-
- .board-action__badge {
- top: -0.25rem;
- right: -0.25rem;
- min-width: 1.35rem;
- font-size: 0.75rem;
- }
-}
diff --git a/website/sitemap.xml b/website/sitemap.xml
index efadfad..26cf7d7 100644
--- a/website/sitemap.xml
+++ b/website/sitemap.xml
@@ -5,32 +5,12 @@
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
https://lit.ruv.wtf/
- 2025-02-09T22:00:52+00:00
+ 2026-03-04T00:00:00+00:00
1.00
-
- https://lit.ruv.wtf/materials/
- 2025-02-09T22:00:52+00:00
- 0.80
-
-
- https://lit.ruv.wtf/numbermatch/
- 2025-11-19T00:00:00+00:00
- 0.70
-
https://lit.ruv.wtf/docs/sitemap.xml
- 2025-02-09T22:00:52+00:00
+ 2026-03-04T00:00:00+00:00
0.80
-
- https://lit.ruv.wtf/privacy.html
- 2025-02-09T22:00:52+00:00
- 0.50
-
-
- https://lit.ruv.wtf/mining/
- 2025-11-24T00:00:00+00:00
- 0.70
-
diff --git a/website/style.css b/website/style.css
deleted file mode 100644
index 6b77712..0000000
--- a/website/style.css
+++ /dev/null
@@ -1,730 +0,0 @@
-:root {
- --main-bg: #242424;
- --main-fg: #f0f0f0;
- --accent: #2563eb;
- --like: #db2777;
- --comment: #059669;
- --github: #181717;
- --youtube: #FF0000;
- --steam: #1b2838;
- --discord: #7289da;
- --bluesky: #3E5BFF;
- --post-bg: #fff;
- --post-border: #000;
- --avatar-bg: #f0f0f0;
- --avatar-border: #000;
- --shadow-light: rgba(0, 0, 0, 0.3);
- --shadow-heavy: rgba(0, 0, 0, 1);
- --shadow-mid: rgba(0, 0, 0, 0.05);
- --header-shadow1: #ffffff52;
- --header-shadow2: #000;
-}
-
-html {
- box-sizing: border-box;
- scroll-behavior: smooth;
-}
-
-*, *::before, *::after {
- box-sizing: inherit;
-}
-
-body {
- margin: 0;
- min-height: 100vh;
- background: var(--main-bg);
- font-family: monospace;
- background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0h20v20H0V0zm2 2v16h16V2H2z' fill='%23000000' fill-opacity='0.05'/%3E%3C/svg%3E");
- overflow-x: hidden;
-}
-
-a {
- text-decoration: none;
- color: inherit;
-}
-
-.bg-pattern {
- position: fixed;
- inset: 0;
- opacity: 0.05;
- z-index: 0;
- background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%239C92AC' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
-}
-
-.container {
- max-width: 1560px; /* increased by 30% from 1200px */
- width: 100%;
- margin: 0 auto;
- padding: 2rem 0 2rem 0;
- position: relative;
- z-index: 1;
- padding-top: 160px; /* increased from 100px */
- box-sizing: border-box;
- overflow: visible; /* ensure expanded content is visible */
-}
-
-header {
- text-align: center;
- margin-bottom: 80px;
- color: var(--main-fg);
- font-size: 20px;
- padding-top: 2rem; /* add extra space above header */
- overflow: visible;
-}
-
-h1 {
- transition: all 0.3s ease;
- text-shadow: 2px 2px 0 var(--header-shadow1), 3px 3px 0 var(--header-shadow2);
- margin-bottom: 0.5rem;
- transform-origin: center;
- cursor: pointer;
- position: relative;
- display: inline-block;
-}
-
-.social-links {
- margin-top: 1rem;
- font-size: 1rem;
-}
-
-.social-links a {
- margin: 0 10px;
- color: var(--main-fg);
- text-decoration: none;
- font-weight: bold;
- font-size: 2rem;
- display: inline-block;
-}
-
-.social-links a:hover {
- transform: scale(1.1);
- transition: transform 0.3s ease;
- text-shadow: 0 0 3px white;
-}
-
-.social-links a:hover.fa-github,
-.social-links a:hover .fab.fa-github {
- color: var(--github) !important;
-}
-
-.social-links a:hover.fa-youtube,
-.social-links a:hover .fab.fa-youtube {
- color: var(--youtube) !important;
-}
-
-.social-links a:hover.fa-steam,
-.social-links a:hover .fab.fa-steam {
- color: var(--steam) !important;
-}
-
-.social-links a:hover.fa-discord,
-.social-links a:hover .fab.fa-discord {
- color: var(--discord) !important;
-}
-
-.social-links a:hover.fa-bluesky,
-.social-links a:hover .fa-brands.fa-bluesky {
- color: var(--bluesky) !important;
-}
-
-#posts {
- max-width: 1560px; /* increased by 30% from 1200px */
- width: 100%;
- margin-left: auto;
- margin-right: auto;
- margin-bottom: 2rem;
- display: flex;
- justify-content: center;
- align-items: flex-start;
- padding-left: 80px; /* Increase left padding to balance the negative margin */
- padding-right: 40px;
- box-sizing: border-box;
- text-align: left;
- min-height: 0; /* Prevent flex container from causing jumps */
- /* Add stable height to prevent jumps */
- contain: layout style;
-}
-
-.masonry-col {
- flex: 1 1 0;
- min-width: 320px;
- max-width: 100%;
- display: flex;
- flex-direction: column;
- gap: 0; /* Remove gap so negative margins overlap */
- transition: none; /* Prevent transitions during layout changes */
- will-change: auto; /* Prevent unnecessary layer promotion */
- /* Prevent reflow during resize */
-}
-
-@media (max-width: 799px) {
- #posts {
- flex-direction: column;
- gap: 0;
- transition: none; /* Prevent transitions during layout changes */
- /* Remove the min-height that was causing issues */
- min-height: auto;
- margin-top: 2rem; /* Add top margin to push posts below header */
- margin-right: 0px;
- margin-left:0px;
- padding-right: 0; /* Remove right padding on mobile */
- }
- .masonry-col {
- min-width: 0;
- width: 100%;
- /* Hide overflow columns on mobile */
- }
- .masonry-col:not(:first-child) {
- display: none;
- }
-
- /* Ensure posts don't have excessive negative margins on mobile */
- .post {
- margin-top: 1rem;
- margin-bottom: 1rem;
- }
-}
-
-.post {
- background: var(--post-bg);
- /* Add post-it note lines: slightly darker than background, full width, spaced ~32px apart */
- background-image:
- repeating-linear-gradient(
- to bottom,
- transparent 0px,
- transparent 27px,
- rgba(0,0,0,0.04) 27px,
- rgba(0,0,0,0.04) 29px,
- transparent 29px,
- transparent 32px
- );
- background-size: 100% 32px;
- background-repeat: repeat;
- padding: 2rem 1.5rem;
- margin-bottom: -0.2rem;
- margin-top: -0.2rem;
- margin-left: -40px;
- margin-right: -40px;
- border: 1.8px solid var(--post-border);
- position: relative; /* Add back for z-index to work */
- box-shadow: 3px 7px 4px var(--shadow-light);
- transition: transform 0.35s cubic-bezier(0.68, -0.55, 0.27, 1.55),
- box-shadow 0.5s cubic-bezier(0.68, -0.55, 0.27, 1.55),
- opacity 0.2s ease;
- display: inline-block;
- width: 100%;
- min-width: 320px;
- break-inside: avoid;
- z-index: 1;
-
- /* Dynamic rotation based on height */
- --post-height: 300px; /* Default fallback */
- --min-rotation: 0.5deg; /* Minimum rotation for tall posts */
- --max-rotation: 3deg; /* Maximum rotation for short posts */
- --rotation-factor: clamp(var(--min-rotation), calc(var(--max-rotation) - (var(--post-height) - 200px) * 0.005), var(--max-rotation));
-
- /* Apply rotation with random direction */
- transform: rotate(var(--rotation-factor));
-}
-
-.post::before {
- content: "";
- position: absolute;
- top: 5px;
- left: 0;
- width: 100%;
- height: 55px; /* Keep existing height */
- background-color: rgba(0, 0, 0, 0.05); /* Keep existing background color */
- z-index: 0; /* Ensure it's behind the post content but above the post background lines */
- border-radius: 25%; /* Add radius to top-left corner */
- filter: blur(5px); /* Add a slight blur to soften the edges */
-}
-
-/* Remove these alternate rotation rules, JS now handles rotation and color */
-/*
-.post:nth-child(odd) {
- transform: rotate(calc(-1 * var(--rotation-factor)));
-}
-.post:nth-child(even) {
- transform: rotate(var(--rotation-factor));
-}
-*/
-
-.post:hover {
- /* Remove transform here, JS will handle */
- box-shadow: 6px 6px 30px var(--shadow-heavy);
- z-index: 100;
-}
-
-.post-header {
- display: flex;
- gap: 1rem;
- margin-bottom: 1rem;
- margin: -2rem -1.5rem 1.25rem -1.5rem; /* stretch to post edge, extra space below */
- padding: 0.75rem 1.5rem 0.75rem 1.5rem;
- border-top-left-radius: 0;
- border-top-right-radius: 0;
-}
-
-.avatar {
- width: 48px;
- height: 48px;
- background: var(--avatar-bg);
- border: 2px solid var(--avatar-border);
- display: flex;
- align-items: center;
- justify-content: center;
- font-weight: bold;
- transform: rotate(-2deg);
- box-shadow: 2px 2px 0 var(--avatar-border);
-}
-
-.post-content {
- text-shadow: 1px 1px 0 #fff;
-}
-
-/* Ensure image containers can hold positioned tape */
-.image-placeholder,
-.image-placeholder img {
- position: relative;
-}
-
-/* Force relative positioning on image links (containers) */
-a:has(.image-placeholder) {
- position: relative !important;
- display: block !important;
-}
-
-/* JavaScript-generated tape styling */
-.tape {
- position: absolute;
- background: rgba(255, 248, 220, 0.9);
- border: 0.5px solid rgba(0, 0, 0, 0.18);
- z-index: 100;
- pointer-events: none;
- box-shadow: 1px 1px 3px rgba(0,0,0,0.3);
- background-image: linear-gradient(45deg,
- rgba(255, 255, 255, 0.3) 25%,
- transparent 25%,
- transparent 75%,
- rgba(255, 255, 255, 0.3) 75%);
- background-size: 3px 3px;
- /* Zig-zag mask on top/bottom (long) sides, subtle and thin */
- -webkit-mask-image: url("data:image/svg+xml;utf8, ");
- mask-image: url("data:image/svg+xml;utf8, ");
- -webkit-mask-size: 100% 100%;
- mask-size: 100% 100%;
- -webkit-mask-repeat: no-repeat;
- mask-repeat: no-repeat;
-}
-
-.post-actions {
- margin-top: 1rem;
- display: flex;
- gap: 1rem;
-}
-
-.action-btn {
- border: none;
- background: none;
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 0.5rem;
- font-family: inherit;
-}
-
-.action-btn.like {
- color: var(--like);
- transform: rotate(-1deg);
-}
-
-.action-btn.repost {
- color: var(--accent);
- transform: rotate(1deg);
-}
-
-.action-btn.comment {
- color: var(--comment);
- transform: rotate(-0.5deg);
-}
-
-.avatar-img {
- width: 250px;
- margin: 20px auto;
- display: block;
-}
-
-.post:hover {
- /* Remove transform here, JS will handle */
- box-shadow: 6px 6px 30px var(--shadow-heavy);
- z-index: 100;
-}
-
-.subtext {
- margin-bottom: 30px;
-}
-
-@keyframes shake {
-
- 0%,
- 100% {
- transform: translateX(0);
- }
-
- 25% {
- transform: translateX(-5px) translateY(5px);
- }
-
- 50% {
- transform: translateX(5px) translateY(-5px);
- }
-
- 75% {
- transform: translateX(-5px) translateY(-5px);
- }
-}
-
-h1:hover {
- animation:
- wildSpin 3s ease-in-out,
- rainbow 3s linear,
- shake 0.5s ease-in-out infinite;
-}
-
-social-links a {
- position: relative;
-}
-
-social-links a::after {
- content: attr(title);
- position: absolute;
- bottom: 100%;
- left: 50%;
- transform: translateX(-50%);
- background-color: rgba(0, 0, 0, 0.7);
- color: white;
- padding: 0.3rem;
- border-radius: 0.3rem;
- font-size: 0.875rem;
- opacity: 0;
- transition: opacity 0.2s;
- white-space: nowrap;
-}
-
-social-links a:hover::after {
- opacity: 1;
-}
-
-.project-links {
- margin-top: 2rem;
- font-size: 1rem;
-}
-
-.project-links a {
- display: inline-block;
- margin: 0.5rem 0;
- padding: 0.5rem 1rem;
- background: var(--post-bg);
- color: var(--main-bg);
- text-decoration: none;
- font-weight: bold;
- border-radius: 20px;
- transition: background-color 0.3s ease, color 0.3s ease;
-}
-
-.project-links a:hover {
- background: var(--accent);
- color: var(--post-bg);
-}
-
-footer {
- text-align: center;
- margin-top: 3rem;
- margin-bottom: 2rem;
- font-size: 0.9rem;
- color: var(--main-fg);
-}
-
-footer a {
- color: var(--main-fg);
- text-decoration: underline;
-}
-
-footer a:hover {
- color: var(--accent);
-}
-
-.hashtag {
- color: #222;
- /* Remove text rotation/skew, only background is "sloppy" */
- background: none;
- border-radius: 6px 12px 8px 10px / 10px 8px 12px 6px;
- padding: 0 6px 2px 6px;
- font-weight: 500;
- cursor: pointer;
- box-shadow:
- 0 2px 8px 0 rgba(255, 248, 154, 0.18),
- 0 1px 0 0 rgba(255, 248, 154, 0.25);
- display: inline-block;
- margin: 0 2px;
- position: relative;
- z-index: 1;
-}
-.hashtag::before {
- content: "";
- position: absolute;
- z-index: -1;
- left: -3px;
- right: -3px;
- top: 2px;
- bottom: 2px;
- /* Default fallback color, will be overridden inline by JS */
- background: linear-gradient(
- 4deg,
- var(--hashtag-bg, #fff89a) 0%,
- var(--hashtag-bg, #fff89a) 100%
- );
- opacity: 0.7;
- border-radius: 8px 14px 10px 12px / 12px 10px 14px 8px;
- transform: rotate(-2deg) skewX(-2deg);
- pointer-events: none;
-}
-
-.mention {
- color: #388e3c;
- background: #e8f5e9;
- border-radius: 4px;
- color: #388e3c;
- background: #e8f5e9;
- border-radius: 4px;
- padding: 0 4px;
- font-weight: 500; cursor: pointer;
-}
-
-/* Video center play button styling */
-.video-center-play {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 80px;
- height: 80px;
- background: rgba(0, 0, 0, 0.7);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- transition: all 0.3s ease;
- z-index: 10;
- pointer-events: auto;
-}
-
-.video-center-play::before {
- content: "▶";
- color: white;
- font-size: 24px;
- margin-left: 4px; /* Slight offset to center the triangle visually */
-}
-
-.video-center-play:hover {
- background: rgba(0, 0, 0, 0.9);
- transform: translate(-50%, -50%) scale(1.1);
-}
-
-.video-center-play:focus {
- outline: 2px solid white;
- outline-offset: 2px;
-}
-
-/* Video embed container and video styling */
-.video-embed-container {
- position: relative;
-}
-
-.video-embed {
- width: 100%;
- max-height: 60vh;
- margin: 1.5rem 0 12px 0;
- background: #000;
- border-radius: 8px;
-}
-
-/* Shadowbox modal styling */
-.shadowbox-modal {
- position: fixed;
- top: 0;
- left: 0;
- width: 100vw;
- height: 100vh;
- background: rgba(0,0,0,0.0);
- display: flex;
- align-items: flex-end;
- justify-content: center;
- z-index: 9999;
- cursor: zoom-out;
- transition: background 0.35s cubic-bezier(.5,1.5,.5,1);
-}
-
-.shadowbox-modal.visible {
- background: rgba(0,0,0,0.7);
-}
-
-.shadowbox-post {
- transform: translateY(100vh) scale(0.98);
- max-width: 75vw;
- max-height: 90vh;
- overflow: auto;
- position: relative;
- z-index: 10001;
- cursor: default;
- opacity: 0;
- transition: opacity 0.25s cubic-bezier(.5,1.5,.5,1), transform 0.5s cubic-bezier(.5,1.5,.5,1);
-}
-
-.shadowbox-post.visible {
- transform: translateY(0) scale(1) rotate(0deg);
- opacity: 1;
-}
-
-.shadowbox-image {
- max-width: 90vw !important;
- max-height: 80vh !important;
- width: auto !important;
- object-fit: contain !important;
- display: block !important;
- margin: 24px auto !important;
- opacity: 1 !important;
-}
-
-.shadowbox-close {
- position: absolute;
- top: 10px;
- right: 16px;
- font-size: 2rem;
- background: rgba(0,0,0,0.2);
- color: #fff;
- border: none;
- width: 40px;
- height: 40px;
- cursor: pointer;
- z-index: 10002;
- transition: background 0.2s;
-}
-
-.shadowbox-close:hover {
- background: rgba(0,0,0,0.5);
-}
-
-/* Post header avatar image styling */
-.avatar img {
- width: 100%;
- height: 100%;
- object-fit: cover;
-}
-
-/* Post metadata styling */
-.post-author {
- font-weight: bold;
-}
-
-.post-date {
- font-size: 0.875rem;
- color: #666;
-}
-
-/* Image placeholder styling */
-.image-placeholder {
- width: 100%;
- margin-top: 1.5rem;
- border: 4px solid var(--post-border);
- background: var(--avatar-bg);
- text-align: center;
- transform: rotate(0deg);
- box-shadow: 4px 4px 0 var(--post-border);
- display: block;
-}
-
-/* Post animation for loading */
-@keyframes slideUpFadeIn {
- from {
- opacity: 0;
- transform: translateY(50px) rotate(var(--post-rotation, 0deg));
- }
- to {
- opacity: 1;
- transform: translateY(0) rotate(var(--post-rotation, 0deg));
- }
-}
-
-.post.loading {
- opacity: 0;
- transform: translateY(50px) rotate(var(--post-rotation, 0deg));
- animation: slideUpFadeIn 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
-}
-
-/* Loading indicator and view more button styles */
-.loading-posts, .view-more-posts {
- text-align: center;
- margin: 2rem auto;
- padding: 2rem;
- background: var(--post-bg);
- border: 2px solid var(--post-border);
- box-shadow: 3px 3px 0 var(--shadow-light);
- max-width: 400px;
- transform: rotate(-1deg);
- color: #666;
- font-family: inherit;
- position: relative;
- transition: transform 0.3s ease, box-shadow 0.3s ease;
-}
-
-.view-more-posts {
- cursor: pointer;
- text-decoration: none;
- display: block;
- clear: both;
- margin-top: 3rem;
-}
-
-.view-more-posts:hover {
- transform: rotate(-1deg) scale(1.05);
- box-shadow: 6px 6px 0 var(--shadow-light);
- color: var(--bluesky);
-}
-
-.loading-posts::before, .view-more-posts::before {
- content: "";
- position: absolute;
- top: 5px;
- left: 0;
- width: 100%;
- height: 40px;
- background-color: rgba(0, 0, 0, 0.05);
- z-index: 0;
- border-radius: 25%;
- filter: blur(3px);
-}
-
-.loading-posts-content, .view-more-posts-content {
- position: relative;
- z-index: 1;
-}
-
-.loading-spinner {
- display: inline-block;
- width: 20px;
- height: 20px;
- border: 3px solid #f3f3f3;
- border-top: 3px solid var(--accent);
- border-radius: 50%;
- animation: spin 1s linear infinite;
- margin-right: 10px;
- vertical-align: middle;
-}
-
-@keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
-}
\ No newline at end of file
diff --git a/website/styles.css b/website/styles.css
new file mode 100644
index 0000000..040e1e7
--- /dev/null
+++ b/website/styles.css
@@ -0,0 +1,222 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Courier New', Courier, monospace;
+ background: #000;
+ color: #0f0;
+ overflow: hidden;
+ height: 100vh;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.container {
+ width: 95vw;
+ max-width: 1400px;
+ height: 90vh;
+ background: #001800;
+ border: 3px solid #0f0;
+ border-radius: 8px;
+ box-shadow:
+ 0 0 20px rgba(0, 255, 0, 0.3),
+ inset 0 0 50px rgba(0, 255, 0, 0.05);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.terminal-header {
+ background: #002200;
+ border-bottom: 2px solid #0f0;
+ padding: 8px 16px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 14px;
+ font-weight: bold;
+ text-shadow: 0 0 5px #0f0;
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+}
+
+.header-links {
+ display: flex;
+ gap: 12px;
+ font-size: 12px;
+ font-weight: normal;
+}
+
+.header-link {
+ color: #0f0;
+ text-decoration: none;
+ padding: 4px 8px;
+ border: 1px solid #0f0;
+ border-radius: 3px;
+ transition: all 0.2s;
+ text-shadow: 0 0 5px #0f0;
+}
+
+.header-link:hover {
+ background: #0f0;
+ color: #001800;
+ box-shadow: 0 0 10px #0f0;
+}
+
+.terminal-title {
+ letter-spacing: 2px;
+}
+
+.system-time {
+ font-size: 12px;
+ opacity: 0.8;
+}
+
+#terminal {
+ flex: 1;
+ padding: 5px 10px;
+ overflow: hidden;
+}
+
+.xterm {
+ height: 100%;
+ padding: 0;
+}
+
+.xterm-viewport {
+ background: transparent !important;
+}
+
+.xterm-screen {
+ cursor: text;
+}
+
+.status-bar {
+ background: #002200;
+ border-top: 2px solid #0f0;
+ padding: 6px 16px;
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ text-shadow: 0 0 3px #0f0;
+}
+
+.status-left {
+ font-weight: bold;
+}
+
+.status-right {
+ opacity: 0.8;
+}
+
+/* CRT screen effect */
+.container::before {
+ content: " ";
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%),
+ linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
+ z-index: 2;
+ background-size: 100% 2px, 3px 100%;
+ pointer-events: none;
+}
+
+/* Subtle screen flicker */
+@keyframes flicker {
+ 0% { opacity: 0.98; }
+ 50% { opacity: 1; }
+ 100% { opacity: 0.98; }
+}
+
+.container {
+ animation: flicker 1s infinite;
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+ .container {
+ width: 98vw;
+ height: 95vh;
+ border-radius: 4px;
+ }
+
+ .terminal-header {
+ padding: 6px 12px;
+ font-size: 12px;
+ }
+
+ .header-links {
+ gap: 8px;
+ font-size: 10px;
+ }
+
+ .header-link {
+ padding: 3px 6px;
+ }
+
+ .system-time {
+ font-size: 10px;
+ }
+
+ .status-bar {
+ padding: 4px 12px;
+ font-size: 10px;
+ }
+}
+
+@media (max-width: 480px) {
+ .terminal-title {
+ letter-spacing: 1px;
+ font-size: 10px;
+ }
+
+ .header-left {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .header-links {
+ gap: 6px;
+ font-size: 9px;
+ }
+
+ .header-link {
+ padding: 2px 4px;
+ }
+}
+
+ .status-right {
+ display: none;
+ }
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #001800;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #0f0;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #0c0;
+}
diff --git a/website/tape.js b/website/tape.js
deleted file mode 100644
index bc25ea6..0000000
--- a/website/tape.js
+++ /dev/null
@@ -1,173 +0,0 @@
-function addTapeToImages() {
- const images = document.querySelectorAll('.image-placeholder');
-
- if (images.length === 0) {
- return;
- }
-
- images.forEach((image, index) => {
- const existingTape = image.querySelectorAll('.tape');
- if (existingTape.length > 0) {
- return;
- }
-
- const imageParent = image.parentElement;
- if (imageParent && window.getComputedStyle(imageParent).position === 'static') {
- imageParent.style.position = 'relative';
- }
-
- const tape1 = createTapeElement(index, 1);
- const tape2 = createTapeElement(index, 2);
-
- const usePattern1 = index % 2 === 0;
- if (usePattern1) {
- positionTape(tape1, 'top-left');
- positionTape(tape2, 'bottom-right');
- } else {
- positionTape(tape1, 'top-right');
- positionTape(tape2, 'bottom-left');
- }
-
- imageParent.appendChild(tape1);
- imageParent.appendChild(tape2);
- });
-}
-
-function createTapeElement(imageIndex, tapeNumber) {
- const tape = document.createElement('div');
- tape.className = 'tape';
-
- const width = 60 + Math.random() * 20;
- const height = 40 + Math.random() * 16;
-
- tape.style.cssText = `
- position: absolute;
- width: ${width}px;
- height: ${height}px;
- background: rgba(255, 248, 220, 0.93);
- z-index: 100;
- pointer-events: none;
- box-shadow: 2px 2px 6px rgba(0,0,0,0.18);
- background-image: linear-gradient(45deg,
- rgba(255, 255, 255, 0.25) 25%,
- transparent 25%,
- transparent 75%,
- rgba(255, 255, 255, 0.25) 75%);
- background-size: 4px 4px;
- `;
-
- return tape;
-}
-
-function positionTape(tape, corner) {
- const randomOffset = () => Math.random() * 8 - 4;
- const randomRotation = (Math.random() * 20 - 10);
- const OUT_X = 25;
- const OUT_Y = 18;
-
- switch (corner) {
- case 'top-left':
- tape.style.top = `${-OUT_Y + randomOffset()}px`;
- tape.style.left = `${-OUT_X + randomOffset()}px`;
- tape.style.transform = `rotate(${-45 + randomRotation}deg)`;
- break;
- case 'top-right':
- tape.style.top = `${-OUT_Y + randomOffset()}px`;
- tape.style.right = `${-OUT_X + randomOffset()}px`;
- tape.style.transform = `rotate(${45 + randomRotation}deg)`;
- break;
- case 'bottom-left':
- tape.style.bottom = `${-OUT_Y + randomOffset()}px`;
- tape.style.left = `${-OUT_X + randomOffset()}px`;
- tape.style.transform = `rotate(${45 + randomRotation}deg)`;
- break;
- case 'bottom-right':
- tape.style.bottom = `${-OUT_Y + randomOffset()}px`;
- tape.style.right = `${-OUT_X + randomOffset()}px`;
- tape.style.transform = `rotate(${-45 + randomRotation}deg)`;
- break;
- }
-}
-
-function refreshTape() {
- const existingTape = document.querySelectorAll('.tape');
- existingTape.forEach(tape => tape.remove());
- addTapeToImages();
-}
-
-function addTapeToPost(postElement) {
- const images = postElement.querySelectorAll('.image-placeholder');
-
- if (images.length === 0) {
- return;
- }
-
- images.forEach((image, index) => {
- const existingTape = image.parentElement.querySelectorAll('.tape');
- if (existingTape.length > 0) {
- return;
- }
-
- const imageParent = image.parentElement;
- if (imageParent && window.getComputedStyle(imageParent).position === 'static') {
- imageParent.style.position = 'relative';
- }
-
- const tape1 = createTapeElement(index, 1);
- const tape2 = createTapeElement(index, 2);
-
- const usePattern1 = index % 2 === 0;
- if (usePattern1) {
- positionTape(tape1, 'top-left');
- positionTape(tape2, 'bottom-right');
- } else {
- positionTape(tape1, 'top-right');
- positionTape(tape2, 'bottom-left');
- }
-
- imageParent.appendChild(tape1);
- imageParent.appendChild(tape2);
- });
-}
-
-function testSimpleTape() {
- const firstImage = document.querySelector('.image-placeholder');
- if (!firstImage) {
- console.error('[Tape Test] No images found');
- return;
- }
-
- const parent = firstImage.parentElement;
- parent.style.position = 'relative';
-
- const testTape = document.createElement('div');
- testTape.className = 'tape';
- testTape.style.cssText = `
- position: absolute;
- top: 10px;
- left: 10px;
- width: 60px;
- height: 30px;
- background: red;
- border: 2px solid black;
- z-index: 999;
- color: white;
- display: flex;
- align-items: center;
- justify-content: center;
- font-weight: bold;
- `;
- testTape.textContent = 'TEST';
-
- parent.appendChild(testTape);
-
- setTimeout(() => {
- testTape.remove();
- }, 3000);
-}
-
-// Export functions
-window.addTapeToImages = addTapeToImages;
-window.addTapeToPost = addTapeToPost;
-window.refreshTape = refreshTape;
-window.testSimpleTape = testSimpleTape;
diff --git a/website/terminal.js b/website/terminal.js
new file mode 100644
index 0000000..be930d5
--- /dev/null
+++ b/website/terminal.js
@@ -0,0 +1,1152 @@
+// Version
+const VERSION = '1.0.0';
+
+// Initialize xterm.js terminal
+const term = new Terminal({
+ cursorBlink: true,
+ cursorStyle: 'block',
+ fontFamily: '"Courier New", Courier, monospace',
+ fontSize: 14,
+ theme: {
+ background: '#001800',
+ foreground: '#00ff00',
+ cursor: '#00ff00',
+ cursorAccent: '#001800',
+ selection: 'rgba(0, 255, 0, 0.3)',
+ black: '#000000',
+ red: '#ff0000',
+ green: '#00ff00',
+ yellow: '#ffff00',
+ blue: '#0066ff',
+ magenta: '#ff00ff',
+ cyan: '#00ffff',
+ white: '#ffffff',
+ brightBlack: '#666666',
+ brightRed: '#ff6666',
+ brightGreen: '#66ff66',
+ brightYellow: '#ffff66',
+ brightBlue: '#6666ff',
+ brightMagenta: '#ff66ff',
+ brightCyan: '#66ffff',
+ brightWhite: '#ffffff'
+ },
+ allowTransparency: true,
+ scrollback: 1000
+});
+
+// Add addons
+const fitAddon = new FitAddon.FitAddon();
+const webLinksAddon = new WebLinksAddon.WebLinksAddon();
+
+term.loadAddon(fitAddon);
+term.loadAddon(webLinksAddon);
+
+// Open terminal
+term.open(document.getElementById('terminal'));
+fitAddon.fit();
+
+// Adjust font size based on screen width
+function adjustFontSize() {
+ const width = window.innerWidth;
+ if (width < 480) {
+ term.options.fontSize = 10;
+ } else if (width < 768) {
+ term.options.fontSize = 12;
+ } else {
+ term.options.fontSize = 14;
+ }
+ fitAddon.fit();
+}
+
+adjustFontSize();
+
+// Make terminal responsive
+window.addEventListener('resize', () => {
+ adjustFontSize();
+});
+
+// Update system time
+function updateTime() {
+ const now = new Date();
+ const timeStr = now.toLocaleTimeString('en-US', {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ });
+ document.getElementById('systemTime').textContent = timeStr;
+}
+updateTime();
+setInterval(updateTime, 1000);
+
+// Command history
+let commandHistory = [];
+let historyIndex = -1;
+let currentLine = '';
+let cursorPosition = 0;
+
+// Chat mode state
+let chatMode = {
+ active: false,
+ messages: [],
+ lastSync: null,
+ pollInterval: null,
+ inputLine: '',
+ displayNames: {} // Cache for display names
+};
+
+// Matrix API helper
+const matrixApi = async (endpoint, method = 'GET', body = null) => {
+ if (!window.matrixSession) return null;
+ const homeserver = 'https://b.ruv.wtf';
+ const url = `${homeserver}/_matrix/client/r0${endpoint}`;
+ const headers = {
+ 'Content-Type': 'application/json'
+ };
+
+ if (window.matrixSession.accessToken) {
+ headers['Authorization'] = `Bearer ${window.matrixSession.accessToken}`;
+ }
+
+ const options = { method, headers };
+ if (body) options.body = JSON.stringify(body);
+
+ const response = await fetch(url, options);
+ return response.json();
+};
+
+// Get display name for a user (with caching)
+async function getDisplayName(userId) {
+ // Check cache first
+ if (chatMode.displayNames[userId]) {
+ return chatMode.displayNames[userId];
+ }
+
+ try {
+ const data = await matrixApi(`/profile/${encodeURIComponent(userId)}/displayname`, 'GET');
+ const displayName = data.displayname || userId.split(':')[0].substring(1);
+ chatMode.displayNames[userId] = displayName;
+ return displayName;
+ } catch (error) {
+ // Fallback to username part
+ const fallback = userId.split(':')[0].substring(1);
+ chatMode.displayNames[userId] = fallback;
+ return fallback;
+ }
+}
+
+// Get color for user based on their ID (consistent hashing)
+function getUserColor(username) {
+ // Color palette that works well on dark green background
+ const colors = [
+ '\x1b[91m', // bright red
+ '\x1b[92m', // bright green
+ '\x1b[93m', // bright yellow
+ '\x1b[94m', // bright blue
+ '\x1b[95m', // bright magenta
+ '\x1b[96m', // bright cyan
+ '\x1b[33m', // yellow
+ '\x1b[35m', // magenta
+ '\x1b[36m', // cyan
+ ];
+
+ // Simple hash function
+ let hash = 0;
+ for (let i = 0; i < username.length; i++) {
+ hash = ((hash << 5) - hash) + username.charCodeAt(i);
+ hash = hash & hash; // Convert to 32bit integer
+ }
+
+ return colors[Math.abs(hash) % colors.length];
+}
+
+// Check if display name is already taken
+async function isDisplayNameTaken(newName) {
+ try {
+ const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET');
+ if (members && members.joined) {
+ for (const [userId, member] of Object.entries(members.joined)) {
+ // Skip our own user
+ if (userId === window.matrixSession.userId) continue;
+
+ const displayName = member.display_name || userId.split(':')[0].substring(1);
+ if (displayName.toLowerCase() === newName.toLowerCase()) {
+ return true;
+ }
+ }
+ }
+ return false;
+ } catch (error) {
+ console.error('Error checking display names:', error);
+ return false; // Allow on error
+ }
+}
+
+// Enter chat mode
+async function enterChatMode() {
+ if (!window.matrixSession || !window.matrixSession.roomId) {
+ term.writeln('\r\n Error: Not connected to chat. Use "chat" command first.\r\n');
+ return;
+ }
+
+ chatMode.active = true;
+ chatMode.messages = [];
+ chatMode.lastSync = null;
+ chatMode.inputLine = '';
+
+ term.clear();
+ term.writeln('╔════════════════════════════════════════════════════════════╗');
+ term.writeln('║ CHAT - #generalchat ║');
+ term.writeln('║ Type /help for commands ║');
+ term.writeln('╚════════════════════════════════════════════════════════════╝');
+
+ // Fetch initial messages
+ await syncChatMessages();
+
+ // Start polling for new messages
+ chatMode.pollInterval = setInterval(async () => {
+ await syncChatMessages(true);
+ }, 3000);
+
+ // Add separator and initial prompt
+ term.writeln('');
+ term.writeln('─'.repeat(term.cols || 60));
+ term.write(`\x1b[1;32m>\x1b[0m `);
+}
+
+// Exit chat mode
+function exitChatMode() {
+ chatMode.active = false;
+ if (chatMode.pollInterval) {
+ clearInterval(chatMode.pollInterval);
+ chatMode.pollInterval = null;
+ }
+ chatMode.displayNames = {}; // Clear display name cache
+ term.clear();
+ term.writeln(' Exited chat mode.\r\n');
+ term.write(prompt);
+}
+
+// Sync messages from Matrix
+async function syncChatMessages(onlyNew = false) {
+ try {
+ let endpoint = `/rooms/${window.matrixSession.roomId}/messages?dir=b&limit=50`;
+ const data = await matrixApi(endpoint);
+
+ if (!data || !data.chunk) return;
+
+ const newMessages = [];
+ for (const event of data.chunk.reverse()) {
+ if (event.type === 'm.room.message' && event.content.msgtype === 'm.text') {
+ const msgId = event.event_id;
+ const exists = chatMode.messages.find(m => m.id === msgId);
+
+ if (!exists) {
+ const timestamp = new Date(event.origin_server_ts);
+ const time = timestamp.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+
+ // Get display name for sender
+ const displayName = await getDisplayName(event.sender);
+
+ newMessages.push({
+ id: msgId,
+ time: time,
+ sender: displayName,
+ userId: event.sender,
+ text: event.content.body
+ });
+ }
+ }
+ }
+
+ if (newMessages.length > 0) {
+ chatMode.messages.push(...newMessages);
+
+ // Only keep last 100 messages in memory
+ if (chatMode.messages.length > 100) {
+ chatMode.messages = chatMode.messages.slice(-100);
+ }
+
+ // Render new messages if in chat mode and only updating
+ if (onlyNew && chatMode.active) {
+ newMessages.forEach(msg => {
+ renderChatMessage(msg);
+ });
+ } else if (!onlyNew && chatMode.active) {
+ // Initial load - show last 20 messages (simple print, no cursor manipulation)
+ const recent = chatMode.messages.slice(-20);
+ recent.forEach(msg => {
+ const color = getUserColor(msg.sender);
+ term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${msg.text}`);
+ });
+ }
+ }
+
+ chatMode.lastSync = Date.now();
+ } catch (error) {
+ console.error('Sync error:', error);
+ }
+}
+
+// Render a chat message (insert above the prompt area)
+function renderChatMessage(msg) {
+ const color = getUserColor(msg.sender);
+
+ // Move up to separator line and clear it and the prompt line
+ term.write('\x1b[1A\x1b[2K\r'); // Move up to separator, clear it
+
+ // Write the new message
+ term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${msg.text}`);
+
+ // Redraw separator
+ term.writeln('─'.repeat(term.cols || 60));
+
+ // Redraw prompt with current input
+ term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`);
+}
+
+// Render chat input prompt (efficiently)
+function renderChatPrompt() {
+ // Move to beginning of line, redraw prompt and input
+ term.write('\r\x1b[K'); // CR + clear rest of line
+ term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`);
+}
+
+// Send chat message
+async function sendChatMessage(message) {
+ if (!message.trim()) return;
+
+ try {
+ const txnId = Date.now();
+ await matrixApi(
+ `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}`,
+ 'PUT',
+ {
+ msgtype: 'm.text',
+ body: message
+ }
+ );
+
+ // Clear the input
+ chatMode.inputLine = '';
+ renderChatPrompt();
+
+ // Immediately sync to show our message
+ setTimeout(() => syncChatMessages(true), 500);
+ } catch (error) {
+ // Show error above separator
+ term.write('\x1b[1A\x1b[2K\r');
+ term.writeln(`\x1b[31mError: ${error.message}\x1b[0m`);
+ term.writeln('─'.repeat(term.cols || 60));
+ term.write(`\x1b[1;32m>\x1b[0m `);
+ }
+}
+
+// Available commands
+const commands = {
+ help: {
+ description: 'Display available commands',
+ execute: () => {
+ return [
+ '',
+ '╔════════════════════════════════════════════════════════════╗',
+ '║ AVAILABLE COMMANDS ║',
+ '╚════════════════════════════════════════════════════════════╝',
+ '',
+ ' help - Display this help message',
+ ' about - Information about this terminal',
+ ' clear - Clear the terminal screen',
+ ' echo - Echo back your message (usage: echo [message])',
+ ' date - Display current date and time',
+ ' whoami - Display current user information',
+ ' history - Show command history',
+ ' color - Change terminal color scheme',
+ ' banner - Display welcome banner',
+ ' bluesky - Fetch recent posts from Bluesky',
+ ' chat - Enter interactive chat (type /quit to exit)',
+ ' github - Visit GitHub repository',
+ ' contact - Display contact information',
+ ' privacy - Display privacy policy',
+ '',
+ 'Navigate: Use ↑/↓ arrows for command history',
+ 'Mouse: Click to position cursor, scroll to view history',
+ ''
+ ].join('\r\n');
+ }
+ },
+ about: {
+ description: 'About this terminal',
+ execute: () => {
+ return [
+ '',
+ '╔════════════════════════════════════════════════════════════╗',
+ '║ LIT.RUV.WTF TERMINAL ║',
+ '╚════════════════════════════════════════════════════════════╝',
+ '',
+ ' A classic terminal interface built with xterm.js',
+ ' Features: Keyboard navigation, Mouse support, CRT effects',
+ ' Version: ' + VERSION,
+ ' Built: ' + new Date().getFullYear(),
+ '',
+ ' Technologies:',
+ ' • xterm.js - Terminal emulator',
+ ' • JavaScript - Terminal logic',
+ ' • CSS3 - Classic CRT styling',
+ ''
+ ].join('\r\n');
+ }
+ },
+ clear: {
+ description: 'Clear terminal screen',
+ execute: () => {
+ term.clear();
+ return null;
+ }
+ },
+ echo: {
+ description: 'Echo back message',
+ execute: (args) => {
+ return args.join(' ') || '';
+ }
+ },
+ date: {
+ description: 'Display current date and time',
+ execute: () => {
+ const now = new Date();
+ return [
+ '',
+ ' ' + now.toLocaleDateString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ }),
+ ' ' + now.toLocaleTimeString('en-US'),
+ ''
+ ].join('\r\n');
+ }
+ },
+ whoami: {
+ description: 'Display user information',
+ execute: () => {
+ return [
+ '',
+ ' User: visitor@lit.ruv.wtf',
+ ' Session: ' + Date.now(),
+ ' Terminal: xterm.js',
+ ''
+ ].join('\r\n');
+ }
+ },
+ history: {
+ description: 'Show command history',
+ execute: () => {
+ if (commandHistory.length === 0) {
+ return '\r\n No command history yet.\r\n';
+ }
+ let output = ['\r\n Command History:', ' ───────────────'];
+ commandHistory.forEach((cmd, idx) => {
+ output.push(` ${(idx + 1).toString().padStart(3, ' ')} ${cmd}`);
+ });
+ output.push('');
+ return output.join('\r\n');
+ }
+ },
+ color: {
+ description: 'Change color scheme',
+ execute: (args) => {
+ const scheme = args[0] || '';
+ const schemes = {
+ green: { bg: '#001800', fg: '#00ff00', border: '#0f0' },
+ amber: { bg: '#1a0f00', fg: '#ffb000', border: '#ffb000' },
+ blue: { bg: '#000818', fg: '#00a0ff', border: '#00a0ff' },
+ white: { bg: '#0a0a0a', fg: '#e0e0e0', border: '#999' }
+ };
+
+ if (!scheme || !schemes[scheme]) {
+ return [
+ '',
+ ' Available color schemes:',
+ ' • green - Classic green terminal',
+ ' • amber - Amber monochrome',
+ ' • blue - IBM blue',
+ ' • white - White phosphor',
+ '',
+ ' Usage: color [scheme]',
+ ''
+ ].join('\r\n');
+ }
+
+ const colors = schemes[scheme];
+ term.options.theme.background = colors.bg;
+ term.options.theme.foreground = colors.fg;
+ document.querySelector('.container').style.borderColor = colors.border;
+ document.querySelector('.container').style.background = colors.bg;
+ document.body.style.color = colors.fg;
+
+ return `\r\n Color scheme changed to: ${scheme}\r\n`;
+ }
+ },
+ banner: {
+ description: 'Display welcome banner',
+ execute: () => {
+ return getWelcomeBanner();
+ }
+ },
+ github: {
+ description: 'Open GitHub repository',
+ execute: () => {
+ return '\r\n Opening GitHub...\r\n (This would open your repository URL)\r\n';
+ }
+ },
+ contact: {
+ description: 'Contact information',
+ execute: () => {
+ return [
+ '',
+ ' Contact Information:',
+ ' ────────────────────',
+ ' Email: contact@lit.ruv.wtf',
+ ' Web: https://lit.ruv.wtf',
+ ''
+ ].join('\r\n');
+ }
+ },
+ privacy: {
+ description: 'Privacy policy',
+ execute: () => {
+ return [
+ '',
+ '╔════════════════════════════════════════════════════════════╗',
+ '║ PRIVACY POLICY ║',
+ '╚════════════════════════════════════════════════════════════╝',
+ '',
+ ' Data Collection:',
+ ' ────────────────',
+ ' • This terminal uses localStorage to save your chat session',
+ ' • Chat messages are stored on our Matrix homeserver',
+ ' • No cookies or tracking scripts are used',
+ ' • No analytics or third-party tracking',
+ '',
+ ' Matrix Chat:',
+ ' ────────────',
+ ' • Chat credentials stored locally in your browser',
+ ' • Messages sent through Matrix protocol (b.ruv.wtf)',
+ ' • Use "chat disconnect" to clear stored credentials',
+ '',
+ ' Your Rights:',
+ ' ────────────',
+ ' • Clear localStorage anytime via browser settings',
+ ' • Request data deletion: contact@lit.ruv.wtf',
+ ' • All code is open source and auditable',
+ '',
+ ' Updates: Privacy policy last updated March 2026',
+ ''
+ ].join('\r\n');
+ }
+ },
+ bluesky: {
+ description: 'Fetch recent posts from Bluesky',
+ execute: async (args) => {
+ const actor = 'lit.mates.dev';
+ const limit = args[0] ? parseInt(args[0]) : 5;
+
+ try {
+ term.writeln('\r\n Fetching posts from Bluesky...\r\n');
+
+ const response = await fetch(
+ `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${actor}&limit=${limit}`
+ );
+
+ if (!response.ok) {
+ return ' Error: Unable to fetch Bluesky posts';
+ }
+
+ const data = await response.json();
+
+ if (!data.feed || data.feed.length === 0) {
+ return ' No posts found.';
+ }
+
+ let output = [' ╔════════════════════════════════════════════════════╗'];
+ output.push(' ║ BLUESKY POSTS - @lit.mates.dev ║');
+ output.push(' ╚════════════════════════════════════════════════════╝');
+ output.push('');
+
+ // Reverse to show oldest first, latest at bottom
+ const reversedFeed = [...data.feed].reverse();
+
+ reversedFeed.forEach((item, idx) => {
+ const post = item.post;
+ const text = post.record.text;
+ const createdAt = new Date(post.record.createdAt);
+ const date = createdAt.toLocaleDateString();
+ const time = createdAt.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+
+ // Extract post ID from URI (at://did:plc:xxx/app.bsky.feed.post/{postId})
+ const postId = post.uri.split('/').pop();
+ const postUrl = `https://bsky.app/profile/${actor}/post/${postId}`;
+
+ output.push(` [${idx + 1}] ${date} ${time}`);
+ output.push(` ${postUrl}`);
+ output.push(' ────────────────────────────────────────');
+
+ // Wrap text to max 50 chars
+ const words = text.split(' ');
+ let line = ' ';
+ words.forEach(word => {
+ if (line.length + word.length + 1 > 52) {
+ output.push(line);
+ line = ' ' + word;
+ } else {
+ line += (line.length > 2 ? ' ' : '') + word;
+ }
+ });
+ if (line.length > 2) output.push(line);
+
+ output.push('');
+ output.push(` ♡ ${post.likeCount || 0} ↻ ${post.repostCount || 0} 💬 ${post.replyCount || 0}`);
+ output.push('');
+ });
+
+ output.push(` Usage: bluesky [count] (default: 5, max: 20)`);
+ output.push('');
+
+ return output.join('\r\n');
+ } catch (error) {
+ return ' Error: Failed to connect to Bluesky API';
+ }
+ }
+ },
+ chat: {
+ description: 'Connect to chat room',
+ execute: async (args) => {
+ const homeserver = 'https://b.ruv.wtf';
+ const roomAlias = '#generalchat:b.ruv.wtf';
+
+ // Generate UUID
+ const generateUUID = () => {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = Math.random() * 16 | 0;
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+ };
+
+ // Check if user is providing credentials
+ let username = null;
+ let password = null;
+ let subcommand = args[0];
+ let subcommandArgs = args.slice(1);
+
+ // If first arg is not a known subcommand and second arg exists, treat as credentials
+ if (args[0] && args[1] && args[0] !== 'send' && args[0] !== 'disconnect' && isNaN(parseInt(args[0]))) {
+ username = args[0];
+ password = args[1];
+ subcommand = args[2];
+ subcommandArgs = args.slice(3);
+ }
+
+ // Initialize Matrix session
+ if (!window.matrixSession) {
+ window.matrixSession = {
+ accessToken: localStorage.getItem('matrix_access_token'),
+ userId: localStorage.getItem('matrix_user_id'),
+ deviceId: localStorage.getItem('matrix_device_id'),
+ roomId: localStorage.getItem('matrix_room_id'),
+ username: localStorage.getItem('matrix_username'),
+ password: localStorage.getItem('matrix_password')
+ };
+ }
+
+ // Register/login user if not logged in
+ if (!window.matrixSession.accessToken) {
+ try {
+ // Use provided credentials or generate new ones
+ if (!username && window.matrixSession.username && window.matrixSession.password) {
+ username = window.matrixSession.username;
+ password = window.matrixSession.password;
+ } else if (!username) {
+ username = generateUUID();
+ password = generateUUID();
+ }
+
+ term.writeln('\r\n Connecting to chat server...\r\n');
+
+ // Try to register - first attempt to get auth flows
+ let regData = await matrixApi('/register', 'POST', {
+ username: username,
+ password: password
+ });
+
+ // If we need to complete auth flow (dummy auth)
+ if (regData.flows && !regData.access_token) {
+ term.writeln(' Completing registration...\r\n');
+ regData = await matrixApi('/register', 'POST', {
+ auth: {
+ type: 'm.login.dummy',
+ session: regData.session
+ },
+ username: username,
+ password: password
+ });
+ }
+
+ if (regData.access_token) {
+ window.matrixSession.accessToken = regData.access_token;
+ window.matrixSession.userId = regData.user_id;
+ window.matrixSession.deviceId = regData.device_id;
+ window.matrixSession.username = username;
+ window.matrixSession.password = password;
+
+ localStorage.setItem('matrix_access_token', regData.access_token);
+ localStorage.setItem('matrix_user_id', regData.user_id);
+ localStorage.setItem('matrix_device_id', regData.device_id);
+ localStorage.setItem('matrix_username', username);
+ localStorage.setItem('matrix_password', password);
+
+ term.writeln(` Registered as: @${username}:b.ruv.wtf\r\n`);
+ } else if (regData.errcode === 'M_USER_IN_USE') {
+ // Username exists, try to login
+ term.writeln(' Username exists, logging in...\r\n');
+ const loginData = await matrixApi('/login', 'POST', {
+ type: 'm.login.password',
+ identifier: {
+ type: 'm.id.user',
+ user: username
+ },
+ password: password
+ });
+
+ if (loginData.access_token) {
+ window.matrixSession.accessToken = loginData.access_token;
+ window.matrixSession.userId = loginData.user_id;
+ window.matrixSession.deviceId = loginData.device_id;
+ window.matrixSession.username = username;
+ window.matrixSession.password = password;
+
+ localStorage.setItem('matrix_access_token', loginData.access_token);
+ localStorage.setItem('matrix_user_id', loginData.user_id);
+ localStorage.setItem('matrix_device_id', loginData.device_id);
+ localStorage.setItem('matrix_username', username);
+ localStorage.setItem('matrix_password', password);
+
+ term.writeln(` Logged in as: ${loginData.user_id}\r\n`);
+ } else {
+ return ` Error: Failed to login - ${loginData.error || loginData.errcode || 'Unknown error'}`;
+ }
+ } else {
+ return ` Error: Failed to register account - ${regData.error || regData.errcode || 'Unknown error'}`;
+ }
+ } catch (error) {
+ console.error('Chat error:', error);
+ return ` Error: Could not connect to chat server - ${error.message}`;
+ }
+ }
+
+ // Join room if not already joined
+ if (!window.matrixSession.roomId) {
+ try {
+ term.writeln(' Joining #generalchat...\r\n');
+
+ // Try joining directly with the room alias
+ const joinData = await matrixApi(`/join/${encodeURIComponent(roomAlias)}`, 'POST', {});
+
+ if (joinData && joinData.room_id) {
+ window.matrixSession.roomId = joinData.room_id;
+ localStorage.setItem('matrix_room_id', joinData.room_id);
+ } else if (joinData && joinData.errcode) {
+ // If direct join fails, try resolving alias first then joining by room ID
+ term.writeln(' Trying alternate join method...\r\n');
+
+ const resolveData = await matrixApi(`/directory/room/${encodeURIComponent(roomAlias)}`, 'GET');
+
+ if (!resolveData || !resolveData.room_id) {
+ return ` Error: Could not find room - ${resolveData?.error || resolveData?.errcode || 'Room not found'}`;
+ }
+
+ const roomId = resolveData.room_id;
+
+ // Try POST to /join/{roomId}
+ const joinData2 = await matrixApi(`/join/${encodeURIComponent(roomId)}`, 'POST', {});
+
+ if (joinData2 && joinData2.room_id) {
+ window.matrixSession.roomId = joinData2.room_id;
+ localStorage.setItem('matrix_room_id', joinData2.room_id);
+ } else if (joinData2 && joinData2.errcode) {
+ return ` Error: Failed to join room - ${joinData2.error || joinData2.errcode}`;
+ } else {
+ window.matrixSession.roomId = roomId;
+ localStorage.setItem('matrix_room_id', roomId);
+ }
+ } else {
+ // Empty response might still be success - try to get room ID from alias
+ const resolveData = await matrixApi(`/directory/room/${encodeURIComponent(roomAlias)}`, 'GET');
+ if (resolveData && resolveData.room_id) {
+ window.matrixSession.roomId = resolveData.room_id;
+ localStorage.setItem('matrix_room_id', resolveData.room_id);
+ } else {
+ return ' Error: Failed to join room - Could not determine room ID';
+ }
+ }
+ } catch (error) {
+ console.error('Room join error:', error);
+ return ` Error: Could not join chat room - ${error.message}`;
+ }
+ }
+
+ // Handle subcommands
+ if (subcommand === 'disconnect') {
+ // Exit chat mode if active
+ if (chatMode.active) {
+ if (chatMode.pollInterval) {
+ clearInterval(chatMode.pollInterval);
+ }
+ chatMode.active = false;
+ chatMode.displayNames = {}; // Clear display name cache
+ term.clear();
+ }
+
+ localStorage.removeItem('matrix_access_token');
+ localStorage.removeItem('matrix_user_id');
+ localStorage.removeItem('matrix_device_id');
+ localStorage.removeItem('matrix_room_id');
+ localStorage.removeItem('matrix_username');
+ localStorage.removeItem('matrix_password');
+ window.matrixSession = null;
+ return '\r\n Disconnected from chat\r\n';
+ } else {
+ // Enter interactive chat mode (default)
+ await enterChatMode();
+ return null;
+ }
+ }
+ }
+};
+
+// Welcome banner - full size (62 chars wide)
+const welcomeBannerFull = [
+ '',
+ '╔════════════════════════════════════════════════════════════╗',
+ '║ ║',
+ '║ ██╗ ██╗████████╗ ██████╗ ██╗ ██╗██╗ ██╗ ║',
+ '║ ██║ ██║╚══██╔══╝ ██╔══██╗██║ ██║██║ ██║ ║',
+ '║ ██║ ██║ ██║ ██████╔╝██║ ██║██║ ██║ ║',
+ '║ ██║ ██║ ██║ ██╗ ██╔══██╗██║ ██║╚██╗ ██╔╝ ║',
+ '║ ███████╗██║ ██║ ╚═╝ ██║ ██║╚██████╔╝ ╚████╔╝ ║',
+ '║ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═══╝ ║',
+ '║ ██╗ ██╗████████╗███████╗ ║',
+ '║ ██║ ██║╚══██╔══╝██╔════╝ ║',
+ '║ ██║ █╗ ██║ ██║ █████╗ ║',
+ '║ ██║███╗██║ ██║ ██╔══╝ ║',
+ '║ ╚███╔███╔╝ ██║ ██║ ║',
+ '║ ╚══╝╚══╝ ╚═╝ ╚═╝ ║',
+ '║ ║',
+ '╚════════════════════════════════════════════════════════════╝',
+ '',
+ ' Type "help" for available commands.',
+ ' Use ↑/↓ arrows to navigate command history.',
+ ''
+].join('\r\n');
+
+// Welcome banner - compact (40 chars wide)
+const welcomeBannerCompact = [
+ '',
+ '╔══════════════════════════════════════╗',
+ `║ LIT.RUV.WTF TERMINAL v${VERSION} ║`,
+ '╚══════════════════════════════════════╝',
+ '',
+ ' Welcome! Type "help" for commands.',
+ ''
+].join('\r\n');
+
+// Welcome banner - minimal (25 chars wide)
+const welcomeBannerMinimal = [
+ '',
+ ' ═══════════════════════',
+ ' LIT.RUV.WTF TERMINAL',
+ ' ═══════════════════════',
+ '',
+ ' Type "help"',
+ ''
+].join('\r\n');
+
+// Get appropriate banner based on terminal width
+function getWelcomeBanner() {
+ const cols = term.cols;
+ if (cols >= 62) {
+ return welcomeBannerFull;
+ } else if (cols >= 40) {
+ return welcomeBannerCompact;
+ } else {
+ return welcomeBannerMinimal;
+ }
+}
+
+// Prompt
+const prompt = '\r\n\x1b[1;32muser@lit.ruv.wtf\x1b[0m $ ';
+
+// Initialize terminal
+function init() {
+ term.writeln(getWelcomeBanner());
+ term.write(prompt);
+
+ // Handle input
+ term.onData(data => {
+ handleInput(data);
+ });
+}
+
+// Handle terminal input
+async function handleInput(data) {
+ const code = data.charCodeAt(0);
+
+ // Chat mode input handling
+ if (chatMode.active) {
+ if (code === 13) { // Enter
+ const msg = chatMode.inputLine.trim();
+ chatMode.inputLine = '';
+
+ // Check for quit command
+ if (msg === '/quit' || msg === '/exit') {
+ exitChatMode();
+ return;
+ }
+
+ // Check for help command
+ if (msg === '/help') {
+ // Insert help above separator
+ term.write('\x1b[1A\x1b[2K\r'); // Move up 1 line, clear
+ term.writeln('\x1b[33mChat Commands:\x1b[0m');
+ term.writeln(' /help - Show this help message');
+ term.writeln(' /nick [name] - Change your display name');
+ term.writeln(' /quit - Exit chat mode');
+ term.writeln('─'.repeat(term.cols || 60));
+ term.write(`\x1b[1;32m>\x1b[0m `);
+ return;
+ }
+
+ // Check for nick command
+ if (msg.startsWith('/nick ')) {
+ const newNick = msg.substring(6).trim();
+ term.write('\x1b[1A\x1b[2K\r'); // Move up 1 line, clear
+ if (!newNick) {
+ term.writeln('\x1b[31mError: /nick [name]\x1b[0m');
+ } else {
+ try {
+ // Check if nickname is already taken
+ const isTaken = await isDisplayNameTaken(newNick);
+ if (isTaken) {
+ term.writeln('\x1b[31mError: Nickname already in use\x1b[0m');
+ } else {
+ // Attempt to change nickname
+ const encodedUserId = encodeURIComponent(window.matrixSession.userId);
+ const putResponse = await matrixApi(`/profile/${encodedUserId}/displayname`, 'PUT', {
+ displayname: newNick
+ });
+
+ // Check if PUT request returned an error
+ if (putResponse && putResponse.errcode) {
+ term.writeln(`\x1b[31mError: ${putResponse.error || 'Nickname rejected by server'}\x1b[0m`);
+ } else {
+ // Verify the change was accepted by fetching it back
+ const verifyData = await matrixApi(`/profile/${encodedUserId}/displayname`, 'GET');
+ const actualName = verifyData && verifyData.displayname;
+
+ if (actualName === newNick) {
+ // Success - update local cache
+ chatMode.displayNames[window.matrixSession.userId] = newNick;
+ term.writeln(`\x1b[32mNickname changed to: ${newNick}\x1b[0m`);
+ } else {
+ // Backend silently changed or rejected it
+ if (actualName) {
+ term.writeln(`\x1b[31mError: Server changed nickname to: ${actualName}\x1b[0m`);
+ } else {
+ term.writeln(`\x1b[31mError: Nickname rejected by server (invalid format)\x1b[0m`);
+ }
+ }
+ }
+ }
+ } catch (error) {
+ term.writeln(`\x1b[31mError: Failed to change nickname - ${error.message}\x1b[0m`);
+ }
+ }
+ term.writeln('─'.repeat(term.cols || 60));
+ term.write(`\x1b[1;32m>\x1b[0m `);
+ return;
+ }
+
+ // Send message
+ if (msg && !msg.startsWith('/')) {
+ await sendChatMessage(msg);
+ } else if (msg.startsWith('/')) {
+ term.write('\x1b[1A\x1b[2K\r'); // Move up 1 line, clear
+ term.writeln(`\x1b[31mUnknown command: ${msg.split(' ')[0]}. Type /help\x1b[0m`);
+ term.writeln('─'.repeat(term.cols || 60));
+ term.write(`\x1b[1;32m>\x1b[0m `);
+ } else {
+ // Empty input, just redraw prompt
+ renderChatPrompt();
+ }
+ } else if (code === 127) { // Backspace
+ if (chatMode.inputLine.length > 0) {
+ chatMode.inputLine = chatMode.inputLine.slice(0, -1);
+ term.write('\b \b'); // Move back, write space, move back again
+ }
+ } else if (code === 4) { // Ctrl+D
+ exitChatMode();
+ } else if (code >= 32 && code < 127) { // Printable characters
+ chatMode.inputLine += data;
+ term.write(data); // Just write the character directly
+ }
+ return;
+ }
+
+ // Normal mode input handling
+ // Handle special keys
+ if (code === 13) { // Enter
+ term.write('\r\n');
+ await executeCommand(currentLine.trim());
+ currentLine = '';
+ cursorPosition = 0;
+ // Only show prompt if not in chat mode (chat command enters chat mode)
+ if (!chatMode.active) {
+ term.write(prompt);
+ }
+ } else if (code === 127) { // Backspace
+ if (cursorPosition > 0) {
+ currentLine = currentLine.slice(0, cursorPosition - 1) + currentLine.slice(cursorPosition);
+ cursorPosition--;
+ term.write('\b \b');
+ // Redraw rest of line if needed
+ if (cursorPosition < currentLine.length) {
+ const remaining = currentLine.slice(cursorPosition);
+ term.write(remaining + ' ');
+ for (let i = 0; i <= remaining.length; i++) {
+ term.write('\b');
+ }
+ }
+ }
+ } else if (code === 27) { // Escape sequences (arrows)
+ if (data === '\x1b[A') { // Up arrow
+ navigateHistory(-1);
+ } else if (data === '\x1b[B') { // Down arrow
+ navigateHistory(1);
+ } else if (data === '\x1b[C') { // Right arrow
+ if (cursorPosition < currentLine.length) {
+ cursorPosition++;
+ term.write('\x1b[C');
+ }
+ } else if (data === '\x1b[D') { // Left arrow
+ if (cursorPosition > 0) {
+ cursorPosition--;
+ term.write('\x1b[D');
+ }
+ } else if (data === '\x1b[H') { // Home
+ while (cursorPosition > 0) {
+ cursorPosition--;
+ term.write('\x1b[D');
+ }
+ } else if (data === '\x1b[F') { // End
+ while (cursorPosition < currentLine.length) {
+ cursorPosition++;
+ term.write('\x1b[C');
+ }
+ }
+ } else if (code === 3) { // Ctrl+C
+ term.write('^C\r\n');
+ currentLine = '';
+ cursorPosition = 0;
+ term.write(prompt);
+ } else if (code === 12) { // Ctrl+L
+ term.clear();
+ term.write(prompt + currentLine);
+ const backspaces = currentLine.length - cursorPosition;
+ for (let i = 0; i < backspaces; i++) {
+ term.write('\b');
+ }
+ } else if (code >= 32 && code < 127) { // Printable characters
+ currentLine = currentLine.slice(0, cursorPosition) + data + currentLine.slice(cursorPosition);
+ cursorPosition++;
+ term.write(data);
+ // Redraw rest of line if in middle
+ if (cursorPosition < currentLine.length) {
+ const remaining = currentLine.slice(cursorPosition);
+ term.write(remaining);
+ for (let i = 0; i < remaining.length; i++) {
+ term.write('\b');
+ }
+ }
+ }
+}
+
+// Navigate command history
+function navigateHistory(direction) {
+ if (commandHistory.length === 0) return;
+
+ historyIndex += direction;
+
+ if (historyIndex < 0) {
+ historyIndex = -1;
+ clearCurrentLine();
+ currentLine = '';
+ cursorPosition = 0;
+ } else if (historyIndex >= commandHistory.length) {
+ historyIndex = commandHistory.length - 1;
+ } else {
+ clearCurrentLine();
+ currentLine = commandHistory[historyIndex];
+ cursorPosition = currentLine.length;
+ term.write(currentLine);
+ }
+}
+
+// Clear current line
+function clearCurrentLine() {
+ // Move to start of input
+ for (let i = 0; i < cursorPosition; i++) {
+ term.write('\b');
+ }
+ // Clear line
+ for (let i = 0; i < currentLine.length; i++) {
+ term.write(' ');
+ }
+ // Move back to start
+ for (let i = 0; i < currentLine.length; i++) {
+ term.write('\b');
+ }
+}
+
+// Execute command
+async function executeCommand(input) {
+ if (!input) return;
+
+ // Add to history
+ commandHistory.push(input);
+ historyIndex = commandHistory.length;
+
+ // Parse command
+ const parts = input.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+
+ // Execute command
+ if (commands[cmd]) {
+ const output = await commands[cmd].execute(args);
+ if (output !== null && output !== undefined) {
+ term.writeln(output);
+ }
+ } else {
+ term.writeln(`\r\n Command not found: ${cmd}`);
+ term.writeln(' Type "help" for available commands.\r\n');
+ }
+}
+
+// Initialize when loaded
+init();
diff --git a/website/viewport-scroll-fix.js b/website/viewport-scroll-fix.js
deleted file mode 100644
index 4a8ad38..0000000
--- a/website/viewport-scroll-fix.js
+++ /dev/null
@@ -1,50 +0,0 @@
-(function() {
- let scrollPosition = 0;
- let isResizing = false;
- let resizeTimeout;
-
- function preserveScroll() {
- if (!isResizing) {
- scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
- isResizing = true;
-
- // Temporarily disable smooth scrolling during resize
- document.documentElement.style.scrollBehavior = 'auto';
- }
-
- clearTimeout(resizeTimeout);
- resizeTimeout = setTimeout(() => {
- // Force restore scroll position multiple times to combat layout changes
- const restoreScroll = () => {
- window.scrollTo(0, scrollPosition);
- };
-
- restoreScroll();
- requestAnimationFrame(restoreScroll);
- setTimeout(restoreScroll, 10);
- setTimeout(restoreScroll, 50);
-
- setTimeout(() => {
- // Re-enable smooth scrolling
- document.documentElement.style.scrollBehavior = 'smooth';
- isResizing = false;
- }, 100);
- }, 100);
- }
-
- // Listen for resize events
- window.addEventListener('resize', preserveScroll);
-
- // Listen for orientation changes
- window.addEventListener('orientationchange', () => {
- preserveScroll();
- // Additional delay for orientation changes
- setTimeout(() => {
- window.scrollTo(0, scrollPosition);
- }, 500);
- });
-
- // Also intercept media query changes that affect layout
- const mediaQuery = window.matchMedia('(max-width: 799px)');
- mediaQuery.addListener(preserveScroll);
-})();