Files
OpenMaidEngine/godot/Main.Input.cs
2026-08-02 18:43:15 -04:00

310 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using Godot;
using Age.Engine.Diagnostics;
using Age.Engine.Vm;
public partial class Main
{
private ImageTexture? _ageCursorTexture;
private Label _locatorHud = null!;
private FullwidthTextEditorDialog? _fullwidthTextEditor;
private DebugSceneLauncher? _debugSceneLauncher;
private IReadOnlyList<DebugSceneEntry> _debugSceneEntries = System.Array.Empty<DebugSceneEntry>();
private PageLocatorState _locator = null!;
private bool _locatorHudVisible;
// _Input (not _UnhandledInput): the root Control consumes mouse clicks as GUI input before they
// reach _UnhandledInput, so clicks were swallowed while keyboard ui_accept still got through.
public override void _Input(InputEvent e)
{
if (_selftest) return;
if (e is InputEventKey key && key.Pressed && !key.Echo && key.Keycode == Key.F2)
{
_locatorHudVisible = !_locatorHudVisible;
_locatorHud.Visible = _locatorHudVisible;
if (_locatorHudVisible) _locatorHud.Text = _locator.CurrentDisplay;
return;
}
if (e is InputEventKey copy && copy.Pressed && !copy.Echo && copy.Keycode == Key.F3)
{
DisplayServer.ClipboardSet(_locator.CurrentDisplay);
_locatorHud.Text = _locator.CurrentDisplay + " · copied";
return;
}
if (e is InputEventKey debugKey && debugKey.Pressed && !debugKey.Echo && debugKey.Keycode == Key.F4)
{
ToggleDebugSceneLauncher();
GetViewport().SetInputAsHandled();
return;
}
if (e is InputEventKey diagnosticKey && diagnosticKey.Keycode == Key.F6)
{
if (diagnosticKey.Pressed && !diagnosticKey.Echo) CaptureStallDiagnostic();
GetViewport().SetInputAsHandled();
return;
}
if (_debugSceneLauncher?.Visible == true)
{
if (e is InputEventKey escape && escape.Pressed && !escape.Echo && escape.Keycode == Key.Escape)
{
_debugSceneLauncher.Hide();
GetViewport().SetInputAsHandled();
}
return;
}
if (e is InputEventMouseMotion motion)
{
var p = ToNativeScreen(motion.Position);
_vm.UpdatePointer(p.X, p.Y);
return;
}
if (e is InputEventMouseButton wheel
&& wheel.Pressed
&& (wheel.ButtonIndex == MouseButton.WheelUp || wheel.ButtonIndex == MouseButton.WheelDown))
{
// WM_MOUSEWHEEL supplies signed multiples of WHEEL_DELTA (120). AGE accumulates that
// value until op 0x10d reads and clears it; HISTORY currently uses only its sign.
int direction = wheel.ButtonIndex == MouseButton.WheelUp ? 1 : -1;
int steps = System.Math.Max(1, (int)System.Math.Round(wheel.Factor));
_vm.QueueMouseWheelDelta(direction * 120 * steps);
GetViewport().SetInputAsHandled();
return;
}
if (e is InputEventMouseButton mb
&& (mb.ButtonIndex == MouseButton.Left || mb.ButtonIndex == MouseButton.Right))
{
var p = ToNativeScreen(mb.Position);
bool advPageSuspended = _host.IsAdvPagePresentationSuspended;
bool rawInputCallbackActive = _vm.IsRawInputCallbackActive;
_vm.UpdatePointer(p.X, p.Y);
int nativeButtonBit = mb.ButtonIndex == MouseButton.Left ? 0x1 : 0x2;
int physicalButton = mb.ButtonIndex == MouseButton.Left ? 0 : 1;
_vm.UpdateMouseButtonState(nativeButtonBit, mb.Pressed);
int action = _vm.UpdatePhysicalMouseButtonState(physicalButton, mb.Pressed);
if (mb.Pressed && _host.IsModalMovieWaiting)
{
_host.SignalInput();
GetViewport().SetInputAsHandled();
return;
}
// Native blocking effect services poll logical action 4 before ADV hotspot dispatch.
// Consume the trigger here so a retained hotspot under the transition cannot steal it.
if (mb.Pressed && action == 4 && _host.IsTransitionWaiting)
{
_host.SignalInput();
GetViewport().SetInputAsHandled();
return;
}
// AGE exposes mouse buttons twice: op 0x108 reads the raw bitmask while op 0xff translates
// the held physical button through the script-configured logical action map.
if (mb.Pressed && action >= 0 && _vm.TryActivateInputActions(1 << action))
{
GetViewport().SetInputAsHandled();
return;
}
if (mb.ButtonIndex == MouseButton.Left && mb.Pressed && _vm.TryActivatePointer(p.X, p.Y))
{
GetViewport().SetInputAsHandled();
return;
}
// Modal callback scripts return through their own bytecode. Signaling the enclosing ADV wait
// here would also advance the restored dialogue page after HISTORY/HIDEWIN exits.
if (mb.ButtonIndex == MouseButton.Left && mb.Pressed
&& !advPageSuspended && !rawInputCallbackActive) _host.SignalInput();
return;
}
if (e is InputEventKey gameplayKey && !gameplayKey.Echo
&& Win32VirtualKeyTranslator.TryTranslate(gameplayKey, out int virtualKey))
{
int action = _vm.UpdateKeyboardVirtualKeyState(virtualKey, gameplayKey.Pressed);
if (gameplayKey.Pressed && action == 4 && _host.IsTransitionWaiting)
{
_host.SignalInput();
GetViewport().SetInputAsHandled();
}
else if (gameplayKey.Pressed && action >= 0 && _vm.TryActivateInputActions(1 << action))
{
GetViewport().SetInputAsHandled();
}
else if (gameplayKey.Pressed && IsAdvanceAction(action))
{
if (_host.IsModalMovieWaiting)
{
_host.SignalInput();
GetViewport().SetInputAsHandled();
}
else if (!_host.IsAdvPagePresentationSuspended && !_vm.IsRawInputCallbackActive)
_host.SignalInput();
}
return;
}
if (e is InputEventJoypadButton joyButton)
{
int actionMask = _vm.UpdateJoystickButtonState((int)joyButton.ButtonIndex, joyButton.Pressed);
if (joyButton.Pressed && (actionMask & (1 << 4)) != 0 && _host.IsTransitionWaiting)
{
_host.SignalInput();
GetViewport().SetInputAsHandled();
}
else if (joyButton.Pressed && _vm.TryActivateInputActions(actionMask))
{
GetViewport().SetInputAsHandled();
}
else if (joyButton.Pressed && HasAdvanceAction(actionMask))
{
if (_host.IsModalMovieWaiting)
{
_host.SignalInput();
GetViewport().SetInputAsHandled();
}
else if (!_host.IsAdvPagePresentationSuspended && !_vm.IsRawInputCallbackActive)
_host.SignalInput();
}
return;
}
if (e is InputEventJoypadMotion joyMotion && (int)joyMotion.Axis is 0 or 1)
_vm.UpdateJoystickAxisState((int)joyMotion.Axis, joyMotion.AxisValue);
}
private static bool IsAdvanceAction(int action) => action is 4 or 5;
private static bool HasAdvanceAction(int mask) => (mask & ((1 << 4) | (1 << 5))) != 0;
private void ToggleDebugSceneLauncher()
{
if (_debugSceneLauncher == null) return;
if (_debugSceneLauncher.Visible)
{
_debugSceneLauncher.Hide();
return;
}
if (!TryGetTitleDebugFrame(out var frame, out string reason))
{
_status.Text = reason;
GD.Print($"[debug-launcher] unavailable: {reason}");
return;
}
_debugSceneLauncher.Open(_debugSceneEntries, string.Join(" > ", frame.CallStack));
}
private void LaunchDebugScene(DebugSceneEntry entry)
{
if (_debugSceneLauncher == null || _scripts == null) return;
if (!TryGetTitleDebugFrame(out var frame, out string reason))
{
_debugSceneLauncher.SetStatus(reason);
return;
}
if (!entry.Launchable || _scripts.GetById(entry.PackedId) == null)
{
_debugSceneLauncher.SetStatus("The selected packed script could not be parsed; no state was changed.");
return;
}
var coordinatorWrites = new Dictionary<int, long>
{
[0] = 1,
[0xaba5c] = -1,
[0x62ccf] = 0,
[0x699] = entry.PackedId,
};
if (!_vm.TryRequestDebugFrameReturn(frame.FrameId, coordinatorWrites))
{
_debugSceneLauncher.SetStatus("TITLE changed frames before launch; reopen the launcher and try again.");
return;
}
_timeline?.Event("debug-scene-launch-request", new()
{
["script"] = entry.Name,
["packed_id"] = entry.PackedId,
});
GD.Print($"[debug-launcher] SYSTEM4 dispatch requested: {entry.Name} (0x{entry.PackedId:x8})");
_debugSceneLauncher.Hide();
// ADV waits need an explicit wake; TITLE's actual menu is a 1 ms sleep/poll loop and will consume
// the request at its next opcode boundary without leaving a stale input signal for the child scene.
if (_host.IsWaiting) _host.SignalInput();
}
private bool TryGetTitleDebugFrame(out DebugFrameSnapshot frame, out string reason)
{
frame = _vm.DebugFrame!;
if (_done || frame == null)
{
reason = "Available only while TITLE is the active SYSTEM4 child.";
return false;
}
if (frame.CallStack.Count != 2
|| !frame.CallStack[0].Equals("SYSTEM4.BIN", System.StringComparison.OrdinalIgnoreCase)
|| !frame.CallStack[1].Equals("TITLE.BIN", System.StringComparison.OrdinalIgnoreCase)
|| !frame.CurrentScript.Equals("TITLE.BIN", System.StringComparison.OrdinalIgnoreCase))
{
reason = "Refused: the active stack is not SYSTEM4 > TITLE.";
return false;
}
reason = "";
return true;
}
private (int X, int Y) ToNativeScreen(Vector2 position)
{
// Godot reports input in viewport coordinates after content scaling. This ratio is therefore
// normally identity, while remaining correct for any viewport expansion policy.
Vector2 size = GetViewport().GetVisibleRect().Size;
if (size.X <= 0 || size.Y <= 0) return (0, 0);
return ((int)System.Math.Floor(position.X * _screenWidth / size.X),
(int)System.Math.Floor(position.Y * _screenHeight / size.Y));
}
public void SetAgeCursor(byte[] rgba, int width, int height, int hotspotX, int hotspotY)
{
var image = Image.CreateFromData(width, height, false, Image.Format.Rgba8, rgba);
_ageCursorTexture = ImageTexture.CreateFromImage(image);
Input.SetCustomMouseCursor(_ageCursorTexture, Input.CursorShape.Arrow,
new Vector2(hotspotX, hotspotY));
}
public void ClearAgeCursor()
{
Input.SetCustomMouseCursor(null, Input.CursorShape.Arrow);
_ageCursorTexture = null;
}
public void ShowAgeDiagnostic(string text, string caption)
{
try
{
OS.Alert(text, caption);
}
catch (System.Exception error)
{
GD.PushError($"[diagnostic] native alert failed: {error.Message}");
}
finally
{
_host?.CompleteDiagnosticMessage();
}
}
public void ShowAgeFullwidthTextEditor(string initialText)
{
if (_fullwidthTextEditor != null)
{
GD.PushWarning("[inputname] replaced an already-open full-width text editor");
_fullwidthTextEditor.QueueFree();
}
var editor = new FullwidthTextEditorDialog();
_fullwidthTextEditor = editor;
editor.EditCompleted += (accepted, text) =>
{
if (_fullwidthTextEditor == editor) _fullwidthTextEditor = null;
_host?.CompleteFullwidthTextEdit(accepted, text);
editor.QueueFree();
};
AddChild(editor);
editor.Open(initialText);
}
}