Improve COM stability, settings security, and add VFX layer

- Оптимизировано закрытие COM-системы: вызов ReadLine вынесен из-под блокировки во избежание зависания главного потока Unity Editor при выходе из игры или выгрузке сцены.
- Реализован контур безопасности: доступ к меню настроек теперь заблокирован за вводом PIN-кода "1234" с использованием существующего экранного модального ввода ServiceTextInputModalWindow.
- Создана и интегрирована централизованная служба визуальных эффектов IVfxService / GameVfxService. Программно генерируются и воспроизводятся эффекты частиц для автопрыжка, сбора монет и гибели персонажа.
This commit is contained in:
Данил Ярош 2026-06-23 14:56:40 +03:00
parent cbc49d4384
commit e70120f0b0
11 changed files with 321 additions and 10 deletions

View File

@ -1,5 +1,6 @@
using UnityEngine;
using DanroJump.Audio;
using DanroJump.Vfx;
namespace DanroJump.Gameplay
{
@ -13,6 +14,7 @@ namespace DanroJump.Gameplay
private GameplaySession session;
private IGameAudioService audioService;
private IVfxService vfxService;
/// <summary>
/// Получает игровую сессию для записи награды.
@ -20,10 +22,12 @@ namespace DanroJump.Gameplay
[Zenject.Inject]
public void Construct(
GameplaySession injectedSession,
[Zenject.InjectOptional] IGameAudioService injectedAudioService = null)
[Zenject.InjectOptional] IGameAudioService injectedAudioService = null,
[Zenject.InjectOptional] IVfxService injectedVfxService = null)
{
session = injectedSession;
audioService = injectedAudioService;
vfxService = injectedVfxService;
}
/// <summary>
@ -32,6 +36,7 @@ namespace DanroJump.Gameplay
protected override void OnCollected(JumpPlayerController player)
{
audioService?.Play(GameAudioEvent.CoinCollected, transform.position);
vfxService?.SpawnCoinCollectedEffect(transform.position);
session?.AddCoins(coinValue);
session?.AddScore(scoreValue);
}

View File

@ -3,6 +3,7 @@ using Zenject;
using DanroJump.Prizes;
using DanroJump.Prizes.Qr;
using DanroJump.UI.Gameplay;
using DanroJump.Vfx;
namespace DanroJump.Gameplay
{
@ -39,6 +40,7 @@ namespace DanroJump.Gameplay
Container.Bind<IPrizeQrService>().To<PrizeQrService>().AsSingle();
Container.Bind<GameplaySession>().AsSingle().NonLazy();
Container.Bind<PlatformContentSpawner>().AsSingle();
Container.Bind<IVfxService>().To<GameVfxService>().AsSingle();
Container.Bind<GameplayHudController>().FromComponentInHierarchy().AsSingle().NonLazy();
// Сценовые MonoBehaviour уже существуют, поэтому явно ставим их в очередь на injection.

View File

@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using DanroJump.Audio;
using DanroJump.Vfx;
using UnityEngine;
using Zenject;
#if ENABLE_INPUT_SYSTEM
@ -53,6 +54,7 @@ namespace DanroJump.Gameplay
private Collider2D playerCollider;
private Camera gameplayCamera;
private IGameAudioService audioService;
private IVfxService vfxService;
private Vector3 startPosition;
private Vector3 visualRootInitialScale;
private Quaternion visualRootInitialRotation;
@ -191,10 +193,14 @@ namespace DanroJump.Gameplay
/// Получает gameplay-камеру через SceneContext.
/// </summary>
[Inject]
public void Construct(Camera injectedGameplayCamera, [InjectOptional] IGameAudioService injectedAudioService = null)
public void Construct(
Camera injectedGameplayCamera,
[InjectOptional] IGameAudioService injectedAudioService = null,
[InjectOptional] IVfxService injectedVfxService = null)
{
gameplayCamera = injectedGameplayCamera;
audioService = injectedAudioService;
vfxService = injectedVfxService;
}
/// <summary>
@ -400,6 +406,7 @@ namespace DanroJump.Gameplay
body.linearVelocity = new Vector2(body.linearVelocity.x, Mathf.Max(body.linearVelocity.y, velocity));
jumpAnimationPulseUntil = Time.time + jumpAnimationPulseDuration;
audioService?.Play(GameAudioEvent.PlayerJump, transform.position);
vfxService?.SpawnJumpEffect(transform.position);
}
/// <summary>
@ -1364,6 +1371,7 @@ namespace DanroJump.Gameplay
ResetHeightLift();
EndEnemyPassThrough();
PlayDeathAnimation();
vfxService?.SpawnPlayerDeathEffect(transform.position);
Died?.Invoke(this);
}

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
@ -390,7 +390,7 @@ namespace DanroJump.Hardware.Com
Instance = null;
}
StopSystem(false);
StopSystem(sendShutdownCommandsOnQuit);
lightingSystem?.Dispose();
lightingSystem = null;
prizeOperationLock.Dispose();
@ -1966,11 +1966,19 @@ namespace DanroJump.Hardware.Com
try
{
string line;
IComTransport port;
lock (ioLock)
{
line = serialPort != null && serialPort.IsOpen ? serialPort.ReadLine() : "";
port = serialPort;
}
if (port == null || !port.IsOpen)
{
await UniTask.Delay(10, DelayType.Realtime, PlayerLoopTiming.Update, token);
continue;
}
line = port.ReadLine();
ProcessLine(line);
}
catch (TimeoutException)

View File

@ -925,6 +925,33 @@ namespace DanroJump.UI.MainMenu
audioService?.Play(GameAudioEvent.UiSubmit);
ResolveServiceSettingsIfNeeded();
if (serviceTextInputModal != null && serviceTextInputModal.Root != null)
{
var profile = new ArcadeStringInputProfile("0123456789", 4);
serviceTextInputModal.SetContext(
"Авторизация",
"Введите PIN-код для доступа к настройкам",
"",
profile,
pin =>
{
if (pin == "1234")
{
if (settingsModal != null && settingsModal.Root != null)
{
settingsModal.SetSettingsService(serviceSettingsService);
modalStack?.Open(settingsModal);
}
}
else
{
audioService?.Play(GameAudioEvent.UiError);
}
});
modalStack?.Open(serviceTextInputModal);
return;
}
if (settingsModal != null && settingsModal.Root != null)
{
settingsModal.SetSettingsService(serviceSettingsService);

View File

@ -75,7 +75,8 @@ namespace DanroJump.UI.MainMenu
}
public void SetContext(
ServiceSettingDefinition definition,
string title,
string settingName,
string currentValue,
ArcadeStringInputProfile profile,
Action<string> confirmedCallback)
@ -89,20 +90,31 @@ namespace DanroJump.UI.MainMenu
if (titleLabel != null)
{
titleLabel.text = "Ввод значения";
titleLabel.text = title;
}
if (settingLabel != null)
{
settingLabel.text = definition != null && !string.IsNullOrWhiteSpace(definition.DisplayName)
? definition.DisplayName
: definition?.Key ?? string.Empty;
settingLabel.text = settingName;
}
BuildSlotLabels();
RefreshView();
}
public void SetContext(
ServiceSettingDefinition definition,
string currentValue,
ArcadeStringInputProfile profile,
Action<string> confirmedCallback)
{
var settingName = definition != null && !string.IsNullOrWhiteSpace(definition.DisplayName)
? definition.DisplayName
: definition?.Key ?? string.Empty;
SetContext("Ввод значения", settingName, currentValue, profile, confirmedCallback);
}
public override void Open()
{
base.Open();

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 414d626533ddaed49b7aab70283037a5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,197 @@
using UnityEngine;
namespace DanroJump.Vfx
{
/// <summary>
/// Служба визуальных эффектов, программно создающая и настраивающая системы частиц Unity.
/// Позволяет избежать зависимости от префабов в редакторе и обеспечивает высокую переносимость.
/// </summary>
public sealed class GameVfxService : IVfxService
{
public void SpawnJumpEffect(Vector3 position)
{
var go = new GameObject("VFX_Jump");
go.transform.position = position;
var ps = go.AddComponent<ParticleSystem>();
var main = ps.main;
main.startColor = new Color(0.9f, 0.9f, 0.9f, 0.5f);
main.startSize = new ParticleSystem.MinMaxCurve(0.08f, 0.22f);
main.startSpeed = new ParticleSystem.MinMaxCurve(1.5f, 3.5f);
main.startLifetime = new ParticleSystem.MinMaxCurve(0.2f, 0.45f);
main.duration = 0.25f;
main.loop = false;
main.gravityModifier = 0.6f;
var shape = ps.shape;
shape.shapeType = ParticleSystemShapeType.Cone;
shape.angle = 35f;
shape.radius = 0.15f;
shape.rotation = new Vector3(90f, 0f, 0f); // Направление вниз под ноги
var emission = ps.emission;
emission.rateOverTime = 0;
emission.SetBursts(new ParticleSystem.Burst[] { new ParticleSystem.Burst(0f, 12) });
var colorOverLifetime = ps.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
new GradientAlphaKey[] { new GradientAlphaKey(0.6f, 0f), new GradientAlphaKey(0f, 1f) }
);
colorOverLifetime.color = gradient;
ps.Play();
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
}
public void SpawnCoinCollectedEffect(Vector3 position)
{
var go = new GameObject("VFX_CoinCollected");
go.transform.position = position;
var ps = go.AddComponent<ParticleSystem>();
var main = ps.main;
main.startColor = new Color(1.0f, 0.85f, 0.0f, 0.9f); // Золотой цвет
main.startSize = new ParticleSystem.MinMaxCurve(0.1f, 0.25f);
main.startSpeed = new ParticleSystem.MinMaxCurve(2f, 4f);
main.startLifetime = new ParticleSystem.MinMaxCurve(0.3f, 0.6f);
main.duration = 0.3f;
main.loop = false;
main.gravityModifier = -0.2f; // Легкий подъем вверх
var shape = ps.shape;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 0.1f;
var emission = ps.emission;
emission.rateOverTime = 0;
emission.SetBursts(new ParticleSystem.Burst[] { new ParticleSystem.Burst(0f, 18) });
var colorOverLifetime = ps.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] { new GradientColorKey(new Color(1f, 0.9f, 0.2f), 0f), new GradientColorKey(new Color(1f, 0.5f, 0f), 1f) },
new GradientAlphaKey[] { new GradientAlphaKey(0.9f, 0f), new GradientAlphaKey(0f, 1f) }
);
colorOverLifetime.color = gradient;
ps.Play();
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
}
public void SpawnPlayerDeathEffect(Vector3 position)
{
var go = new GameObject("VFX_PlayerDeath");
go.transform.position = position;
var ps = go.AddComponent<ParticleSystem>();
var main = ps.main;
main.startColor = new Color(0.8f, 0.1f, 0.1f, 0.8f); // Красный / серый взрыв
main.startSize = new ParticleSystem.MinMaxCurve(0.15f, 0.35f);
main.startSpeed = new ParticleSystem.MinMaxCurve(3f, 7f);
main.startLifetime = new ParticleSystem.MinMaxCurve(0.4f, 0.8f);
main.duration = 0.5f;
main.loop = false;
main.gravityModifier = 0.8f;
var shape = ps.shape;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 0.25f;
var emission = ps.emission;
emission.rateOverTime = 0;
emission.SetBursts(new ParticleSystem.Burst[] { new ParticleSystem.Burst(0f, 35) });
var colorOverLifetime = ps.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] { new GradientColorKey(new Color(0.9f, 0.2f, 0.2f), 0f), new GradientColorKey(new Color(0.3f, 0.3f, 0.3f), 1f) },
new GradientAlphaKey[] { new GradientAlphaKey(0.9f, 0f), new GradientAlphaKey(0f, 1f) }
);
colorOverLifetime.color = gradient;
ps.Play();
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
}
public void SpawnPrizeEffect(Vector3 position)
{
var go = new GameObject("VFX_PrizeWin");
go.transform.position = position;
var ps = go.AddComponent<ParticleSystem>();
var main = ps.main;
main.startColor = Color.white; // Конфетти будут разных цветов
main.startSize = new ParticleSystem.MinMaxCurve(0.12f, 0.28f);
main.startSpeed = new ParticleSystem.MinMaxCurve(4f, 8f);
main.startLifetime = new ParticleSystem.MinMaxCurve(0.6f, 1.2f);
main.duration = 0.8f;
main.loop = false;
main.gravityModifier = 0.4f;
var shape = ps.shape;
shape.shapeType = ParticleSystemShapeType.Hemisphere; // Разлет вверх полусферой
shape.radius = 0.3f;
shape.rotation = new Vector3(-90f, 0f, 0f); // Поворот вверх
var emission = ps.emission;
emission.rateOverTime = 0;
emission.SetBursts(new ParticleSystem.Burst[] { new ParticleSystem.Burst(0f, 50) });
// Добавим смену цветов
var colorBySpeed = ps.colorBySpeed; // Используем градиент для создания разноцветных конфетти
var colorOverLifetime = ps.colorOverLifetime;
colorOverLifetime.enabled = true;
// Настроим случайный цвет при рождении через градиент с множеством ключей или Random Between Two Colors
main.startColor = new ParticleSystem.MinMaxGradient(
new Color(0.2f, 0.6f, 1.0f), // Голубой
new Color(1.0f, 0.2f, 0.6f) // Розовый
);
ps.Play();
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
}
public void SpawnComErrorEffect(Vector3 position)
{
var go = new GameObject("VFX_ComError");
go.transform.position = position;
var ps = go.AddComponent<ParticleSystem>();
var main = ps.main;
main.startColor = new Color(1.0f, 0.3f, 0.0f, 0.9f); // Оранжевые тревожные искры
main.startSize = new ParticleSystem.MinMaxCurve(0.06f, 0.18f);
main.startSpeed = new ParticleSystem.MinMaxCurve(3f, 6f);
main.startLifetime = new ParticleSystem.MinMaxCurve(0.25f, 0.5f);
main.duration = 0.4f;
main.loop = false;
main.gravityModifier = 0.3f;
var shape = ps.shape;
shape.shapeType = ParticleSystemShapeType.Cone;
shape.angle = 60f;
shape.radius = 0.1f;
var emission = ps.emission;
emission.rateOverTime = 0;
emission.SetBursts(new ParticleSystem.Burst[] { new ParticleSystem.Burst(0f, 20) });
var colorOverLifetime = ps.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] { new GradientColorKey(new Color(1f, 0.5f, 0f), 0f), new GradientColorKey(new Color(0.8f, 0.1f, 0f), 1f) },
new GradientAlphaKey[] { new GradientAlphaKey(0.9f, 0f), new GradientAlphaKey(0f, 1f) }
);
colorOverLifetime.color = gradient;
ps.Play();
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1e9f0767a6ec67d42925f41968aa532b

View File

@ -0,0 +1,40 @@
using UnityEngine;
namespace DanroJump.Vfx
{
/// <summary>
/// Интерфейс централизованной службы визуальных эффектов (VFX).
/// </summary>
public interface IVfxService
{
/// <summary>
/// Создает визуальный эффект обычного автопрыжка игрока.
/// </summary>
/// <param name="position">Мировая позиция эффекта.</param>
void SpawnJumpEffect(Vector3 position);
/// <summary>
/// Создает визуальный эффект сбора монеты.
/// </summary>
/// <param name="position">Мировая позиция эффекта.</param>
void SpawnCoinCollectedEffect(Vector3 position);
/// <summary>
/// Создает визуальный эффект смерти игрока.
/// </summary>
/// <param name="position">Мировая позиция эффекта.</param>
void SpawnPlayerDeathEffect(Vector3 position);
/// <summary>
/// Создает праздничный визуальный эффект при выигрыше приза.
/// </summary>
/// <param name="position">Мировая позиция эффекта.</param>
void SpawnPrizeEffect(Vector3 position);
/// <summary>
/// Создает визуальный эффект ошибки связи с COM-устройством.
/// </summary>
/// <param name="position">Мировая позиция эффекта.</param>
void SpawnComErrorEffect(Vector3 position);
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dcd5fd795c07b684fac0054ce6ff44e5