retroterm 0.1.0
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/README.md +125 -0
- package/assets/systems/Atari 2600.png +0 -0
- package/assets/systems/Atari 5200.png +0 -0
- package/assets/systems/Atari 7800.png +0 -0
- package/assets/systems/Atari 800.png +0 -0
- package/assets/systems/ColecoVision.png +0 -0
- package/assets/systems/Game Boy Advance.png +0 -0
- package/assets/systems/Game Boy Color.png +0 -0
- package/assets/systems/Game Boy.png +0 -0
- package/assets/systems/Game Gear.png +0 -0
- package/assets/systems/Genesis.png +0 -0
- package/assets/systems/Lynx.png +0 -0
- package/assets/systems/Master System.png +0 -0
- package/assets/systems/NES.png +0 -0
- package/assets/systems/Neo Geo Pocket Color.png +0 -0
- package/assets/systems/Neo Geo Pocket.png +0 -0
- package/assets/systems/PC Engine.png +0 -0
- package/assets/systems/PlayStation.png +0 -0
- package/assets/systems/SG-1000.png +0 -0
- package/assets/systems/SNES.png +0 -0
- package/assets/systems/Vectrex.png +0 -0
- package/assets/systems/WonderSwan Color.png +0 -0
- package/assets/systems/WonderSwan.png +0 -0
- package/assets/systems/ZX Spectrum.png +0 -0
- package/bin/cli.js +6 -0
- package/index.js +3 -0
- package/package.json +46 -0
- package/src/Launcher.js +880 -0
- package/src/Preferences.js +56 -0
- package/src/RomScanner.js +149 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# retroterm
|
|
2
|
+
|
|
3
|
+
Terminal-based retro game launcher. A blessed TUI frontend for [retroemu](https://github.com/monteslu/retroemu).
|
|
4
|
+
|
|
5
|
+
- **ROM browser** — Scans your ROMs directory and organizes by system
|
|
6
|
+
- **Recent games** — Quick access to recently played games
|
|
7
|
+
- **Keyboard navigation** — vim-style controls
|
|
8
|
+
- **Preferences** — Configurable ROMs and saves directories
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
retroterm
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g retroterm
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This will also install `retroemu` as a dependency.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
Just run `retroterm` to launch the browser:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
retroterm
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
On first run, press `S` to configure your ROMs directory.
|
|
31
|
+
|
|
32
|
+
### Controls
|
|
33
|
+
|
|
34
|
+
| Key | Action |
|
|
35
|
+
|-----|--------|
|
|
36
|
+
| `Enter` | Launch selected game |
|
|
37
|
+
| `j` / `k` or arrows | Navigate list |
|
|
38
|
+
| `A` | Show all ROMs |
|
|
39
|
+
| `R` | Show recent games |
|
|
40
|
+
| `S` | Settings |
|
|
41
|
+
| `F5` | Refresh ROM list |
|
|
42
|
+
| `Q` | Quit |
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
Settings are stored in `~/.config/retroterm/config.json`:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"romsDir": "/home/user/roms",
|
|
51
|
+
"savesDir": "/home/user/.config/retroterm/saves",
|
|
52
|
+
"recentGames": [],
|
|
53
|
+
"maxRecent": 10
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Supported Systems
|
|
58
|
+
|
|
59
|
+
retroterm supports all systems that retroemu supports:
|
|
60
|
+
|
|
61
|
+
- **Nintendo** — NES, SNES, Game Boy, Game Boy Color, Game Boy Advance
|
|
62
|
+
- **Sega** — Genesis, Master System, Game Gear, SG-1000
|
|
63
|
+
- **Atari** — 2600, 5200, 7800, 800/XL/XE, Lynx
|
|
64
|
+
- **NEC** — TurboGrafx-16 / PC Engine
|
|
65
|
+
- **SNK** — Neo Geo Pocket, Neo Geo Pocket Color
|
|
66
|
+
- **Bandai** — WonderSwan, WonderSwan Color
|
|
67
|
+
- **Other** — ColecoVision, Vectrex, ZX Spectrum, MSX
|
|
68
|
+
|
|
69
|
+
## Streaming / Remote Play
|
|
70
|
+
|
|
71
|
+
retroterm can stream games over the network using a custom binary protocol optimized for terminal output.
|
|
72
|
+
|
|
73
|
+
### Recommended Settings
|
|
74
|
+
|
|
75
|
+
| Terminal Size | Virtual Resolution | Bandwidth (30 FPS) |
|
|
76
|
+
|---------------|-------------------|-------------------|
|
|
77
|
+
| 60 rows | 160×120 | ~600 KB/s (4.8 Mbps) |
|
|
78
|
+
| 120 rows | 320×240 (PS1 native) | ~2.3 MB/s (18 Mbps) |
|
|
79
|
+
|
|
80
|
+
### Render Modes
|
|
81
|
+
|
|
82
|
+
| Mode | Quality | Bandwidth | Best For |
|
|
83
|
+
|------|---------|-----------|----------|
|
|
84
|
+
| `half-block-256` | Chunky pixels | Lowest | Retro games (default) |
|
|
85
|
+
| `block-256` | Smooth shading | Medium | When detail matters |
|
|
86
|
+
| `ascii-256` | Textured | Medium | Aesthetic preference |
|
|
87
|
+
| `braille` | Dot matrix | Tiny | Monochrome games |
|
|
88
|
+
|
|
89
|
+
### Custom Binary Protocol
|
|
90
|
+
|
|
91
|
+
Instead of ANSI escape codes (~15 bytes/cell), use raw color indices:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
[fg_index][bg_index][fg_index][bg_index]... (2 bytes/cell)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Decode on client:
|
|
98
|
+
```javascript
|
|
99
|
+
output += `\x1b[38;5;${data[i]};48;5;${data[i+1]}m▀`;
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**10x bandwidth reduction** vs standard ANSI output.
|
|
103
|
+
|
|
104
|
+
### Architecture Options
|
|
105
|
+
|
|
106
|
+
**Option A: WebRTC P2P** (current)
|
|
107
|
+
- Best latency, works peer-to-peer
|
|
108
|
+
- Requires signaling server for NAT traversal
|
|
109
|
+
|
|
110
|
+
**Option B: Simple WebSocket relay**
|
|
111
|
+
- Easier to deploy (single server)
|
|
112
|
+
- Works through all firewalls
|
|
113
|
+
- node-datachannel for optional WebRTC upgrade
|
|
114
|
+
|
|
115
|
+
## Dependencies
|
|
116
|
+
|
|
117
|
+
| Package | Purpose |
|
|
118
|
+
|---------|---------|
|
|
119
|
+
| [retroemu](https://github.com/monteslu/retroemu) | Terminal emulator engine with libretro WASM cores |
|
|
120
|
+
| [blessed](https://github.com/chjj/blessed) | Terminal UI library |
|
|
121
|
+
| [chafa-wasm](https://github.com/monteslu/chafa-wasm) | Image-to-ANSI conversion (SIMD-optimized fork) |
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/bin/cli.js
ADDED
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "retroterm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Terminal-based retro game launcher with ROM browser and box art",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"retroterm": "./bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"assets/",
|
|
14
|
+
"index.js"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"emulator",
|
|
18
|
+
"retro",
|
|
19
|
+
"terminal",
|
|
20
|
+
"launcher",
|
|
21
|
+
"nes",
|
|
22
|
+
"snes",
|
|
23
|
+
"gameboy",
|
|
24
|
+
"genesis",
|
|
25
|
+
"blessed",
|
|
26
|
+
"tui"
|
|
27
|
+
],
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/monteslu/retroterm.git"
|
|
31
|
+
},
|
|
32
|
+
"author": "Luis Montes",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22.0.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"blessed": "^0.1.81",
|
|
39
|
+
"@monteslu/chafa-wasm": "^0.4.0",
|
|
40
|
+
"gamepad-node": "^1.3.1",
|
|
41
|
+
"hsync": "^0.33.0",
|
|
42
|
+
"retroemu": "^0.1.1",
|
|
43
|
+
"sharp": "^0.34.5",
|
|
44
|
+
"yauzl": "^3.2.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/Launcher.js
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
import blessed from 'blessed';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { installNavigatorShim } from 'gamepad-node';
|
|
7
|
+
import { Preferences } from './Preferences.js';
|
|
8
|
+
import { RomScanner } from './RomScanner.js';
|
|
9
|
+
import initChafa from '@monteslu/chafa-wasm';
|
|
10
|
+
import sharp from 'sharp';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const ASSETS_DIR = join(__dirname, '..', 'assets', 'systems');
|
|
14
|
+
|
|
15
|
+
// Chafa constants
|
|
16
|
+
const CHAFA_SYMBOL_TAG_SPACE = 0x1;
|
|
17
|
+
const CHAFA_SYMBOL_TAG_BLOCK = 0x8;
|
|
18
|
+
const CHAFA_CANVAS_MODE_TRUECOLOR = 0;
|
|
19
|
+
|
|
20
|
+
export class Launcher {
|
|
21
|
+
constructor() {
|
|
22
|
+
this.prefs = new Preferences();
|
|
23
|
+
this.screen = null;
|
|
24
|
+
this.romList = null;
|
|
25
|
+
this.statusBar = null;
|
|
26
|
+
this.controllerBox = null;
|
|
27
|
+
this.systemArtBox = null;
|
|
28
|
+
this.chafa = null;
|
|
29
|
+
this.roms = [];
|
|
30
|
+
this.sortedRoms = null;
|
|
31
|
+
this.systems = []; // Available systems
|
|
32
|
+
this.systemRoms = {}; // ROMs grouped by system
|
|
33
|
+
this.currentSystemIndex = 0;
|
|
34
|
+
this._lastSelected = 0;
|
|
35
|
+
this.currentView = 'system'; // 'system' or 'recent'
|
|
36
|
+
this.gamepadManager = null;
|
|
37
|
+
this.gamepadPollInterval = null;
|
|
38
|
+
this._settingsOpen = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async start() {
|
|
42
|
+
// Initialize chafa for image rendering
|
|
43
|
+
if (!this.chafa) {
|
|
44
|
+
this.chafa = await initChafa();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Initialize gamepad support
|
|
48
|
+
this.gamepadManager = installNavigatorShim();
|
|
49
|
+
|
|
50
|
+
this.screen = blessed.screen({
|
|
51
|
+
smartCSR: true,
|
|
52
|
+
title: 'retroterm',
|
|
53
|
+
fullUnicode: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this._createUI();
|
|
57
|
+
this._loadRoms();
|
|
58
|
+
this._setupKeys();
|
|
59
|
+
this._startControllerPolling();
|
|
60
|
+
|
|
61
|
+
this.screen.render();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_createUI() {
|
|
65
|
+
// System logo box (horizontal banner)
|
|
66
|
+
this.systemArtBox = blessed.box({
|
|
67
|
+
top: 0,
|
|
68
|
+
left: 0,
|
|
69
|
+
width: 40,
|
|
70
|
+
height: 5,
|
|
71
|
+
tags: true,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// System info (right of logo)
|
|
75
|
+
this.systemTitle = blessed.box({
|
|
76
|
+
top: 0,
|
|
77
|
+
left: 40,
|
|
78
|
+
width: '100%-40',
|
|
79
|
+
height: 5,
|
|
80
|
+
tags: true,
|
|
81
|
+
padding: { left: 1, top: 1 },
|
|
82
|
+
content: '{bold}{cyan-fg}RETROTERM{/}',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ROM list
|
|
86
|
+
this.romList = blessed.list({
|
|
87
|
+
top: 5,
|
|
88
|
+
left: 0,
|
|
89
|
+
width: '50%',
|
|
90
|
+
bottom: 3,
|
|
91
|
+
border: { type: 'line' },
|
|
92
|
+
style: {
|
|
93
|
+
border: { fg: 'blue' },
|
|
94
|
+
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
95
|
+
item: { fg: 'white' },
|
|
96
|
+
},
|
|
97
|
+
keys: true,
|
|
98
|
+
vi: true,
|
|
99
|
+
mouse: true,
|
|
100
|
+
scrollbar: {
|
|
101
|
+
ch: ' ',
|
|
102
|
+
style: { bg: 'blue' },
|
|
103
|
+
},
|
|
104
|
+
tags: true,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Info panel (for game details and eventually cover art)
|
|
108
|
+
this.infoPanel = blessed.box({
|
|
109
|
+
top: 0,
|
|
110
|
+
right: 0,
|
|
111
|
+
width: '50%',
|
|
112
|
+
bottom: 8,
|
|
113
|
+
border: { type: 'line' },
|
|
114
|
+
style: { border: { fg: 'green' } },
|
|
115
|
+
tags: true,
|
|
116
|
+
padding: { left: 1, right: 1 },
|
|
117
|
+
content: '{gray-fg}Select a game{/}',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Controller status
|
|
121
|
+
this.controllerBox = blessed.box({
|
|
122
|
+
right: 0,
|
|
123
|
+
bottom: 3,
|
|
124
|
+
width: '50%',
|
|
125
|
+
height: 5,
|
|
126
|
+
border: { type: 'line' },
|
|
127
|
+
style: { border: { fg: 'magenta' } },
|
|
128
|
+
tags: true,
|
|
129
|
+
padding: { left: 1, right: 1 },
|
|
130
|
+
content: '{gray-fg}Scanning controllers...{/}',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Status bar
|
|
134
|
+
this.statusBar = blessed.box({
|
|
135
|
+
bottom: 0,
|
|
136
|
+
left: 0,
|
|
137
|
+
width: '100%',
|
|
138
|
+
height: 3,
|
|
139
|
+
border: { type: 'line' },
|
|
140
|
+
style: { border: { fg: 'gray' } },
|
|
141
|
+
tags: true,
|
|
142
|
+
content: ' {bold}A{/} Play {bold}←→{/} System {bold}↑↓{/} Browse {bold}LB/RB{/} Page {bold}X{/} Recent {bold}Y{/} Settings {bold}Q{/} Quit',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
this.screen.append(this.systemArtBox);
|
|
146
|
+
this.screen.append(this.systemTitle);
|
|
147
|
+
this.screen.append(this.romList);
|
|
148
|
+
this.screen.append(this.infoPanel);
|
|
149
|
+
this.screen.append(this.controllerBox);
|
|
150
|
+
this.screen.append(this.statusBar);
|
|
151
|
+
|
|
152
|
+
this.romList.focus();
|
|
153
|
+
|
|
154
|
+
// Update info panel on selection
|
|
155
|
+
this.romList.on('select item', (item, index) => {
|
|
156
|
+
this._lastSelected = index;
|
|
157
|
+
this._updateInfoPanel(index);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async _loadRoms() {
|
|
162
|
+
const romsDir = this.prefs.get('romsDir');
|
|
163
|
+
|
|
164
|
+
if (!existsSync(romsDir)) {
|
|
165
|
+
this.romList.setItems(['{yellow-fg}ROMs directory not found{/}', '', `{gray-fg}${romsDir}{/}`, '', '{white-fg}Press S to configure{/}']);
|
|
166
|
+
this.roms = [];
|
|
167
|
+
this.screen.render();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.romList.setItems(['{cyan-fg}Scanning ROMs...{/}']);
|
|
172
|
+
this.screen.render();
|
|
173
|
+
|
|
174
|
+
const scanner = new RomScanner(romsDir);
|
|
175
|
+
this.roms = await scanner.scan();
|
|
176
|
+
|
|
177
|
+
if (this.roms.length === 0) {
|
|
178
|
+
this.romList.setItems(['{yellow-fg}No ROMs found{/}', '', `{gray-fg}${romsDir}{/}`, '', '{white-fg}Press S to configure{/}']);
|
|
179
|
+
} else {
|
|
180
|
+
this._groupRomsBySystem();
|
|
181
|
+
this._showCurrentSystem();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this.screen.render();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_groupRomsBySystem() {
|
|
188
|
+
// Group by system, then alphabetize within each group
|
|
189
|
+
this.systemRoms = {};
|
|
190
|
+
for (const rom of this.roms) {
|
|
191
|
+
if (!this.systemRoms[rom.system]) {
|
|
192
|
+
this.systemRoms[rom.system] = [];
|
|
193
|
+
}
|
|
194
|
+
this.systemRoms[rom.system].push(rom);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Sort systems alphabetically, sort ROMs within each system
|
|
198
|
+
this.systems = Object.keys(this.systemRoms).sort();
|
|
199
|
+
for (const system of this.systems) {
|
|
200
|
+
this.systemRoms[system].sort((a, b) => a.name.localeCompare(b.name));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
_showCurrentSystem() {
|
|
205
|
+
this.currentView = 'system';
|
|
206
|
+
|
|
207
|
+
if (this.systems.length === 0) return;
|
|
208
|
+
|
|
209
|
+
const system = this.systems[this.currentSystemIndex];
|
|
210
|
+
const roms = this.systemRoms[system];
|
|
211
|
+
this.sortedRoms = roms;
|
|
212
|
+
|
|
213
|
+
const items = roms.map(rom => rom.name);
|
|
214
|
+
this.romList.setItems(items);
|
|
215
|
+
this.romList.select(0);
|
|
216
|
+
|
|
217
|
+
// Update system title
|
|
218
|
+
this.systemTitle.setContent(
|
|
219
|
+
`{bold}{yellow-fg}${system}{/}\n` +
|
|
220
|
+
`{white-fg}${roms.length} games{/} {gray-fg}[${this.currentSystemIndex + 1}/${this.systems.length}]{/}`
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Render system art (async, will update when done)
|
|
224
|
+
this._renderSystemArt(system);
|
|
225
|
+
|
|
226
|
+
this._updateInfoPanel(0);
|
|
227
|
+
this.screen.render();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async _renderSystemArt(system) {
|
|
231
|
+
const artPath = join(ASSETS_DIR, `${system}.png`);
|
|
232
|
+
if (!existsSync(artPath) || !this.chafa) {
|
|
233
|
+
this.systemArtBox.setContent('');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
// Decode image with sharp
|
|
239
|
+
const { data, info } = await sharp(artPath)
|
|
240
|
+
.raw()
|
|
241
|
+
.ensureAlpha()
|
|
242
|
+
.toBuffer({ resolveWithObject: true });
|
|
243
|
+
|
|
244
|
+
const chafa = this.chafa;
|
|
245
|
+
|
|
246
|
+
// Set up chafa with block characters
|
|
247
|
+
const symbolMap = chafa._chafa_symbol_map_new();
|
|
248
|
+
chafa._chafa_symbol_map_add_by_tags(symbolMap, CHAFA_SYMBOL_TAG_SPACE | CHAFA_SYMBOL_TAG_BLOCK);
|
|
249
|
+
|
|
250
|
+
const canvasConfig = chafa._chafa_canvas_config_new();
|
|
251
|
+
chafa._chafa_canvas_config_set_geometry(canvasConfig, 36, 6); // 3 rows with half-blocks
|
|
252
|
+
chafa._chafa_canvas_config_set_canvas_mode(canvasConfig, CHAFA_CANVAS_MODE_TRUECOLOR);
|
|
253
|
+
chafa._chafa_canvas_config_set_symbol_map(canvasConfig, symbolMap);
|
|
254
|
+
|
|
255
|
+
const canvas = chafa._chafa_canvas_new(canvasConfig);
|
|
256
|
+
|
|
257
|
+
// Copy RGBA data to heap
|
|
258
|
+
const dataPtr = chafa._malloc(data.length);
|
|
259
|
+
chafa.HEAPU8.set(data, dataPtr);
|
|
260
|
+
|
|
261
|
+
// Set canvas contents
|
|
262
|
+
chafa._chafa_canvas_set_contents_rgba8(canvas, dataPtr, info.width, info.height, info.width * 4);
|
|
263
|
+
chafa._free(dataPtr);
|
|
264
|
+
|
|
265
|
+
// Get ANSI output
|
|
266
|
+
const gsPtr = chafa._chafa_canvas_build_ansi(canvas);
|
|
267
|
+
const strPtr = chafa._g_string_free_and_steal(gsPtr);
|
|
268
|
+
const ansi = chafa.UTF8ToString(strPtr);
|
|
269
|
+
chafa._free(strPtr);
|
|
270
|
+
|
|
271
|
+
// Cleanup
|
|
272
|
+
chafa._chafa_canvas_unref(canvas);
|
|
273
|
+
chafa._chafa_canvas_config_unref(canvasConfig);
|
|
274
|
+
chafa._chafa_symbol_map_unref(symbolMap);
|
|
275
|
+
|
|
276
|
+
// Trim any trailing newlines to prevent clipping
|
|
277
|
+
this.systemArtBox.setContent(ansi.trimEnd());
|
|
278
|
+
this.screen.render();
|
|
279
|
+
} catch (err) {
|
|
280
|
+
this.systemArtBox.setContent('');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_showRecentRoms() {
|
|
285
|
+
this.currentView = 'recent';
|
|
286
|
+
const recent = this.prefs.get('recentGames');
|
|
287
|
+
const recentRoms = recent
|
|
288
|
+
.map(path => this.roms.find(r => r.path === path))
|
|
289
|
+
.filter(r => r);
|
|
290
|
+
|
|
291
|
+
this.sortedRoms = recentRoms;
|
|
292
|
+
|
|
293
|
+
if (recentRoms.length === 0) {
|
|
294
|
+
this.romList.setItems(['{gray-fg}No recent games{/}']);
|
|
295
|
+
this.sortedRoms = [];
|
|
296
|
+
} else {
|
|
297
|
+
const items = recentRoms.map(rom => `{cyan-fg}[${rom.system}]{/} ${rom.name}`);
|
|
298
|
+
this.romList.setItems(items);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Update header for recent view
|
|
302
|
+
this.systemTitle.setContent(
|
|
303
|
+
`{bold}{magenta-fg}Recent Games{/}\n` +
|
|
304
|
+
`{white-fg}${recentRoms.length} games{/}`
|
|
305
|
+
);
|
|
306
|
+
this.systemArtBox.setContent('');
|
|
307
|
+
|
|
308
|
+
this.romList.select(0);
|
|
309
|
+
this._updateInfoPanel(0);
|
|
310
|
+
this.screen.render();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
_updateInfoPanel(index) {
|
|
314
|
+
const rom = this._getSelectedRom(index);
|
|
315
|
+
if (!rom) {
|
|
316
|
+
this.infoPanel.setContent('{gray-fg}Select a game{/}');
|
|
317
|
+
this.screen.render();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const lines = [
|
|
322
|
+
`{bold}{white-fg}${rom.name}{/}`,
|
|
323
|
+
'',
|
|
324
|
+
`{cyan-fg}System:{/} ${rom.system}`,
|
|
325
|
+
`{cyan-fg}File:{/} ${rom.ext}`,
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
if (rom.zipEntry) {
|
|
329
|
+
lines.push(`{cyan-fg}In ZIP:{/} ${rom.zipEntry}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
lines.push('', `{gray-fg}${rom.path}{/}`);
|
|
333
|
+
|
|
334
|
+
this.infoPanel.setContent(lines.join('\n'));
|
|
335
|
+
this.screen.render();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
_getSelectedRom(index) {
|
|
339
|
+
return this.sortedRoms ? this.sortedRoms[index] : null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_setupKeys() {
|
|
343
|
+
// Quit
|
|
344
|
+
this.screen.key(['q', 'C-c'], () => {
|
|
345
|
+
process.exit(0);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Launch game
|
|
349
|
+
this.romList.key(['enter'], () => {
|
|
350
|
+
const index = this.romList.selected;
|
|
351
|
+
const rom = this._getSelectedRom(index);
|
|
352
|
+
if (rom) {
|
|
353
|
+
this._launchGame(rom);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// View toggles
|
|
358
|
+
this.screen.key(['a'], () => {
|
|
359
|
+
this._showCurrentSystem();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Left/Right arrow keys for system navigation
|
|
363
|
+
this.screen.key(['left'], () => {
|
|
364
|
+
this._navigatePrevSystem();
|
|
365
|
+
});
|
|
366
|
+
this.screen.key(['right'], () => {
|
|
367
|
+
this._navigateNextSystem();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
this.screen.key(['r'], () => {
|
|
371
|
+
this._showRecentRoms();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Settings
|
|
375
|
+
this.screen.key(['s'], () => {
|
|
376
|
+
this._showSettings();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Refresh
|
|
380
|
+
this.screen.key(['f5'], () => {
|
|
381
|
+
this._loadRoms();
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
_launchGame(rom) {
|
|
386
|
+
this.prefs.addRecentGame(rom.path);
|
|
387
|
+
|
|
388
|
+
// Stop controller polling and clean up
|
|
389
|
+
this._stopControllerPolling();
|
|
390
|
+
if (this.gamepadManager && this.gamepadManager.destroy) {
|
|
391
|
+
try {
|
|
392
|
+
this.gamepadManager.destroy();
|
|
393
|
+
} catch {
|
|
394
|
+
// Ignore cleanup errors
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Hide the screen and launch retroemu
|
|
399
|
+
this.screen.destroy();
|
|
400
|
+
|
|
401
|
+
const args = [rom.path];
|
|
402
|
+
const renderMode = this.prefs.get('renderMode');
|
|
403
|
+
if (renderMode === 'ascii') {
|
|
404
|
+
args.push('--ascii');
|
|
405
|
+
} else if (renderMode === 'braille') {
|
|
406
|
+
args.push('--braille');
|
|
407
|
+
} else if (renderMode === 'braille-dither') {
|
|
408
|
+
args.push('--braille-dither');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Convert 1-10 slider to contrast value (1=0.5, 5=1.0, 10=2.0)
|
|
412
|
+
const contrastSlider = this.prefs.get('contrast') || 5;
|
|
413
|
+
const contrastValue = 0.5 + (contrastSlider - 1) * (1.5 / 9);
|
|
414
|
+
if (contrastValue !== 1.0) {
|
|
415
|
+
args.push('--contrast', contrastValue.toFixed(2));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const retroemuBin = join(__dirname, '..', 'node_modules', '.bin', 'retroemu');
|
|
419
|
+
const child = spawn(retroemuBin, args, {
|
|
420
|
+
stdio: 'inherit',
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
child.on('close', () => {
|
|
424
|
+
// Give SDL time to clean up before reinitializing
|
|
425
|
+
setTimeout(() => this.start(), 500);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
_showSettings() {
|
|
430
|
+
this._settingsOpen = true;
|
|
431
|
+
const currentRenderMode = this.prefs.get('renderMode') || 'block';
|
|
432
|
+
|
|
433
|
+
const form = blessed.form({
|
|
434
|
+
parent: this.screen,
|
|
435
|
+
top: 'center',
|
|
436
|
+
left: 'center',
|
|
437
|
+
width: '60%',
|
|
438
|
+
height: 20,
|
|
439
|
+
border: { type: 'line' },
|
|
440
|
+
style: { border: { fg: 'yellow' } },
|
|
441
|
+
keys: true,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
blessed.text({
|
|
445
|
+
parent: form,
|
|
446
|
+
top: 0,
|
|
447
|
+
left: 2,
|
|
448
|
+
content: '{bold}Settings{/}',
|
|
449
|
+
tags: true,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
blessed.text({
|
|
453
|
+
parent: form,
|
|
454
|
+
top: 2,
|
|
455
|
+
left: 2,
|
|
456
|
+
content: 'ROMs Directory:',
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const romsInput = blessed.textbox({
|
|
460
|
+
parent: form,
|
|
461
|
+
top: 3,
|
|
462
|
+
left: 2,
|
|
463
|
+
right: 2,
|
|
464
|
+
height: 3,
|
|
465
|
+
border: { type: 'line' },
|
|
466
|
+
style: {
|
|
467
|
+
border: { fg: 'blue' },
|
|
468
|
+
focus: { border: { fg: 'green' } },
|
|
469
|
+
},
|
|
470
|
+
inputOnFocus: true,
|
|
471
|
+
value: this.prefs.get('romsDir'),
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
blessed.text({
|
|
475
|
+
parent: form,
|
|
476
|
+
top: 7,
|
|
477
|
+
left: 2,
|
|
478
|
+
content: 'Render Mode:',
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const RENDER_MODES = ['block', 'ascii', 'braille', 'braille-dither'];
|
|
482
|
+
const RENDER_MODE_LABELS = {
|
|
483
|
+
'block': 'Block characters',
|
|
484
|
+
'ascii': 'ASCII characters',
|
|
485
|
+
'braille': 'Braille (B&W)',
|
|
486
|
+
'braille-dither': 'Braille dithered',
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const renderModeBox = blessed.box({
|
|
490
|
+
parent: form,
|
|
491
|
+
top: 8,
|
|
492
|
+
left: 2,
|
|
493
|
+
width: 30,
|
|
494
|
+
height: 3,
|
|
495
|
+
border: { type: 'line' },
|
|
496
|
+
style: {
|
|
497
|
+
border: { fg: 'blue' },
|
|
498
|
+
focus: { border: { fg: 'green' } },
|
|
499
|
+
},
|
|
500
|
+
tags: true,
|
|
501
|
+
content: ` ${RENDER_MODE_LABELS[currentRenderMode] || RENDER_MODE_LABELS['block']}`,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
let selectedRenderMode = currentRenderMode;
|
|
505
|
+
|
|
506
|
+
const toggleRenderMode = () => {
|
|
507
|
+
const idx = RENDER_MODES.indexOf(selectedRenderMode);
|
|
508
|
+
selectedRenderMode = RENDER_MODES[(idx + 1) % RENDER_MODES.length];
|
|
509
|
+
renderModeBox.setContent(` ${RENDER_MODE_LABELS[selectedRenderMode]}`);
|
|
510
|
+
this.screen.render();
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
renderModeBox.on('click', toggleRenderMode);
|
|
514
|
+
|
|
515
|
+
// Contrast slider
|
|
516
|
+
blessed.text({
|
|
517
|
+
parent: form,
|
|
518
|
+
top: 11,
|
|
519
|
+
left: 2,
|
|
520
|
+
content: 'Contrast:',
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const currentContrast = this.prefs.get('contrast') || 5;
|
|
524
|
+
let selectedContrast = currentContrast;
|
|
525
|
+
|
|
526
|
+
const renderSlider = (value) => {
|
|
527
|
+
const filled = '█'.repeat(value);
|
|
528
|
+
const empty = '░'.repeat(10 - value);
|
|
529
|
+
return ` ${filled}${empty} ${value}`;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const contrastBox = blessed.box({
|
|
533
|
+
parent: form,
|
|
534
|
+
top: 12,
|
|
535
|
+
left: 2,
|
|
536
|
+
width: 20,
|
|
537
|
+
height: 3,
|
|
538
|
+
border: { type: 'line' },
|
|
539
|
+
style: {
|
|
540
|
+
border: { fg: 'blue' },
|
|
541
|
+
focus: { border: { fg: 'green' } },
|
|
542
|
+
},
|
|
543
|
+
tags: true,
|
|
544
|
+
content: renderSlider(selectedContrast),
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const adjustContrast = (delta) => {
|
|
548
|
+
selectedContrast = Math.max(1, Math.min(10, selectedContrast + delta));
|
|
549
|
+
contrastBox.setContent(renderSlider(selectedContrast));
|
|
550
|
+
this.screen.render();
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
blessed.text({
|
|
554
|
+
parent: form,
|
|
555
|
+
top: 15,
|
|
556
|
+
left: 2,
|
|
557
|
+
content: '{white-fg}↑↓{/} field {white-fg}←→{/} adjust {white-fg}X{/} toggle {white-fg}A{/} save {white-fg}B{/} cancel',
|
|
558
|
+
tags: true,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const FIELDS = ['roms', 'render', 'contrast'];
|
|
562
|
+
let focusedField = 'roms';
|
|
563
|
+
|
|
564
|
+
const updateFieldStyles = () => {
|
|
565
|
+
romsInput.style.border.fg = focusedField === 'roms' ? 'green' : 'blue';
|
|
566
|
+
renderModeBox.style.border.fg = focusedField === 'render' ? 'green' : 'blue';
|
|
567
|
+
contrastBox.style.border.fg = focusedField === 'contrast' ? 'green' : 'blue';
|
|
568
|
+
if (focusedField === 'roms') romsInput.focus();
|
|
569
|
+
this.screen.render();
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const focusField = (field) => {
|
|
573
|
+
focusedField = field;
|
|
574
|
+
updateFieldStyles();
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const focusNextField = () => {
|
|
578
|
+
const idx = FIELDS.indexOf(focusedField);
|
|
579
|
+
focusField(FIELDS[(idx + 1) % FIELDS.length]);
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const focusPrevField = () => {
|
|
583
|
+
const idx = FIELDS.indexOf(focusedField);
|
|
584
|
+
focusField(FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length]);
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
focusField('roms');
|
|
588
|
+
|
|
589
|
+
const closeForm = () => {
|
|
590
|
+
this._settingsOpen = false;
|
|
591
|
+
this._settingsState = null;
|
|
592
|
+
form.destroy();
|
|
593
|
+
this.romList.focus();
|
|
594
|
+
this.screen.render();
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const saveAndClose = () => {
|
|
598
|
+
const newPath = romsInput.getValue().trim();
|
|
599
|
+
if (newPath) {
|
|
600
|
+
this.prefs.set('romsDir', newPath);
|
|
601
|
+
}
|
|
602
|
+
this.prefs.set('renderMode', selectedRenderMode);
|
|
603
|
+
this.prefs.set('contrast', selectedContrast);
|
|
604
|
+
this._settingsOpen = false;
|
|
605
|
+
this._settingsState = null;
|
|
606
|
+
form.destroy();
|
|
607
|
+
this._loadRoms();
|
|
608
|
+
this.romList.focus();
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
// Store state for gamepad handling
|
|
612
|
+
this._settingsState = {
|
|
613
|
+
focusNextField,
|
|
614
|
+
focusPrevField,
|
|
615
|
+
toggleRenderMode,
|
|
616
|
+
adjustContrast,
|
|
617
|
+
closeForm,
|
|
618
|
+
saveAndClose,
|
|
619
|
+
getFocusedField: () => focusedField,
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
romsInput.key(['escape'], closeForm);
|
|
623
|
+
romsInput.key(['tab'], focusNextField);
|
|
624
|
+
romsInput.key(['enter'], saveAndClose);
|
|
625
|
+
|
|
626
|
+
form.key(['escape'], closeForm);
|
|
627
|
+
form.key(['tab'], focusNextField);
|
|
628
|
+
form.key(['space'], () => {
|
|
629
|
+
if (focusedField === 'render') toggleRenderMode();
|
|
630
|
+
});
|
|
631
|
+
form.key(['left'], () => {
|
|
632
|
+
if (focusedField === 'contrast') adjustContrast(-1);
|
|
633
|
+
});
|
|
634
|
+
form.key(['right'], () => {
|
|
635
|
+
if (focusedField === 'contrast') adjustContrast(1);
|
|
636
|
+
});
|
|
637
|
+
form.key(['enter'], saveAndClose);
|
|
638
|
+
|
|
639
|
+
this.screen.render();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
_navigateUp() {
|
|
643
|
+
const index = this.romList.selected - 1;
|
|
644
|
+
if (index >= 0) {
|
|
645
|
+
this.romList.select(index);
|
|
646
|
+
this._updateInfoPanel(index);
|
|
647
|
+
this.screen.render();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
_navigateDown() {
|
|
652
|
+
const index = this.romList.selected + 1;
|
|
653
|
+
const max = this.sortedRoms ? this.sortedRoms.length : 0;
|
|
654
|
+
if (index < max) {
|
|
655
|
+
this.romList.select(index);
|
|
656
|
+
this._updateInfoPanel(index);
|
|
657
|
+
this.screen.render();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
_navigatePrevSystem() {
|
|
662
|
+
if (this.currentView !== 'system' || this.systems.length === 0) return;
|
|
663
|
+
|
|
664
|
+
this.currentSystemIndex--;
|
|
665
|
+
if (this.currentSystemIndex < 0) {
|
|
666
|
+
this.currentSystemIndex = this.systems.length - 1;
|
|
667
|
+
}
|
|
668
|
+
this._showCurrentSystem();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
_navigateNextSystem() {
|
|
672
|
+
if (this.currentView !== 'system' || this.systems.length === 0) return;
|
|
673
|
+
|
|
674
|
+
this.currentSystemIndex++;
|
|
675
|
+
if (this.currentSystemIndex >= this.systems.length) {
|
|
676
|
+
this.currentSystemIndex = 0;
|
|
677
|
+
}
|
|
678
|
+
this._showCurrentSystem();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
_startControllerPolling() {
|
|
682
|
+
// Track previous button states for edge detection
|
|
683
|
+
this._prevButtons = {};
|
|
684
|
+
// Track held time for repeat
|
|
685
|
+
this._holdTime = {};
|
|
686
|
+
// Wait for all buttons to be released before accepting input (prevents re-launch on resume)
|
|
687
|
+
this._waitingForRelease = true;
|
|
688
|
+
|
|
689
|
+
this.gamepadPollInterval = setInterval(() => {
|
|
690
|
+
const gamepads = navigator.getGamepads().filter(gp => gp !== null);
|
|
691
|
+
this._updateControllerStatus(gamepads);
|
|
692
|
+
|
|
693
|
+
// Check if we're waiting for buttons to be released
|
|
694
|
+
if (this._waitingForRelease && gamepads.length > 0) {
|
|
695
|
+
const gp = gamepads[0];
|
|
696
|
+
const anyPressed = gp.buttons.some(b => b?.pressed);
|
|
697
|
+
if (!anyPressed) {
|
|
698
|
+
this._waitingForRelease = false;
|
|
699
|
+
}
|
|
700
|
+
return; // Skip input handling until released
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
this._handleGamepadInput(gamepads);
|
|
704
|
+
}, 16); // Poll at ~60Hz for responsive input
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
_stopControllerPolling() {
|
|
708
|
+
if (this.gamepadPollInterval) {
|
|
709
|
+
clearInterval(this.gamepadPollInterval);
|
|
710
|
+
this.gamepadPollInterval = null;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
_updateControllerStatus(gamepads) {
|
|
715
|
+
if (gamepads.length === 0) {
|
|
716
|
+
this.controllerBox.setContent('{gray-fg}No controllers{/}\n{gray-fg}Keyboard: arrows + Enter{/}');
|
|
717
|
+
} else {
|
|
718
|
+
const lines = gamepads.slice(0, 2).map((gp, i) => {
|
|
719
|
+
const name = gp.id.length > 24 ? gp.id.substring(0, 24) + '...' : gp.id;
|
|
720
|
+
return `{green-fg}P${i + 1}:{/} ${name}`;
|
|
721
|
+
});
|
|
722
|
+
if (gamepads.length > 2) {
|
|
723
|
+
lines.push(`{gray-fg}+${gamepads.length - 2} more{/}`);
|
|
724
|
+
}
|
|
725
|
+
this.controllerBox.setContent(lines.join('\n'));
|
|
726
|
+
}
|
|
727
|
+
this.screen.render();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
_handleGamepadInput(gamepads) {
|
|
731
|
+
if (gamepads.length === 0) return;
|
|
732
|
+
|
|
733
|
+
const now = Date.now();
|
|
734
|
+
const gp = gamepads[0]; // Use first controller for navigation
|
|
735
|
+
const buttons = gp.buttons;
|
|
736
|
+
const prev = this._prevButtons;
|
|
737
|
+
const hold = this._holdTime;
|
|
738
|
+
|
|
739
|
+
// Helper: detect button press (edge detection only)
|
|
740
|
+
const pressed = (idx) => {
|
|
741
|
+
const isPressed = buttons[idx]?.pressed;
|
|
742
|
+
const wasPressed = prev[idx];
|
|
743
|
+
prev[idx] = isPressed;
|
|
744
|
+
return isPressed && !wasPressed;
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
// Handle settings dialog input separately
|
|
748
|
+
if (this._settingsOpen && this._settingsState) {
|
|
749
|
+
const s = this._settingsState;
|
|
750
|
+
|
|
751
|
+
// B button = close/cancel
|
|
752
|
+
if (pressed(1)) {
|
|
753
|
+
s.closeForm();
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// A button or Start = save
|
|
758
|
+
if (pressed(0) || pressed(9)) {
|
|
759
|
+
s.saveAndClose();
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// D-pad up/down = switch fields
|
|
764
|
+
if (pressed(12)) {
|
|
765
|
+
s.focusPrevField();
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (pressed(13)) {
|
|
769
|
+
s.focusNextField();
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// D-pad left/right = adjust contrast (when on contrast field)
|
|
774
|
+
if (s.getFocusedField() === 'contrast') {
|
|
775
|
+
if (pressed(14)) {
|
|
776
|
+
s.adjustContrast(-1);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (pressed(15)) {
|
|
780
|
+
s.adjustContrast(1);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// X button = toggle render mode
|
|
786
|
+
if (pressed(2)) {
|
|
787
|
+
s.toggleRenderMode();
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return; // Don't process normal navigation while settings open
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Helper: detect held with repeat (initial delay 300ms, then repeat every 80ms)
|
|
795
|
+
const heldWithRepeat = (key, isActive) => {
|
|
796
|
+
if (isActive) {
|
|
797
|
+
if (!hold[key]) {
|
|
798
|
+
hold[key] = now;
|
|
799
|
+
return true; // First press
|
|
800
|
+
}
|
|
801
|
+
const elapsed = now - hold[key];
|
|
802
|
+
if (elapsed > 300) {
|
|
803
|
+
// Repeat phase - check if enough time for next repeat
|
|
804
|
+
const repeatElapsed = (elapsed - 300) % 80;
|
|
805
|
+
const prevRepeatElapsed = (elapsed - 300 - 16) % 80; // ~16ms ago
|
|
806
|
+
if (repeatElapsed < prevRepeatElapsed || elapsed - 300 < 16) {
|
|
807
|
+
return true;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return false;
|
|
811
|
+
} else {
|
|
812
|
+
delete hold[key];
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
// D-pad navigation (buttons 12-15) or left stick
|
|
818
|
+
const leftStickY = gp.axes[1] || 0;
|
|
819
|
+
const leftStickX = gp.axes[0] || 0;
|
|
820
|
+
|
|
821
|
+
const upActive = buttons[12]?.pressed || leftStickY < -0.5;
|
|
822
|
+
const downActive = buttons[13]?.pressed || leftStickY > 0.5;
|
|
823
|
+
const leftActive = buttons[14]?.pressed || leftStickX < -0.5;
|
|
824
|
+
const rightActive = buttons[15]?.pressed || leftStickX > 0.5;
|
|
825
|
+
|
|
826
|
+
if (heldWithRepeat('up', upActive)) {
|
|
827
|
+
this._navigateUp();
|
|
828
|
+
}
|
|
829
|
+
if (heldWithRepeat('down', downActive)) {
|
|
830
|
+
this._navigateDown();
|
|
831
|
+
}
|
|
832
|
+
if (heldWithRepeat('left', leftActive)) {
|
|
833
|
+
this._navigatePrevSystem();
|
|
834
|
+
}
|
|
835
|
+
if (heldWithRepeat('right', rightActive)) {
|
|
836
|
+
this._navigateNextSystem();
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Shoulder bumpers (buttons 4 and 5) = fast scroll with repeat
|
|
840
|
+
if (heldWithRepeat('lb', buttons[4]?.pressed)) {
|
|
841
|
+
for (let i = 0; i < 10; i++) this._navigateUp();
|
|
842
|
+
}
|
|
843
|
+
if (heldWithRepeat('rb', buttons[5]?.pressed)) {
|
|
844
|
+
for (let i = 0; i < 10; i++) this._navigateDown();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// A button (south/button 0) = Enter/Select
|
|
848
|
+
if (pressed(0)) {
|
|
849
|
+
const index = this.romList.selected;
|
|
850
|
+
const rom = this._getSelectedRom(index);
|
|
851
|
+
if (rom) {
|
|
852
|
+
this._launchGame(rom);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// B button (east/button 1) = Back to system view
|
|
857
|
+
if (pressed(1)) {
|
|
858
|
+
this._showCurrentSystem();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// X button (west/button 2) = Recent
|
|
862
|
+
if (pressed(2)) {
|
|
863
|
+
this._showRecentRoms();
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Y button (north/button 3) = Settings
|
|
867
|
+
if (pressed(3)) {
|
|
868
|
+
this._showSettings();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Start button (button 9) = Launch
|
|
872
|
+
if (pressed(9)) {
|
|
873
|
+
const index = this.romList.selected;
|
|
874
|
+
const rom = this._getSelectedRom(index);
|
|
875
|
+
if (rom) {
|
|
876
|
+
this._launchGame(rom);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), '.config', 'retroterm');
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
romsDir: join(homedir(), 'roms'),
|
|
10
|
+
savesDir: join(homedir(), '.config', 'retroterm', 'saves'),
|
|
11
|
+
recentGames: [],
|
|
12
|
+
maxRecent: 10,
|
|
13
|
+
renderMode: 'block', // 'block', 'ascii', 'braille', 'braille-dither'
|
|
14
|
+
contrast: 5, // 1-10, where 5 = 1.0 (normal)
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class Preferences {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.config = { ...DEFAULTS };
|
|
20
|
+
this.load();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
load() {
|
|
24
|
+
try {
|
|
25
|
+
const data = readFileSync(CONFIG_FILE, 'utf-8');
|
|
26
|
+
this.config = { ...DEFAULTS, ...JSON.parse(data) };
|
|
27
|
+
} catch {
|
|
28
|
+
// No config file yet, use defaults
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
save() {
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
35
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(this.config, null, 2));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// Ignore save errors
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get(key) {
|
|
42
|
+
return this.config[key];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
set(key, value) {
|
|
46
|
+
this.config[key] = value;
|
|
47
|
+
this.save();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
addRecentGame(romPath) {
|
|
51
|
+
const recent = this.config.recentGames.filter(p => p !== romPath);
|
|
52
|
+
recent.unshift(romPath);
|
|
53
|
+
this.config.recentGames = recent.slice(0, this.config.maxRecent);
|
|
54
|
+
this.save();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, extname, basename } from 'path';
|
|
3
|
+
import { open } from 'yauzl';
|
|
4
|
+
|
|
5
|
+
// Supported ROM extensions (from retroemu)
|
|
6
|
+
const ROM_EXTENSIONS = new Set([
|
|
7
|
+
'.nes', '.fds', '.unf', '.unif', // NES
|
|
8
|
+
'.sfc', '.smc', // SNES
|
|
9
|
+
'.gb', '.gbc', '.gba', // Game Boy
|
|
10
|
+
'.md', '.gen', '.smd', '.bin', // Genesis
|
|
11
|
+
'.sms', '.gg', '.sg', // Master System / Game Gear
|
|
12
|
+
'.a26', '.a52', '.a78', // Atari consoles
|
|
13
|
+
'.xex', '.atr', '.atx', '.bas', '.car', '.xfd', // Atari computers
|
|
14
|
+
'.lnx', '.o', // Lynx
|
|
15
|
+
'.pce', '.cue', '.ccd', '.chd', // PC Engine
|
|
16
|
+
'.ngp', '.ngc', // Neo Geo Pocket
|
|
17
|
+
'.ws', '.wsc', // WonderSwan
|
|
18
|
+
'.col', // ColecoVision
|
|
19
|
+
'.vec', // Vectrex
|
|
20
|
+
'.tzx', '.z80', '.sna', // ZX Spectrum
|
|
21
|
+
'.mx1', '.mx2', '.rom', '.dsk', '.cas', // MSX
|
|
22
|
+
'.iso', '.pbp', '.m3u', // PlayStation
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const SYSTEM_NAMES = {
|
|
26
|
+
'.nes': 'NES', '.fds': 'NES', '.unf': 'NES', '.unif': 'NES',
|
|
27
|
+
'.sfc': 'SNES', '.smc': 'SNES',
|
|
28
|
+
'.gb': 'Game Boy', '.gbc': 'Game Boy Color', '.gba': 'Game Boy Advance',
|
|
29
|
+
'.md': 'Genesis', '.gen': 'Genesis', '.smd': 'Genesis', '.bin': 'Genesis',
|
|
30
|
+
'.sms': 'Master System', '.gg': 'Game Gear', '.sg': 'SG-1000',
|
|
31
|
+
'.a26': 'Atari 2600', '.a52': 'Atari 5200', '.a78': 'Atari 7800',
|
|
32
|
+
'.xex': 'Atari 800', '.atr': 'Atari 800', '.atx': 'Atari 800',
|
|
33
|
+
'.bas': 'Atari 800', '.car': 'Atari 800', '.xfd': 'Atari 800',
|
|
34
|
+
'.lnx': 'Lynx', '.o': 'Lynx',
|
|
35
|
+
'.pce': 'PC Engine', '.cue': 'PC Engine', '.ccd': 'PC Engine', '.chd': 'PC Engine',
|
|
36
|
+
'.ngp': 'Neo Geo Pocket', '.ngc': 'Neo Geo Pocket Color',
|
|
37
|
+
'.ws': 'WonderSwan', '.wsc': 'WonderSwan Color',
|
|
38
|
+
'.col': 'ColecoVision',
|
|
39
|
+
'.vec': 'Vectrex',
|
|
40
|
+
'.tzx': 'ZX Spectrum', '.z80': 'ZX Spectrum', '.sna': 'ZX Spectrum',
|
|
41
|
+
'.mx1': 'MSX', '.mx2': 'MSX', '.rom': 'MSX', '.dsk': 'MSX', '.cas': 'MSX',
|
|
42
|
+
'.iso': 'PlayStation', '.pbp': 'PlayStation', '.m3u': 'PlayStation',
|
|
43
|
+
'.zip': 'Archive',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export class RomScanner {
|
|
47
|
+
constructor(romsDir) {
|
|
48
|
+
this.romsDir = romsDir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async scan() {
|
|
52
|
+
const roms = [];
|
|
53
|
+
await this._scanDir(this.romsDir, roms);
|
|
54
|
+
return roms.sort((a, b) => a.name.localeCompare(b.name));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async _scanDir(dir, roms, depth = 0) {
|
|
58
|
+
if (depth > 3) return; // Don't recurse too deep
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const entries = readdirSync(dir);
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (entry.startsWith('.')) continue;
|
|
64
|
+
|
|
65
|
+
const fullPath = join(dir, entry);
|
|
66
|
+
try {
|
|
67
|
+
const stat = statSync(fullPath);
|
|
68
|
+
if (stat.isDirectory()) {
|
|
69
|
+
await this._scanDir(fullPath, roms, depth + 1);
|
|
70
|
+
} else if (stat.isFile()) {
|
|
71
|
+
const ext = extname(entry).toLowerCase();
|
|
72
|
+
if (ext === '.zip') {
|
|
73
|
+
// Peek inside ZIP to find ROMs
|
|
74
|
+
const zipRoms = await this._scanZip(fullPath);
|
|
75
|
+
roms.push(...zipRoms);
|
|
76
|
+
} else if (ROM_EXTENSIONS.has(ext)) {
|
|
77
|
+
roms.push({
|
|
78
|
+
name: basename(entry, ext),
|
|
79
|
+
path: fullPath,
|
|
80
|
+
ext,
|
|
81
|
+
system: SYSTEM_NAMES[ext] || 'Unknown',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Skip inaccessible files
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Skip inaccessible directories
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async _scanZip(zipPath) {
|
|
95
|
+
const roms = [];
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const zipfile = await new Promise((resolve, reject) => {
|
|
99
|
+
open(zipPath, { lazyEntries: true }, (err, zf) => {
|
|
100
|
+
if (err) reject(err);
|
|
101
|
+
else resolve(zf);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const zipName = basename(zipPath, extname(zipPath));
|
|
106
|
+
|
|
107
|
+
await new Promise((resolve) => {
|
|
108
|
+
zipfile.on('entry', (entry) => {
|
|
109
|
+
const ext = extname(entry.fileName).toLowerCase();
|
|
110
|
+
if (ROM_EXTENSIONS.has(ext) && !entry.fileName.startsWith('__MACOSX')) {
|
|
111
|
+
let romName = basename(entry.fileName, ext);
|
|
112
|
+
// If the inner file has a generic/numeric name, use the zip filename instead
|
|
113
|
+
if (/^\d+$/.test(romName) || romName.length < 3) {
|
|
114
|
+
romName = zipName;
|
|
115
|
+
}
|
|
116
|
+
roms.push({
|
|
117
|
+
name: romName,
|
|
118
|
+
path: zipPath,
|
|
119
|
+
zipEntry: entry.fileName,
|
|
120
|
+
ext,
|
|
121
|
+
system: SYSTEM_NAMES[ext] || 'Unknown',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
zipfile.readEntry();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
zipfile.on('end', resolve);
|
|
128
|
+
zipfile.readEntry();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
zipfile.close();
|
|
132
|
+
} catch {
|
|
133
|
+
// Skip unreadable ZIPs
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return roms;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getSystemGroups(roms) {
|
|
140
|
+
const groups = {};
|
|
141
|
+
for (const rom of roms) {
|
|
142
|
+
if (!groups[rom.system]) {
|
|
143
|
+
groups[rom.system] = [];
|
|
144
|
+
}
|
|
145
|
+
groups[rom.system].push(rom);
|
|
146
|
+
}
|
|
147
|
+
return groups;
|
|
148
|
+
}
|
|
149
|
+
}
|