Legacy Input System(Old)을 New Input System으로 Wrapping하는 클래스
Unity 6000.1.11f1
URP
InputSystem 1.14.1
NGUI 2025.07.01
----
Legacy Input System을 new Input System으로 변환하면서 기존 Third Party 에셋들이 기존 Legacy Input을 계속 사용하고 있어서(특히 NGUI)
에러나는 부분을 가능하면 손을 덜 대고 해결하기 위해서,
Legacy Input System을 New Input System으로 Wrapping하는 클래스를 만들었다.
(왠만한 건 다 Wrapping 한 것 같은데 혹시나 빠진게 있을수도 있다.)
Activate Input Handling을 New Input System으로 변경하고 아래 스크립트를 Plugins 폴더에 넣어주면 된다.
[파일]
[코드]
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Controls;
using Debug = UnityEngine.Debug;
// 별칭 지정
using InputSystemTouchPhase = UnityEngine.InputSystem.TouchPhase;
using LegacyTouchPhase = UnityEngine.TouchPhase;
using InputSystemGyroscope = UnityEngine.InputSystem.Gyroscope;
/// <summary>
/// Legacy Input System을 New Input System으로 Wrapping하는 클래스.
/// </summary>
public static class Input
{
#region 필드
/// <summary>
/// 가속도계 이벤트를 나타냅니다.
/// </summary>
public struct AccelerationEvent
{
public Vector3 acceleration;
public float deltaTime;
}
/// <summary>
/// Legacy Gyroscope API를 모방하는 래퍼 클래스입니다.
/// </summary>
public class Gyroscope
{
private readonly InputSystemGyroscope device;
private readonly GravitySensor gravityDevice;
private readonly AttitudeSensor attitudeDevice;
internal Gyroscope(InputSystemGyroscope device)
{
this.device = device;
this.gravityDevice = GravitySensor.current;
this.attitudeDevice = AttitudeSensor.current;
}
/// <summary>
/// 자이로스코프 활성화 여부입니다.
/// </summary>
public bool enabled
{
get => device != null && device.enabled;
set
{
if (device != null && device.enabled != value)
{
if (value)
{
InputSystem.EnableDevice(device);
if (attitudeDevice != null) InputSystem.EnableDevice(attitudeDevice);
}
else
{
InputSystem.DisableDevice(device);
if (attitudeDevice != null) InputSystem.DisableDevice(attitudeDevice);
}
}
}
}
/// <summary>
/// 장치의 자세(Attitude)를 반환합니다.
/// </summary>
public Quaternion attitude => attitudeDevice?.attitude.ReadValue() ?? Quaternion.identity;
/// <summary>
/// 장치의 중력 가속도 벡터를 반환합니다.
/// </summary>
public Vector3 gravity => gravityDevice?.gravity.ReadValue() ?? Vector3.zero;
/// <summary>
/// 장치의 회전 속도를 반환합니다. (deg/s)
/// </summary>
public Vector3 rotationRate => (device?.angularVelocity.ReadValue() ?? Vector3.zero) * Mathf.Rad2Deg;
/// <summary>
/// 바이어스가 보정된 장치의 회전 속도를 반환합니다. (New Input System에서는 rotationRate와 동일)
/// </summary>
public Vector3 rotationRateUnbiased => rotationRate;
/// <summary>
/// 자이로스코프의 업데이트 간격(초)입니다.
/// </summary>
public float updateInterval
{
get => (device != null && device.samplingFrequency > 0) ? 1.0f / device.samplingFrequency : 0;
set
{
// New Input System에서는 프로그래밍 방식으로 업데이트 간격을 설정할 수 없습니다.
LogInfo("Gyroscope.updateInterval은 New Input System에서 설정할 수 없습니다.");
}
}
}
private static readonly Dictionary<KeyCode, Key> keyMapping = new Dictionary<KeyCode, Key>();
private static readonly Dictionary<string, InputAction> axisActions = new Dictionary<string, InputAction>();
private static readonly List<Touch> cachedTouches = new List<Touch>();
private static readonly List<AccelerationEvent> accelerationEventBuffer = new List<AccelerationEvent>();
private static InputAction anyKeyAction;
private static float lastTouchUpdateTime;
private static bool isInitialized;
private static bool enableDebugLogs = true;
private static Gyroscope _gyroscope;
// UIInput 호환성을 위한 필드
private static readonly StringBuilder inputBuffer = new StringBuilder();
public static string compositionString { get; private set; } = "";
public static Vector2 compositionCursorPos { get; set; }
// 현재 입력 장치들
private static Mouse currentMouse => Mouse.current;
private static Keyboard currentKeyboard => Keyboard.current;
private static Gamepad currentGamepad => Gamepad.current;
private static Touchscreen currentTouchscreen => Touchscreen.current;
private static Accelerometer currentAccelerometer => Accelerometer.current;
private static InputSystemGyroscope currentGyro => InputSystemGyroscope.current;
private enum KeyState { Down, Up, Held }
#endregion
#region 초기화
static Input()
{
Initialize();
}
/// <summary>
/// NewInput 시스템을 명시적으로 초기화합니다.
/// </summary>
public static void Initialize()
{
if (isInitialized) return;
try
{
LogInfo("NewInput 시스템 초기화 시작...");
InitializeKeyMapping();
InitializeDefaultActions();
SetupAnyKeyAction();
SetupTextHandling();
#if UNITY_ANDROID || UNITY_IOS
if (!UnityEngine.InputSystem.EnhancedTouch.EnhancedTouchSupport.enabled)
{
UnityEngine.InputSystem.EnhancedTouch.EnhancedTouchSupport.Enable();
LogInfo("EnhancedTouchSupport 활성화됨.");
}
if (currentAccelerometer != null && !currentAccelerometer.enabled)
{
InputSystem.EnableDevice(currentAccelerometer);
LogInfo("가속도계 활성화됨.");
}
if (currentGyro != null && !currentGyro.enabled)
{
InputSystem.EnableDevice(currentGyro);
LogInfo("자이로스코프 활성화됨.");
}
if (GravitySensor.current != null && !GravitySensor.current.enabled)
{
InputSystem.EnableDevice(GravitySensor.current);
LogInfo("중력 센서 활성화됨.");
}
if (AttitudeSensor.current != null && !AttitudeSensor.current.enabled)
{
InputSystem.EnableDevice(AttitudeSensor.current);
LogInfo("자세 센서 활성화됨.");
}
#endif
isInitialized = true;
LogInfo("NewInput 시스템 초기화 완료.");
}
catch (Exception ex)
{
LogError($"NewInput 초기화 중 오류 발생: {ex.Message}");
}
}
private static void InitializeKeyMapping()
{
keyMapping.Clear();
// 알파벳, 숫자, 특수 키 매핑 (기존과 동일)
for (int i = 0; i < 26; i++) keyMapping[(KeyCode)((int)KeyCode.A + i)] = Key.A + i;
for (int i = 0; i < 10; i++) keyMapping[(KeyCode)((int)KeyCode.Alpha0 + i)] = Key.Digit0 + i;
for (int i = 0; i < 10; i++) keyMapping[(KeyCode)((int)KeyCode.Keypad0 + i)] = Key.Numpad0 + i;
for (int i = 0; i < 15; i++) keyMapping[(KeyCode)((int)KeyCode.F1 + i)] = Key.F1 + i;
keyMapping[KeyCode.Backspace] = Key.Backspace;
keyMapping[KeyCode.Tab] = Key.Tab;
keyMapping[KeyCode.Return] = Key.Enter;
keyMapping[KeyCode.Pause] = Key.Pause;
keyMapping[KeyCode.Escape] = Key.Escape;
keyMapping[KeyCode.Space] = Key.Space;
keyMapping[KeyCode.Delete] = Key.Delete;
keyMapping[KeyCode.LeftShift] = Key.LeftShift;
keyMapping[KeyCode.RightShift] = Key.RightShift;
keyMapping[KeyCode.LeftControl] = Key.LeftCtrl;
keyMapping[KeyCode.RightControl] = Key.RightCtrl;
keyMapping[KeyCode.LeftAlt] = Key.LeftAlt;
keyMapping[KeyCode.RightAlt] = Key.RightAlt;
keyMapping[KeyCode.LeftArrow] = Key.LeftArrow;
keyMapping[KeyCode.RightArrow] = Key.RightArrow;
keyMapping[KeyCode.UpArrow] = Key.UpArrow;
keyMapping[KeyCode.DownArrow] = Key.DownArrow;
keyMapping[KeyCode.KeypadEnter] = Key.NumpadEnter;
keyMapping[KeyCode.Insert] = Key.Insert;
keyMapping[KeyCode.Home] = Key.Home;
keyMapping[KeyCode.End] = Key.End;
keyMapping[KeyCode.PageUp] = Key.PageUp;
keyMapping[KeyCode.PageDown] = Key.PageDown;
}
private static void InitializeDefaultActions()
{
// 기존 축 액션 정리
if (axisActions.TryGetValue("Horizontal", out var horizontalAction)) horizontalAction.Disable();
if (axisActions.TryGetValue("Vertical", out var verticalAction)) verticalAction.Disable();
// Horizontal 축 설정 (키보드 좌/우 화살표, A/D 키)
var horizontal = new InputAction("Horizontal", type: InputActionType.Value);
horizontal.AddCompositeBinding("1DAxis")
.With("negative", "<Keyboard>/leftArrow")
.With("positive", "<Keyboard>/rightArrow");
horizontal.AddCompositeBinding("1DAxis")
.With("negative", "<Keyboard>/a")
.With("positive", "<Keyboard>/d");
horizontal.AddBinding("<Gamepad>/leftStick/x");
horizontal.Enable();
axisActions["Horizontal"] = horizontal;
// Vertical 축 설정 (키보드 상/하 화살표, W/S 키)
var vertical = new InputAction("Vertical", type: InputActionType.Value);
vertical.AddCompositeBinding("1DAxis")
.With("negative", "<Keyboard>/downArrow")
.With("positive", "<Keyboard>/upArrow");
vertical.AddCompositeBinding("1DAxis")
.With("negative", "<Keyboard>/s")
.With("positive", "<Keyboard>/w");
vertical.AddBinding("<Gamepad>/leftStick/y");
vertical.Enable();
axisActions["Vertical"] = vertical;
// 마우스 축 설정
CreateAxisAction("Mouse X", "<Mouse>/delta/x");
CreateAxisAction("Mouse Y", "<Mouse>/delta/y");
CreateAxisAction("Mouse ScrollWheel", "<Mouse>/scroll/y");
}
private static void SetupAnyKeyAction()
{
anyKeyAction?.Disable();
anyKeyAction = new InputAction("AnyKey", type: InputActionType.PassThrough);
anyKeyAction.AddBinding("<Keyboard>/anyKey");
anyKeyAction.AddBinding("<Mouse>/leftButton");
anyKeyAction.AddBinding("<Mouse>/rightButton");
anyKeyAction.AddBinding("<Mouse>/middleButton");
anyKeyAction.AddBinding("<Touchscreen>/primaryTouch/press");
anyKeyAction.AddBinding("<Gamepad>/buttonSouth");
anyKeyAction.AddBinding("<Gamepad>/buttonNorth");
anyKeyAction.AddBinding("<Gamepad>/buttonEast");
anyKeyAction.AddBinding("<Gamepad>/buttonWest");
anyKeyAction.Enable();
}
private static void CreateAxisAction(string name, params string[] bindings)
{
var action = new InputAction(name, InputActionType.Value);
foreach (var binding in bindings) action.AddBinding(binding);
action.Enable();
axisActions[name] = action;
}
private static void SetupTextHandling()
{
if (currentKeyboard != null)
{
currentKeyboard.onTextInput -= OnTextInput;
currentKeyboard.onTextInput += OnTextInput;
}
// InputSystem.onIMECompositionChange는 존재하지 않으므로 제거합니다.
// IME 처리는 onTextInput을 통해 간접적으로 지원됩니다.
Application.onBeforeRender -= FrameReset;
Application.onBeforeRender += FrameReset;
}
#endregion
#region 공개 API
// UIInput 호환성 API
public static string inputString => inputBuffer.ToString();
public static IMECompositionMode imeCompositionMode { get; set; }
public static bool imeIsSelected => !string.IsNullOrEmpty(compositionString);
public static bool GetKeyDown(KeyCode key) => GetKeyState(key, KeyState.Down);
public static bool GetKeyUp(KeyCode key) => GetKeyState(key, KeyState.Up);
public static bool GetKey(KeyCode key) => GetKeyState(key, KeyState.Held);
public static bool GetKeyDown(string name)
{
if (TryParseKeyCode(name, out var key))
{
return GetKeyDown(key);
}
return false;
}
public static bool GetKeyUp(string name)
{
if (TryParseKeyCode(name, out var key))
{
return GetKeyUp(key);
}
return false;
}
public static bool GetKey(string name)
{
if (TryParseKeyCode(name, out var key))
{
return GetKey(key);
}
return false;
}
public static float GetAxis(string axisName)
{
if (string.IsNullOrEmpty(axisName) || !axisActions.TryGetValue(axisName, out var action) || action == null || !action.enabled)
return 0f;
if (action.expectedControlType == "Vector2")
{
var value = action.ReadValue<Vector2>();
if (axisName.EndsWith("X") || axisName == "Horizontal") return value.x;
if (axisName.EndsWith("Y") || axisName == "Vertical") return value.y;
return value.magnitude;
}
return action.ReadValue<float>();
}
public static float GetAxisRaw(string axisName)
{
if (string.IsNullOrEmpty(axisName) || !axisActions.TryGetValue(axisName, out var action) || action == null || !action.enabled)
return 0f;
if (action.expectedControlType == "Vector2")
{
var value = action.ReadValue<Vector2>();
if (axisName.EndsWith("X") || axisName == "Horizontal") return value.x;
if (axisName.EndsWith("Y") || axisName == "Vertical") return value.y;
return value.magnitude;
}
var rawValue = action.ReadValue<float>();
return Mathf.Abs(rawValue) > 0 ? Mathf.Sign(rawValue) : 0;
}
public static bool GetButton(string buttonName) => GetButtonState(buttonName, KeyState.Held);
public static bool GetButtonDown(string buttonName) => GetButtonState(buttonName, KeyState.Down);
public static bool GetButtonUp(string buttonName) => GetButtonState(buttonName, KeyState.Up);
public static bool GetMouseButton(int button) => GetKey(KeyCode.Mouse0 + button);
public static bool GetMouseButtonDown(int button) => GetKeyDown(KeyCode.Mouse0 + button);
public static bool GetMouseButtonUp(int button) => GetKeyUp(KeyCode.Mouse0 + button);
public static Vector3 mousePosition => currentMouse?.position.ReadValue() ?? Vector3.zero;
public static Vector2 mouseScrollDelta => currentMouse?.scroll.ReadValue() ?? Vector2.zero;
public static bool mousePresent => currentMouse != null;
public static bool anyKey => anyKeyAction?.IsPressed() ?? false;
public static bool anyKeyDown => anyKeyAction?.triggered ?? false;
public static bool touchSupported => currentTouchscreen != null;
public static bool stylusTouchSupported => Pen.current != null;
public static bool touchPressureSupported => currentTouchscreen?.pressure.IsActuated() ?? false;
public static bool multiTouchEnabled { get; set; } = true;
public static bool simulateMouseWithTouches { get; set; } = true;
public static int touchCount
{
get
{
UpdateCachedTouches();
return cachedTouches.Count;
}
}
public static Touch GetTouch(int index)
{
UpdateCachedTouches();
if (index < 0 || index >= cachedTouches.Count)
throw new ArgumentOutOfRangeException(nameof(index), $"Touch index {index} is out of range. Count: {cachedTouches.Count}");
return cachedTouches[index];
}
public static Touch[] touches
{
get
{
UpdateCachedTouches();
return cachedTouches.ToArray();
}
}
public static Vector3 acceleration
{
get
{
if (currentAccelerometer != null && currentAccelerometer.enabled)
{
return currentAccelerometer.acceleration.ReadValue();
}
return Vector3.zero;
}
}
public static int accelerationEventCount
{
get
{
UpdateAccelerationEvents();
return accelerationEventBuffer.Count;
}
}
public static AccelerationEvent[] accelerationEvents
{
get
{
UpdateAccelerationEvents();
return accelerationEventBuffer.ToArray();
}
}
public static AccelerationEvent GetAccelerationEvent(int index)
{
UpdateAccelerationEvents();
if (index < 0 || index >= accelerationEventBuffer.Count)
throw new ArgumentOutOfRangeException(nameof(index));
return accelerationEventBuffer[index];
}
public static Gyroscope gyro
{
get
{
if (_gyroscope == null && currentGyro != null)
{
_gyroscope = new Gyroscope(currentGyro);
}
return _gyroscope;
}
}
public static bool isGyroAvailable => currentGyro != null && currentGyro.enabled;
public static DeviceOrientation deviceOrientation
{
get
{
switch (Screen.orientation)
{
case ScreenOrientation.Portrait:
return DeviceOrientation.Portrait;
case ScreenOrientation.PortraitUpsideDown:
return DeviceOrientation.PortraitUpsideDown;
case ScreenOrientation.LandscapeLeft:
return DeviceOrientation.LandscapeLeft;
case ScreenOrientation.LandscapeRight:
return DeviceOrientation.LandscapeRight;
default:
return DeviceOrientation.Unknown;
}
}
}
/// <summary>
/// 안드로이드에서 뒤로가기 버튼이 앱을 종료할지 여부를 결정합니다.
/// New Input System에서는 이 기능을 직접 제어할 수 없으므로, 이 속성은 동작하지 않습니다.
/// </summary>
public static bool backButtonLeavesApp { get; set; } = true;
public static string[] GetJoystickNames()
{
return Gamepad.all.Select(g => g.displayName).ToArray();
}
public static void ResetInputAxes()
{
foreach (var action in axisActions.Values)
{
// New Input System에서는 직접 리셋하는 기능 대신,
// 다음 프레임에 값이 0으로 돌아가는 것을 기대합니다.
// 필요 시, action.Disable() / action.Enable()을 고려할 수 있습니다.
}
LogInfo("Input axes reset requested.");
}
#endregion
#region 내부 로직
private static void OnTextInput(char character) => inputBuffer.Append(character);
private static void FrameReset()
{
inputBuffer.Clear();
accelerationEventBuffer.Clear();
}
private static bool TryParseKeyCode(string name, out KeyCode key)
{
try
{
key = (KeyCode)Enum.Parse(typeof(KeyCode), name, true);
return true;
}
catch (ArgumentException)
{
LogError($"'{name}'은(는) 유효한 KeyCode가 아닙니다.");
key = KeyCode.None;
return false;
}
}
private static bool GetButtonState(string buttonName, KeyState state)
{
if (TryParseKeyCode(buttonName, out var key))
{
return GetKeyState(key, state);
}
LogError($"'{buttonName}'은(는) 유효한 KeyCode 이름이 아닙니다.");
return false;
}
private static bool GetKeyState(KeyCode key, KeyState state)
{
try
{
// 마우스 버튼 처리
if (key >= KeyCode.Mouse0 && key <= KeyCode.Mouse6)
{
if (currentMouse == null) return false;
var button = (key - KeyCode.Mouse0) switch
{
0 => currentMouse.leftButton,
1 => currentMouse.rightButton,
2 => currentMouse.middleButton,
_ => null
};
return button != null && CheckButtonState(button, state);
}
// 조이스틱 버튼 처리
if (key >= KeyCode.JoystickButton0 && key <= KeyCode.JoystickButton19)
{
if (currentGamepad == null) return false;
int buttonIndex = (int)key - (int)KeyCode.JoystickButton0;
if (currentGamepad.allControls.Count <= buttonIndex) return false;
var button = currentGamepad.allControls[buttonIndex] as ButtonControl;
return button != null && CheckButtonState(button, state);
}
// 키보드 키 처리
if (currentKeyboard != null && keyMapping.TryGetValue(key, out var mappedKey) && mappedKey != Key.None)
{
return CheckButtonState(currentKeyboard[mappedKey], state);
}
}
catch (Exception ex)
{
LogError($"GetKeyState 오류 (Key: {key}, State: {state}): {ex.Message}");
}
return false;
}
private static bool CheckButtonState(ButtonControl button, KeyState state)
{
return state switch
{
KeyState.Down => button.wasPressedThisFrame,
KeyState.Up => button.wasReleasedThisFrame,
KeyState.Held => button.isPressed,
_ => false
};
}
private static void UpdateCachedTouches()
{
if (Time.unscaledTime == lastTouchUpdateTime) return;
lastTouchUpdateTime = Time.unscaledTime;
cachedTouches.Clear();
#if UNITY_EDITOR || UNITY_STANDALONE
if (simulateMouseWithTouches) SimulateMouseAsTouch();
#else
if (currentTouchscreen == null) return;
foreach (var activeTouch in UnityEngine.InputSystem.EnhancedTouch.Touch.activeTouches)
{
cachedTouches.Add(ConvertEnhancedTouch(activeTouch));
if (!multiTouchEnabled) break;
}
#endif
}
private static void UpdateAccelerationEvents()
{
if (currentAccelerometer != null && currentAccelerometer.enabled)
{
var accel = currentAccelerometer.acceleration.ReadValue();
accelerationEventBuffer.Add(new AccelerationEvent { acceleration = accel, deltaTime = Time.deltaTime });
}
}
private static Touch ConvertEnhancedTouch(UnityEngine.InputSystem.EnhancedTouch.Touch enhancedTouch)
{
var legacyPhase = enhancedTouch.phase switch
{
InputSystemTouchPhase.Began => LegacyTouchPhase.Began,
InputSystemTouchPhase.Moved => LegacyTouchPhase.Moved,
InputSystemTouchPhase.Stationary => LegacyTouchPhase.Stationary,
InputSystemTouchPhase.Ended => LegacyTouchPhase.Ended,
InputSystemTouchPhase.Canceled => LegacyTouchPhase.Canceled,
_ => LegacyTouchPhase.Canceled
};
return new Touch
{
fingerId = enhancedTouch.finger.index,
position = enhancedTouch.screenPosition,
deltaPosition = legacyPhase == LegacyTouchPhase.Began ? Vector2.zero : enhancedTouch.delta,
deltaTime = Time.unscaledDeltaTime,
tapCount = enhancedTouch.tapCount,
phase = legacyPhase
};
}
private static void SimulateMouseAsTouch()
{
if (currentMouse == null) return;
const int mouseFingerId = 0;
var phase = LegacyTouchPhase.Canceled;
bool addToList = false;
if (currentMouse.leftButton.wasPressedThisFrame)
{
phase = LegacyTouchPhase.Began;
addToList = true;
}
else if (currentMouse.leftButton.wasReleasedThisFrame)
{
phase = LegacyTouchPhase.Ended;
addToList = true;
}
else if (currentMouse.leftButton.isPressed)
{
phase = currentMouse.delta.ReadValue().sqrMagnitude < 0.1f ? LegacyTouchPhase.Stationary : LegacyTouchPhase.Moved;
addToList = true;
}
if (addToList)
{
cachedTouches.Add(new Touch
{
fingerId = mouseFingerId,
position = currentMouse.position.ReadValue(),
deltaPosition = phase == LegacyTouchPhase.Began ? Vector2.zero : currentMouse.delta.ReadValue(),
deltaTime = Time.unscaledDeltaTime,
tapCount = 1,
phase = phase
});
}
}
#endregion
#region 유틸리티 및 헬퍼 클래스
[Conditional("UNITY_EDITOR"), Conditional("DEVELOPMENT_BUILD")]
private static void LogInfo(string message)
{
if (enableDebugLogs) Debug.Log($"[Input(Legacy Input Wrapper)] {message}");
}
[Conditional("UNITY_EDITOR"), Conditional("DEVELOPMENT_BUILD")]
private static void LogError(string message)
{
if (enableDebugLogs) Debug.LogError($"[Input(Legacy Input Wrapper)] {message}");
}
public static void SetDebugLogging(bool enabled) => enableDebugLogs = enabled;
#endregion
}
[참고]
강제로 Wrapping 하지 않고, 필요한 부분만 하고싶다면,
위 Input.cs를 NewInput.cs로 클래스/파일 명을 변경하고
적용하고 싶은 스크립트 상단에 using Input = NewInput; 을 써서 해당 스크립트만 부분적으로 적용해도 될 것 같다.
'Unity3D > Script' 카테고리의 다른 글
[Unity] 캔디 랩(Candy Wrap) 현상 보정 방법 (0) | 2025.02.27 |
---|---|
[펌] OcclusionCulling2D (0) | 2024.03.14 |
[펌] GameQualitySettings (0) | 2024.01.19 |
[최적화] Automatic quality settings (0) | 2024.01.19 |
[ChatGPT] Multi-Key Dictionary in Unity? (0) | 2023.08.07 |