@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.
@@ -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
- function (module: DoomModule) {
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 - only send if not already pressed
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
- // Ignore error if renderer already stopped
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(values.wad!);
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();