@muhammedaksam/opentui-doom 0.2.0 → 0.3.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 +43 -12
- package/doom/build/doom.js +1 -1
- package/doom/build/doom.wasm +0 -0
- package/doom/i_sound.c +2 -1
- package/doom/i_system.c +345 -0
- package/doom/s_sound.c +566 -0
- package/package.json +6 -1
- package/scripts/build-doom.sh +4 -2
- package/src/debug.ts +27 -0
- package/src/doom-audio.ts +4 -11
- package/src/doom-engine.ts +120 -2
- package/src/doom-input.ts +18 -3
- package/src/doom-saves.ts +121 -0
- package/src/index.ts +49 -2
package/src/doom-engine.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { readFile } from "fs/promises";
|
|
9
9
|
import { join, resolve } from "path";
|
|
10
|
+
import { debugLog } from "./debug";
|
|
11
|
+
import { loadExistingSaves, writeSave } from "./doom-saves";
|
|
10
12
|
|
|
11
13
|
// DOOM screen dimensions
|
|
12
14
|
export const DOOM_WIDTH = 1280;
|
|
@@ -35,6 +37,12 @@ export interface DoomModule {
|
|
|
35
37
|
canRead: boolean,
|
|
36
38
|
canWrite: boolean
|
|
37
39
|
) => string;
|
|
40
|
+
FS?: {
|
|
41
|
+
readFile: (path: string, opts?: { encoding?: string }) => Uint8Array;
|
|
42
|
+
readdir: (path: string) => string[];
|
|
43
|
+
stat: (path: string) => { mode: number };
|
|
44
|
+
isDir: (mode: number) => boolean;
|
|
45
|
+
};
|
|
38
46
|
ccall: (name: string, returnType: string | null, argTypes: string[], args: any[]) => any;
|
|
39
47
|
cwrap: (name: string, returnType: string | null, argTypes: string[]) => (...args: any[]) => any;
|
|
40
48
|
setValue: (ptr: number, value: number, type: string) => void;
|
|
@@ -45,6 +53,7 @@ export interface DoomEngineOptions {
|
|
|
45
53
|
wadPath: string;
|
|
46
54
|
print?: (text: string) => void;
|
|
47
55
|
printErr?: (text: string) => void;
|
|
56
|
+
onQuit?: () => void;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
export class DoomEngine {
|
|
@@ -54,6 +63,8 @@ export class DoomEngine {
|
|
|
54
63
|
private wadPath: string;
|
|
55
64
|
private print: (text: string) => void;
|
|
56
65
|
private printErr: (text: string) => void;
|
|
66
|
+
private onQuit: (() => void) | null = null;
|
|
67
|
+
private emscriptenFS: any = null; // FS reference captured from Emscripten
|
|
57
68
|
|
|
58
69
|
constructor(optionsOrPath: string | DoomEngineOptions) {
|
|
59
70
|
if (typeof optionsOrPath === "string") {
|
|
@@ -64,6 +75,7 @@ export class DoomEngine {
|
|
|
64
75
|
this.wadPath = resolve(optionsOrPath.wadPath);
|
|
65
76
|
this.print = optionsOrPath.print || ((text: string) => console.log('[DOOM]', text));
|
|
66
77
|
this.printErr = optionsOrPath.printErr || ((text: string) => console.error('[DOOM]', text));
|
|
78
|
+
this.onQuit = optionsOrPath.onQuit || null;
|
|
67
79
|
}
|
|
68
80
|
}
|
|
69
81
|
|
|
@@ -101,13 +113,39 @@ export class DoomEngine {
|
|
|
101
113
|
stopMusic: () => audio.stopMusic(),
|
|
102
114
|
setMusicVolume: (volume: number) => audio.setMusicVolume(volume),
|
|
103
115
|
|
|
116
|
+
// Game lifecycle callbacks - called from C via EM_ASM
|
|
117
|
+
quitGame: () => {
|
|
118
|
+
debugLog('Engine', 'quitGame callback called from WASM');
|
|
119
|
+
debugLog('Engine', `this.onQuit is: ${this.onQuit ? 'defined' : 'undefined'}`);
|
|
120
|
+
if (this.onQuit) {
|
|
121
|
+
debugLog('Engine', 'calling this.onQuit()');
|
|
122
|
+
this.onQuit();
|
|
123
|
+
debugLog('Engine', 'this.onQuit() returned');
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
104
127
|
// preRun receives Module as first argument
|
|
105
128
|
preRun: [
|
|
106
|
-
|
|
107
|
-
// Create /doom directory
|
|
129
|
+
(module: any) => {
|
|
130
|
+
// Create /doom directory for WAD
|
|
108
131
|
module.FS_createPath("/", "doom", true, true);
|
|
109
132
|
// Write WAD file to virtual filesystem
|
|
110
133
|
module.FS_createDataFile("/doom", "doom1.wad", wadArray, true, false);
|
|
134
|
+
|
|
135
|
+
// Create .savegame directory for saves (DOOM looks here by default)
|
|
136
|
+
module.FS_createPath("/", ".savegame", true, true);
|
|
137
|
+
|
|
138
|
+
// Load existing saves from ~/.opentui-doom/ into virtual filesystem
|
|
139
|
+
const existingSaves = loadExistingSaves();
|
|
140
|
+
for (const [slot, data] of existingSaves) {
|
|
141
|
+
const filename = `doomsav${slot}.dsg`;
|
|
142
|
+
try {
|
|
143
|
+
module.FS_createDataFile("/.savegame", filename, Array.from(data), true, true);
|
|
144
|
+
debugLog("Engine", `Pre-loaded save slot ${slot} to virtual FS`);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
debugLog("Engine", `Failed to pre-load save slot ${slot}: ${e}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
111
149
|
}
|
|
112
150
|
],
|
|
113
151
|
};
|
|
@@ -117,6 +155,18 @@ export class DoomEngine {
|
|
|
117
155
|
if (!this.module) {
|
|
118
156
|
throw new Error("Failed to initialize DOOM module");
|
|
119
157
|
}
|
|
158
|
+
|
|
159
|
+
// Capture FS reference after module is fully loaded
|
|
160
|
+
// Try different methods to access Emscripten's FS
|
|
161
|
+
if ((this.module as any).FS) {
|
|
162
|
+
this.emscriptenFS = (this.module as any).FS;
|
|
163
|
+
debugLog("Engine", "Captured FS from module.FS");
|
|
164
|
+
} else if (typeof (globalThis as any).FS !== 'undefined') {
|
|
165
|
+
this.emscriptenFS = (globalThis as any).FS;
|
|
166
|
+
debugLog("Engine", "Captured FS from globalThis.FS");
|
|
167
|
+
} else {
|
|
168
|
+
debugLog("Engine", "Warning: Could not find Emscripten FS object");
|
|
169
|
+
}
|
|
120
170
|
|
|
121
171
|
// Initialize DOOM
|
|
122
172
|
this.initDoom();
|
|
@@ -215,4 +265,72 @@ export class DoomEngine {
|
|
|
215
265
|
isInitialized(): boolean {
|
|
216
266
|
return this.initialized;
|
|
217
267
|
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Sync save games from the virtual filesystem to disk (~/.opentui-doom/)
|
|
271
|
+
* Call this periodically or after save operations to persist saves
|
|
272
|
+
*/
|
|
273
|
+
syncSaves(): void {
|
|
274
|
+
if (!this.module || !this.emscriptenFS) {
|
|
275
|
+
debugLog("Engine", "syncSaves: module or FS not available");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// DOOM can save to different paths depending on configuration
|
|
280
|
+
// Try multiple possible locations
|
|
281
|
+
const savePaths = [
|
|
282
|
+
"/", // Root
|
|
283
|
+
"/.savegame", // Default when configdir is "."
|
|
284
|
+
".savegame", // Relative path (CWD)
|
|
285
|
+
"/doom", // Our custom path
|
|
286
|
+
"/tmp", // Temp directory
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
const FS = this.emscriptenFS;
|
|
290
|
+
|
|
291
|
+
// List root directory to see what exists
|
|
292
|
+
try {
|
|
293
|
+
const rootEntries = FS.readdir("/");
|
|
294
|
+
debugLog("Engine", `VFS root contents: ${rootEntries.join(", ")}`);
|
|
295
|
+
|
|
296
|
+
// Check each directory at root
|
|
297
|
+
for (const entry of rootEntries) {
|
|
298
|
+
if (entry === "." || entry === "..") continue;
|
|
299
|
+
try {
|
|
300
|
+
const stat = FS.stat(`/${entry}`);
|
|
301
|
+
if (FS.isDir(stat.mode)) {
|
|
302
|
+
const subEntries = FS.readdir(`/${entry}`);
|
|
303
|
+
const dsgFiles = subEntries.filter((e: string) => e.endsWith(".dsg"));
|
|
304
|
+
if (dsgFiles.length > 0) {
|
|
305
|
+
debugLog("Engine", `Found .dsg files in /${entry}: ${dsgFiles.join(", ")}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch (e) {
|
|
309
|
+
// Not a directory or can't read
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} catch (e) {
|
|
313
|
+
debugLog("Engine", `Failed to list VFS root: ${e}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (let slot = 0; slot <= 5; slot++) {
|
|
317
|
+
const filename = `doomsav${slot}.dsg`;
|
|
318
|
+
|
|
319
|
+
for (const basePath of savePaths) {
|
|
320
|
+
const vfsPath = basePath === "/" ? `/${filename}` : `${basePath}/${filename}`;
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
// Try to read the file from virtual FS
|
|
324
|
+
const data = FS.readFile(vfsPath);
|
|
325
|
+
if (data && data.length > 0) {
|
|
326
|
+
debugLog("Engine", `Found save at ${vfsPath}, syncing slot ${slot} (${data.length} bytes)`);
|
|
327
|
+
writeSave(slot, data);
|
|
328
|
+
break; // Found this slot, move to next
|
|
329
|
+
}
|
|
330
|
+
} catch (e) {
|
|
331
|
+
// File doesn't exist at this path, try next
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
218
336
|
}
|
package/src/doom-input.ts
CHANGED
|
@@ -151,6 +151,11 @@ export function createDoomInputHandler(options: DoomInputOptions) {
|
|
|
151
151
|
|
|
152
152
|
const keyId = key.name || key.sequence || "";
|
|
153
153
|
const wasPressed = keyStates.get(keyId) ?? false;
|
|
154
|
+
const keyName = key.name?.toLowerCase() ?? "";
|
|
155
|
+
|
|
156
|
+
// Menu confirmation keys (y/n) should always send keydown on every press
|
|
157
|
+
// These are used for quit dialogs and other prompts
|
|
158
|
+
const isMenuConfirmKey = keyName === "y" || keyName === "n";
|
|
154
159
|
|
|
155
160
|
// Clear any existing release timer for this key
|
|
156
161
|
const existingTimer = keyTimers.get(keyId);
|
|
@@ -159,13 +164,23 @@ export function createDoomInputHandler(options: DoomInputOptions) {
|
|
|
159
164
|
keyTimers.delete(keyId);
|
|
160
165
|
}
|
|
161
166
|
|
|
162
|
-
// Key press -
|
|
163
|
-
if (!wasPressed) {
|
|
167
|
+
// Key press - send if not already pressed, OR if it's a menu confirmation key
|
|
168
|
+
if (!wasPressed || isMenuConfirmKey) {
|
|
164
169
|
keyStates.set(keyId, true);
|
|
165
170
|
engine.pushKey(true, doomKey);
|
|
171
|
+
|
|
172
|
+
// For menu confirmation keys, immediately send release too
|
|
173
|
+
// since DOOM only cares about the keydown event
|
|
174
|
+
if (isMenuConfirmKey) {
|
|
175
|
+
setTimeout(() => {
|
|
176
|
+
engine.pushKey(false, doomKey);
|
|
177
|
+
keyStates.set(keyId, false);
|
|
178
|
+
}, 50);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
166
181
|
}
|
|
167
182
|
|
|
168
|
-
// Schedule key release after 300ms of no input
|
|
183
|
+
// Schedule key release after 300ms of no input (for non-menu keys)
|
|
169
184
|
const timer = setTimeout(() => {
|
|
170
185
|
if (keyStates.get(keyId)) {
|
|
171
186
|
keyStates.set(keyId, false);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Save Game Manager for OpenTUI-DOOM
|
|
3
|
+
*
|
|
4
|
+
* Handles persistence of DOOM save games to ~/.opentui-doom/
|
|
5
|
+
* DOOM uses 6 save slots (0-5) with files named doomsav{N}.dsg
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { debugLog } from "./debug";
|
|
12
|
+
|
|
13
|
+
// Save game directory path
|
|
14
|
+
const SAVE_DIR = join(homedir(), ".opentui-doom");
|
|
15
|
+
|
|
16
|
+
// DOOM save file format: doomsav{0-5}.dsg
|
|
17
|
+
const SAVE_FILE_PATTERN = /^doomsav([0-5])\.dsg$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Ensure the save directory exists
|
|
21
|
+
*/
|
|
22
|
+
export function ensureSaveDir(): void {
|
|
23
|
+
if (!existsSync(SAVE_DIR)) {
|
|
24
|
+
mkdirSync(SAVE_DIR, { recursive: true });
|
|
25
|
+
debugLog("Saves", `Created save directory: ${SAVE_DIR}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the save game directory path
|
|
31
|
+
*/
|
|
32
|
+
export function getSaveGameDir(): string {
|
|
33
|
+
ensureSaveDir();
|
|
34
|
+
return SAVE_DIR;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get the path to a save file for a given slot (0-5)
|
|
39
|
+
*/
|
|
40
|
+
export function getSaveFilePath(slot: number): string {
|
|
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
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Load all existing save games from disk
|
|
49
|
+
* Returns a Map of slot number to file contents (Uint8Array)
|
|
50
|
+
*/
|
|
51
|
+
export function loadExistingSaves(): Map<number, Uint8Array> {
|
|
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}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
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
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Write a save game to disk
|
|
81
|
+
*/
|
|
82
|
+
export function writeSave(slot: number, data: Uint8Array): boolean {
|
|
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
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if a save exists for a given slot
|
|
98
|
+
*/
|
|
99
|
+
export function saveExists(slot: number): boolean {
|
|
100
|
+
const filePath = getSaveFilePath(slot);
|
|
101
|
+
return existsSync(filePath);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Read a save game from disk
|
|
106
|
+
*/
|
|
107
|
+
export function readSave(slot: number): Uint8Array | null {
|
|
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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { DoomEngine, DOOM_WIDTH, DOOM_HEIGHT } from "./doom-engine";
|
|
19
19
|
import { createDoomInputHandler, getControlsHelp } from "./doom-input";
|
|
20
20
|
import { shutdownAudio } from "./doom-audio";
|
|
21
|
+
import { debugLog } from "./debug";
|
|
21
22
|
import { parseArgs } from "util";
|
|
22
23
|
|
|
23
24
|
// Parse command line arguments
|
|
@@ -60,12 +61,42 @@ const renderer = await createCliRenderer({
|
|
|
60
61
|
|
|
61
62
|
// Handle graceful shutdown
|
|
62
63
|
const cleanup = (signal?: string) => {
|
|
64
|
+
debugLog('Exit', `cleanup called with signal: ${signal}`);
|
|
65
|
+
|
|
66
|
+
// Set flag to stop the game loop FIRST - this is critical
|
|
67
|
+
isExiting = true;
|
|
68
|
+
debugLog('Exit', 'isExiting set to true');
|
|
69
|
+
|
|
70
|
+
// Sync saves before exiting
|
|
71
|
+
if (doomEngine) {
|
|
72
|
+
try {
|
|
73
|
+
doomEngine.syncSaves();
|
|
74
|
+
debugLog('Exit', 'saves synced to disk');
|
|
75
|
+
} catch (e) {
|
|
76
|
+
debugLog('Exit', `failed to sync saves: ${e}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Clear the frame callback to stop DOOM from ticking
|
|
81
|
+
try {
|
|
82
|
+
renderer.setFrameCallback(null as any);
|
|
83
|
+
debugLog('Exit', 'frame callback cleared');
|
|
84
|
+
} catch (e) {
|
|
85
|
+
debugLog('Exit', `failed to clear frame callback: ${e}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
63
88
|
shutdownAudio();
|
|
89
|
+
debugLog('Exit', 'shutdownAudio completed');
|
|
90
|
+
|
|
64
91
|
try {
|
|
65
92
|
renderer.stop();
|
|
93
|
+
debugLog('Exit', 'renderer.stop completed');
|
|
66
94
|
} catch (e) {
|
|
67
|
-
|
|
95
|
+
debugLog('Exit', `renderer.stop error: ${e}`);
|
|
68
96
|
}
|
|
97
|
+
|
|
98
|
+
// Exit the process
|
|
99
|
+
debugLog('Exit', 'calling process.exit(0)');
|
|
69
100
|
process.exit(0);
|
|
70
101
|
};
|
|
71
102
|
|
|
@@ -96,12 +127,18 @@ container.add(loadingText);
|
|
|
96
127
|
// Try to initialize DOOM engine
|
|
97
128
|
let doomEngine: DoomEngine | null = null;
|
|
98
129
|
let framebufferRenderable: FrameBufferRenderable | null = null;
|
|
130
|
+
let isExiting = false; // Flag to stop the game loop when exiting
|
|
131
|
+
let lastSaveSyncTime = 0; // Track when we last synced saves
|
|
132
|
+
const SAVE_SYNC_INTERVAL = 5000; // Sync saves every 5 seconds
|
|
99
133
|
|
|
100
134
|
async function initDoom() {
|
|
101
135
|
try {
|
|
102
136
|
loadingText.content = `Loading DOOM from: ${values.wad}`;
|
|
103
137
|
|
|
104
|
-
doomEngine = new DoomEngine(
|
|
138
|
+
doomEngine = new DoomEngine({
|
|
139
|
+
wadPath: values.wad!,
|
|
140
|
+
onQuit: cleanup,
|
|
141
|
+
});
|
|
105
142
|
await doomEngine.init();
|
|
106
143
|
|
|
107
144
|
// Remove loading text
|
|
@@ -172,10 +209,20 @@ async function initDoom() {
|
|
|
172
209
|
}
|
|
173
210
|
|
|
174
211
|
async function gameLoop(deltaMs: number) {
|
|
212
|
+
// Bail out immediately if we're exiting
|
|
213
|
+
if (isExiting) return;
|
|
214
|
+
|
|
175
215
|
if (!doomEngine || !framebufferRenderable) return;
|
|
176
216
|
|
|
177
217
|
// Run DOOM tick
|
|
178
218
|
doomEngine.tick();
|
|
219
|
+
|
|
220
|
+
// Periodic save sync (every 5 seconds)
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
if (now - lastSaveSyncTime > SAVE_SYNC_INTERVAL) {
|
|
223
|
+
doomEngine.syncSaves();
|
|
224
|
+
lastSaveSyncTime = now;
|
|
225
|
+
}
|
|
179
226
|
|
|
180
227
|
// Get framebuffer from DOOM
|
|
181
228
|
const pixels = doomEngine.getFrameBuffer();
|