@muhammedaksam/opentui-doom 0.3.0 → 0.3.6
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 +37 -13
- package/package.json +12 -5
- package/src/debug.ts +3 -3
- package/src/doom-audio.ts +146 -146
- package/src/doom-engine.ts +299 -287
- package/src/doom-input.ts +178 -167
- package/src/doom-mouse.ts +129 -0
- package/src/doom-saves.ts +60 -60
- package/src/index.ts +58 -25
package/src/doom-saves.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Save Game Manager for OpenTUI-DOOM
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Handles persistence of DOOM save games to ~/.opentui-doom/
|
|
5
5
|
* DOOM uses 6 save slots (0-5) with files named doomsav{N}.dsg
|
|
6
6
|
*/
|
|
@@ -20,28 +20,28 @@ const SAVE_FILE_PATTERN = /^doomsav([0-5])\.dsg$/;
|
|
|
20
20
|
* Ensure the save directory exists
|
|
21
21
|
*/
|
|
22
22
|
export function ensureSaveDir(): void {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
if (!existsSync(SAVE_DIR)) {
|
|
24
|
+
mkdirSync(SAVE_DIR, { recursive: true });
|
|
25
|
+
debugLog("Saves", `Created save directory: ${SAVE_DIR}`);
|
|
26
|
+
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Get the save game directory path
|
|
31
31
|
*/
|
|
32
32
|
export function getSaveGameDir(): string {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
ensureSaveDir();
|
|
34
|
+
return SAVE_DIR;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Get the path to a save file for a given slot (0-5)
|
|
39
39
|
*/
|
|
40
40
|
export function getSaveFilePath(slot: number): string {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
if (slot < 0 || slot > 5) {
|
|
42
|
+
throw new Error(`Invalid save slot: ${slot}. Must be 0-5.`);
|
|
43
|
+
}
|
|
44
|
+
return join(SAVE_DIR, `doomsav${slot}.dsg`);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
@@ -49,73 +49,73 @@ export function getSaveFilePath(slot: number): string {
|
|
|
49
49
|
* Returns a Map of slot number to file contents (Uint8Array)
|
|
50
50
|
*/
|
|
51
51
|
export function loadExistingSaves(): Map<number, Uint8Array> {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
}
|
|
52
|
+
ensureSaveDir();
|
|
53
|
+
const saves = new Map<number, Uint8Array>();
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const files = readdirSync(SAVE_DIR);
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const match = file.match(SAVE_FILE_PATTERN);
|
|
59
|
+
if (match && match[1]) {
|
|
60
|
+
const slot = parseInt(match[1], 10);
|
|
61
|
+
const filePath = join(SAVE_DIR, file);
|
|
62
|
+
try {
|
|
63
|
+
const data = readFileSync(filePath);
|
|
64
|
+
saves.set(slot, new Uint8Array(data));
|
|
65
|
+
debugLog("Saves", `Loaded save slot ${slot}: ${data.length} bytes`);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
debugLog("Saves", `Failed to read save file ${filePath}: ${e}`);
|
|
70
68
|
}
|
|
71
|
-
|
|
72
|
-
debugLog("Saves", `Failed to list save directory: ${e}`);
|
|
69
|
+
}
|
|
73
70
|
}
|
|
74
|
-
|
|
75
|
-
debugLog("Saves", `
|
|
76
|
-
|
|
71
|
+
} catch (e) {
|
|
72
|
+
debugLog("Saves", `Failed to list save directory: ${e}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
debugLog("Saves", `Loaded ${saves.size} existing saves`);
|
|
76
|
+
return saves;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Write a save game to disk
|
|
81
81
|
*/
|
|
82
82
|
export function writeSave(slot: number, data: Uint8Array): boolean {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
ensureSaveDir();
|
|
84
|
+
const filePath = getSaveFilePath(slot);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
writeFileSync(filePath, data);
|
|
88
|
+
debugLog("Saves", `Wrote save slot ${slot}: ${data.length} bytes to ${filePath}`);
|
|
89
|
+
return true;
|
|
90
|
+
} catch (e) {
|
|
91
|
+
debugLog("Saves", `Failed to write save slot ${slot}: ${e}`);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
97
|
* Check if a save exists for a given slot
|
|
98
98
|
*/
|
|
99
99
|
export function saveExists(slot: number): boolean {
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
const filePath = getSaveFilePath(slot);
|
|
101
|
+
return existsSync(filePath);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
105
|
* Read a save game from disk
|
|
106
106
|
*/
|
|
107
107
|
export function readSave(slot: number): Uint8Array | null {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
108
|
+
const filePath = getSaveFilePath(slot);
|
|
109
|
+
|
|
110
|
+
if (!existsSync(filePath)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const data = readFileSync(filePath);
|
|
116
|
+
return new Uint8Array(data);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
debugLog("Saves", `Failed to read save slot ${slot}: ${e}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
121
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
3
|
* DOOM for OpenTUI
|
|
4
|
-
*
|
|
4
|
+
*
|
|
5
5
|
* Plays DOOM in your terminal using OpenTUI's framebuffer rendering.
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
7
|
* Usage: bun run dev -- --wad /path/to/doom1.wad
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "@opentui/core";
|
|
18
18
|
import { DoomEngine, DOOM_WIDTH, DOOM_HEIGHT } from "./doom-engine";
|
|
19
19
|
import { createDoomInputHandler, getControlsHelp } from "./doom-input";
|
|
20
|
+
import { createDoomMouseHandler, type DoomMouseHandler } from "./doom-mouse";
|
|
20
21
|
import { shutdownAudio } from "./doom-audio";
|
|
21
22
|
import { debugLog } from "./debug";
|
|
22
23
|
import { parseArgs } from "util";
|
|
@@ -35,6 +36,11 @@ const { values } = parseArgs({
|
|
|
35
36
|
short: "h",
|
|
36
37
|
default: false,
|
|
37
38
|
},
|
|
39
|
+
mouse: {
|
|
40
|
+
type: "boolean",
|
|
41
|
+
short: "m",
|
|
42
|
+
default: true,
|
|
43
|
+
},
|
|
38
44
|
},
|
|
39
45
|
});
|
|
40
46
|
|
|
@@ -48,7 +54,7 @@ Options:
|
|
|
48
54
|
-w, --wad Path to DOOM WAD file (default: doom1.wad)
|
|
49
55
|
-h, --help Show this help message
|
|
50
56
|
|
|
51
|
-
${getControlsHelp()}
|
|
57
|
+
${getControlsHelp()}${values.mouse ? "\n Mouse=Aim/Fire" : ""}
|
|
52
58
|
`);
|
|
53
59
|
process.exit(0);
|
|
54
60
|
}
|
|
@@ -61,42 +67,42 @@ const renderer = await createCliRenderer({
|
|
|
61
67
|
|
|
62
68
|
// Handle graceful shutdown
|
|
63
69
|
const cleanup = (signal?: string) => {
|
|
64
|
-
debugLog(
|
|
65
|
-
|
|
70
|
+
debugLog("Exit", `cleanup called with signal: ${signal}`);
|
|
71
|
+
|
|
66
72
|
// Set flag to stop the game loop FIRST - this is critical
|
|
67
73
|
isExiting = true;
|
|
68
|
-
debugLog(
|
|
69
|
-
|
|
74
|
+
debugLog("Exit", "isExiting set to true");
|
|
75
|
+
|
|
70
76
|
// Sync saves before exiting
|
|
71
77
|
if (doomEngine) {
|
|
72
78
|
try {
|
|
73
79
|
doomEngine.syncSaves();
|
|
74
|
-
debugLog(
|
|
80
|
+
debugLog("Exit", "saves synced to disk");
|
|
75
81
|
} catch (e) {
|
|
76
|
-
debugLog(
|
|
82
|
+
debugLog("Exit", `failed to sync saves: ${e}`);
|
|
77
83
|
}
|
|
78
84
|
}
|
|
79
|
-
|
|
85
|
+
|
|
80
86
|
// Clear the frame callback to stop DOOM from ticking
|
|
81
87
|
try {
|
|
82
88
|
renderer.setFrameCallback(null as any);
|
|
83
|
-
debugLog(
|
|
89
|
+
debugLog("Exit", "frame callback cleared");
|
|
84
90
|
} catch (e) {
|
|
85
|
-
debugLog(
|
|
91
|
+
debugLog("Exit", `failed to clear frame callback: ${e}`);
|
|
86
92
|
}
|
|
87
|
-
|
|
93
|
+
|
|
88
94
|
shutdownAudio();
|
|
89
|
-
debugLog(
|
|
90
|
-
|
|
95
|
+
debugLog("Exit", "shutdownAudio completed");
|
|
96
|
+
|
|
91
97
|
try {
|
|
92
98
|
renderer.stop();
|
|
93
|
-
debugLog(
|
|
99
|
+
debugLog("Exit", "renderer.stop completed");
|
|
94
100
|
} catch (e) {
|
|
95
|
-
debugLog(
|
|
101
|
+
debugLog("Exit", `renderer.stop error: ${e}`);
|
|
96
102
|
}
|
|
97
|
-
|
|
103
|
+
|
|
98
104
|
// Exit the process
|
|
99
|
-
debugLog(
|
|
105
|
+
debugLog("Exit", "calling process.exit(0)");
|
|
100
106
|
process.exit(0);
|
|
101
107
|
};
|
|
102
108
|
|
|
@@ -130,6 +136,7 @@ let framebufferRenderable: FrameBufferRenderable | null = null;
|
|
|
130
136
|
let isExiting = false; // Flag to stop the game loop when exiting
|
|
131
137
|
let lastSaveSyncTime = 0; // Track when we last synced saves
|
|
132
138
|
const SAVE_SYNC_INTERVAL = 5000; // Sync saves every 5 seconds
|
|
139
|
+
let mouseHandler: DoomMouseHandler | null = null;
|
|
133
140
|
|
|
134
141
|
async function initDoom() {
|
|
135
142
|
try {
|
|
@@ -157,9 +164,12 @@ async function initDoom() {
|
|
|
157
164
|
renderer.root.add(framebufferRenderable);
|
|
158
165
|
|
|
159
166
|
// Add controls overlay
|
|
167
|
+
const controlsContent = values.mouse
|
|
168
|
+
? "DOOM | Ctrl+C to exit | WASD=Move Mouse=Aim Click=Fire"
|
|
169
|
+
: "DOOM | Ctrl+C to exit | Arrow/WASD=Move Space=Use Ctrl=Fire";
|
|
160
170
|
const controlsText = new TextRenderable(renderer, {
|
|
161
171
|
id: "controls",
|
|
162
|
-
content:
|
|
172
|
+
content: controlsContent,
|
|
163
173
|
position: "absolute",
|
|
164
174
|
left: 1,
|
|
165
175
|
top: 0,
|
|
@@ -176,9 +186,32 @@ async function initDoom() {
|
|
|
176
186
|
});
|
|
177
187
|
renderer.keyInput.on("keypress", inputHandler);
|
|
178
188
|
|
|
189
|
+
// Set up mouse handler if enabled
|
|
190
|
+
if (values.mouse) {
|
|
191
|
+
mouseHandler = createDoomMouseHandler({
|
|
192
|
+
engine: doomEngine,
|
|
193
|
+
sensitivity: 2, // Adjust for terminal cell size
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Attach mouse events to framebuffer
|
|
197
|
+
framebufferRenderable.onMouseMove = (event) => {
|
|
198
|
+
mouseHandler?.onMouseMove(event.x, event.y);
|
|
199
|
+
};
|
|
200
|
+
framebufferRenderable.onMouseDrag = (event) => {
|
|
201
|
+
mouseHandler?.onMouseMove(event.x, event.y);
|
|
202
|
+
};
|
|
203
|
+
framebufferRenderable.onMouseDown = (event) => {
|
|
204
|
+
mouseHandler?.onMouseDown(event.button);
|
|
205
|
+
};
|
|
206
|
+
framebufferRenderable.onMouseUp = (event) => {
|
|
207
|
+
mouseHandler?.onMouseUp(event.button);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
debugLog("Input", "Mouse look enabled");
|
|
211
|
+
}
|
|
212
|
+
|
|
179
213
|
// Start game loop
|
|
180
214
|
renderer.setFrameCallback(gameLoop);
|
|
181
|
-
|
|
182
215
|
} catch (error) {
|
|
183
216
|
loadingText.content = `Error: ${error}`;
|
|
184
217
|
loadingText.fg = RGBA.fromInts(255, 100, 100);
|
|
@@ -208,15 +241,15 @@ async function initDoom() {
|
|
|
208
241
|
}
|
|
209
242
|
}
|
|
210
243
|
|
|
211
|
-
async function gameLoop(
|
|
244
|
+
async function gameLoop(_deltaMs: number) {
|
|
212
245
|
// Bail out immediately if we're exiting
|
|
213
246
|
if (isExiting) return;
|
|
214
|
-
|
|
247
|
+
|
|
215
248
|
if (!doomEngine || !framebufferRenderable) return;
|
|
216
249
|
|
|
217
250
|
// Run DOOM tick
|
|
218
251
|
doomEngine.tick();
|
|
219
|
-
|
|
252
|
+
|
|
220
253
|
// Periodic save sync (every 5 seconds)
|
|
221
254
|
const now = Date.now();
|
|
222
255
|
if (now - lastSaveSyncTime > SAVE_SYNC_INTERVAL) {
|
|
@@ -236,7 +269,7 @@ async function gameLoop(deltaMs: number) {
|
|
|
236
269
|
// Render to OpenTUI framebuffer using half-block characters
|
|
237
270
|
// The upper half-block character (▀) uses foreground for top pixel, background for bottom
|
|
238
271
|
for (let y = 0; y < fb.height; y++) {
|
|
239
|
-
const srcY1 = Math.floor(y * 2 * scaleY);
|
|
272
|
+
const srcY1 = Math.floor(y * 2 * scaleY); // Top pixel row
|
|
240
273
|
const srcY2 = Math.floor((y * 2 + 1) * scaleY); // Bottom pixel row
|
|
241
274
|
|
|
242
275
|
for (let x = 0; x < fb.width; x++) {
|