@muhammedaksam/opentui-doom 0.1.2 → 0.2.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.
Files changed (121) hide show
  1. package/doom/build/doom.js +1 -1
  2. package/doom/build/doom.wasm +0 -0
  3. package/doom/doom_js_sound_bridge.c +240 -0
  4. package/doom/i_sound.c +326 -0
  5. package/package.json +4 -1
  6. package/scripts/build-doom.sh +6 -1
  7. package/sound/d_bunny.mp3 +0 -0
  8. package/sound/d_e1m1.mp3 +0 -0
  9. package/sound/d_e1m5.mp3 +0 -0
  10. package/sound/d_intro.mp3 +0 -0
  11. package/sound/dsbarexp.wav +0 -0
  12. package/sound/dsbdcls.wav +0 -0
  13. package/sound/dsbdopn.wav +0 -0
  14. package/sound/dsbfg.wav +0 -0
  15. package/sound/dsbgact.wav +0 -0
  16. package/sound/dsbgdth1.wav +0 -0
  17. package/sound/dsbgdth2.wav +0 -0
  18. package/sound/dsbgsit1.wav +0 -0
  19. package/sound/dsbgsit2.wav +0 -0
  20. package/sound/dsboscub.wav +0 -0
  21. package/sound/dsbosdth.wav +0 -0
  22. package/sound/dsbospit.wav +0 -0
  23. package/sound/dsbospn.wav +0 -0
  24. package/sound/dsbossit.wav +0 -0
  25. package/sound/dsbrsdth.wav +0 -0
  26. package/sound/dsbrssit.wav +0 -0
  27. package/sound/dsbspact.wav +0 -0
  28. package/sound/dsbspdth.wav +0 -0
  29. package/sound/dsbspsit.wav +0 -0
  30. package/sound/dsbspwlk.wav +0 -0
  31. package/sound/dscacdth.wav +0 -0
  32. package/sound/dscacsit.wav +0 -0
  33. package/sound/dsclaw.wav +0 -0
  34. package/sound/dscybdth.wav +0 -0
  35. package/sound/dscybsit.wav +0 -0
  36. package/sound/dsdbcls.wav +0 -0
  37. package/sound/dsdbload.wav +0 -0
  38. package/sound/dsdbopn.wav +0 -0
  39. package/sound/dsdmact.wav +0 -0
  40. package/sound/dsdmpain.wav +0 -0
  41. package/sound/dsdorcls.wav +0 -0
  42. package/sound/dsdoropn.wav +0 -0
  43. package/sound/dsdshtgn.wav +0 -0
  44. package/sound/dsfirsht.wav +0 -0
  45. package/sound/dsfirxpl.wav +0 -0
  46. package/sound/dsflame.wav +0 -0
  47. package/sound/dsflamst.wav +0 -0
  48. package/sound/dsgetpow.wav +0 -0
  49. package/sound/dshoof.wav +0 -0
  50. package/sound/dsitemup.wav +0 -0
  51. package/sound/dsitmbk.wav +0 -0
  52. package/sound/dskeendt.wav +0 -0
  53. package/sound/dskeenpn.wav +0 -0
  54. package/sound/dskntdth.wav +0 -0
  55. package/sound/dskntsit.wav +0 -0
  56. package/sound/dsmanatk.wav +0 -0
  57. package/sound/dsmandth.wav +0 -0
  58. package/sound/dsmansit.wav +0 -0
  59. package/sound/dsmetal.wav +0 -0
  60. package/sound/dsmnpain.wav +0 -0
  61. package/sound/dsnoway.wav +0 -0
  62. package/sound/dsoof.wav +0 -0
  63. package/sound/dspdiehi.wav +0 -0
  64. package/sound/dspedth.wav +0 -0
  65. package/sound/dspepain.wav +0 -0
  66. package/sound/dspesit.wav +0 -0
  67. package/sound/dspistol.wav +0 -0
  68. package/sound/dsplasma.wav +0 -0
  69. package/sound/dspldeth.wav +0 -0
  70. package/sound/dsplpain.wav +0 -0
  71. package/sound/dspodth1.wav +0 -0
  72. package/sound/dspodth2.wav +0 -0
  73. package/sound/dspodth3.wav +0 -0
  74. package/sound/dspopain.wav +0 -0
  75. package/sound/dsposact.wav +0 -0
  76. package/sound/dsposit1.wav +0 -0
  77. package/sound/dsposit2.wav +0 -0
  78. package/sound/dsposit3.wav +0 -0
  79. package/sound/dspstart.wav +0 -0
  80. package/sound/dspstop.wav +0 -0
  81. package/sound/dspunch.wav +0 -0
  82. package/sound/dsradio.wav +0 -0
  83. package/sound/dsrlaunc.wav +0 -0
  84. package/sound/dsrxplod.wav +0 -0
  85. package/sound/dssawful.wav +0 -0
  86. package/sound/dssawhit.wav +0 -0
  87. package/sound/dssawidl.wav +0 -0
  88. package/sound/dssawup.wav +0 -0
  89. package/sound/dssgcock.wav +0 -0
  90. package/sound/dssgtatk.wav +0 -0
  91. package/sound/dssgtdth.wav +0 -0
  92. package/sound/dssgtsit.wav +0 -0
  93. package/sound/dsshotgn.wav +0 -0
  94. package/sound/dsskeact.wav +0 -0
  95. package/sound/dsskeatk.wav +0 -0
  96. package/sound/dsskedth.wav +0 -0
  97. package/sound/dsskepch.wav +0 -0
  98. package/sound/dsskesit.wav +0 -0
  99. package/sound/dsskeswg.wav +0 -0
  100. package/sound/dssklatk.wav +0 -0
  101. package/sound/dsskldth.wav +0 -0
  102. package/sound/dsslop.wav +0 -0
  103. package/sound/dsspidth.wav +0 -0
  104. package/sound/dsspisit.wav +0 -0
  105. package/sound/dsssdth.wav +0 -0
  106. package/sound/dssssit.wav +0 -0
  107. package/sound/dsstnmov.wav +0 -0
  108. package/sound/dsswtchn.wav +0 -0
  109. package/sound/dsswtchx.wav +0 -0
  110. package/sound/dstelept.wav +0 -0
  111. package/sound/dstink.wav +0 -0
  112. package/sound/dsvilact.wav +0 -0
  113. package/sound/dsvilatk.wav +0 -0
  114. package/sound/dsvildth.wav +0 -0
  115. package/sound/dsvilsit.wav +0 -0
  116. package/sound/dsvipain.wav +0 -0
  117. package/sound/dswpnup.wav +0 -0
  118. package/src/doom-audio.ts +243 -0
  119. package/src/doom-engine.ts +32 -6
  120. package/src/doom-input.ts +18 -3
  121. package/src/index.ts +21 -2
Binary file
Binary file
@@ -0,0 +1,243 @@
1
+ /**
2
+ * DOOM Audio Bridge for OpenTUI
3
+ *
4
+ * Handles audio playback using mpv with proper process management.
5
+ * All spawned processes are tracked and terminated on shutdown.
6
+ */
7
+
8
+ import { spawn, ChildProcess } from "child_process";
9
+ import { join } from "path";
10
+ import { existsSync, appendFileSync, unlinkSync } from "fs";
11
+ import { createConnection, Socket } from "net";
12
+
13
+ // Log file for debugging
14
+ const logFile = join(import.meta.dir, "..", "debug.log");
15
+
16
+ function log(message: string): void {
17
+ const timestamp = new Date().toISOString();
18
+ const line = `[${timestamp}] [Audio] ${message}\n`;
19
+ try {
20
+ appendFileSync(logFile, line);
21
+ } catch (e) {
22
+ // Ignore logging errors
23
+ }
24
+ }
25
+
26
+ // Track all spawned mpv processes for cleanup
27
+ const activeProcesses = new Set<ChildProcess>();
28
+
29
+ // Current music process (only one music track at a time)
30
+ let musicProcess: ChildProcess | null = null;
31
+
32
+ // Current volume (0-127, DOOM standard)
33
+ let currentVolume = 100;
34
+
35
+ // Sound directory path
36
+ const soundDir = join(import.meta.dir, "..", "sound");
37
+
38
+ // IPC socket path for music volume control
39
+ const musicSocketPath = "/tmp/doom-music-mpv.sock";
40
+
41
+ // Whether audio is initialized
42
+ let initialized = false;
43
+
44
+ // Current music state for volume changes
45
+ let currentMusicName: string | null = null;
46
+ let currentMusicLooping: boolean = false;
47
+
48
+ /**
49
+ * Initialize the audio system
50
+ */
51
+ export function initAudio(): void {
52
+ if (initialized) return;
53
+ initialized = true;
54
+ log(`Initialized, sound dir: ${soundDir}`);
55
+ }
56
+
57
+ /**
58
+ * Shutdown the audio system and kill ALL spawned processes
59
+ */
60
+ export function shutdownAudio(): void {
61
+ if (!initialized) return;
62
+
63
+ // Kill music process
64
+ if (musicProcess) {
65
+ try {
66
+ musicProcess.kill("SIGKILL");
67
+ } catch (e) {
68
+ // Process may have already exited
69
+ }
70
+ musicProcess = null;
71
+ }
72
+
73
+ // Kill ALL tracked processes
74
+ for (const proc of activeProcesses) {
75
+ try {
76
+ proc.kill("SIGKILL");
77
+ } catch (e) {
78
+ // Process may have already exited
79
+ }
80
+ }
81
+ activeProcesses.clear();
82
+
83
+ initialized = false;
84
+ log("Shutdown complete");
85
+ }
86
+
87
+ /**
88
+ * Helper to spawn mpv with common options
89
+ */
90
+ function spawnMpv(filePath: string, options: string[] = []): ChildProcess | null {
91
+ if (!existsSync(filePath)) {
92
+ log(`File not found: ${filePath}`);
93
+ return null;
94
+ }
95
+
96
+ const args = [
97
+ "--no-video", // No video output
98
+ "--no-terminal", // No terminal output
99
+ "--really-quiet", // Suppress all output
100
+ ...options,
101
+ filePath
102
+ ];
103
+
104
+ try {
105
+ const proc = spawn("mpv", args, {
106
+ stdio: "ignore",
107
+ detached: false, // Keep attached to parent process
108
+ });
109
+
110
+ // Track the process
111
+ activeProcesses.add(proc);
112
+
113
+ // Remove from tracking when process exits
114
+ proc.on("exit", () => {
115
+ activeProcesses.delete(proc);
116
+ });
117
+
118
+ proc.on("error", (err) => {
119
+ log(`mpv error: ${err.message}`);
120
+ activeProcesses.delete(proc);
121
+ });
122
+
123
+ return proc;
124
+ } catch (e) {
125
+ log(`Failed to spawn mpv: ${e}`);
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Play a sound effect
132
+ * Sound files should be in sound/ds{name}.wav
133
+ * Volume is 0-127 (DOOM standard)
134
+ */
135
+ export function playSound(name: string, volume: number = 127): void {
136
+ if (!initialized) {
137
+ log("playSound called but not initialized");
138
+ return;
139
+ }
140
+
141
+ const soundPath = join(soundDir, `ds${name.toLowerCase()}.wav`);
142
+
143
+ // Convert DOOM volume (0-127) to mpv volume (0-100)
144
+ const mpvVolume = Math.round((volume / 127) * 100);
145
+ log(`Playing sound: ${soundPath} at volume ${mpvVolume}`);
146
+
147
+ // Fire and forget - process will auto-cleanup when done
148
+ const proc = spawnMpv(soundPath, [`--volume=${mpvVolume}`]);
149
+ log(`Spawn result: ${proc ? "success" : "failed"}`);
150
+ }
151
+
152
+ /**
153
+ * Play music track
154
+ * Music files should be in sound/{name}.mp3
155
+ */
156
+ export function playMusic(name: string, looping: boolean): void {
157
+ if (!initialized) {
158
+ log("playMusic called but not initialized");
159
+ return;
160
+ }
161
+
162
+ // Stop any currently playing music
163
+ stopMusic();
164
+
165
+ // Clean up any stale socket file
166
+ try {
167
+ if (existsSync(musicSocketPath)) {
168
+ unlinkSync(musicSocketPath);
169
+ }
170
+ } catch (e) {
171
+ // Ignore
172
+ }
173
+
174
+ // Store music state for volume changes
175
+ currentMusicName = name;
176
+ currentMusicLooping = looping;
177
+
178
+ const musicPath = join(soundDir, `${name.toLowerCase()}.mp3`);
179
+ log(`Playing music: ${musicPath}, looping: ${looping}`);
180
+ const options: string[] = [
181
+ `--input-ipc-server=${musicSocketPath}`, // Enable IPC for volume control
182
+ ];
183
+ if (looping) {
184
+ options.push("--loop=inf");
185
+ }
186
+
187
+ // Set volume (mpv uses 0-100 scale, DOOM uses 0-127)
188
+ const mpvVolume = Math.round((currentVolume / 127) * 100);
189
+ options.push(`--volume=${mpvVolume}`);
190
+
191
+ musicProcess = spawnMpv(musicPath, options);
192
+ }
193
+
194
+ /**
195
+ * Stop the currently playing music
196
+ */
197
+ export function stopMusic(): void {
198
+ if (musicProcess) {
199
+ try {
200
+ musicProcess.kill("SIGTERM");
201
+ } catch (e) {
202
+ // Process may have already exited
203
+ }
204
+ activeProcesses.delete(musicProcess);
205
+ musicProcess = null;
206
+ }
207
+ currentMusicName = null;
208
+ }
209
+
210
+ /**
211
+ * Set music volume (0-127)
212
+ * Uses IPC socket to change volume without restarting music
213
+ */
214
+ export function setMusicVolume(volume: number): void {
215
+ const newVolume = Math.max(0, Math.min(127, volume));
216
+ currentVolume = newVolume;
217
+
218
+ // If no music is playing, just save the volume for next play
219
+ if (!musicProcess || !currentMusicName) {
220
+ return;
221
+ }
222
+
223
+ // Convert to mpv volume (0-100)
224
+ const mpvVolume = Math.round((newVolume / 127) * 100);
225
+ log(`Setting music volume to ${mpvVolume} via IPC`);
226
+
227
+ // Send volume command via IPC socket
228
+ try {
229
+ const socket = createConnection(musicSocketPath);
230
+
231
+ socket.on("connect", () => {
232
+ const cmd = JSON.stringify({ command: ["set_property", "volume", mpvVolume] }) + "\n";
233
+ socket.write(cmd);
234
+ socket.end();
235
+ });
236
+
237
+ socket.on("error", (err) => {
238
+ log(`IPC socket error: ${err.message}`);
239
+ });
240
+ } catch (e) {
241
+ log(`Failed to send IPC command: ${e}`);
242
+ }
243
+ }
@@ -41,15 +41,30 @@ export interface DoomModule {
41
41
  getValue: (ptr: number, type: string) => number;
42
42
  }
43
43
 
44
+ export interface DoomEngineOptions {
45
+ wadPath: string;
46
+ print?: (text: string) => void;
47
+ printErr?: (text: string) => void;
48
+ }
49
+
44
50
  export class DoomEngine {
45
51
  private module: DoomModule | null = null;
46
52
  private frameBufferPtr: number = 0;
47
53
  private initialized: boolean = false;
48
54
  private wadPath: string;
49
-
50
- constructor(wadPath: string) {
51
- // Resolve to absolute path
52
- this.wadPath = resolve(wadPath);
55
+ private print: (text: string) => void;
56
+ private printErr: (text: string) => void;
57
+
58
+ constructor(optionsOrPath: string | DoomEngineOptions) {
59
+ if (typeof optionsOrPath === "string") {
60
+ this.wadPath = resolve(optionsOrPath);
61
+ this.print = (text: string) => console.log('[DOOM]', text);
62
+ this.printErr = (text: string) => console.error('[DOOM]', text);
63
+ } else {
64
+ this.wadPath = resolve(optionsOrPath.wadPath);
65
+ this.print = optionsOrPath.print || ((text: string) => console.log('[DOOM]', text));
66
+ this.printErr = optionsOrPath.printErr || ((text: string) => console.error('[DOOM]', text));
67
+ }
53
68
  }
54
69
 
55
70
  async init(): Promise<void> {
@@ -64,6 +79,9 @@ export class DoomEngine {
64
79
  // Dynamic import of the compiled DOOM module
65
80
  const createDoomModule = require(doomJsPath);
66
81
 
82
+ // Import audio system
83
+ const audio = await import("./doom-audio");
84
+
67
85
  // Create module with proper callbacks
68
86
  const moduleConfig: any = {
69
87
  locateFile: (path: string) => {
@@ -72,8 +90,16 @@ export class DoomEngine {
72
90
  }
73
91
  return path;
74
92
  },
75
- print: (text: string) => console.log('[DOOM]', text),
76
- printErr: (text: string) => console.error('[DOOM]', text),
93
+ print: (text: string) => this.print(text),
94
+ printErr: (text: string) => this.printErr(text),
95
+
96
+ // Audio callbacks - called from C via EM_ASM
97
+ initAudio: () => audio.initAudio(),
98
+ shutdownAudio: () => audio.shutdownAudio(),
99
+ playSound: (name: string, volume: number) => audio.playSound(name, volume),
100
+ playMusic: (name: string, looping: boolean) => audio.playMusic(name, looping),
101
+ stopMusic: () => audio.stopMusic(),
102
+ setMusicVolume: (volume: number) => audio.setMusicVolume(volume),
77
103
 
78
104
  // preRun receives Module as first argument
79
105
  preRun: [
package/src/doom-input.ts CHANGED
@@ -80,8 +80,8 @@ function mapKeyToDoom(key: KeyEvent): number | null {
80
80
  if (name === "tab") return DoomKeys.KEY_TAB;
81
81
  if (name === "backspace") return DoomKeys.KEY_BACKSPACE;
82
82
 
83
- // Fire (Ctrl)
84
- if (key.ctrl) return DoomKeys.KEY_FIRE;
83
+ // Fire (Ctrl) - but not Ctrl+C which should exit
84
+ if (key.ctrl && key.name !== "c") return DoomKeys.KEY_FIRE;
85
85
 
86
86
  // Alt for strafe
87
87
  if (key.meta || key.name === "alt") return DoomKeys.KEY_LALT;
@@ -128,8 +128,23 @@ function mapKeyToDoom(key: KeyEvent): number | null {
128
128
  // Track release timers for each key
129
129
  const keyTimers = new Map<string, ReturnType<typeof setTimeout>>();
130
130
 
131
- export function createDoomInputHandler(engine: DoomEngine) {
131
+ export interface DoomInputOptions {
132
+ engine: DoomEngine;
133
+ onExit?: () => void;
134
+ }
135
+
136
+ export function createDoomInputHandler(options: DoomInputOptions) {
137
+ const { engine, onExit } = options;
138
+
132
139
  return (key: KeyEvent) => {
140
+ // Handle Ctrl+C for exit
141
+ if (key.ctrl && (key.name === "c" || key.sequence === "\x03")) {
142
+ if (onExit) {
143
+ onExit();
144
+ }
145
+ return;
146
+ }
147
+
133
148
  const doomKey = mapKeyToDoom(key);
134
149
 
135
150
  if (doomKey === null) return;
package/src/index.ts CHANGED
@@ -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 { shutdownAudio } from "./doom-audio";
20
21
  import { parseArgs } from "util";
21
22
 
22
23
  // Parse command line arguments
@@ -53,10 +54,25 @@ ${getControlsHelp()}
53
54
 
54
55
  // Initialize renderer
55
56
  const renderer = await createCliRenderer({
56
- exitOnCtrlC: true,
57
+ exitOnCtrlC: false, // We handle exit manually to cleanup audio
57
58
  targetFps: 35, // DOOM's native framerate
58
59
  });
59
60
 
61
+ // Handle graceful shutdown
62
+ const cleanup = (signal?: string) => {
63
+ shutdownAudio();
64
+ try {
65
+ renderer.stop();
66
+ } catch (e) {
67
+ // Ignore error if renderer already stopped
68
+ }
69
+ process.exit(0);
70
+ };
71
+
72
+ process.on("SIGINT", () => cleanup("SIGINT"));
73
+ process.on("SIGTERM", () => cleanup("SIGTERM"));
74
+ process.on("exit", () => shutdownAudio());
75
+
60
76
  renderer.start();
61
77
 
62
78
  // Create UI container
@@ -117,7 +133,10 @@ async function initDoom() {
117
133
  renderer.root.add(controlsText);
118
134
 
119
135
  // Set up input handler
120
- const inputHandler = createDoomInputHandler(doomEngine);
136
+ const inputHandler = createDoomInputHandler({
137
+ engine: doomEngine,
138
+ onExit: cleanup,
139
+ });
121
140
  renderer.keyInput.on("keypress", inputHandler);
122
141
 
123
142
  // Start game loop