Skip to content

Commit 8fedb16

Browse files
dvcdsysclaude
andcommitted
feat(macos): WASD camera controls + Cmd key-up fix
Opt-in WASD camera panning (GEN_WASD_CAMERA / launcher toggle). Bare W/A/S/D pan the battlefield camera like the arrow keys (which keep working); the commands those keys displace relocate to Cmd+key. - LookAtXlat: mirror W/A/S/D onto the camera scroll directions; no-op when the flag is off. - MetaEvent: in WASD mode bare W/A/S/D pass through to pan instead of firing/eating their meta command; Cmd+W/S fire the bare-key command via a targeted lookup (the localized Data\English\CommandMap.ini binds W=SelectAllAircraft and S=Stop, which were consuming the keys before the camera translator saw them). - HotKey: bare W/A/S/D never fire command-bar buttons; only Cmd+A/D fire the relocated Attack-Move / Dozer button (W/S are handled as meta). - metal_backend: synthesize an immediate key-up for any Cmd+key press. macOS does not deliver keyUp while Command is held, which left the camera stuck scrolling after Cmd+W/S and stopped Cmd+A/D (fire on key-up) from ever triggering. General macOS input fix. - launcher: "WASD camera controls" checkbox (Play tab, persisted, default off) + GEN_WASD_CAMERA env; help text documents the Cmd remap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5cf3aa4 commit 8fedb16

6 files changed

Lines changed: 183 additions & 4 deletions

File tree

‎GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/HotKey.cpp‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@
6666
// PUBLIC FUNCTIONS ///////////////////////////////////////////////////////////
6767
//-----------------------------------------------------------------------------
6868

69+
// TheSuperHackers @port macOS @feature GEN_WASD_CAMERA: when the launcher's
70+
// "WASD camera controls" toggle is on, W/A/S/D pan the camera (see LookAtXlat).
71+
// W is unused by the command bar, but A/S/D collide with build/order shortcuts
72+
// (Attack-Move, Stop, Dozer, ...). To avoid losing them we relocate those bare
73+
// shortcuts to Cmd+A/S/D — on macOS the Cmd key already maps to the engine's
74+
// Ctrl (CocoaKeyboard.cpp), so Cmd+letter arrives here as a CTRL-modified key.
75+
static Bool wasdCameraEnabled()
76+
{
77+
static int s_enabled = -1;
78+
if (s_enabled < 0)
79+
{
80+
const char *e = ::getenv("GEN_WASD_CAMERA");
81+
s_enabled = (e && e[0] && e[0] != '0') ? 1 : 0;
82+
}
83+
return s_enabled ? TRUE : FALSE;
84+
}
85+
6986
//-----------------------------------------------------------------------------
7087
GameMessageDisposition HotKeyTranslator::translateGameMessage(const GameMessage *msg)
7188
{
@@ -96,13 +113,41 @@ GameMessageDisposition HotKeyTranslator::translateGameMessage(const GameMessage
96113
{
97114
newModState |= ALT;
98115
}
99-
if(newModState != 0)
100-
return disp;
116+
101117
WideChar key = TheKeyboard->getPrintableKey((KeyDefType)msg->getArgument(0)->integer, 0);
102118
UnicodeString uKey;
103119
uKey.concat(key);
104120
AsciiString aKey;
105121
aKey.translate(uKey);
122+
123+
// TheSuperHackers @port macOS @feature GEN_WASD_CAMERA conflict handling.
124+
if (wasdCameraEnabled())
125+
{
126+
AsciiString lowKey = aKey;
127+
lowKey.toLower();
128+
const char *k = lowKey.str();
129+
const Bool isWasdLetter = (k && k[0] && k[1] == '\0' &&
130+
(k[0] == 'w' || k[0] == 'a' || k[0] == 's' || k[0] == 'd'));
131+
if (isWasdLetter)
132+
{
133+
// Bare W/A/S/D belong to the camera now: don't fire a command button,
134+
// leave the message so LookAtXlat can stop the scroll. On Cmd, only
135+
// the command-bar letters A and D fire their relocated button here;
136+
// W (SelectAllAircraft) and S (Stop) are meta commands already fired
137+
// by MetaEventTranslator, so firing a button for them too would e.g.
138+
// build a &Sniper on Cmd+S.
139+
const Bool isCmdButtonLetter = (k[0] == 'a' || k[0] == 'd');
140+
if (newModState == CTRL && isCmdButtonLetter &&
141+
TheHotKeyManager && TheHotKeyManager->executeHotKey(aKey))
142+
{
143+
disp = DESTROY_MESSAGE;
144+
}
145+
return disp;
146+
}
147+
}
148+
149+
if(newModState != 0)
150+
return disp;
106151
if(TheHotKeyManager && TheHotKeyManager->executeHotKey(aKey))
107152
disp = DESTROY_MESSAGE;
108153
}

‎GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/LookAtXlat.cpp‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,23 @@ enum
6565

6666
static Bool scrollDir[4] = { false, false, false, false };
6767

68+
// TheSuperHackers @port macOS @feature GEN_WASD_CAMERA=1 lets W/A/S/D pan the
69+
// camera exactly like the arrow keys (arrows keep working too). Opt-in, set by
70+
// the macOS launcher's "WASD camera controls" toggle. W is free in the retail/CWC
71+
// key layout; A/S/D normally trigger command-bar shortcuts (Attack-Move, Stop,
72+
// Dozer, ...), so when this mode is on those are relocated to Cmd+A/S/D in
73+
// HotKey.cpp and the bare keys are freed up here to scroll.
74+
static Bool wasdCameraEnabled()
75+
{
76+
static int s_enabled = -1;
77+
if (s_enabled < 0)
78+
{
79+
const char *e = ::getenv("GEN_WASD_CAMERA");
80+
s_enabled = (e && e[0] && e[0] != '0') ? 1 : 0;
81+
}
82+
return s_enabled ? TRUE : FALSE;
83+
}
84+
6885
// TheSuperHackers @tweak Introduces the SCROLL_MULTIPLIER for all scrolling to
6986
//
7087
// 1. bring the RMB scroll speed back to how it was at 30 FPS in the retail game version
@@ -231,6 +248,36 @@ GameMessageDisposition LookAtTranslator::translateGameMessage(const GameMessage
231248
break;
232249
}
233250

251+
// TheSuperHackers @port macOS @feature GEN_WASD_CAMERA: mirror W/A/S/D
252+
// onto the same scroll directions as the arrow keys. On press we only
253+
// START a pan when no Cmd/Ctrl and no Alt is held, so Cmd+A/S/D (the
254+
// relocated command-bar shortcuts, see HotKey.cpp) reach the command bar
255+
// instead of panning. On release we always CLEAR, so adding/removing a
256+
// modifier mid-press can never leave the camera stuck scrolling.
257+
if (wasdCameraEnabled())
258+
{
259+
const Bool modBlocksStart =
260+
(state & KEY_STATE_CONTROL) || (state & KEY_STATE_ALT);
261+
if (!isPressed || !modBlocksStart)
262+
{
263+
switch (key)
264+
{
265+
case KEY_W:
266+
scrollDir[DIR_UP] = isPressed;
267+
break;
268+
case KEY_S:
269+
scrollDir[DIR_DOWN] = isPressed;
270+
break;
271+
case KEY_A:
272+
scrollDir[DIR_LEFT] = isPressed;
273+
break;
274+
case KEY_D:
275+
scrollDir[DIR_RIGHT] = isPressed;
276+
break;
277+
}
278+
}
279+
}
280+
234281
if (TheInGameUI->isSelecting() || (m_isScrolling && m_scrollType != SCROLL_KEY))
235282
break;
236283

‎GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,23 @@ static Bool isMessageUsable(CommandUsableInType usableIn)
436436
}
437437

438438
//-------------------------------------------------------------------------------------------------
439+
// TheSuperHackers @port macOS @feature GEN_WASD_CAMERA: see LookAtXlat.cpp. The
440+
// localized CommandMap (Data\<lang>\CommandMap.ini) binds bare W = SELECT_ALL_AIRCRAFT
441+
// and bare S = STOP as meta-events, which this translator (priority 20) would fire
442+
// and destroy before the camera translator (priority 60) ever sees the key. When
443+
// WASD mode is on we let bare W/A/S/D fall through to pan, and move those meta
444+
// commands to Cmd+W/S (macOS Cmd maps to the engine's Ctrl, CocoaKeyboard.cpp).
445+
static Bool wasdCameraEnabled()
446+
{
447+
static int s_enabled = -1;
448+
if (s_enabled < 0)
449+
{
450+
const char *e = ::getenv("GEN_WASD_CAMERA");
451+
s_enabled = (e && e[0] && e[0] != '0') ? 1 : 0;
452+
}
453+
return s_enabled ? TRUE : FALSE;
454+
}
455+
439456
GameMessageDisposition MetaEventTranslator::translateGameMessage(const GameMessage *msg)
440457
{
441458
GameMessageDisposition disp = KEEP_MESSAGE;
@@ -465,8 +482,36 @@ GameMessageDisposition MetaEventTranslator::translateGameMessage(const GameMessa
465482
newModState |= ALT;
466483
}
467484

485+
// TheSuperHackers @port macOS @feature GEN_WASD_CAMERA conflict handling.
486+
// Bare W/A/S/D now pan the camera, so they must not fire/eat their meta
487+
// command here. Cmd(Ctrl)+W/A/S/D fires the command the bare key used to map
488+
// to — done with a targeted lookup of the un-modified binding rather than by
489+
// mutating newModState (which would trip the "mods-only" special case below
490+
// and spuriously destroy Cmd+A / Cmd+D, blocking their command-bar relocation).
491+
Bool wasdSkipMetaMatch = FALSE;
492+
if (wasdCameraEnabled() &&
493+
(key == MK_W || key == MK_A || key == MK_S || key == MK_D) &&
494+
!(keyState & KEY_STATE_ALT))
495+
{
496+
wasdSkipMetaMatch = TRUE;
497+
if (keyState & KEY_STATE_CONTROL)
498+
{
499+
for (const MetaMapRec *m = TheMetaMap->getFirstMetaMapRec(); m; m = m->m_next)
500+
{
501+
if (m->m_key == key && m->m_modState == NONE && isMessageUsable(m->m_usableIn) &&
502+
( (m->m_transition == DOWN && (keyState & KEY_STATE_DOWN)) ||
503+
(m->m_transition == UP && (keyState & KEY_STATE_UP)) ))
504+
{
505+
if (!(keyState & KEY_STATE_AUTOREPEAT))
506+
TheMessageStream->appendMessage(m->m_meta);
507+
disp = DESTROY_MESSAGE;
508+
break;
509+
}
510+
}
511+
}
512+
}
468513

469-
for (const MetaMapRec *map = TheMetaMap->getFirstMetaMapRec(); map; map = map->m_next)
514+
for (const MetaMapRec *map = TheMetaMap->getFirstMetaMapRec(); !wasdSkipMetaMatch && map; map = map->m_next)
470515
{
471516
if (!isMessageUsable(map->m_usableIn))
472517
continue;

‎cmake/dx8_stub/metal_backend.mm‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,18 @@ - (BOOL)acceptsFirstResponder { return YES; }
571571
// NOTE: do NOT call [super keyDown:] / [super keyUp:] — the default impl
572572
// forwards up the chain to NSWindow, which beeps on unhandled ESC.
573573
- (void)keyDown:(NSEvent*)e {
574-
if (!e.isARepeat) MetalView_PushKeyEvent((int)e.keyCode, 1);
574+
if (!e.isARepeat) {
575+
MetalView_PushKeyEvent((int)e.keyCode, 1);
576+
// TheSuperHackers @port macOS @bugfix: macOS does NOT deliver -keyUp: for a
577+
// key released while Command (Cmd) is held. Without the matching up the
578+
// engine thinks the key stays down and auto-repeats it forever — e.g. with
579+
// GEN_WASD_CAMERA, Cmd+W fires "select all aircraft" but W then sticks and
580+
// the camera scrolls up endlessly; likewise Cmd+A/Cmd+D never fire because
581+
// the command-bar hotkey triggers on key-up. A Cmd+key can't be held-repeated
582+
// on macOS anyway, so treat it as a tap: synthesize the up immediately.
583+
if (e.modifierFlags & NSEventModifierFlagCommand)
584+
MetalView_PushKeyEvent((int)e.keyCode, 0);
585+
}
575586
// Text-entry input: NSEvent has already applied the live system keyboard
576587
// layout + shift/caps/option/dead-keys, so -characters is the correct typed
577588
// character (unlike the raw keyCode path, which only knows a handful of

‎launcher/Sources/GeneralsZHLauncher/GameInstaller.swift‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,19 @@ final class LauncherModel: ObservableObject {
6262
didSet { UserDefaults.standard.set(disableMenuShellMap, forKey: "disableMenuShellMap") }
6363
}
6464

65+
/// Pan the battlefield camera with W/A/S/D as well as the arrow keys. Sets the
66+
/// GEN_WASD_CAMERA env hook (LookAtXlat.cpp), which mirrors W/A/S/D onto the
67+
/// camera scroll directions. The arrow keys keep working too. Those keys are
68+
/// normally bound: W=SelectAllAircraft and S=Stop are meta commands (localized
69+
/// CommandMap.ini), A/D and the build shortcut on each letter are command-bar
70+
/// buttons; while this mode is on all of them relocate to Cmd+key (MetaEvent.cpp
71+
/// + HotKey.cpp), so nothing is lost from the keyboard. Defaults to OFF
72+
/// (original arrow-only behaviour). Persisted.
73+
@Published var wasdCamera: Bool =
74+
(UserDefaults.standard.object(forKey: "wasdCamera") as? Bool) ?? false {
75+
didSet { UserDefaults.standard.set(wasdCamera, forKey: "wasdCamera") }
76+
}
77+
6578
// Update state (populated by checkForUpdates).
6679
@Published var latestEngineVersion: String? = nil
6780
@Published var latestLauncherVersion: String? = nil
@@ -661,6 +674,11 @@ final class LauncherModel: ObservableObject {
661674
// GEN_NO_SHELLMAP is applied after INI and sticks.
662675
env.removeValue(forKey: "GEN_NO_SHELLMAP")
663676
if disableMenuShellMap { env["GEN_NO_SHELLMAP"] = "1" }
677+
// Pan the camera with W/A/S/D as well as the arrow keys (LookAtXlat.cpp).
678+
// When on, the engine relocates the A/S/D command-bar shortcuts to
679+
// Cmd+A/S/D (HotKey.cpp) so nothing is lost from the keyboard.
680+
env.removeValue(forKey: "GEN_WASD_CAMERA")
681+
if wasdCamera { env["GEN_WASD_CAMERA"] = "1" }
664682
proc.environment = env
665683
proc.terminationHandler = { [weak self] _ in
666684
Task { @MainActor in

‎launcher/Sources/GeneralsZHLauncher/PlayTab.swift‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,19 @@ struct PlayTab: View {
147147
+ "the animated scene. (Sets GEN_NO_SHELLMAP for the engine.)")
148148
Spacer()
149149
}
150+
HStack {
151+
Toggle("WASD camera controls", isOn: $model.wasdCamera)
152+
.toggleStyle(.checkbox)
153+
.disabled(model.gameRunning)
154+
.help("Pan the battlefield with W/A/S/D as well as the arrow keys "
155+
+ "(arrows keep working). Those keys normally do other things — "
156+
+ "W selects all aircraft, S stops, A is attack-move, D builds a "
157+
+ "dozer, plus the build shortcut on each letter — so while this "
158+
+ "is on they move to \u{2318} (Command): \u{2318}W, \u{2318}S, "
159+
+ "\u{2318}A, \u{2318}D. Nothing is lost from the keyboard. "
160+
+ "Off by default. (Sets GEN_WASD_CAMERA for the engine.)")
161+
Spacer()
162+
}
150163
if model.gameRunning {
151164
Text("Quit the game to change display settings.")
152165
.font(.caption2).foregroundColor(.secondary)

0 commit comments

Comments
 (0)