@muhammedaksam/opentui-doom 0.2.1 → 0.3.5
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 +61 -14
- package/doom/build/doom.js +1 -1
- package/doom/build/doom.wasm +0 -0
- package/doom/i_system.c +345 -0
- package/package.json +6 -2
- package/scripts/build-doom.sh +3 -2
- package/src/debug.ts +27 -0
- package/src/doom-audio.ts +4 -11
- package/src/doom-engine.ts +137 -2
- package/src/doom-input.ts +72 -46
- package/src/doom-mouse.ts +129 -0
- package/src/doom-saves.ts +121 -0
- package/src/index.ts +85 -4
package/src/doom-input.ts
CHANGED
|
@@ -56,70 +56,74 @@ export const DoomKeys = {
|
|
|
56
56
|
const keyStates = new Map<string, boolean>();
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* Map an OpenTUI key event to
|
|
59
|
+
* Map an OpenTUI key event to DOOM key code(s)
|
|
60
|
+
* Returns an array of key codes - for most keys this is a single code,
|
|
61
|
+
* but for WASD we return both the movement key AND the character
|
|
62
|
+
* so both gameplay movement and text input work.
|
|
60
63
|
*/
|
|
61
|
-
function mapKeyToDoom(key: KeyEvent): number
|
|
64
|
+
function mapKeyToDoom(key: KeyEvent): number[] {
|
|
62
65
|
const name = key.name?.toLowerCase() ?? "";
|
|
63
66
|
|
|
64
67
|
// Arrow keys
|
|
65
|
-
if (name === "up" || key.sequence === "\x1b[A") return DoomKeys.KEY_UPARROW;
|
|
66
|
-
if (name === "down" || key.sequence === "\x1b[B") return DoomKeys.KEY_DOWNARROW;
|
|
67
|
-
if (name === "left" || key.sequence === "\x1b[D") return DoomKeys.KEY_LEFTARROW;
|
|
68
|
-
if (name === "right" || key.sequence === "\x1b[C") return DoomKeys.KEY_RIGHTARROW;
|
|
69
|
-
|
|
70
|
-
// WASD movement
|
|
71
|
-
|
|
72
|
-
if (name === "
|
|
73
|
-
if (name === "
|
|
74
|
-
if (name === "
|
|
68
|
+
if (name === "up" || key.sequence === "\x1b[A") return [DoomKeys.KEY_UPARROW];
|
|
69
|
+
if (name === "down" || key.sequence === "\x1b[B") return [DoomKeys.KEY_DOWNARROW];
|
|
70
|
+
if (name === "left" || key.sequence === "\x1b[D") return [DoomKeys.KEY_LEFTARROW];
|
|
71
|
+
if (name === "right" || key.sequence === "\x1b[C") return [DoomKeys.KEY_RIGHTARROW];
|
|
72
|
+
|
|
73
|
+
// WASD movement - send BOTH movement key AND character
|
|
74
|
+
// Movement key ensures gameplay works, character ensures text input works
|
|
75
|
+
if (name === "w") return [DoomKeys.KEY_UPARROW, "w".charCodeAt(0)];
|
|
76
|
+
if (name === "s") return [DoomKeys.KEY_DOWNARROW, "s".charCodeAt(0)];
|
|
77
|
+
if (name === "a") return [DoomKeys.KEY_STRAFE_L, "a".charCodeAt(0)];
|
|
78
|
+
if (name === "d") return [DoomKeys.KEY_STRAFE_R, "d".charCodeAt(0)];
|
|
75
79
|
|
|
76
80
|
// Action keys
|
|
77
|
-
if (name === "space") return " ".charCodeAt(0); // Use
|
|
78
|
-
if (name === "return" || name === "enter") return DoomKeys.KEY_ENTER;
|
|
79
|
-
if (name === "escape") return DoomKeys.KEY_ESCAPE;
|
|
80
|
-
if (name === "tab") return DoomKeys.KEY_TAB;
|
|
81
|
-
if (name === "backspace") return DoomKeys.KEY_BACKSPACE;
|
|
81
|
+
if (name === "space") return [" ".charCodeAt(0)]; // Use
|
|
82
|
+
if (name === "return" || name === "enter") return [DoomKeys.KEY_ENTER];
|
|
83
|
+
if (name === "escape") return [DoomKeys.KEY_ESCAPE];
|
|
84
|
+
if (name === "tab") return [DoomKeys.KEY_TAB];
|
|
85
|
+
if (name === "backspace") return [DoomKeys.KEY_BACKSPACE];
|
|
82
86
|
|
|
83
87
|
// Fire (Ctrl) - but not Ctrl+C which should exit
|
|
84
|
-
if (key.ctrl && key.name !== "c") return DoomKeys.KEY_FIRE;
|
|
88
|
+
if (key.ctrl && key.name !== "c") return [DoomKeys.KEY_FIRE];
|
|
85
89
|
|
|
86
90
|
// Alt for strafe
|
|
87
|
-
if (key.meta || key.name === "alt") return DoomKeys.KEY_LALT;
|
|
91
|
+
if (key.meta || key.name === "alt") return [DoomKeys.KEY_LALT];
|
|
88
92
|
|
|
89
93
|
// Shift for run
|
|
90
|
-
if (key.shift) return DoomKeys.KEY_RSHIFT;
|
|
94
|
+
if (key.shift) return [DoomKeys.KEY_RSHIFT];
|
|
91
95
|
|
|
92
96
|
// Function keys
|
|
93
|
-
if (name === "f1") return DoomKeys.KEY_F1;
|
|
94
|
-
if (name === "f2") return DoomKeys.KEY_F2;
|
|
95
|
-
if (name === "f3") return DoomKeys.KEY_F3;
|
|
96
|
-
if (name === "f4") return DoomKeys.KEY_F4;
|
|
97
|
-
if (name === "f5") return DoomKeys.KEY_F5;
|
|
98
|
-
if (name === "f6") return DoomKeys.KEY_F6;
|
|
99
|
-
if (name === "f7") return DoomKeys.KEY_F7;
|
|
100
|
-
if (name === "f8") return DoomKeys.KEY_F8;
|
|
101
|
-
if (name === "f9") return DoomKeys.KEY_F9;
|
|
102
|
-
if (name === "f10") return DoomKeys.KEY_F10;
|
|
103
|
-
if (name === "f11") return DoomKeys.KEY_F11;
|
|
104
|
-
if (name === "f12") return DoomKeys.KEY_F12;
|
|
97
|
+
if (name === "f1") return [DoomKeys.KEY_F1];
|
|
98
|
+
if (name === "f2") return [DoomKeys.KEY_F2];
|
|
99
|
+
if (name === "f3") return [DoomKeys.KEY_F3];
|
|
100
|
+
if (name === "f4") return [DoomKeys.KEY_F4];
|
|
101
|
+
if (name === "f5") return [DoomKeys.KEY_F5];
|
|
102
|
+
if (name === "f6") return [DoomKeys.KEY_F6];
|
|
103
|
+
if (name === "f7") return [DoomKeys.KEY_F7];
|
|
104
|
+
if (name === "f8") return [DoomKeys.KEY_F8];
|
|
105
|
+
if (name === "f9") return [DoomKeys.KEY_F9];
|
|
106
|
+
if (name === "f10") return [DoomKeys.KEY_F10];
|
|
107
|
+
if (name === "f11") return [DoomKeys.KEY_F11];
|
|
108
|
+
if (name === "f12") return [DoomKeys.KEY_F12];
|
|
105
109
|
|
|
106
110
|
// Weapon selection (1-9, 0)
|
|
107
|
-
if (name >= "0" && name <= "9") return name.charCodeAt(0);
|
|
111
|
+
if (name >= "0" && name <= "9") return [name.charCodeAt(0)];
|
|
108
112
|
|
|
109
113
|
// Plus/minus for gamma/zoom
|
|
110
|
-
if (name === "+" || name === "=") return DoomKeys.KEY_EQUALS;
|
|
111
|
-
if (name === "-") return DoomKeys.KEY_MINUS;
|
|
114
|
+
if (name === "+" || name === "=") return [DoomKeys.KEY_EQUALS];
|
|
115
|
+
if (name === "-") return [DoomKeys.KEY_MINUS];
|
|
112
116
|
|
|
113
117
|
// Y/N for prompts
|
|
114
|
-
if (name === "y") return "y".charCodeAt(0);
|
|
115
|
-
if (name === "n") return "n".charCodeAt(0);
|
|
118
|
+
if (name === "y") return ["y".charCodeAt(0)];
|
|
119
|
+
if (name === "n") return ["n".charCodeAt(0)];
|
|
116
120
|
|
|
117
121
|
// Other letter keys (for cheats, etc)
|
|
118
122
|
if (name.length === 1 && name >= "a" && name <= "z") {
|
|
119
|
-
return name.charCodeAt(0);
|
|
123
|
+
return [name.charCodeAt(0)];
|
|
120
124
|
}
|
|
121
125
|
|
|
122
|
-
return
|
|
126
|
+
return [];
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
/**
|
|
@@ -145,12 +149,17 @@ export function createDoomInputHandler(options: DoomInputOptions) {
|
|
|
145
149
|
return;
|
|
146
150
|
}
|
|
147
151
|
|
|
148
|
-
const
|
|
152
|
+
const doomKeys = mapKeyToDoom(key);
|
|
149
153
|
|
|
150
|
-
if (
|
|
154
|
+
if (doomKeys.length === 0) return;
|
|
151
155
|
|
|
152
156
|
const keyId = key.name || key.sequence || "";
|
|
153
157
|
const wasPressed = keyStates.get(keyId) ?? false;
|
|
158
|
+
const keyName = key.name?.toLowerCase() ?? "";
|
|
159
|
+
|
|
160
|
+
// Menu confirmation keys (y/n) should always send keydown on every press
|
|
161
|
+
// These are used for quit dialogs and other prompts
|
|
162
|
+
const isMenuConfirmKey = keyName === "y" || keyName === "n";
|
|
154
163
|
|
|
155
164
|
// Clear any existing release timer for this key
|
|
156
165
|
const existingTimer = keyTimers.get(keyId);
|
|
@@ -159,17 +168,34 @@ export function createDoomInputHandler(options: DoomInputOptions) {
|
|
|
159
168
|
keyTimers.delete(keyId);
|
|
160
169
|
}
|
|
161
170
|
|
|
162
|
-
// Key press -
|
|
163
|
-
if (!wasPressed) {
|
|
171
|
+
// Key press - send if not already pressed, OR if it's a menu confirmation key
|
|
172
|
+
if (!wasPressed || isMenuConfirmKey) {
|
|
164
173
|
keyStates.set(keyId, true);
|
|
165
|
-
|
|
174
|
+
// Send all mapped keys (for WASD this includes both movement and character)
|
|
175
|
+
for (const doomKey of doomKeys) {
|
|
176
|
+
engine.pushKey(true, doomKey);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// For menu confirmation keys, immediately send release too
|
|
180
|
+
// since DOOM only cares about the keydown event
|
|
181
|
+
if (isMenuConfirmKey) {
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
for (const doomKey of doomKeys) {
|
|
184
|
+
engine.pushKey(false, doomKey);
|
|
185
|
+
}
|
|
186
|
+
keyStates.set(keyId, false);
|
|
187
|
+
}, 50);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
166
190
|
}
|
|
167
191
|
|
|
168
|
-
// Schedule key release after 300ms of no input
|
|
192
|
+
// Schedule key release after 300ms of no input (for non-menu keys)
|
|
169
193
|
const timer = setTimeout(() => {
|
|
170
194
|
if (keyStates.get(keyId)) {
|
|
171
195
|
keyStates.set(keyId, false);
|
|
172
|
-
|
|
196
|
+
for (const doomKey of doomKeys) {
|
|
197
|
+
engine.pushKey(false, doomKey);
|
|
198
|
+
}
|
|
173
199
|
keyTimers.delete(keyId);
|
|
174
200
|
}
|
|
175
201
|
}, 300);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOOM Mouse Input Handler
|
|
3
|
+
*
|
|
4
|
+
* Provides mouse-based turning and firing for DOOM.
|
|
5
|
+
* Mouse horizontal movement translates to left/right turning.
|
|
6
|
+
* Left click fires the weapon.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DoomEngine } from "./doom-engine";
|
|
10
|
+
import { DoomKeys } from "./doom-input";
|
|
11
|
+
import { debugLog } from "./debug";
|
|
12
|
+
|
|
13
|
+
export interface DoomMouseOptions {
|
|
14
|
+
engine: DoomEngine;
|
|
15
|
+
sensitivity?: number; // Cells of movement before triggering turn (default: 2)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DoomMouseHandler {
|
|
19
|
+
onMouseMove: (x: number, y: number) => void;
|
|
20
|
+
onMouseDown: (button: number) => void;
|
|
21
|
+
onMouseUp: (button: number) => void;
|
|
22
|
+
reset: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a mouse handler that forwards mouse events to DOOM
|
|
27
|
+
*/
|
|
28
|
+
export function createDoomMouseHandler(options: DoomMouseOptions): DoomMouseHandler {
|
|
29
|
+
const { engine } = options;
|
|
30
|
+
|
|
31
|
+
let lastMouseX: number | null = null;
|
|
32
|
+
let isLeftMouseDown = false;
|
|
33
|
+
let currentTurnKey: number | null = null;
|
|
34
|
+
let releaseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
35
|
+
|
|
36
|
+
const RELEASE_DELAY = 100; // Release key 100ms after last movement
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
/**
|
|
40
|
+
* Handle mouse movement - hold turn key while moving
|
|
41
|
+
*/
|
|
42
|
+
onMouseMove(x: number, _y: number): void {
|
|
43
|
+
if (lastMouseX === null) {
|
|
44
|
+
lastMouseX = x;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const delta = x - lastMouseX;
|
|
49
|
+
lastMouseX = x;
|
|
50
|
+
|
|
51
|
+
if (delta === 0) return;
|
|
52
|
+
|
|
53
|
+
const newTurnKey = delta > 0 ? DoomKeys.KEY_RIGHTARROW : DoomKeys.KEY_LEFTARROW;
|
|
54
|
+
|
|
55
|
+
// Clear any pending release
|
|
56
|
+
if (releaseTimer) {
|
|
57
|
+
clearTimeout(releaseTimer);
|
|
58
|
+
releaseTimer = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// If direction changed, release old and press new
|
|
62
|
+
if (currentTurnKey !== newTurnKey) {
|
|
63
|
+
if (currentTurnKey !== null) {
|
|
64
|
+
engine.pushKey(false, currentTurnKey);
|
|
65
|
+
}
|
|
66
|
+
engine.pushKey(true, newTurnKey);
|
|
67
|
+
currentTurnKey = newTurnKey;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Schedule release after delay (will be cancelled if more movement comes)
|
|
71
|
+
releaseTimer = setTimeout(() => {
|
|
72
|
+
if (currentTurnKey !== null) {
|
|
73
|
+
engine.pushKey(false, currentTurnKey);
|
|
74
|
+
currentTurnKey = null;
|
|
75
|
+
}
|
|
76
|
+
releaseTimer = null;
|
|
77
|
+
}, RELEASE_DELAY);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Handle mouse button press
|
|
82
|
+
*/
|
|
83
|
+
onMouseDown(button: number): void {
|
|
84
|
+
// Left click (button 0) = fire
|
|
85
|
+
if (button === 0 && !isLeftMouseDown) {
|
|
86
|
+
isLeftMouseDown = true;
|
|
87
|
+
engine.pushKey(true, DoomKeys.KEY_FIRE);
|
|
88
|
+
debugLog("Mouse", "Fire pressed");
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Handle mouse button release
|
|
94
|
+
*/
|
|
95
|
+
onMouseUp(button: number): void {
|
|
96
|
+
// Left click release = stop firing
|
|
97
|
+
if (button === 0 && isLeftMouseDown) {
|
|
98
|
+
isLeftMouseDown = false;
|
|
99
|
+
engine.pushKey(false, DoomKeys.KEY_FIRE);
|
|
100
|
+
debugLog("Mouse", "Fire released");
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reset mouse state (useful when window loses focus)
|
|
106
|
+
*/
|
|
107
|
+
reset(): void {
|
|
108
|
+
lastMouseX = null;
|
|
109
|
+
|
|
110
|
+
// Clear pending release timer
|
|
111
|
+
if (releaseTimer) {
|
|
112
|
+
clearTimeout(releaseTimer);
|
|
113
|
+
releaseTimer = null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Release any held turn key
|
|
117
|
+
if (currentTurnKey !== null) {
|
|
118
|
+
engine.pushKey(false, currentTurnKey);
|
|
119
|
+
currentTurnKey = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Release fire if held
|
|
123
|
+
if (isLeftMouseDown) {
|
|
124
|
+
isLeftMouseDown = false;
|
|
125
|
+
engine.pushKey(false, DoomKeys.KEY_FIRE);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -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
|
@@ -17,7 +17,9 @@ 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";
|
|
22
|
+
import { debugLog } from "./debug";
|
|
21
23
|
import { parseArgs } from "util";
|
|
22
24
|
|
|
23
25
|
// Parse command line arguments
|
|
@@ -34,6 +36,11 @@ const { values } = parseArgs({
|
|
|
34
36
|
short: "h",
|
|
35
37
|
default: false,
|
|
36
38
|
},
|
|
39
|
+
mouse: {
|
|
40
|
+
type: "boolean",
|
|
41
|
+
short: "m",
|
|
42
|
+
default: true,
|
|
43
|
+
},
|
|
37
44
|
},
|
|
38
45
|
});
|
|
39
46
|
|
|
@@ -47,7 +54,7 @@ Options:
|
|
|
47
54
|
-w, --wad Path to DOOM WAD file (default: doom1.wad)
|
|
48
55
|
-h, --help Show this help message
|
|
49
56
|
|
|
50
|
-
${getControlsHelp()}
|
|
57
|
+
${getControlsHelp()}${values.mouse ? '\n Mouse=Aim/Fire' : ''}
|
|
51
58
|
`);
|
|
52
59
|
process.exit(0);
|
|
53
60
|
}
|
|
@@ -60,12 +67,42 @@ const renderer = await createCliRenderer({
|
|
|
60
67
|
|
|
61
68
|
// Handle graceful shutdown
|
|
62
69
|
const cleanup = (signal?: string) => {
|
|
70
|
+
debugLog('Exit', `cleanup called with signal: ${signal}`);
|
|
71
|
+
|
|
72
|
+
// Set flag to stop the game loop FIRST - this is critical
|
|
73
|
+
isExiting = true;
|
|
74
|
+
debugLog('Exit', 'isExiting set to true');
|
|
75
|
+
|
|
76
|
+
// Sync saves before exiting
|
|
77
|
+
if (doomEngine) {
|
|
78
|
+
try {
|
|
79
|
+
doomEngine.syncSaves();
|
|
80
|
+
debugLog('Exit', 'saves synced to disk');
|
|
81
|
+
} catch (e) {
|
|
82
|
+
debugLog('Exit', `failed to sync saves: ${e}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Clear the frame callback to stop DOOM from ticking
|
|
87
|
+
try {
|
|
88
|
+
renderer.setFrameCallback(null as any);
|
|
89
|
+
debugLog('Exit', 'frame callback cleared');
|
|
90
|
+
} catch (e) {
|
|
91
|
+
debugLog('Exit', `failed to clear frame callback: ${e}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
63
94
|
shutdownAudio();
|
|
95
|
+
debugLog('Exit', 'shutdownAudio completed');
|
|
96
|
+
|
|
64
97
|
try {
|
|
65
98
|
renderer.stop();
|
|
99
|
+
debugLog('Exit', 'renderer.stop completed');
|
|
66
100
|
} catch (e) {
|
|
67
|
-
|
|
101
|
+
debugLog('Exit', `renderer.stop error: ${e}`);
|
|
68
102
|
}
|
|
103
|
+
|
|
104
|
+
// Exit the process
|
|
105
|
+
debugLog('Exit', 'calling process.exit(0)');
|
|
69
106
|
process.exit(0);
|
|
70
107
|
};
|
|
71
108
|
|
|
@@ -96,12 +133,19 @@ container.add(loadingText);
|
|
|
96
133
|
// Try to initialize DOOM engine
|
|
97
134
|
let doomEngine: DoomEngine | null = null;
|
|
98
135
|
let framebufferRenderable: FrameBufferRenderable | null = null;
|
|
136
|
+
let isExiting = false; // Flag to stop the game loop when exiting
|
|
137
|
+
let lastSaveSyncTime = 0; // Track when we last synced saves
|
|
138
|
+
const SAVE_SYNC_INTERVAL = 5000; // Sync saves every 5 seconds
|
|
139
|
+
let mouseHandler: DoomMouseHandler | null = null;
|
|
99
140
|
|
|
100
141
|
async function initDoom() {
|
|
101
142
|
try {
|
|
102
143
|
loadingText.content = `Loading DOOM from: ${values.wad}`;
|
|
103
144
|
|
|
104
|
-
doomEngine = new DoomEngine(
|
|
145
|
+
doomEngine = new DoomEngine({
|
|
146
|
+
wadPath: values.wad!,
|
|
147
|
+
onQuit: cleanup,
|
|
148
|
+
});
|
|
105
149
|
await doomEngine.init();
|
|
106
150
|
|
|
107
151
|
// Remove loading text
|
|
@@ -120,9 +164,12 @@ async function initDoom() {
|
|
|
120
164
|
renderer.root.add(framebufferRenderable);
|
|
121
165
|
|
|
122
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";
|
|
123
170
|
const controlsText = new TextRenderable(renderer, {
|
|
124
171
|
id: "controls",
|
|
125
|
-
content:
|
|
172
|
+
content: controlsContent,
|
|
126
173
|
position: "absolute",
|
|
127
174
|
left: 1,
|
|
128
175
|
top: 0,
|
|
@@ -139,6 +186,30 @@ async function initDoom() {
|
|
|
139
186
|
});
|
|
140
187
|
renderer.keyInput.on("keypress", inputHandler);
|
|
141
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
|
+
|
|
142
213
|
// Start game loop
|
|
143
214
|
renderer.setFrameCallback(gameLoop);
|
|
144
215
|
|
|
@@ -172,10 +243,20 @@ async function initDoom() {
|
|
|
172
243
|
}
|
|
173
244
|
|
|
174
245
|
async function gameLoop(deltaMs: number) {
|
|
246
|
+
// Bail out immediately if we're exiting
|
|
247
|
+
if (isExiting) return;
|
|
248
|
+
|
|
175
249
|
if (!doomEngine || !framebufferRenderable) return;
|
|
176
250
|
|
|
177
251
|
// Run DOOM tick
|
|
178
252
|
doomEngine.tick();
|
|
253
|
+
|
|
254
|
+
// Periodic save sync (every 5 seconds)
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
if (now - lastSaveSyncTime > SAVE_SYNC_INTERVAL) {
|
|
257
|
+
doomEngine.syncSaves();
|
|
258
|
+
lastSaveSyncTime = now;
|
|
259
|
+
}
|
|
179
260
|
|
|
180
261
|
// Get framebuffer from DOOM
|
|
181
262
|
const pixels = doomEngine.getFrameBuffer();
|