@seflless/ghosttown 1.1.3 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ascii.js +158 -0
- package/bin/ghosttown.js +40 -20
- package/package.json +1 -1
package/bin/ascii.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { PNG } from 'pngjs';
|
|
3
|
+
|
|
4
|
+
// Access sync API from PNG
|
|
5
|
+
const PNGSync = PNG.sync;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Converts RGB color to ANSI 256-color code.
|
|
9
|
+
* Uses the standard 256-color palette mapping.
|
|
10
|
+
*/
|
|
11
|
+
function rgbToAnsi256(r, g, b) {
|
|
12
|
+
// Standard 16 colors (0-15) are handled specially
|
|
13
|
+
// Colors 16-231 are a 6x6x6 color cube
|
|
14
|
+
// Colors 232-255 are grayscale
|
|
15
|
+
|
|
16
|
+
// If color is very close to grayscale, use grayscale palette (232-255)
|
|
17
|
+
const gray = Math.round((r + g + b) / 3);
|
|
18
|
+
const grayDiff = Math.abs(r - g) + Math.abs(g - b) + Math.abs(b - r);
|
|
19
|
+
|
|
20
|
+
if (grayDiff < 10) {
|
|
21
|
+
// Use grayscale palette (232-255)
|
|
22
|
+
const grayIndex = Math.round((gray / 255) * 23);
|
|
23
|
+
return 232 + Math.max(0, Math.min(23, grayIndex));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Use color cube (16-231)
|
|
27
|
+
// Each component is mapped to 0-5 (6 levels)
|
|
28
|
+
const rIndex = Math.round((r / 255) * 5);
|
|
29
|
+
const gIndex = Math.round((g / 255) * 5);
|
|
30
|
+
const bIndex = Math.round((b / 255) * 5);
|
|
31
|
+
|
|
32
|
+
// Color cube formula: 16 + 36*r + 6*g + b
|
|
33
|
+
return 16 + 36 * rIndex + 6 * gIndex + bIndex;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generates ANSI escape code for foreground color using 256-color palette.
|
|
38
|
+
*/
|
|
39
|
+
function ansiColorCode(color256) {
|
|
40
|
+
return `\x1b[38;5;${color256}m`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resets ANSI color formatting.
|
|
45
|
+
*/
|
|
46
|
+
function ansiReset() {
|
|
47
|
+
return '\x1b[0m';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Solid block character for opaque pixels.
|
|
52
|
+
* Using FULL BLOCK (U+2588) which fills the character cell.
|
|
53
|
+
*/
|
|
54
|
+
const SOLID_CHAR = '█';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Converts an image file to ASCII art with ANSI colors.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} imagePath - Path to the image file (PNG supported)
|
|
60
|
+
* @param {Object} options - Optional configuration
|
|
61
|
+
* @param {number} [options.maxWidth=80] - Maximum width in characters
|
|
62
|
+
* @param {number} [options.maxHeight=24] - Maximum height in characters
|
|
63
|
+
* @param {number} [options.transparentThreshold=128] - Alpha threshold below which pixel is considered transparent
|
|
64
|
+
* @returns {Promise<string>} ASCII art string with ANSI color codes
|
|
65
|
+
*/
|
|
66
|
+
export async function asciiArt(imagePath, options = {}) {
|
|
67
|
+
const {
|
|
68
|
+
maxWidth = 80,
|
|
69
|
+
maxHeight = 24,
|
|
70
|
+
transparentThreshold = 128, // Alpha values below this are considered transparent
|
|
71
|
+
} = options;
|
|
72
|
+
|
|
73
|
+
// Read image file
|
|
74
|
+
const imageData = fs.readFileSync(imagePath);
|
|
75
|
+
|
|
76
|
+
// Parse PNG
|
|
77
|
+
const png = PNGSync.read(imageData);
|
|
78
|
+
const { width, height, data } = png;
|
|
79
|
+
|
|
80
|
+
// Calculate scaling to fit within max dimensions while preserving aspect ratio
|
|
81
|
+
const scaleX = maxWidth / width;
|
|
82
|
+
const scaleY = maxHeight / height;
|
|
83
|
+
const scale = Math.min(scaleX, scaleY, 1); // Don't upscale
|
|
84
|
+
|
|
85
|
+
const outputWidth = Math.max(1, Math.floor(width * scale));
|
|
86
|
+
const outputHeight = Math.max(1, Math.floor(height * scale));
|
|
87
|
+
|
|
88
|
+
const result = [];
|
|
89
|
+
|
|
90
|
+
// Process each output row
|
|
91
|
+
for (let outY = 0; outY < outputHeight; outY++) {
|
|
92
|
+
let line = '';
|
|
93
|
+
let lastColor = null;
|
|
94
|
+
|
|
95
|
+
// Process each output column
|
|
96
|
+
for (let outX = 0; outX < outputWidth; outX++) {
|
|
97
|
+
// Map output coordinates back to source image
|
|
98
|
+
const srcX = Math.floor((outX / outputWidth) * width);
|
|
99
|
+
const srcY = Math.floor((outY / outputHeight) * height);
|
|
100
|
+
|
|
101
|
+
// Get pixel data (RGBA format)
|
|
102
|
+
const idx = (srcY * width + srcX) * 4;
|
|
103
|
+
const r = data[idx];
|
|
104
|
+
const g = data[idx + 1];
|
|
105
|
+
const b = data[idx + 2];
|
|
106
|
+
const a = data[idx + 3];
|
|
107
|
+
|
|
108
|
+
// Handle transparent pixels - render as space
|
|
109
|
+
if (a < transparentThreshold) {
|
|
110
|
+
line += ' ';
|
|
111
|
+
lastColor = null;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Solid pixels - use block character with color
|
|
116
|
+
// Convert RGB to ANSI 256-color
|
|
117
|
+
const color256 = rgbToAnsi256(r, g, b);
|
|
118
|
+
|
|
119
|
+
// Only add color code if it changed from previous pixel
|
|
120
|
+
if (lastColor !== color256) {
|
|
121
|
+
if (lastColor !== null) {
|
|
122
|
+
line += ansiReset();
|
|
123
|
+
}
|
|
124
|
+
line += ansiColorCode(color256);
|
|
125
|
+
lastColor = color256;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
line += SOLID_CHAR;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Reset color at end of line
|
|
132
|
+
if (lastColor !== null) {
|
|
133
|
+
line += ansiReset();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
result.push(line);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Ensure we always end with a reset to clear any color state
|
|
140
|
+
return result.join('\n') + ansiReset();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// If running directly, execute the function
|
|
144
|
+
const isMainModule =
|
|
145
|
+
import.meta.url === `file://${process.argv[1]}` ||
|
|
146
|
+
process.argv[1]?.endsWith('/ascii.js');
|
|
147
|
+
|
|
148
|
+
if (isMainModule) {
|
|
149
|
+
const imagePath = process.argv[2] || 'demo/images/ghosts.png';
|
|
150
|
+
asciiArt(imagePath)
|
|
151
|
+
.then((art) => {
|
|
152
|
+
console.log(art);
|
|
153
|
+
})
|
|
154
|
+
.catch((err) => {
|
|
155
|
+
console.error('Error generating ASCII art:', err);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
});
|
|
158
|
+
}
|
package/bin/ghosttown.js
CHANGED
|
@@ -29,6 +29,8 @@ import { fileURLToPath } from 'url';
|
|
|
29
29
|
import pty from '@lydell/node-pty';
|
|
30
30
|
// WebSocket server
|
|
31
31
|
import { WebSocketServer } from 'ws';
|
|
32
|
+
// ASCII art generator
|
|
33
|
+
import { asciiArt } from './ascii.js';
|
|
32
34
|
|
|
33
35
|
const __filename = fileURLToPath(import.meta.url);
|
|
34
36
|
const __dirname = path.dirname(__filename);
|
|
@@ -643,36 +645,39 @@ function getLocalIPs() {
|
|
|
643
645
|
|
|
644
646
|
function printBanner(url) {
|
|
645
647
|
const localIPs = getLocalIPs();
|
|
646
|
-
//
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
const
|
|
651
|
-
[' Open:', url],
|
|
652
|
-
[' Network:', ''],
|
|
653
|
-
[' Shell:', getShell()],
|
|
654
|
-
[' Home:', homedir()],
|
|
655
|
-
];
|
|
648
|
+
// ANSI color codes
|
|
649
|
+
const RESET = '\x1b[0m';
|
|
650
|
+
const CYAN = '\x1b[36m'; // Labels
|
|
651
|
+
const BEIGE = '\x1b[38;2;255;220;150m'; // Warm yellow/beige for values (RGB: 255,220,150)
|
|
652
|
+
const DIM = '\x1b[2m'; // Dimmed text
|
|
656
653
|
|
|
657
|
-
console.log(
|
|
654
|
+
console.log('');
|
|
658
655
|
|
|
659
|
-
|
|
656
|
+
// Open: URL
|
|
657
|
+
console.log(` ${CYAN}Open:${RESET} ${BEIGE}${url}${RESET}`);
|
|
660
658
|
|
|
659
|
+
// Network: URLs
|
|
660
|
+
const network = [` ${CYAN}Network: ${RESET}`];
|
|
661
661
|
if (localIPs.length > 0) {
|
|
662
662
|
let networkCount = 0;
|
|
663
663
|
for (const ip of localIPs) {
|
|
664
664
|
networkCount++;
|
|
665
665
|
const spaces = networkCount !== 1 ? ' ' : '';
|
|
666
|
-
network.push(`${spaces}http://${ip}:${HTTP_PORT}\n`);
|
|
666
|
+
network.push(`${spaces}${BEIGE}http://${ip}:${HTTP_PORT}${RESET}\n`);
|
|
667
667
|
}
|
|
668
668
|
}
|
|
669
|
-
console.log(`\n${network.join('')}`);
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
669
|
+
console.log(`\n${network.join('')} `);
|
|
670
|
+
|
|
671
|
+
// Shell: path
|
|
672
|
+
console.log(` ${CYAN}Shell:${RESET} ${BEIGE}${getShell()}${RESET}`);
|
|
673
|
+
|
|
674
|
+
console.log('');
|
|
675
|
+
|
|
676
|
+
// Home: path
|
|
677
|
+
console.log(` ${CYAN}Home:${RESET} ${BEIGE}${homedir()}${RESET}`);
|
|
678
|
+
|
|
674
679
|
console.log('');
|
|
675
|
-
console.log(
|
|
680
|
+
console.log(` ${DIM}Press Ctrl+C to stop.${RESET}\n`);
|
|
676
681
|
}
|
|
677
682
|
|
|
678
683
|
process.on('SIGINT', () => {
|
|
@@ -685,6 +690,21 @@ process.on('SIGINT', () => {
|
|
|
685
690
|
process.exit(0);
|
|
686
691
|
});
|
|
687
692
|
|
|
688
|
-
httpServer.listen(HTTP_PORT, '0.0.0.0', () => {
|
|
693
|
+
httpServer.listen(HTTP_PORT, '0.0.0.0', async () => {
|
|
694
|
+
// Display ASCII art banner
|
|
695
|
+
try {
|
|
696
|
+
const imagePath = path.join(__dirname, '..', 'demo', 'images', 'ghosts.png');
|
|
697
|
+
if (fs.existsSync(imagePath)) {
|
|
698
|
+
// Welcome text with orange/yellow color (bright yellow, bold)
|
|
699
|
+
console.log('\n \x1b[1;93mWelcome to Ghosttown!\x1b[0m\n');
|
|
700
|
+
const art = await asciiArt(imagePath, { maxWidth: 80, maxHeight: 20 });
|
|
701
|
+
console.log(art);
|
|
702
|
+
console.log('');
|
|
703
|
+
}
|
|
704
|
+
} catch (err) {
|
|
705
|
+
// Silently fail if ASCII art can't be displayed
|
|
706
|
+
// This allows the server to start even if the image is missing
|
|
707
|
+
}
|
|
708
|
+
|
|
689
709
|
printBanner(`http://localhost:${HTTP_PORT}`);
|
|
690
710
|
});
|